diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b243b69 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + test: + name: Test and build + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + - name: Formatting + run: cargo fmt --check + - name: Clippy + run: cargo clippy --locked --all-targets -- -D warnings + - name: Test + run: cargo test --locked + - name: Release build + run: cargo build --release --locked diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..cac3485 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,17 @@ +# Changelog + +## 0.1.0 — 2026-10-20 + +- Turned the old --count placeholder into bounded parallel file-worker processing. +- Introduced verified temporary-file staging before writing final destinations. +- Existing destination files are now protected unless --overwrite is specified. +- Reject source/destination collisions, self-recursive directory copies and unexpected destination symlink parents. +- Move deletes sources only after the entire copy plan has succeeded. +- Implemented --dry-run, --quiet and --no-color. +- Preserved the eight hash algorithms and the existing basic CLI flags. +- Added integration tests, complete documentation and GitHub Actions CI. +- Updated the Rust package version to 0.1.0. + +### Behavioral change + +MCH 0.1.0 intentionally refuses overwriting an existing file by default. Use --overwrite to replace existing regular files or symbolic links. This protects data compared with earlier alpha versions. diff --git a/Cargo.lock b/Cargo.lock index e933f43..dbb7727 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -70,6 +70,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + [[package]] name = "blake2" version = "0.10.6" @@ -231,6 +237,22 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "find-msvc-tools" version = "0.1.8" @@ -247,6 +269,17 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + [[package]] name = "heck" version = "0.5.0" @@ -280,9 +313,15 @@ version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + [[package]] name = "mch" -version = "0.0.1-alpha.5" +version = "0.1.0" dependencies = [ "anyhow", "blake2", @@ -292,9 +331,16 @@ dependencies = [ "rayon", "sha2", "sha3", + "tempfile", "xxhash-rust", ] +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "once_cell_polyfill" version = "1.70.2" @@ -319,6 +365,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rayon" version = "1.11.0" @@ -339,6 +391,19 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "sha2" version = "0.10.9" @@ -389,6 +454,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "typenum" version = "1.19.0" diff --git a/Cargo.toml b/Cargo.toml index 9d5d036..2b2e557 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mch" -version = "0.0.1-alpha.5" +version = "0.1.0" edition = "2024" license = "AGPL-3.0-or-later" description = "Mighty Copy with Hash (mch) - copy/move with post-copy verification (default: BLAKE3)." @@ -19,3 +19,6 @@ xxhash-rust = { version = "0.8", features = ["xxh3"] } hex = "0.4" rayon = "1" + +[dev-dependencies] +tempfile = "3" diff --git a/README.md b/README.md index 0beb3e8..2fec2c5 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,124 @@ # Mighty Copy with Hash (mch) -`mch` copies (or moves) files and directories and optionally verifies the result with a hash. -Default hash: **BLAKE3**. +**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**. -License: **GNU AGPL-3.0-or-later** (see `LICENSE`). +Current version: **0.1.0**. Licensed under **GNU AGPL-3.0-or-later**. -> PS.: Life is short. Time is small. Take it easy and fuck it all! +## Features + +- Copy and move local files, directory trees, and multiple sources. +- Verify each regular file by reading and hashing both source and staged destination **before publishing the result**. +- Hash choices: BLAKE3 (default), SHA-256, SHA-512, SHA3-256, SHA3-512, BLAKE2b, BLAKE2s, and XXH3. XXH3 is intended for speed, not cryptographic security. +- Use `--count N` to process up to N independent file operations concurrently (1-256). +- Preserve source file permissions and newly created directory permissions. +- Preserve Unix symbolic links as links, including dangling links; links are **not followed** when traversing a directory. +- 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**. +- Reject copying a directory into itself, duplicate destination names, destination parent symlinks, and special files (e.g. FIFOs). + +## Install + +Requires the Rust toolchain with Cargo. + +```bash +git clone https://github.com/Olize/mch.git +cd mch +cargo build --release --locked +``` + +The binary is at `target/release/mch` (or `mch.exe` on Windows). Windows builds support regular files and directories, but copying symlinks is not supported. + +## Examples + +```bash +# Copy one file; verify with BLAKE3 +mch source.iso /backup/source.iso + +# Copy a directory inside /backup (=> /backup/photos) +mch photos /backup + +# Copy the contents only (=> /backup/) +mch -O photos /backup + +# Copy multiple files in parallel +mch --count 8 first.iso second.iso third.iso /backup + +# Verify with SHA-512 +mch --hash sha512 source.iso /backup/source.iso + +# Move only after all files have copied and verified +mch --move photos /backup + +# Preview without writing anything +mch --dry-run --move photos /backup + +# Explicitly allow overwriting an existing file +mch --overwrite source.iso /backup/source.iso + +# Faster but without integrity verification; use carefully +mch -N source.iso /backup/source.iso + +# Show version/help +mch -v +mch --help +``` + +### Options + +| Option | Meaning | +|---|---| +| `-m, --move` | Move after all copies have succeeded | +| `-N, --no-hash` | Disable content verification | +| `--hash ALGORITHM` | Select hash algorithm; default BLAKE3 | +| `--count N` | Number of parallel file workers; default 1, max 256 | +| `-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 | +| `-q, --quiet` | Suppress normal status output | +| `--no-color` | Disable ANSI colors | +| `-v, --version` | Show version information | +| `-h, --help` | Show CLI 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. + +## Data safety and limitations + +**Important: MCH is not a transactional backup system.** + +- 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. +- 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. +- 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 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. + +Return code: **0** for success, **2** for errors (argument errors from Clap may also use its own exit status). + +## Development + +```bash +cargo fmt --check +cargo clippy --locked --all-targets -- -D warnings +cargo test --locked +cargo build --release --locked +``` + +The GitHub Actions CI checks builds and tests on Linux, macOS, and Windows. + +See [Specification](docs/SPECIFICATION.md), [Roadmap](docs/ROADMAP.md) and [Changelog](CHANGELOG.md). + +## License + +Copyright (C) 2026 Olize. GNU AGPL-3.0-or-later — see [LICENSE](LICENSE). + +PS.: Life is short. Time is small. Take it easy and fuck it all! --- @@ -13,4 +126,6 @@ License: **GNU AGPL-3.0-or-later** (see `LICENSE`). If you find this project useful, you can support my open-source work: +[![Liberapay](https://img.shields.io/badge/Liberapay-Support-F6C915?style=for-the-badge&logo=liberapay&logoColor=000000)](https://liberapay.com/Olize/) + [![Buy Me a Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-FFDD00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=000000)](https://buymeacoffee.com/Olize) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..db12b0e --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,28 @@ +# Roadmap + +## v0.1.0 — Implemented + +- Copy/move files, directories and Unix symlinks. +- BLAKE3 default and seven selectable alternatives. +- Full source/staging file verification. +- Multiple sources and copy-contents (-O) mode. +- --count parallel file workers (previously a no-op). +- --dry-run, --overwrite, quiet/no-color modes. +- Preflight collision and self-copy checks. +- Deferred deletion after verified copy. +- Temp-file staging and per-file atomic publish on Unix. +- Integration tests and cross-platform CI/build workflow. +- README, behavior spec, changelog. + +## Potential future improvements (proposals, not recovered user requirements) + +- True multi-file transaction / rollback journal and crash recovery. +- Streaming progress by bytes and human-readable ETA. +- Filesystem metadata preservation (timestamps, xattrs, ACLs, ownership, hardlinks, sparse files). +- Snapshot support and safe resumable transfers of very large files. +- Performance benchmarks across storage/media. +- Native packaging for additional platforms, including Termux. +- Explicit adversarial filesystem-race defenses using directory handles. +- Security audit and fuzz testing before claiming production-grade durability. + +None of these potential improvements is part of v0.1.0 implementation contract. See SPECIFICATION.md for implemented guarantees and limitations. diff --git a/docs/SPECIFICATION.md b/docs/SPECIFICATION.md new file mode 100644 index 0000000..a295d1d --- /dev/null +++ b/docs/SPECIFICATION.md @@ -0,0 +1,50 @@ +# Technical specification — MCH 0.1.0 + +Status: implementation contract, not a formal safety proof. The original January 2026 plan could not be completely recovered, so undocumented future features are not presented as original requirements. + +## Goal and interface + +MCH is a Rust CLI for local copy/move with optional post-copy hash verification. Invocation: + +mch [OPTIONS] SOURCE... DESTINATION + +Supports regular files, directory trees and Unix symlinks; does not copy sockets, devices or FIFOs. Multiple source paths are supported. For a directory source, the copied directory is created as DESTINATION/source-basename; -O copies only the contents of one directory. For a single file, an existing destination directory receives its source basename; otherwise the destination is interpreted as its file path. + +## Options + +- -m, --move: remove original sources after all copy and verification tasks succeed. +- -N, --no-hash: skip content verification. +- --hash: BLAKE3 (default), SHA256, SHA512, SHA3-256, SHA3-512, BLAKE2B, BLAKE2S, XXH3. +- --count N: 1–256 parallel file workers (default 1); individual large files are not split into chunks. +- -O: copy only directory contents. +- --overwrite: allow replacement of existing files or links, never a directory with a file. +- --dry-run: preflight and print proposed operations, no changes. +- -q: quiet output. +- --no-color: disable ANSI colors. +- -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. +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. + +## 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. +- 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. +- Temporary files may remain if the process is killed or power fails. + +## Release gates + +cargo fmt --check; cargo clippy --locked --all-targets -- -D warnings; cargo test --locked; cargo build --release --locked. Test matrix: Linux, macOS, Windows. The integration tests verify both successful workflows and safety refusals. diff --git a/src/engine.rs b/src/engine.rs new file mode 100644 index 0000000..e75571c --- /dev/null +++ b/src/engine.rs @@ -0,0 +1,592 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::{Cli, hashing::hash_file}; +use anyhow::{Context, Result, anyhow, bail}; +use rayon::prelude::*; +use std::collections::HashSet; +use std::ffi::OsString; +use std::fs; +use std::io::ErrorKind; +use std::path::{Component, Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0); + +#[derive(Clone, Copy)] +enum Kind { + Regular, + #[cfg_attr(not(unix), allow(dead_code))] + Symlink, +} + +struct FileTask { + source: PathBuf, + destination: PathBuf, + kind: Kind, +} + +struct DirTask { + source: PathBuf, + destination: PathBuf, + preexisting: bool, +} + +pub(crate) struct Plan { + files: Vec, + directories: Vec, + sources: Vec, +} + +fn absolute(path: &Path) -> Result { + let path = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir()?.join(path) + }; + let mut result = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + if !result.pop() { + bail!("path escapes root: {}", path.display()); + } + } + Component::Normal(part) => result.push(part), + Component::RootDir | Component::Prefix(_) => result.push(component.as_os_str()), + } + } + // macOS exposes /var, /tmp and /etc as system-managed symlinks into /private. + // Normalize ONLY these verified system roots; other symlink parents stay forbidden. + #[cfg(target_os = "macos")] + for (alias, physical) in [ + ("/var", "/private/var"), + ("/tmp", "/private/tmp"), + ("/etc", "/private/etc"), + ] { + let alias = Path::new(alias); + let physical = Path::new(physical); + if result.starts_with(alias) + && fs::symlink_metadata(alias).is_ok_and(|meta| meta.file_type().is_symlink()) + && fs::canonicalize(alias).ok().as_deref() == Some(physical) + { + return Ok(physical.join(result.strip_prefix(alias)?)); + } + } + Ok(result) +} + +fn existing_metadata(path: &Path) -> Result> { + match fs::symlink_metadata(path) { + Ok(meta) => Ok(Some(meta)), + Err(error) if error.kind() == ErrorKind::NotFound => Ok(None), + Err(error) => Err(error).with_context(|| format!("stat {}", path.display())), + } +} + +// Resolve existing ancestors (including symbolic links), while keeping future path components. +fn intended_path(path: &Path) -> Result { + let mut base = absolute(path)?; + let mut suffix = Vec::::new(); + while existing_metadata(&base)?.is_none() { + let name = base + .file_name() + .ok_or_else(|| anyhow!("no existing ancestor: {}", path.display()))?; + suffix.push(name.to_os_string()); + if !base.pop() { + bail!("no existing ancestor: {}", path.display()); + } + } + let mut resolved = + fs::canonicalize(&base).with_context(|| format!("resolve {}", base.display()))?; + for item in suffix.into_iter().rev() { + resolved.push(item); + } + Ok(resolved) +} + +fn no_symlink_parents(path: &Path) -> Result<()> { + for ancestor in path.ancestors().skip(1) { + if ancestor.as_os_str().is_empty() { + continue; + } + if let Some(meta) = existing_metadata(ancestor)? { + if meta.file_type().is_symlink() { + bail!( + "destination parent is a symlink (unsafe): {}", + ancestor.display() + ); + } + if !meta.is_dir() { + bail!( + "destination parent is not a directory: {}", + ancestor.display() + ); + } + } + } + Ok(()) +} + +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; + loop { + if let Some(meta) = existing_metadata(cursor)? { + if !meta.is_dir() { + bail!( + "destination directory is not a real directory: {}", + cursor.display() + ); + } + break; + } + missing.push(cursor.to_path_buf()); + cursor = cursor + .parent() + .ok_or_else(|| anyhow!("no directory ancestor: {}", path.display()))?; + } + for directory in missing.iter().rev() { + match fs::create_dir(directory) { + Ok(()) => {} + Err(error) if error.kind() == ErrorKind::AlreadyExists => {} + Err(error) => { + return Err(error).with_context(|| format!("mkdir {}", directory.display())); + } + } + let meta = fs::symlink_metadata(directory)?; + if !meta.is_dir() { + bail!( + "destination directory changed unexpectedly: {}", + directory.display() + ); + } + } + Ok(()) +} + +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() => { + bail!( + "cannot replace a directory with a file: {}", + destination.display() + ) + } + Some(_) if !overwrite => { + bail!( + "destination already exists (use --overwrite): {}", + destination.display() + ) + } + Some(_) + if fs::canonicalize(source) + .is_ok_and(|src| fs::canonicalize(destination).is_ok_and(|dst| src == dst)) => + { + bail!( + "source and destination resolve to the same file: {}", + source.display() + ); + } + Some(_) => + { + #[cfg(unix)] + if let (Ok(a), Ok(b)) = (fs::metadata(source), fs::metadata(destination)) { + use std::os::unix::fs::MetadataExt; + if a.ino() == b.ino() && a.dev() == b.dev() { + bail!("source and destination are hard links to the same file"); + } + } + } + None => {} + } + Ok(()) +} + +fn register_file(plan: &mut Plan, source: &Path, target: &Path, kind: Kind) { + plan.files.push(FileTask { + source: source.to_path_buf(), + destination: target.to_path_buf(), + kind, + }); +} + +fn collect_directory(plan: &mut Plan, source: &Path, destination: &Path) -> Result<()> { + no_symlink_parents(destination)?; + let preexisting = match existing_metadata(destination)? { + Some(meta) if meta.is_dir() => true, + Some(_) => bail!("destination is not a directory: {}", destination.display()), + None => false, + }; + plan.directories.push(DirTask { + source: source.to_path_buf(), + destination: destination.to_path_buf(), + preexisting, + }); + + let mut entries = fs::read_dir(source) + .with_context(|| format!("read directory {}", source.display()))? + .collect::>>()?; + entries.sort_by_key(|entry| entry.file_name()); + + for entry in entries { + let source_path = entry.path(); + let target_path = destination.join(entry.file_name()); + let ty = fs::symlink_metadata(&source_path) + .with_context(|| format!("stat {}", source_path.display()))? + .file_type(); + if ty.is_dir() { + collect_directory(plan, &source_path, &target_path)?; + } else if ty.is_file() { + register_file(plan, &source_path, &target_path, Kind::Regular); + } else if ty.is_symlink() { + #[cfg(unix)] + register_file(plan, &source_path, &target_path, Kind::Symlink); + #[cfg(not(unix))] + bail!( + "symlinks are unsupported on this platform: {}", + source_path.display() + ); + } else { + bail!("unsupported special file: {}", source_path.display()); + } + } + Ok(()) +} + +pub(crate) fn build_plan(cli: &Cli, destination: &Path) -> Result { + let dest = absolute(destination)?; + let explicit_directory = destination + .as_os_str() + .to_string_lossy() + .ends_with(std::path::MAIN_SEPARATOR); + let destination_is_directory = existing_metadata(&dest)?.is_some_and(|meta| meta.is_dir()); + + if cli.sources.len() > 1 && existing_metadata(&dest)?.is_some_and(|meta| !meta.is_dir()) { + bail!("multiple sources require a destination directory"); + } + + let sources = cli + .sources + .iter() + .map(|p| absolute(p)) + .collect::>>()?; + let mut source_paths = HashSet::new(); + for source in &sources { + if !source_paths.insert(source.clone()) { + bail!("duplicate source: {}", source.display()); + } + } + // Moving a parent and its child in one invocation would delete an already-moved input. + if cli.move_mode { + for (i, source) in sources.iter().enumerate() { + for other in sources.iter().skip(i + 1) { + if source.starts_with(other) || other.starts_with(source) { + bail!("overlapping move sources are not supported"); + } + } + } + } + + let mut plan = Plan { + files: Vec::new(), + directories: Vec::new(), + sources, + }; + for source in plan.sources.clone() { + let kind = fs::symlink_metadata(&source) + .with_context(|| format!("source not accessible: {}", source.display()))? + .file_type(); + let name = source + .file_name() + .ok_or_else(|| anyhow!("source has no basename: {}", source.display()))?; + if cli.only_src_content && !kind.is_dir() { + bail!("-O requires a directory source"); + } + let target = if cli.only_src_content { + dest.clone() + } else if kind.is_dir() + || plan.sources.len() > 1 + || destination_is_directory + || explicit_directory + { + if kind.is_dir() + && (plan.sources.len() == 1 || destination_is_directory || explicit_directory) + { + // Existing CLI semantics: directory always lands inside destination/basename. + dest.join(name) + } else { + dest.join(name) + } + } else { + dest.clone() + }; + + if kind.is_dir() { + let source_real = fs::canonicalize(&source)?; + let target_real = intended_path(&target)?; + if target_real.starts_with(&source_real) { + bail!( + "refusing to copy a directory into itself: {} -> {}", + source.display(), + target.display() + ); + } + collect_directory(&mut plan, &source, &target)?; + } else if kind.is_file() { + register_file(&mut plan, &source, &target, Kind::Regular); + } else if kind.is_symlink() { + #[cfg(unix)] + register_file(&mut plan, &source, &target, Kind::Symlink); + #[cfg(not(unix))] + bail!( + "symlinks are unsupported on this platform: {}", + source.display() + ); + } else { + bail!("unsupported source type: {}", source.display()); + } + } + + let mut targets = HashSet::new(); + let mut directory_targets = HashSet::new(); + for dir in &plan.directories { + if !directory_targets.insert(dir.destination.clone()) { + bail!( + "multiple sources target the same directory: {}", + dir.destination.display() + ); + } + } + for task in &plan.files { + if !targets.insert(task.destination.clone()) { + bail!( + "multiple sources target the same file: {}", + task.destination.display() + ); + } + if directory_targets.contains(&task.destination) { + bail!( + "file/directory destination conflict: {}", + task.destination.display() + ); + } + check_destination_file(&task.source, &task.destination, cli.overwrite)?; + } + // Detect file paths that would become parents of any planned target. + for dir in &plan.directories { + if targets.contains(&dir.destination) { + bail!("directory/file conflict: {}", dir.destination.display()); + } + } + Ok(plan) +} + +struct TempPath(PathBuf); +impl Drop for TempPath { + fn drop(&mut self) { + let _ = fs::remove_file(&self.0); + } +} + +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); + Ok(TempPath( + parent.join(format!(".mch-tmp-{}-{index}", std::process::id())), + )) +} + +fn atomic_replace(stage: &Path, destination: &Path) -> Result<()> { + #[cfg(windows)] + { + if fs::symlink_metadata(destination).is_ok() { + let backup = new_temp_path(destination)?; + fs::rename(destination, &backup.0)?; + if let Err(publish_error) = fs::rename(stage, destination) { + if let Err(restore_error) = fs::rename(&backup.0, destination) { + // Never delete the only surviving copy of the old destination. + // Leave the backup on disk for manual recovery and report its location. + let recovery_path = backup.0.clone(); + std::mem::forget(backup); + bail!( + "failed to publish {}: {}; could not restore previous destination: {}; ORIGINAL DATA RETAINED AT {} (manual recovery required)", + destination.display(), + publish_error, + restore_error, + recovery_path.display() + ); + } + return Err(publish_error.into()); + } + return Ok(()); + } + } + fs::rename(stage, destination) + .with_context(|| format!("publish {} -> {}", stage.display(), destination.display())) +} + +fn process_file(cli: &Cli, task: &FileTask) -> Result<()> { + let parent = task + .destination + .parent() + .context("destination has no parent")?; + ensure_directory(parent)?; + check_destination_file(&task.source, &task.destination, cli.overwrite)?; + + let temporary = new_temp_path(&task.destination)?; + 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() + ) + })?; + + if !cli.no_hash { + let original_hash = hash_file(&task.source, cli.hash)?; + let staged_hash = hash_file(&temporary.0, cli.hash)?; + if original_hash != staged_hash { + bail!( + "{} verification FAILED for {} -> {}", + cli.hash.label(), + task.source.display(), + task.destination.display() + ); + } + } + } + Kind::Symlink => { + #[cfg(unix)] + { + use std::os::unix::fs::symlink; + let link = fs::read_link(&task.source)?; + symlink(&link, &temporary.0)?; + if !cli.no_hash && fs::read_link(&temporary.0)? != link { + bail!("symlink verification FAILED: {}", task.source.display()); + } + } + #[cfg(not(unix))] + bail!("symlink copies are unsupported on this platform"); + } + } + + // 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)?; + + let label = if matches!(task.kind, Kind::Symlink) { + "SYMLINK" + } else if cli.no_hash { + "COPIED" + } else { + cli.hash.label() + }; + cli.report( + &format!( + "{label} OK {} -> {}", + task.source.display(), + task.destination.display() + ), + true, + ); + Ok(()) +} + +pub(crate) fn execute(cli: &Cli, plan: &Plan) -> Result<()> { + if cli.dry_run { + for dir in &plan.directories { + cli.report(&format!("MKDIR {}", dir.destination.display()), true); + } + for file in &plan.files { + cli.report( + &format!( + "COPY {} -> {}", + file.source.display(), + file.destination.display() + ), + true, + ); + } + if cli.move_mode { + for source in &plan.sources { + cli.report(&format!("AFTER SUCCESS REMOVE {}", source.display()), true); + } + } + if !cli.quiet { + println!( + "DRY RUN: {} files/links; {} directories; no changes made", + plan.files.len(), + plan.directories.len() + ); + } + return Ok(()); + } + + for dir in &plan.directories { + ensure_directory(&dir.destination)?; + } + let pool = rayon::ThreadPoolBuilder::new() + .num_threads(cli.count) + .build() + .context("create worker pool")?; + pool.install(|| { + plan.files + .par_iter() + .try_for_each(|task| process_file(cli, task)) + })?; + + // Preserve source directory permissions where we actually created a directory. + // Do it after writing children: source directories can be read-only. + for directory in plan.directories.iter().rev() { + if !directory.preexisting { + fs::set_permissions( + &directory.destination, + fs::metadata(&directory.source)?.permissions(), + ) + .with_context(|| { + format!( + "copy directory permissions {}", + directory.destination.display() + ) + })?; + } + } + + if cli.move_mode { + // IMPORTANT: none of the originals are removed until every planned copy has verified. + 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); + } + } + if !cli.quiet { + println!( + "DONE: {} files/links, {} directories{}", + plan.files.len(), + plan.directories.len(), + if cli.move_mode { + " (sources removed after verification)" + } else { + "" + } + ); + } + Ok(()) +} diff --git a/src/hashing.rs b/src/hashing.rs new file mode 100644 index 0000000..eef60b6 --- /dev/null +++ b/src/hashing.rs @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::HashAlg; +use anyhow::{Context, Result}; +use sha2::Digest; +use std::fs::File; +use std::io::Read; +use std::path::Path; + +pub(crate) fn hash_file(path: &Path, algorithm: HashAlg) -> Result { + let mut file = + File::open(path).with_context(|| format!("open for hashing: {}", path.display()))?; + let mut buffer = vec![0_u8; 1024 * 1024]; + + macro_rules! hash_digest { + ($type:ty) => {{ + let mut hasher = <$type>::new(); + loop { + let n = file.read(&mut buffer)?; + if n == 0 { + break; + } + hasher.update(&buffer[..n]); + } + Ok(hex::encode(hasher.finalize())) + }}; + } + + match algorithm { + HashAlg::Blake3 => { + let mut hasher = blake3::Hasher::new(); + loop { + let n = file.read(&mut buffer)?; + if n == 0 { + break; + } + hasher.update(&buffer[..n]); + } + Ok(hasher.finalize().to_hex().to_string()) + } + HashAlg::Sha256 => hash_digest!(sha2::Sha256), + HashAlg::Sha512 => hash_digest!(sha2::Sha512), + HashAlg::Sha3_256 => hash_digest!(sha3::Sha3_256), + HashAlg::Sha3_512 => hash_digest!(sha3::Sha3_512), + HashAlg::Blake2b => hash_digest!(blake2::Blake2b512), + HashAlg::Blake2s => hash_digest!(blake2::Blake2s256), + HashAlg::Xxh3 => { + let mut hasher = xxhash_rust::xxh3::Xxh3::new(); + loop { + let n = file.read(&mut buffer)?; + if n == 0 { + break; + } + hasher.update(&buffer[..n]); + } + Ok(format!("{:016x}", hasher.digest())) + } + } +} diff --git a/src/main.rs b/src/main.rs index d7f475a..686f1c3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,19 +1,14 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -use anyhow::{anyhow, Context, Result}; -use clap::{Parser, ValueEnum}; -use std::ffi::OsStr; -use std::fs; -use std::io::Read; -use std::path::{Path, PathBuf}; - -const VERSION: &str = env!("CARGO_PKG_VERSION"); +mod engine; +mod hashing; -const COLOR_OK: &str = "\x1b[32;1m"; -const COLOR_BAD: &str = "\x1b[31;1m"; -const COLOR_RESET: &str = "\x1b[0m"; +use anyhow::Result; +use clap::{Parser, ValueEnum}; +use std::io::IsTerminal; +use std::path::PathBuf; -#[derive(Copy, Clone, Debug, ValueEnum)] +#[derive(Copy, Clone, Debug, ValueEnum, Eq, PartialEq)] enum HashAlg { Blake3, Sha256, @@ -26,16 +21,16 @@ enum HashAlg { } impl HashAlg { - fn as_str(self) -> &'static str { + fn label(self) -> &'static str { match self { - HashAlg::Blake3 => "BLAKE3", - HashAlg::Sha256 => "SHA256", - HashAlg::Sha512 => "SHA512", - HashAlg::Sha3_256 => "SHA3-256", - HashAlg::Sha3_512 => "SHA3-512", - HashAlg::Blake2b => "BLAKE2B", - HashAlg::Blake2s => "BLAKE2S", - HashAlg::Xxh3 => "XXH3", + Self::Blake3 => "BLAKE3", + Self::Sha256 => "SHA256", + Self::Sha512 => "SHA512", + Self::Sha3_256 => "SHA3-256", + Self::Sha3_512 => "SHA3-512", + Self::Blake2b => "BLAKE2B", + Self::Blake2s => "BLAKE2S", + Self::Xxh3 => "XXH3", } } } @@ -43,397 +38,121 @@ impl HashAlg { #[derive(Parser, Debug)] #[command( name = "mch", - disable_help_subcommand = true, + about = "Mighty Copy with Hash — verified file copying and moving", + long_about = None, disable_version_flag = true, - version = VERSION, - about = concat!("Mighty Copy with Hash -Copyright (C) 2026 Olize -", env!("CARGO_PKG_VERSION")), - long_about = None + version )] struct Cli { - /// Print version information (custom header) + /// Print the MCH version and exit #[arg(short = 'v', long = "version")] version: bool, - /// Print version information + /// Move sources (remove them only after all copies succeed) #[arg(short = 'm', long = "move")] move_mode: bool, + /// Skip source/destination verification (less safe, faster) #[arg(short = 'N', long = "no-hash")] no_hash: bool, - #[arg( - long = "hash", - value_enum, - default_value = "blake3", - ignore_case = true - )] + /// Select the verification hash + #[arg(long = "hash", value_enum, default_value_t = HashAlg::Blake3, ignore_case = true)] hash: HashAlg, + /// Parallel file workers (1-256; 1 is the default) #[arg(long = "count", default_value_t = 1)] - count: u32, + count: usize, + /// Copy only the contents of one source directory #[arg(short = 'O', long = "only-src-content")] only_src_content: bool, - #[arg(required = true)] - sources: Vec, + /// Allow replacing existing regular files or symlinks + #[arg(long = "overwrite")] + overwrite: bool, - #[arg(required = true)] - destination: String, -} + /// Display planned operations without writing or deleting anything + #[arg(long = "dry-run")] + dry_run: bool, -fn ensure_parent_dir(dst: &Path) -> Result<()> { - if let Some(parent) = dst.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("create parent dir: {}", parent.display()))?; - } - Ok(()) -} + /// Suppress per-file verification output + #[arg(short = 'q', long = "quiet")] + quiet: bool, -fn file_name_of(p: &Path) -> Result<&OsStr> { - p.file_name() - .ok_or_else(|| anyhow!("cannot determine filename for {}", p.display())) -} + /// Never emit ANSI color sequences + #[arg(long = "no-color")] + no_color: bool, -fn is_dir(p: &Path) -> bool { - fs::metadata(p).map(|m| m.is_dir()).unwrap_or(false) -} - -fn hash_file(path: &Path, alg: HashAlg) -> Result { - let mut f = - fs::File::open(path).with_context(|| format!("open for hashing: {}", path.display()))?; - let mut buf = vec![0u8; 1024 * 1024]; - - match alg { - HashAlg::Blake3 => { - let mut hasher = blake3::Hasher::new(); - loop { - let n = f.read(&mut buf)?; - if n == 0 { - break; - } - hasher.update(&buf[..n]); - } - Ok(hasher.finalize().to_hex().to_string()) - } - HashAlg::Sha256 => { - use sha2::Digest; - let mut hasher = sha2::Sha256::new(); - loop { - let n = f.read(&mut buf)?; - if n == 0 { - break; - } - hasher.update(&buf[..n]); - } - Ok(hex::encode(hasher.finalize())) - } - HashAlg::Sha512 => { - use sha2::Digest; - let mut hasher = sha2::Sha512::new(); - loop { - let n = f.read(&mut buf)?; - if n == 0 { - break; - } - hasher.update(&buf[..n]); - } - Ok(hex::encode(hasher.finalize())) - } - HashAlg::Sha3_256 => { - use sha3::Digest; - let mut hasher = sha3::Sha3_256::new(); - loop { - let n = f.read(&mut buf)?; - if n == 0 { - break; - } - hasher.update(&buf[..n]); - } - Ok(hex::encode(hasher.finalize())) - } - HashAlg::Sha3_512 => { - use sha3::Digest; - let mut hasher = sha3::Sha3_512::new(); - loop { - let n = f.read(&mut buf)?; - if n == 0 { - break; - } - hasher.update(&buf[..n]); - } - Ok(hex::encode(hasher.finalize())) - } - HashAlg::Blake2b => { - use blake2::digest::Digest; - let mut hasher = blake2::Blake2b512::new(); - loop { - let n = f.read(&mut buf)?; - if n == 0 { - break; - } - hasher.update(&buf[..n]); - } - Ok(hex::encode(hasher.finalize())) - } - HashAlg::Blake2s => { - use blake2::digest::Digest; - let mut hasher = blake2::Blake2s256::new(); - loop { - let n = f.read(&mut buf)?; - if n == 0 { - break; - } - hasher.update(&buf[..n]); - } - Ok(hex::encode(hasher.finalize())) - } - HashAlg::Xxh3 => { - use xxhash_rust::xxh3::Xxh3; - let mut hasher = Xxh3::new(); - loop { - let n = f.read(&mut buf)?; - if n == 0 { - break; - } - hasher.update(&buf[..n]); - } - Ok(format!("{:016x}", hasher.digest())) - } - } -} + /// One or more source files/directories + #[arg(required = true, num_args = 1..)] + sources: Vec, -fn print_verify(ok: bool, label: &str, alg: HashAlg) { - if ok { - println!("{COLOR_OK}{label} {} SRC ≙ DEST{COLOR_RESET}", alg.as_str()); - } else { - println!( - "{COLOR_BAD}{label} {} SRC ≠ DEST{COLOR_RESET}", - alg.as_str() - ); - } + /// Target file or directory + #[arg(required = true)] + destination: PathBuf, } -#[cfg(unix)] -fn copy_symlink(src: &Path, dst: &Path, cli: &Cli) -> Result<()> { - use std::os::unix::fs as unixfs; - - ensure_parent_dir(dst)?; - let target = fs::read_link(src).with_context(|| format!("read symlink: {}", src.display()))?; - - let _ = fs::remove_file(dst); - unixfs::symlink(&target, dst) - .with_context(|| format!("create symlink: {} -> {}", dst.display(), target.display()))?; - - if !cli.no_hash { - let dst_target = - fs::read_link(dst).with_context(|| format!("read symlink: {}", dst.display()))?; - let ok = target == dst_target; - print_verify(ok, &format!("SYMLINK {}", src.display()), cli.hash); - if !ok { - return Err(anyhow!( - "symlink target mismatch: {} -> {:?} vs {:?}", - src.display(), - target, - dst_target - )); - } +impl Cli { + fn color(&self) -> bool { + !self.no_color && std::env::var_os("NO_COLOR").is_none() && std::io::stdout().is_terminal() } - Ok(()) -} - -fn copy_regular_file(src: &Path, dst: &Path, cli: &Cli) -> Result<()> { - ensure_parent_dir(dst)?; - fs::copy(src, dst) - .with_context(|| format!("copy file: {} -> {}", src.display(), dst.display()))?; - - if !cli.no_hash { - let h1 = - hash_file(src, cli.hash).with_context(|| format!("hash src: {}", src.display()))?; - let h2 = - hash_file(dst, cli.hash).with_context(|| format!("hash dst: {}", dst.display()))?; - let ok = h1 == h2; - print_verify(ok, &src.display().to_string(), cli.hash); - if !ok { - return Err(anyhow!( - "hash mismatch: {} ({} != {})", - src.display(), - h1, - h2 - )); + fn report(&self, line: &str, ok: bool) { + if self.quiet { + return; } - } - - Ok(()) -} - -fn copy_dir_recursive(src_dir: &Path, dst_dir: &Path, cli: &Cli) -> Result<()> { - fs::create_dir_all(dst_dir).with_context(|| format!("create dir: {}", dst_dir.display()))?; - - for entry in - fs::read_dir(src_dir).with_context(|| format!("read dir: {}", src_dir.display()))? - { - let entry = entry?; - let src_path = entry.path(); - let meta = fs::symlink_metadata(&src_path) - .with_context(|| format!("stat: {}", src_path.display()))?; - let dst_path = dst_dir.join(entry.file_name()); - - if meta.file_type().is_dir() { - copy_dir_recursive(&src_path, &dst_path, cli)?; - } else if meta.file_type().is_symlink() { - #[cfg(unix)] - { - copy_symlink(&src_path, &dst_path, cli)?; - } - #[cfg(not(unix))] - { - return Err(anyhow!( - "symlink not supported on this platform: {}", - src_path.display() - )); - } - } else if meta.file_type().is_file() { - copy_regular_file(&src_path, &dst_path, cli)?; + if self.color() { + let color = if ok { "\x1b[32;1m" } else { "\x1b[31;1m" }; + println!("{color}{line}\x1b[0m"); } else { - return Err(anyhow!("unsupported file type: {}", src_path.display())); + println!("{line}"); } } - - Ok(()) -} - -fn compute_target_path_for_source(cli: &Cli, src: &Path, dest: &Path) -> Result { - if cli.sources.len() > 1 || is_dir(dest) { - Ok(dest.join(file_name_of(src)?)) - } else { - Ok(dest.to_path_buf()) - } } -fn run(cli: Cli) -> Result<()> { - // Custom version output for -v/--version - if cli.version { +fn run() -> Result<()> { + let only_version = std::env::args_os() + .nth(1) + .is_some_and(|a| (a == "-v" || a == "--version") && std::env::args_os().count() == 2); + if only_version { println!("Mighty Copy with Hash"); println!("Copyright (C) 2026 Olize"); - println!("{}", VERSION); + println!("{}", env!("CARGO_PKG_VERSION")); return Ok(()); } - - println!("Mighty Copy with Hash Version {}", VERSION); - println!("Copyright (C) 2026 Olize"); - println!("PS.: Life is short. Time is small. Take it easy and fuck it all!"); - println!(); - - if cli.count != 1 { - eprintln!("Note: --count is accepted, but will be implemented in alpha.3. Using 1."); + let cli = Cli::parse(); + if cli.version { + println!( + "Mighty Copy with Hash\nCopyright (C) 2026 Olize\n{}", + env!("CARGO_PKG_VERSION") + ); + return Ok(()); } - let dest = PathBuf::from(&cli.destination); - - if cli.sources.len() > 1 { - fs::create_dir_all(&dest) - .with_context(|| format!("create destination dir: {}", dest.display()))?; - if !is_dir(&dest) { - return Err(anyhow!( - "destination must be a directory when multiple sources are provided: {}", - dest.display() - )); - } - } else if cli.destination.ends_with('/') { - fs::create_dir_all(&dest) - .with_context(|| format!("create destination dir: {}", dest.display()))?; + if !cli.quiet { + println!( + "Mighty Copy with Hash Version {}", + env!("CARGO_PKG_VERSION") + ); + println!("Copyright (C) 2026 Olize"); + println!("PS.: Life is short. Time is small. Take it easy and fuck it all!"); + println!(); } - let mut did_anything = false; - - for src_s in &cli.sources { - let src = PathBuf::from(src_s); - let meta = fs::symlink_metadata(&src) - .with_context(|| format!("stat source: {}", src.display()))?; - - if meta.file_type().is_symlink() { - let target_dst = compute_target_path_for_source(&cli, &src, &dest)?; - #[cfg(unix)] - { - copy_symlink(&src, &target_dst, &cli)?; - } - #[cfg(not(unix))] - { - return Err(anyhow!( - "symlink not supported on this platform: {}", - src.display() - )); - } - - did_anything = true; - if cli.move_mode { - fs::remove_file(&src) - .with_context(|| format!("remove source symlink: {}", src.display()))?; - } - } else if meta.file_type().is_file() { - let target_dst = compute_target_path_for_source(&cli, &src, &dest)?; - copy_regular_file(&src, &target_dst, &cli)?; - - did_anything = true; - if cli.move_mode { - fs::remove_file(&src) - .with_context(|| format!("remove source file: {}", src.display()))?; - } - } else if meta.file_type().is_dir() { - if cli.only_src_content { - fs::create_dir_all(&dest) - .with_context(|| format!("create destination dir: {}", dest.display()))?; - if !is_dir(&dest) { - return Err(anyhow!( - "destination must be a directory when using -O: {}", - dest.display() - )); - } - copy_dir_recursive(&src, &dest, &cli)?; - did_anything = true; - - if cli.move_mode { - fs::remove_dir_all(&src) - .with_context(|| format!("remove source dir: {}", src.display()))?; - } - } else { - fs::create_dir_all(&dest) - .with_context(|| format!("create destination dir: {}", dest.display()))?; - if !is_dir(&dest) { - return Err(anyhow!( - "destination must be a directory when copying a directory: {}", - dest.display() - )); - } - let dst_dir = dest.join(file_name_of(&src)?); - copy_dir_recursive(&src, &dst_dir, &cli)?; - did_anything = true; - - if cli.move_mode { - fs::remove_dir_all(&src) - .with_context(|| format!("remove source dir: {}", src.display()))?; - } - } - } else { - return Err(anyhow!("unsupported file type: {}", src.display())); - } + if !(1..=256).contains(&cli.count) { + anyhow::bail!("--count must be between 1 and 256"); } - - if !did_anything { - return Err(anyhow!("nothing copied")); + if cli.only_src_content && cli.sources.len() != 1 { + anyhow::bail!("-O/--only-src-content requires exactly one source directory"); } - - Ok(()) + let plan = engine::build_plan(&cli, &cli.destination)?; + engine::execute(&cli, &plan) } fn main() { - let cli = Cli::parse(); - if let Err(e) = run(cli) { - eprintln!("ERROR: {:#}", e); + if let Err(error) = run() { + eprintln!("ERROR: {error:#}"); std::process::exit(2); } } diff --git a/tests/cli.rs b/tests/cli.rs new file mode 100644 index 0000000..38883d0 --- /dev/null +++ b/tests/cli.rs @@ -0,0 +1,408 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::fs; +use std::path::Path; +use std::process::{Command, Output}; +use tempfile::TempDir; + +fn mch(args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_mch")) + .args(args) + .env("NO_COLOR", "1") + .output() + .expect("run mch") +} + +fn write(path: &Path, data: &str) { + fs::write(path, data).expect("write fixture"); +} + +fn assert_success(output: Output) { + assert!( + output.status.success(), + "status={:?}\nstdout={}\nstderr={}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +fn assert_error(output: Output) { + assert!( + !output.status.success(), + "unexpected success:\n{}", + String::from_utf8_lossy(&output.stdout) + ); +} + +#[test] +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_success(mch(&["--version"])); + assert_error(mch(&[])); +} + +#[test] +fn simple_copy_default_hash_and_move() { + let t = TempDir::new().unwrap(); + let a = t.path().join("source.txt"); + let b = t.path().join("destination.txt"); + write(&a, "MCH integrity test"); + assert_success(mch(&[a.to_str().unwrap(), b.to_str().unwrap()])); + assert_eq!(fs::read(&a).unwrap(), fs::read(&b).unwrap()); + assert_error(mch(&[a.to_str().unwrap(), b.to_str().unwrap()])); + let c = t.path().join("moved.txt"); + assert_success(mch(&["-m", a.to_str().unwrap(), c.to_str().unwrap()])); + assert!(!a.exists()); + assert_eq!(fs::read_to_string(c).unwrap(), "MCH integrity test"); +} + +#[test] +fn overwrite_is_opt_in_and_atomic_for_a_file() { + let t = TempDir::new().unwrap(); + let a = t.path().join("a"); + let b = t.path().join("b"); + write(&a, "new"); + write(&b, "old"); + assert_error(mch(&[a.to_str().unwrap(), b.to_str().unwrap()])); + assert_eq!(fs::read_to_string(&b).unwrap(), "old"); + assert_success(mch(&[ + "--overwrite", + a.to_str().unwrap(), + b.to_str().unwrap(), + ])); + assert_eq!(fs::read_to_string(&b).unwrap(), "new"); + assert_eq!(fs::read_to_string(&a).unwrap(), "new"); + let leftovers: Vec<_> = fs::read_dir(t.path()) + .unwrap() + .map(|e| e.unwrap().file_name()) + .filter(|s| s.to_string_lossy().contains(".mch-tmp-")) + .collect(); + assert!(leftovers.is_empty(), "staging files should be removed"); +} + +#[test] +fn self_copy_is_rejected_even_with_overwrite() { + let t = TempDir::new().unwrap(); + let file = t.path().join("data"); + write(&file, "untouched"); + assert_error(mch(&[ + "--overwrite", + file.to_str().unwrap(), + file.to_str().unwrap(), + ])); + assert_eq!(fs::read_to_string(file).unwrap(), "untouched"); +} + +#[test] +fn recursive_parallel_copy_and_verify() { + let t = TempDir::new().unwrap(); + let source = t.path().join("src"); + let output = t.path().join("out"); + fs::create_dir_all(source.join("nested/empty")).unwrap(); + for n in 0..30 { + let file = source.join("nested").join(format!("{n}.txt")); + write(&file, &format!("index={n}\n")); + } + assert_success(mch(&[ + "--count", + "8", + source.to_str().unwrap(), + output.to_str().unwrap(), + ])); + for n in 0..30 { + let name = format!("{n}.txt"); + assert_eq!( + fs::read(source.join("nested").join(&name)).unwrap(), + fs::read(output.join("src/nested").join(&name)).unwrap() + ); + } + assert!(output.join("src/nested/empty").is_dir()); +} + +#[test] +fn only_src_content_and_empty_directories() { + let t = TempDir::new().unwrap(); + let source = t.path().join("input"); + let dest = t.path().join("other"); + fs::create_dir_all(source.join("empty")).unwrap(); + write(&source.join("one"), "1"); + assert_success(mch(&[ + "-O", + source.to_str().unwrap(), + dest.to_str().unwrap(), + ])); + assert!(dest.join("one").exists()); + assert!(dest.join("empty").is_dir()); + assert!(!dest.join("input").exists()); +} + +#[test] +fn no_hash_and_all_hashes_work() { + let t = TempDir::new().unwrap(); + let a = t.path().join("src"); + write(&a, &"hello".repeat(1000)); + for alg in [ + "blake3", "sha256", "sha512", "sha3-256", "sha3-512", "blake2b", "blake2s", "xxh3", + ] { + let dest = t.path().join(alg); + assert_success(mch(&[ + "--hash", + alg, + a.to_str().unwrap(), + dest.to_str().unwrap(), + ])); + assert_eq!(fs::read(&dest).unwrap(), fs::read(&a).unwrap()); + } + let dest = t.path().join("none"); + assert_success(mch(&["-N", a.to_str().unwrap(), dest.to_str().unwrap()])); +} + +#[test] +fn dry_run_makes_no_changes() { + let t = TempDir::new().unwrap(); + let a = t.path().join("a"); + let b = t.path().join("b"); + write(&a, "preserve"); + assert_success(mch(&[ + "--dry-run", + "--move", + a.to_str().unwrap(), + b.to_str().unwrap(), + ])); + assert!(a.is_file()); + assert!(!b.exists()); +} + +#[test] +fn nested_copy_is_rejected_before_writing() { + let t = TempDir::new().unwrap(); + let src = t.path().join("src"); + fs::create_dir_all(&src).unwrap(); + write(&src.join("data"), "keep"); + let inner = src.join("nested"); + assert_error(mch(&[src.to_str().unwrap(), inner.to_str().unwrap()])); + assert!(!inner.exists()); + assert_eq!(fs::read_to_string(src.join("data")).unwrap(), "keep"); +} + +#[test] +fn duplicate_destination_basename_is_rejected() { + let t = TempDir::new().unwrap(); + let a = t.path().join("a"); + let b = t.path().join("b"); + fs::create_dir_all(&a).unwrap(); + fs::create_dir_all(&b).unwrap(); + write(&a.join("dup"), "a"); + write(&b.join("dup"), "b"); + let dest = t.path().join("dst"); + assert_error(mch(&[ + a.join("dup").to_str().unwrap(), + b.join("dup").to_str().unwrap(), + dest.to_str().unwrap(), + ])); + assert!(!dest.exists()); +} + +#[test] +fn move_does_not_remove_any_source_on_preflight_error() { + let t = TempDir::new().unwrap(); + let a = t.path().join("a"); + let b = t.path().join("b"); + let dest = t.path().join("dst"); + fs::create_dir_all(&dest).unwrap(); + write(&a, "first"); + write(&b, "second"); + write(&dest.join("b"), "existing"); + assert_error(mch(&[ + "-m", + a.to_str().unwrap(), + b.to_str().unwrap(), + dest.to_str().unwrap(), + ])); + assert_eq!(fs::read_to_string(a).unwrap(), "first"); + assert_eq!(fs::read_to_string(b).unwrap(), "second"); + assert!(!dest.join("a").exists()); +} + +#[test] +fn invalid_worker_count_fails() { + let t = TempDir::new().unwrap(); + let a = t.path().join("a"); + write(&a, "a"); + let dest = t.path().join("b"); + assert_error(mch(&[ + "--count", + "0", + a.to_str().unwrap(), + dest.to_str().unwrap(), + ])); + assert_error(mch(&[ + "--count", + "257", + a.to_str().unwrap(), + dest.to_str().unwrap(), + ])); +} + +#[cfg(unix)] +#[test] +fn symlink_preserves_link_target_including_dangling_links() { + use std::os::unix::fs::symlink; + let t = TempDir::new().unwrap(); + let input = t.path().join("link"); + let dest = t.path().join("copied"); + symlink("does-not-exist", &input).unwrap(); + assert_success(mch(&[input.to_str().unwrap(), dest.to_str().unwrap()])); + assert_eq!(fs::read_link(dest).unwrap(), Path::new("does-not-exist")); +} + +#[cfg(unix)] +#[test] +fn destination_directory_symlink_is_refused() { + use std::os::unix::fs::symlink; + let t = TempDir::new().unwrap(); + let src = t.path().join("original"); + let safe_dir = t.path().join("safe"); + let alias = t.path().join("alias"); + fs::create_dir(&safe_dir).unwrap(); + symlink(&safe_dir, &alias).unwrap(); + write(&src, "secure"); + let output = alias.join("out"); + assert_error(mch(&[src.to_str().unwrap(), output.to_str().unwrap()])); + assert!(!safe_dir.join("out").exists()); +} + +#[cfg(unix)] +#[test] +fn hard_link_self_overwrite_is_refused() { + let t = TempDir::new().unwrap(); + let src = t.path().join("src"); + let dst = t.path().join("hard-link"); + write(&src, "precious"); + if let Err(error) = fs::hard_link(&src, &dst) { + if matches!( + error.kind(), + std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::Unsupported + ) { + return; // Some Android filesystems disallow app-created hard links. + } + panic!("hard_link fixture failed: {error}"); + } + assert_error(mch(&[ + "--overwrite", + src.to_str().unwrap(), + dst.to_str().unwrap(), + ])); + assert_eq!(fs::read_to_string(src).unwrap(), "precious"); +} + +#[test] +fn move_directory_only_after_copying_everything() { + let t = TempDir::new().unwrap(); + let src = t.path().join("src"); + fs::create_dir_all(src.join("inner")).unwrap(); + write(&src.join("inner/data"), "done"); + let dst = t.path().join("destination"); + assert_success(mch(&[ + "--move", + "--count", + "3", + src.to_str().unwrap(), + dst.to_str().unwrap(), + ])); + assert!(!src.exists()); + assert_eq!( + fs::read_to_string(dst.join("src/inner/data")).unwrap(), + "done" + ); +} + +#[test] +fn unicode_spaces_and_nonempty_name_copy() { + let t = TempDir::new().unwrap(); + let source = t.path().join("ä Ö 🎼 test file.txt"); + let destination = t.path().join("ziel datei.txt"); + write(&source, "Äpfel ☃"); + assert_success(mch(&[ + source.to_str().unwrap(), + destination.to_str().unwrap(), + ])); + assert_eq!(fs::read_to_string(destination).unwrap(), "Äpfel ☃"); +} + +#[test] +fn banner_contains_motto_but_help_and_version_do_not() { + let t = TempDir::new().unwrap(); + let source = t.path().join("src"); + let destination = t.path().join("dst"); + write(&source, "sample"); + let run = mch(&[source.to_str().unwrap(), destination.to_str().unwrap()]); + assert_success(run.clone()); + let full = String::from_utf8_lossy(&run.stdout); + assert!(full.contains("Life is short. Time is small.")); + assert!(full.contains("Copyright (C) 2026 Olize")); + for option in ["-h", "-v", "--version"] { + let output = mch(&[option]); + assert_success(output.clone()); + assert!(!String::from_utf8_lossy(&output.stdout).contains("Life is short.")); + } +} + +#[test] +fn many_files_are_copied_with_parallel_workers() { + let t = TempDir::new().unwrap(); + let src = t.path().join("many"); + let dst = t.path().join("copy"); + fs::create_dir(&src).unwrap(); + for index in 0..128 { + write( + &src.join(format!("file-{index:04}")), + &format!("payload-{index}"), + ); + } + assert_success(mch(&[ + "--count", + "12", + src.to_str().unwrap(), + dst.to_str().unwrap(), + ])); + for index in 0..128 { + let name = format!("file-{index:04}"); + assert_eq!( + fs::read(src.join(&name)).unwrap(), + fs::read(dst.join("many").join(&name)).unwrap() + ); + } +} + +#[cfg(unix)] +#[test] +fn copy_preserves_unix_file_mode() { + use std::os::unix::fs::PermissionsExt; + let t = TempDir::new().unwrap(); + let src = t.path().join("executable"); + let dst = t.path().join("copied"); + write(&src, "hello"); + fs::set_permissions(&src, fs::Permissions::from_mode(0o751)).unwrap(); + assert_success(mch(&[src.to_str().unwrap(), dst.to_str().unwrap()])); + assert_eq!( + fs::metadata(dst).unwrap().permissions().mode() & 0o777, + 0o751 + ); +} + +#[test] +fn move_retains_source_when_destination_file_conflicts() { + let t = TempDir::new().unwrap(); + let src = t.path().join("source"); + let dst = t.path().join("target"); + write(&src, "original"); + write(&dst, "occupied"); + assert_error(mch(&["-m", src.to_str().unwrap(), dst.to_str().unwrap()])); + assert_eq!(fs::read_to_string(&src).unwrap(), "original"); + assert_eq!(fs::read_to_string(&dst).unwrap(), "occupied"); +}