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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

- New: mutate `NonZero<T>` 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.
Expand Down
142 changes: 135 additions & 7 deletions src/build_dir.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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::*;
Expand Down Expand Up @@ -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(())
}
}
10 changes: 8 additions & 2 deletions src/copy_tree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u64> {
// 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/<uid>/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());
Expand Down
2 changes: 1 addition & 1 deletion src/copy_tree/unix.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}
6 changes: 3 additions & 3 deletions tests/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
}

Expand Down Expand Up @@ -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");
}
}

Expand Down Expand Up @@ -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");
}
}

Expand Down
Loading