diff --git a/xtask/src/mutation_campaign.rs b/xtask/src/mutation_campaign.rs index 4ccb949..bc28173 100644 --- a/xtask/src/mutation_campaign.rs +++ b/xtask/src/mutation_campaign.rs @@ -122,7 +122,7 @@ mod tests { use std::ffi::{OsStr, OsString}; use std::path::Path; - use super::{OUTPUT_DIRECTORY, command, report_path, require_no_arguments}; + use super::{OUTPUT_DIRECTORY, command, report_path, require_no_arguments, run}; use crate::repository::snapshot::{ MUTATION_SUBJECT_COMMIT, MUTATION_SUBJECT_ROOT, MUTATION_SUBJECT_TREE, }; @@ -185,4 +185,25 @@ mod tests { .is_err_and(|reason| reason.contains("accepts no arguments")) ); } + + /// Planted reversal: the subject root crosses into cargo-mutants' scratch + /// test processes, so a relative spelling cannot identify one repository + /// independently of either process's working directory. + #[test] + fn a_relative_subject_root_refuses_at_the_launcher() -> Result<(), String> { + let observed = run( + Path::new("relative-mutation-subject"), + std::iter::empty::(), + ); + let Err(reason) = observed else { + return Err(String::from( + "a relative mutation subject root did not refuse", + )); + }; + assert_eq!( + reason.to_string(), + "mutation-campaign requires an absolute repository root; got relative-mutation-subject" + ); + Ok(()) + } } diff --git a/xtask/src/qualification.rs b/xtask/src/qualification.rs index bf084f9..6c48237 100644 --- a/xtask/src/qualification.rs +++ b/xtask/src/qualification.rs @@ -58,7 +58,7 @@ use std::error::Error; use std::path::Path; use std::process::{Command, Stdio}; -use crate::repository::snapshot::cargo_binary; +use crate::repository::snapshot::{cargo_binary, git}; /// One qualification stage: what the log calls it, and the work it is. struct Stage { @@ -259,8 +259,7 @@ fn run_cargo(root: &Path, args: &[&str], env: &[(&str, &str)]) -> Result<(), Str /// git's own complaint about a checkout it cannot read lands in the log next to /// the stage that asked. fn run_worktree_clean(root: &Path) -> Result<(), String> { - let output = Command::new("git") - .current_dir(root) + let output = git(root) .args(["status", "--porcelain"]) .stderr(Stdio::inherit()) .output() @@ -299,7 +298,40 @@ fn dirty_entries(listing: &str) -> Vec<&str> { /// dirty checkout is never proven by dirtying one. #[cfg(test)] mod tests { - use super::dirty_entries; + use std::fs; + use std::io::ErrorKind; + use std::path::PathBuf; + use std::process; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use super::{dirty_entries, run_worktree_clean}; + + /// Creates an empty non-repository coordinate without trusting one + /// process-local name to be absent after an interrupted earlier run. + fn non_repository_directory() -> Result { + const CREATION_ATTEMPTS: usize = 1_024; + static NEXT: AtomicUsize = AtomicUsize::new(0); + for _ in 0..CREATION_ATTEMPTS { + let ordinal = NEXT.fetch_add(1, Ordering::Relaxed); + let root = std::env::temp_dir().join(format!( + "threadpak-qualification-non-repository-{}-{ordinal}", + process::id() + )); + match fs::create_dir(&root) { + Ok(()) => return Ok(root), + Err(error) if error.kind() == ErrorKind::AlreadyExists => {} + Err(error) => { + return Err(format!( + "cannot create non-repository qualification subject {}: {error}", + root.display() + )); + } + } + } + Err(format!( + "cannot create a non-repository qualification subject after {CREATION_ATTEMPTS} attempts" + )) + } /// The pass condition, stated exactly: a clean checkout prints nothing, and /// a trailing newline is still nothing. @@ -332,4 +364,24 @@ mod tests { let found = dirty_entries(" M Cargo.lock\n"); assert_eq!(found.len(), 1, "{found:?}"); } + + /// Planted reversal: a failed Git observation is unknown, not an empty + /// listing. An empty directory is deliberately not a repository, so Git's + /// nonzero exit must reach the qualification verdict as a refusal. + #[test] + fn a_git_status_failure_is_not_a_clean_checkout() -> Result<(), String> { + let root = non_repository_directory()?; + let observed = run_worktree_clean(&root); + fs::remove_dir(&root).map_err(|error| { + format!( + "cannot remove non-repository qualification subject {}: {error}", + root.display() + ) + })?; + let Err(reason) = observed else { + return Err(String::from("a failed Git observation did not refuse")); + }; + assert!(reason.starts_with("git exited "), "{reason}"); + Ok(()) + } } diff --git a/xtask/src/repository/snapshot.rs b/xtask/src/repository/snapshot.rs index 95cd353..628bd3a 100644 --- a/xtask/src/repository/snapshot.rs +++ b/xtask/src/repository/snapshot.rs @@ -462,22 +462,7 @@ fn read_tracked_blobs( } let header = std::str::from_utf8(&header) .map_err(|error| format!("git cat-file emitted a non-UTF-8 header: {error}"))?; - let mut fields = header.split(' '); - let reported_object = fields.next().unwrap_or_default(); - let kind = fields.next().unwrap_or_default(); - let size = fields - .next() - .ok_or_else(|| format!("git cat-file emitted malformed header `{header}`"))? - .parse::() - .map_err(|error| { - format!("git cat-file emitted malformed size in `{header}`: {error}") - })?; - if fields.next().is_some() || reported_object != entry.object || kind != "blob" { - return Err(format!( - "git cat-file reported `{header}` while `{}` was requested for `{}`", - entry.object, entry.path - )); - } + let size = parse_batch_blob_header(header, entry)?; let mut bytes = vec![0_u8; size]; output.read_exact(&mut bytes).map_err(|error| { format!("git blob `{}` for `{}`: {error}", entry.object, entry.path) @@ -530,6 +515,29 @@ fn read_tracked_blobs( Ok(entries) } +/// Decodes one bounded `git cat-file --batch` header for the requested blob. +/// +/// Each protocol field is independently binding: an extra field, another +/// object identity, or another object kind refuses even when the other two +/// facts agree with the request. +fn parse_batch_blob_header(header: &str, entry: &TrackedBlob) -> Result { + let mut fields = header.split(' '); + let reported_object = fields.next().unwrap_or_default(); + let kind = fields.next().unwrap_or_default(); + let size = fields + .next() + .ok_or_else(|| format!("git cat-file emitted malformed header `{header}`"))? + .parse::() + .map_err(|error| format!("git cat-file emitted malformed size in `{header}`: {error}"))?; + if fields.next().is_some() || reported_object != entry.object || kind != "blob" { + return Err(format!( + "git cat-file reported `{header}` while `{}` was requested for `{}`", + entry.object, entry.path + )); + } + Ok(size) +} + /// Splits one byte slice at its first named byte. fn split_once_byte(bytes: &[u8], separator: u8) -> Option<(&[u8], &[u8])> { let at = bytes.iter().position(|byte| *byte == separator)?; @@ -922,7 +930,7 @@ mod tests { RepositorySnapshot, TestRepositorySubject, parse_tracked_blobs, }; use crate::checks::hygiene::check_lf_and_no_symlinks; - use crate::repository::types::{LinkState, Read}; + use crate::repository::types::{CanonicalPath, LinkState, Read}; /// One isolated directory carrying no version-control storage. struct PlainDirectory { @@ -1181,8 +1189,11 @@ mod tests { fn ordinary_subject_child() -> Result<(), String> { match super::test_repository_subject()? { TestRepositorySubject::Ordinary(root) => { - let ordinary = super::repo_root().map_err(|error| error.to_string())?; - assert_eq!(root, &ordinary); + let manifest_directory = Path::new(env!("CARGO_MANIFEST_DIR")); + let expected = manifest_directory + .parent() + .ok_or_else(|| String::from("xtask manifest directory has no parent"))?; + assert_eq!(root, expected); Ok(()) } TestRepositorySubject::Mutation(_) => Err(String::from( @@ -1352,6 +1363,7 @@ mod tests { fixture.commit()?; let snapshot = fixture.snapshot()?; + assert_eq!(snapshot.files().count(), expected.len()); let found: BTreeSet<_> = snapshot .files() .iter() @@ -1361,6 +1373,30 @@ mod tests { Ok(()) } + /// Every `cat-file --batch` header field binds independently. A malformed + /// response cannot borrow agreement from its neighboring fields. + #[test] + fn batch_blob_headers_bind_every_protocol_field() -> Result<(), String> { + let expected = super::TrackedBlob { + path: CanonicalPath::spelled("tracked.txt"), + object: String::from("abcdef"), + link: LinkState::RegularFile, + }; + assert_eq!( + super::parse_batch_blob_header("abcdef blob 4", &expected)?, + 4 + ); + + for malformed in ["abcdef blob 4 extra", "different blob 4", "abcdef tree 4"] { + assert!( + super::parse_batch_blob_header(malformed, &expected) + .is_err_and(|refusal| refusal.contains(malformed)), + "malformed batch header `{malformed}` was accepted" + ); + } + Ok(()) + } + /// Git's symlink mode reaches the existing no-symlink law on every host. #[test] fn a_committed_symlink_mode_reaches_the_no_symlink_law() -> Result<(), String> {