From b340d7b9581547bb28392f9889c9719cd06e3c47 Mon Sep 17 00:00:00 2001 From: Martin Pool Date: Sat, 9 May 2026 08:55:26 -0700 Subject: [PATCH 1/2] Bump file time after copy Avoids scratch files getting deleted by a temp reaper due to appearing old, at least on macOS. Fixes #611 --- Cargo.lock | 6 +++--- Cargo.toml | 1 + NEWS.md | 2 ++ src/copy_tree.rs | 10 ++++++++-- src/copy_tree/unix.rs | 2 +- tests/main.rs | 6 +++--- 6 files changed, 18 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 50c6f1d9..ca88b455 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -169,6 +169,7 @@ dependencies = [ "cp_r", "ctrlc", "fastrand", + "filetime", "flickzeug", "fs2", "globset", @@ -470,14 +471,13 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "filetime" -version = "0.2.25" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35c0522e981e68cbfa8c3f978441a5f34b30b96e146b33cd3359176b50fe8586" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" dependencies = [ "cfg-if", "libc", "libredox", - "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index b942d746..939a8e87 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,7 @@ color-print = "0.3" console = "0.15" ctrlc = { version = "3.4", features = ["termination"] } fastrand = "2" +filetime = "0.2.27" flickzeug = "0.4.5" fs2 = "0.4" globset = "0.4.16" diff --git a/NEWS.md b/NEWS.md index c467ab85..26d52a02 100644 --- a/NEWS.md +++ b/NEWS.md @@ -4,6 +4,8 @@ - New: mutate `NonZero` into `1`, and also `-1` when `T` is or may be signed. +- Fixed: Set the mtime on files copied using reflinks to the scratch directory, so that they're not deleted prematurely by tools that delete old files from `/tmp`. + ## 27.0.0 Released 2026-03-07. diff --git a/src/copy_tree.rs b/src/copy_tree.rs index 58176db1..e115c939 100644 --- a/src/copy_tree.rs +++ b/src/copy_tree.rs @@ -30,13 +30,19 @@ static VCS_DIRS: &[&str] = &[".git", ".hg", ".bzr", ".svn", "_darcs", ".jj", ".p /// Copy a file, attempting to use reflink if supported. /// /// Returns the number of bytes copied. -#[cfg(not(target_env = "musl"))] // https://github.com/sourcefrog/cargo-mutants/issues/581 +#[cfg(not(target_env = "musl"))] // https://github.com/sourcefrog/cargo-mutants/issues/581, musl copy file syscall is non-standard. fn copy_file(src: &Path, dest: &Path, reflink_supported: &AtomicBool) -> Result { // Try reflink first if we haven't determined it's not supported if reflink_supported.load(Ordering::Relaxed) { match reflink::reflink(src, dest) { Ok(()) => { - // Reflink succeeded, get file size for progress tracking + // Set dest mtime to now. clonefile(2) on macOS preserves the source + // mtime, which can be days old; macOS's /usr/libexec/dirhelper + // periodically deletes files in /var/folders//T/ with mtime + // older than CLEAN_FILES_OLDER_THAN_DAYS (default 3), which + // silently unlinks reflinked source files mid-run. + filetime::set_file_mtime(dest, filetime::FileTime::now()) + .with_context(|| format!("set_file_mtime {}", dest.display()))?; let metadata = fs::metadata(dest) .with_context(|| format!("Failed to get metadata for {}", dest.display()))?; return Ok(metadata.len()); diff --git a/src/copy_tree/unix.rs b/src/copy_tree/unix.rs index 9d35bc38..a3f9cf27 100644 --- a/src/copy_tree/unix.rs +++ b/src/copy_tree/unix.rs @@ -9,6 +9,6 @@ pub(super) fn copy_symlink(_ft: FileType, src_path: &Utf8Path, dest_path: &Utf8P let link_target = std::fs::read_link(src_path) .with_context(|| format!("Failed to read link {src_path:?}"))?; std::os::unix::fs::symlink(link_target, dest_path) - .with_context(|| format!("Failed to create symlink {dest_path:?}",))?; + .with_context(|| format!("Failed to create symlink {dest_path:?}"))?; Ok(()) } diff --git a/tests/main.rs b/tests/main.rs index 322fe7b0..376ba105 100644 --- a/tests/main.rs +++ b/tests/main.rs @@ -533,7 +533,7 @@ fn output_option() { "timeout.txt", "unviable.txt", ] { - assert!(mutants_out.join(name).is_file(), "{name} is in mutants.out",); + assert!(mutants_out.join(name).is_file(), "{name} is in mutants.out"); } } @@ -573,7 +573,7 @@ fn output_option_use_env() { "timeout.txt", "unviable.txt", ] { - assert!(mutants_out.join(name).is_file(), "{name} is in mutants.out",); + assert!(mutants_out.join(name).is_file(), "{name} is in mutants.out"); } } @@ -3718,7 +3718,7 @@ fn output_option_use_config() { "timeout.txt", "unviable.txt", ] { - assert!(mutants_out.join(name).is_file(), "{name} is in mutants.out",); + assert!(mutants_out.join(name).is_file(), "{name} is in mutants.out"); } } From 929f858c5f3ff5b208eb1322dfd6e051d4feaa64 Mon Sep 17 00:00:00 2001 From: Martin Pool Date: Sat, 9 May 2026 09:46:20 -0700 Subject: [PATCH 2/2] Better error messages when writing into build dir --- src/build_dir.rs | 142 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 135 insertions(+), 7 deletions(-) diff --git a/src/build_dir.rs b/src/build_dir.rs index da026312..29da0906 100644 --- a/src/build_dir.rs +++ b/src/build_dir.rs @@ -1,12 +1,12 @@ -// Copyright 2021-2024 Martin Pool +// Copyright 2021-2026 Martin Pool //! A directory containing mutated source to run cargo builds and tests. #![warn(clippy::pedantic)] -use std::fs::write; +use std::fs::{symlink_metadata, write}; -use anyhow::{Context, ensure}; +use anyhow::{Context, bail}; use camino::{Utf8Path, Utf8PathBuf}; use tempfile::TempDir; use tracing::info; @@ -92,15 +92,30 @@ impl BuildDir { pub fn overwrite_file(&self, relative_path: &Utf8Path, code: &str) -> Result<()> { let full_path = self.path.join(relative_path); - // for safety, don't follow symlinks - ensure!(full_path.is_file(), "{full_path:?} is not a file"); - write(&full_path, code.as_bytes()) - .with_context(|| format!("failed to write code to {full_path:?}")) + match symlink_metadata(&full_path) { + Ok(metadata) if metadata.file_type().is_symlink() => { + bail!("{full_path:?} is a symlink, refusing to overwrite it") + } + Ok(metadata) if !metadata.file_type().is_file() => { + bail!( + "{full_path:?} is not a regular file (type is {:?}), refusing to overwrite it", + metadata.file_type() + ); + } + Ok(_) => write(&full_path, code.as_bytes()) + .with_context(|| format!("failed to overwrite {full_path:?}")), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + bail!("{full_path:?} does not exist, refusing to create it") + } + Err(e) => bail!("failed to stat {full_path:?}: {e}"), + } } } #[cfg(test)] mod test { + use std::fs::create_dir; + use crate::test_util::copy_of_testdata; use super::*; @@ -174,4 +189,117 @@ mod test { ); Ok(()) } + + /// This shouldn't happen unless we're confused about which files to mutate, but let's make sure + /// we give a clear error. + #[test] + fn fail_to_overwrite_dir() -> Result<()> { + let tmp = copy_of_testdata("factorial"); + let tmp_path: &Utf8Path = tmp.path().try_into().unwrap(); + let build_dir = BuildDir::in_place(tmp_path)?; + create_dir(tmp.path().join("foo"))?; + + let err = build_dir + .overwrite_file(Utf8Path::new("foo"), "code") + .expect_err("expected overwrite_file to fail when the destination is a dir"); + println!("error message is {err:?}"); + assert!( + err.to_string().contains("is not a regular file"), + "unexpected error message: {err}" + ); + Ok(()) + } + + /// This shouldn't normally happen, but if the destination contains a symlink, we shouldn't overwrite it because + /// it might be pointing outside the scratch directory, and we don't want to mess with the user's files. + #[test] + #[cfg(unix)] + fn fail_to_overwrite_symlink() -> Result<()> { + use std::os::unix::fs::symlink; + + let tmp = copy_of_testdata("factorial"); + let tmp_path: &Utf8Path = tmp.path().try_into().unwrap(); + let build_dir = BuildDir::in_place(tmp_path)?; + symlink("foo", tmp.path().join("foo"))?; + + let err = build_dir + .overwrite_file(Utf8Path::new("foo"), "code") + .expect_err("expected overwrite_file to fail when the destination is a symlink"); + println!("error message is {err:?}"); + assert!( + err.to_string() + .contains("is a symlink, refusing to overwrite it"), + "unexpected error message: {err}" + ); + Ok(()) + } + + /// We only ever overwrite existing files in the build dir, and if they don't existing then + /// something surprising has happened. + /// + /// (We could relax this if we ever expect to create new files, but we don't need to today.) + #[test] + fn dont_create_new_files() -> Result<()> { + let tmp = copy_of_testdata("factorial"); + let tmp_path: &Utf8Path = tmp.path().try_into().unwrap(); + let build_dir = BuildDir::in_place(tmp_path)?; + + let err = build_dir + .overwrite_file(Utf8Path::new("foo"), "code") + .expect_err("expected overwrite_file to fail when the destination does not exist"); + println!("error message is {err:?}"); + assert!( + err.to_string().contains("does not exist"), + "unexpected error message: {err}" + ); + Ok(()) + } + + /// Test reporting of generic failures to write into the build dir, by making them unwriteable. + #[test] + #[cfg(unix)] + fn fail_to_overwrite() -> Result<()> { + use std::{fs::set_permissions, os::unix::fs::PermissionsExt}; + + let tmp = copy_of_testdata("factorial"); + let tmp_path: &Utf8Path = tmp.path().try_into().unwrap(); + let build_dir = BuildDir::in_place(tmp_path)?; + let relpath = Utf8Path::new("src/bin/factorial.rs"); + set_permissions( + tmp.path().join(relpath), + std::fs::Permissions::from_mode(0o000), + )?; + + let err = build_dir.overwrite_file(relpath, "code").unwrap_err(); + println!("error message is {err:?}"); + assert!( + err.to_string().contains("failed to overwrite"), + "unexpected error message: {err}" + ); + Ok(()) + } + + /// An edge case: can't even stat the destination file. + #[test] + #[cfg(unix)] + fn fail_to_overwrite_dir_permission_denied() -> Result<()> { + use std::{fs::set_permissions, os::unix::fs::PermissionsExt}; + + let tmp = copy_of_testdata("factorial"); + let tmp_path: &Utf8Path = tmp.path().try_into().unwrap(); + let build_dir = BuildDir::in_place(tmp_path)?; + let relpath = Utf8Path::new("src/bin/factorial.rs"); + set_permissions( + tmp.path().join(relpath.parent().unwrap()), + std::fs::Permissions::from_mode(0o000), + )?; + + let err = build_dir.overwrite_file(relpath, "code").unwrap_err(); + println!("error message is {err:?}"); + assert!( + err.to_string().contains("failed to stat"), + "unexpected error message: {err}" + ); + Ok(()) + } }