diff --git a/.spelling b/.spelling index 73e9491c..1ea8e100 100644 --- a/.spelling +++ b/.spelling @@ -219,6 +219,7 @@ llvm-cov llvm-tools lockfile lockfiles +lookahead lookups M365PT macOS @@ -736,6 +737,7 @@ unbuildable unbuilt uncompilable uncompiled +uncommenting undecisive undelegated unescaping @@ -894,3 +896,28 @@ groupable memoization triaging lexically +OpenSSH +BLAKE3 +syscalls +environ +uClibc +uncensused +errno +neighbouring +mergeable +OSC +unsynchronized +hardcoding +reparse +unassociated +hardcoded +preallocation +coloured +unprovenanced +SGR +CSI +C1 +resize +multibyte +dereferences +prepasses diff --git a/Cargo.toml b/Cargo.toml index 2d11ae6e..45bbb8f4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -205,9 +205,19 @@ clippy.should_panic_without_expect = "allow" clippy.significant_drop_tightening = "allow" # Blocked by Clippy bug: https://github.com/rust-lang/rust-clippy/issues/15036 clippy.wildcard_imports = "allow" - # <<< anvil-managed: anvil-workspace-lints +# --- clippy: catalog entries this project adds outside the managed region --- +# The dotted-key form above keeps `[workspace.lints]` open, so these extend the +# same scope without editing the anvil-managed block and provoking its drift +# detector. They complete the `M-STATIC-VERIFICATION` catalog, which names all +# three: https://microsoft.github.io/rust-guidelines/guidelines/universal/#M-STATIC-VERIFICATION +clippy.empty_structs_with_brackets = "warn" +clippy.too_long_first_doc_paragraph = "warn" +# The guideline's own opt-out: a structured-logging call site legitimately +# passes a literal holding `{field}` placeholders for the logger to expand. +clippy.literal_string_with_formatting_args = "allow" + # A bit of debugging support for release builds. [profile.release] debug = "line-tables-only" diff --git a/crates/cargo-anvil/tests/recipe_contracts.rs b/crates/cargo-anvil/tests/recipe_contracts.rs index fb4d8349..be4e44bc 100644 --- a/crates/cargo-anvil/tests/recipe_contracts.rs +++ b/crates/cargo-anvil/tests/recipe_contracts.rs @@ -637,8 +637,6 @@ fn repository_constants_match_shared_anvil_versions() { // The legacy workflow's broad nightly follows rust-toolchain.toml, // while Anvil's general-purpose nightly has its own compatibility cadence. "rust_nightly", - // These bootstrap/repository-only tools are not managed by Anvil. - "cargo_workspaces_version", "just_version", "sccache_version", ]; diff --git a/crates/cargo-gamma-attrs-impl/docs/DESIGN.md b/crates/cargo-gamma-attrs-impl/docs/DESIGN.md index 02120d97..8a103b4f 100644 --- a/crates/cargo-gamma-attrs-impl/docs/DESIGN.md +++ b/crates/cargo-gamma-attrs-impl/docs/DESIGN.md @@ -14,6 +14,18 @@ attributes exported by `cargo-gamma-attrs`. logic outside rustc makes it directly testable and mutation-testable. - It accepts exactly one Rust expression where an attribute promises an expression and rejects unsupported keys or malformed selectors. +- A stated value is rejected on any function the tool would never mutate: a + declaration with no body, a `const fn`, or a function whose body is empty. + Accepting one there would leave a hint that reads as working and generates + nothing. +- Argument lists are split on their top-level commas and each argument is then + classified on its own, so an attribute accepts exactly the text the equivalent + `// gamma:` comment directive accepts. A positional timeout multiplier + therefore carries no positional meaning: it may sit before, between, or after + selectors, a `reason`, a `tag`, or a trailing comma. What it may not do is + appear twice — a second multiplier, in any spelling and in either order, is + refused rather than silently overriding the first, and the tool's directive + parser refuses the same text. - It returns the original item unchanged after validation. ## Stability diff --git a/crates/cargo-gamma-attrs-impl/src/implementation.rs b/crates/cargo-gamma-attrs-impl/src/implementation.rs index a213c7a3..fae56a6a 100644 --- a/crates/cargo-gamma-attrs-impl/src/implementation.rs +++ b/crates/cargo-gamma-attrs-impl/src/implementation.rs @@ -1,10 +1,12 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -use proc_macro2::{Delimiter, Literal, TokenStream, TokenTree}; +use core::mem; + +use proc_macro2::{Delimiter, Literal, Punct, Spacing, TokenStream, TokenTree}; use syn::parse::Parser as _; use syn::punctuated::Punctuated; -use syn::{Expr, ImplItemFn, ItemFn, Token, TraitItemFn}; +use syn::{Block, Expr, ImplItemFn, ItemFn, Token, TraitItemFn}; /// Validates the argument list of `#[gamma::]` and returns the item untouched. /// @@ -64,6 +66,14 @@ pub fn inert_timeout(name: &str, attr: &TokenStream, item: TokenStream) -> Token /// could only mean "every function beneath this states this value", and one expression essentially /// never type-checks as the body of more than one signature, so the inheriting reading would be a /// promise the compiler breaks at every use. +/// +/// Finally, it insists the function is one a mutant could be spliced into at all. A `const fn` body +/// is a const context throughout, and the guard a mutant sits behind is a run-time call no const +/// context may make; an empty body already evaluates to `()`, so every value written to replace it +/// yields the identical program. Collection returns before it ever reads the stated value in both +/// cases, so accepting them here would leave an attribute that reads as a working hint and produces +/// no mutant anywhere — the same silence the bodiless-declaration diagnostic already exists to +/// prevent. #[must_use] pub fn value(attr: TokenStream, item: TokenStream) -> TokenStream { match validate_value(attr, &item) { @@ -113,6 +123,7 @@ struct Frame { postfix: usize, casts: usize, operators: usize, + ladders: usize, previous: Previous, } @@ -121,7 +132,22 @@ struct Frame { /// Nested groups are walked with an explicit stack rather than recursion, because this code runs /// inside `rustc` while compiling user code: a proc macro that exhausts the stack takes the /// compiler with it. -fn exceeds_nesting_limit(stream: &TokenStream, limit: usize) -> bool { +/// +/// An `else if` ladder nests no delimiter more deeply than one `if` does — each arm's `{ }` block +/// closes before the next opens — yet `syn` parses it into an `ExprIf` chain one level deeper per +/// `else`, and drops that chain the same way. Delimiter depth alone would let a long enough ladder +/// through to overflow the stack instead of producing this guard's diagnostic. `ladders` counts +/// every `else` that follows a completed group at the same level, mirroring +/// `cargo_gamma_engine::parse::nesting`'s `ladders` counter and bounded by the same `postfix_limit` +/// as every other expression-path chain this walk already tracks. +/// +/// Exposed (hidden from docs) so `cargo-gamma-lib`'s agreement test can drive this scanner with +/// the same source-text corpus it drives `cargo_gamma_engine::parse::exceeds_nesting_limit` with — +/// the only way to establish that a syntax family accepted by one is not silently rejected, or +/// accepted one level later, by the other. +#[doc(hidden)] +#[must_use] +pub fn exceeds_nesting_limit(stream: &TokenStream, limit: usize) -> bool { let postfix_limit = limit.saturating_mul(CHAIN_FACTOR); let mut frames = vec![Frame { iter: stream.clone().into_iter(), @@ -129,6 +155,7 @@ fn exceeds_nesting_limit(stream: &TokenStream, limit: usize) -> bool { postfix: 0, casts: 0, operators: 0, + ladders: 0, previous: Previous::Other, }]; @@ -155,8 +182,9 @@ fn exceeds_nesting_limit(stream: &TokenStream, limit: usize) -> bool { return true; } - // A complete group can be the receiver of the next call or index. The child - // gets a fresh postfix chain because only adjacent links share one expression. + // A complete group can be the receiver of the next call or index, or the `{ }` + // block an `else` ladder continues from. The child gets a fresh postfix chain + // because only adjacent links share one expression. frame.previous = Previous::Expression; frames.push(frame); frames.push(Frame { @@ -165,6 +193,7 @@ fn exceeds_nesting_limit(stream: &TokenStream, limit: usize) -> bool { postfix: 0, casts: 0, operators: 0, + ladders: 0, previous: Previous::Other, }); break; @@ -181,6 +210,19 @@ fn exceeds_nesting_limit(stream: &TokenStream, limit: usize) -> bool { frame.previous = Previous::Other; } + TokenTree::Ident(ident) if ident == "else" && frame.previous == Previous::Expression => { + // Reached only right after a completed group, which is what an `else` following + // an `if`'s or a prior arm's `{ }` block looks like at the token-stream level. + frame.postfix = 0; + frame.ladders += 1; + + if frame.ladders > postfix_limit { + return true; + } + + frame.previous = Previous::Other; + } + TokenTree::Ident(_) | TokenTree::Literal(_) => frame.previous = Previous::Expression, TokenTree::Punct(punct) => { @@ -210,6 +252,7 @@ fn exceeds_nesting_limit(stream: &TokenStream, limit: usize) -> bool { if matches!(punct.as_char(), ',' | ';') { frame.casts = 0; frame.operators = 0; + frame.ladders = 0; } frame.previous = if matches!(punct.as_char(), '?' | '>') { @@ -253,17 +296,31 @@ fn validate_value(attr: TokenStream, item: &TokenStream) -> Result<(), String> { }; } - if !is_function(item) { + let Some(parsed) = parse_function(item) else { return Err("expected a function or method; a value stated on an `impl` block or a module would have to type-check as the body of every function beneath it".to_owned()); - } + }; - if !has_a_body(item) { + if !parsed.has_a_body() { return Err( "expected a function with a body; a declaration has none to replace, and a value is not inherited by the implementations of a trait method" .to_owned(), ); } + if parsed.constant { + return Err( + "expected a function that can carry a mutant; a mutant is spliced in behind a run-time guard call, which no `const fn` body may make, so this value would replace nothing" + .to_owned(), + ); + } + + if matches!(parsed.body, Some(Body::Empty)) { + return Err( + "expected a function with something to replace; an empty body already evaluates to `()`, so a mutant substituting this value would be the identical program and no test could detect it" + .to_owned(), + ); + } + if states_a_value(item) { return Err("an item may state one value; two would leave which of them applies to the order they were expanded in".to_owned()); } @@ -271,27 +328,85 @@ fn validate_value(attr: TokenStream, item: &TokenStream) -> Result<(), String> { Ok(()) } -/// Returns whether an item is a function, a method, or a trait method. +/// What an item carrying `#[gamma::value]` turned out to be, reduced to the questions asked of it. /// -/// All three are asked, because the same attribute is written in all three positions and only the -/// grammar differs: a free function is an `ItemFn`, a method an `ImplItemFn`, and a trait method a -/// `TraitItemFn` whose body may be absent entirely. -fn is_function(item: &TokenStream) -> bool { - // #[gamma::skip(logical.or_to_and, reason = "syn accepts every function-with-body form through the adjacent item and method parsers alike; the only distinct form is a bodyless trait declaration, which reaches the final parser unchanged")] - syn::parse2::(item.clone()).is_ok() - || syn::parse2::(item.clone()).is_ok() - || syn::parse2::(item.clone()).is_ok() +/// A free function is an `ItemFn`, a method an `ImplItemFn`, and a trait method a `TraitItemFn` +/// whose body may be absent entirely — the one shape among the three with nothing to replace. The +/// three grammars differ only in where the signature and the body sit, and every question asked +/// below is about one of those two, so the parse is reduced to the answers here rather than kept as +/// three variants each caller would have to match on again. +struct ParsedFunction { + /// Whether the function is `const`, and so a const context throughout. + constant: bool, + + /// The body, when the function has one at all. + body: Option, +} + +/// Whether a function's body holds anything a stated value would displace. +enum Body { + /// `{ }`, which already evaluates to `()` whatever is written to replace it. + Empty, + + /// At least one statement, which is what a substituted value takes the place of. + Statements, +} + +impl ParsedFunction { + /// Returns whether the parsed function has a body to replace. + /// + /// A trait method may be a declaration ending in `;`, and a value stated there would + /// substitute nothing anywhere: the value is not inherited by the implementations, for the + /// same reason it is not inherited from an `impl` block. The other two forms always carry a + /// body — that is what makes them a function definition rather than a declaration. + fn has_a_body(&self) -> bool { + self.body.is_some() + } } -/// Returns whether a function has a body to replace. +/// Parses an item as whichever of the three function grammars accepts it, so that later questions +/// about its shape — whether it is a function at all, whether it has a body, whether that body is +/// empty, and whether it is `const` — are answered from the one parse rather than parsing the +/// complete item again for each. /// -/// A trait method may be a declaration ending in `;`, and a value stated there would substitute -/// nothing anywhere: the value is not inherited by the implementations, for the same reason it is -/// not inherited from an `impl` block. Saying so is better than expanding to an attribute that -/// reads as a working hint and generates no mutant. +/// All three are tried, in this order, because the same attribute is written in all three +/// positions and only the grammar differs. +fn parse_function(item: &TokenStream) -> Option { + if let Ok(function) = syn::parse2::(item.clone()) { + return Some(ParsedFunction { + constant: function.sig.constness.is_some(), + body: Some(body_of(&function.block)), + }); + } + + if let Ok(method) = syn::parse2::(item.clone()) { + return Some(ParsedFunction { + constant: method.sig.constness.is_some(), + body: Some(body_of(&method.block)), + }); + } + + let declared = syn::parse2::(item.clone()).ok()?; + + Some(ParsedFunction { + constant: declared.sig.constness.is_some(), + body: declared.default.as_ref().map(body_of), + }) +} + +/// Classifies a body by whether it holds any statement at all. +fn body_of(block: &Block) -> Body { + if block.stmts.is_empty() { Body::Empty } else { Body::Statements } +} + +#[cfg(test)] +fn is_function(item: &TokenStream) -> bool { + parse_function(item).is_some() +} + +#[cfg(test)] fn has_a_body(item: &TokenStream) -> bool { - // #[gamma::skip(literal.bool_flip, reason = "validate_value calls this only after is_function; every function form with a body parses as a TraitItemFn, so the parse-error default is unreachable for supported input")] - syn::parse2::(item.clone()).map_or(true, |method| method.default.is_some()) + parse_function(item).is_some_and(|parsed| parsed.has_a_body()) } /// Returns whether an item still carries a `#[gamma::value(...)]` attribute of its own. @@ -387,13 +502,40 @@ fn is_bounded_multiplier(value: f64) -> bool { value > 0.0 && value.is_finite() && value <= MOST_FACTOR } -/// Checks that a timeout multiplier attribute has at least one argument and validates its contents. -fn validate_timeout_multiplier(attr: &TokenStream) -> Result<(), String> { - if attr.is_empty() { - return Err("expected a timeout multiplier, as in `#[gamma::test_timeout_multiplier(2.0)]`".to_owned()); +/// Splits an attribute argument list on its top-level commas. +/// +/// Empty segments are dropped, so a trailing comma — and the `a,,b` a careless edit leaves behind — +/// mean here exactly what they mean to the comment-directive parser in `cargo-gamma-lib`, which +/// flushes a comma-delimited argument only when it holds tokens. +/// +/// Only top-level commas separate: a comma inside a group belongs to whatever that group is, and +/// splitting on it would tear one argument in half. +fn arguments_of(attr: TokenStream) -> Vec> { + let mut arguments: Vec> = Vec::new(); + let mut current: Vec = Vec::new(); + + for tree in attr { + if matches!(&tree, TokenTree::Punct(punct) if punct.as_char() == ',') { + if !current.is_empty() { + arguments.push(mem::take(&mut current)); + } + + continue; + } + + current.push(tree); } - let positional = match attr.clone().into_iter().next() { + if !current.is_empty() { + arguments.push(current); + } + + arguments +} + +/// Returns whether an argument is written as a bare number rather than as a key or a selector. +fn is_positional_multiplier(argument: &[TokenTree]) -> bool { + match argument.first() { Some(TokenTree::Literal(_)) => true, Some(TokenTree::Punct(punct)) => matches!(punct.as_char(), '+' | '-'), // `inf`, `nan`, and `infinity` tokenize as identifiers, not literals, so without this arm @@ -404,20 +546,115 @@ fn validate_timeout_multiplier(attr: &TokenStream) -> Result<(), String> { // rejects it later, or an unbounded factor reaches `Duration::mul_f64`. Some(TokenTree::Ident(ident)) => ident.to_string().parse::().is_ok(), _ => false, - }; + } +} + +/// Checks that a timeout multiplier attribute has at least one argument and validates its contents. +/// +/// The list is split on its top-level commas before any of it is read as a number, because the +/// comment-directive parser this attribute shares a grammar with splits on them too. Parsing the +/// whole stream as one `f64` instead refused `2.5,` and `3.0, reason = "slow"` — text the directive +/// channel accepts — so deleting the `//` in front of a working directive turned it into a compile +/// error whose message blamed the numeric bound rather than the comma that actually confused it. +/// +/// Position carries no meaning: every argument is classified on its own, exactly as the directive +/// parser classifies each of its comma-delimited segments, so `arith, 2.5` states a multiplier just +/// as `2.5, arith` does. Reading only the first argument as a number made the identical selector +/// list a compile error on one channel and an ordinary directive on the other, which is the same +/// defect the comma split above was introduced to remove. +/// +/// Whatever is not a positional multiplier is the ordinary selector-and-setting grammar, read in +/// the same strict mode the keyed spelling is read in and told whether a multiplier has already +/// been stated, so that a second one — in any spelling, in either order — is refused rather than +/// silently overriding the first. +fn validate_timeout_multiplier(attr: &TokenStream) -> Result<(), String> { + let arguments = arguments_of(attr.clone()); + + if arguments.is_empty() { + return Err("expected a timeout multiplier, as in `#[gamma::test_timeout_multiplier(2.0)]`".to_owned()); + } + + let mut stated = false; + let mut rest = TokenStream::new(); + + for argument in &arguments { + if is_positional_multiplier(argument) { + // Concatenated from the tokens of this argument alone, rather than rendered from the + // whole stream, so a sign and its digits stay one number and the arguments around it + // stay out of it. + let written: String = argument.iter().map(ToString::to_string).collect(); + + match written.parse::() { + Ok(value) if is_bounded_multiplier(value) => {} + _ => { + return Err(format!( + "timeout multiplier must be a positive number no greater than {MOST_FACTOR}" + )); + } + } + + if stated { + return Err(DUPLICATE_MULTIPLIER.to_owned()); + } + + stated = true; + + continue; + } + + if !rest.is_empty() { + rest.extend(core::iter::once(TokenTree::Punct(Punct::new(',', Spacing::Alone)))); + } + + rest.extend(argument.iter().cloned()); + } - if !positional { - return validate(attr.clone()); + if rest.is_empty() { + return Ok(()); } - match attr.to_string().parse::() { - Ok(value) if is_bounded_multiplier(value) => Ok(()), - _ => Err(format!( - "timeout multiplier must be a positive number no greater than {MOST_FACTOR}" - )), + validate_shape(rest, if stated { Reading::AfterMultiplier } else { Reading::Multiplier }) +} + +/// Reported when an argument list states more than one timeout multiplier. +/// +/// One item has one timeout, so a second multiplier can only mean the author believes something +/// other than what will happen. Silently keeping either one hides that; refusing says which +/// argument to delete. `cargo-gamma-lib`'s directive parser refuses the same text for the same +/// reason, so uncommenting a directive cannot change the verdict. +const DUPLICATE_MULTIPLIER: &str = "a timeout multiplier is stated a second time; only one may apply to an item"; + +/// How strictly one argument list is read. +#[derive(Clone, Copy, Eq, PartialEq)] +enum Reading { + /// A `#[gamma::skip]`-family attribute, which carries no number for validation to protect. + Selectors, + + /// A timeout multiplier's remaining arguments, none of which has stated one positionally. + Multiplier, + + /// A timeout multiplier's remaining arguments, one of which was a positional multiplier — + /// wherever in the list it sat. + AfterMultiplier, +} + +impl Reading { + /// Returns whether a bare literal or a leading sign is a malformed multiplier rather than an + /// unrecognized selector token. + const fn is_strict(self) -> bool { + !matches!(self, Self::Selectors) } } +/// Checks the structural shape of a `#[gamma::skip]`-family argument list. +/// +/// A thin wrapper over [`validate_shape`] in [`Reading::Selectors`] mode: a selector attribute has +/// no numeric argument to protect, so every bare literal or sign a timeout multiplier would refuse +/// is left alone here. +fn validate(attr: TokenStream) -> Result<(), String> { + validate_shape(attr, Reading::Selectors) +} + /// Checks the structural shape of an argument list. /// /// Selector *names* are not checked here: the registry lives in the tool, and duplicating it in a @@ -426,14 +663,29 @@ fn validate_timeout_multiplier(attr: &TokenStream) -> Result<(), String> { /// likely to get wrong without noticing — a `reason` or `tag` that is not a string, or a timeout /// multiplier that is not a positive number. /// +/// `reading` is strict only for a timeout multiplier's grammar, which mixes selectors with at most +/// one numeric setting. There a bare literal, a leading sign, or a second multiplier key is never a +/// selector that merely was not recognized — it is exactly the malformed multiplier this validation +/// exists to catch, so it is rejected here rather than silently walked past. A selector attribute +/// passes [`Reading::Selectors`], because none of its arguments carry a number for this to protect, +/// and [`Reading::AfterMultiplier`] starts out having already seen one so that a keyed multiplier +/// following a positional one is the duplicate it is. +/// /// Nested groups are walked with an explicit stack rather than by recursion. The nesting is /// whatever the user wrote inside an attribute, and this code runs inside `rustc` while their /// crate is being compiled: a proc macro that exhausts the stack takes the compiler with it, and /// presents as a crash nobody would think to blame on a parenthesis in an attribute argument. /// Depth on the heap has no such cliff, and the traversal order is unchanged, so a file with more /// than one malformed argument still reports the first one. -fn validate(attr: TokenStream) -> Result<(), String> { +/// +/// Each level materializes its tokens into a `Vec` rather than walking a bare iterator, because +/// the lookahead a few keys away (`trees.get(index + 1)` and beyond) needs random access within a +/// level, not just the next token. An attribute argument list is small enough that a hand-rolled +/// bounded-lookahead cursor would not pay for the churn at every lookahead site below. +fn validate_shape(attr: TokenStream, reading: Reading) -> Result<(), String> { + let strict = reading.is_strict(); let mut frames: Vec<(Vec, usize)> = vec![(attr.into_iter().collect(), 0)]; + let mut multiplier_seen = reading == Reading::AfterMultiplier; 'frames: while let Some((trees, mut index)) = frames.pop() { while index < trees.len() { @@ -479,6 +731,23 @@ fn validate(attr: TokenStream) -> Result<(), String> { _ => return Err(format!("`{key}` must be a positive number no greater than {MOST_FACTOR}")), } + if strict { + if multiplier_seen { + // Which of the two came first in the source is not recoverable here — + // a positional multiplier is lifted out of the list before this pass + // runs — and it does not matter: both orders are the same mistake, so + // both get the same order-free wording rather than one that blames + // whichever argument this pass happened to reach. + return Err(if reading == Reading::AfterMultiplier { + format!("a timeout multiplier is stated both on its own and as `{key}`; only one may apply to an item") + } else { + format!("`{key}` states a timeout multiplier a second time; only one may apply to an item") + }); + } + + multiplier_seen = true; + } + if trees .get(index + 3) .is_some_and(|tree| !matches!(tree, TokenTree::Punct(punct) if punct.as_char() == ',')) @@ -491,6 +760,24 @@ fn validate(attr: TokenStream) -> Result<(), String> { } } + if strict { + if let TokenTree::Literal(literal) = &trees[index] { + return Err(format!( + "unexpected `{literal}`; a timeout multiplier must be written as `test_timeout_multiplier = `, or given alone" + )); + } + + if let TokenTree::Punct(punct) = &trees[index] + && matches!(punct.as_char(), '+' | '-') + && matches!(trees.get(index + 1), Some(TokenTree::Literal(_))) + { + return Err( + "unexpected signed number; a timeout multiplier must be written as `test_timeout_multiplier = `, or given alone" + .to_owned(), + ); + } + } + if let TokenTree::Group(group) = &trees[index] && group.delimiter() == Delimiter::Parenthesis { @@ -857,7 +1144,7 @@ mod tests { "core::iter::once(1).collect()", ] { assert_eq!( - validate_value(stream(expression), &stream("fn f() {}")), + validate_value(stream(expression), &stream("fn f() -> u32 { 1 }")), Ok(()), "`{expression}` should have been accepted" ); @@ -984,6 +1271,15 @@ mod tests { let _ = syn::parse2::(declaration).unwrap(); } + #[test] + fn an_impl_only_method_shape_is_parsed() { + let method = stream("default fn f(&self) -> u32 { 1 }"); + + let _not_an_item = syn::parse2::(method.clone()).unwrap_err(); + let _method = syn::parse2::(method.clone()).unwrap(); + assert_eq!(validate_value(stream("0"), &method), Ok(())); + } + /// A value stated on an `impl` block or a module would have to mean "every function beneath /// this returns this", and one expression essentially never type-checks as more than one /// signature's body. Rejecting it is what keeps inheritance from being invented by accident. @@ -1045,6 +1341,53 @@ mod tests { assert!(rejected.contains("a declaration has none"), "{rejected}"); } + /// A `const fn` body is a const context throughout, and the guard a mutant is spliced in behind + /// is a run-time call no const context may make. Collection returns before it ever reads the + /// stated value there, so accepting the attribute would leave a hint that reads as working and + /// generates nothing — the same silence the bodiless-declaration diagnostic above prevents. + /// + /// All three function grammars are covered, because the attribute is written in all three + /// positions and only one of them is an `ItemFn`. + #[test] + fn a_value_stated_on_a_const_function_is_rejected() { + for item in [ + "const fn f() -> u32 { 1 }", + "pub const fn f() -> u32 { 1 }", + "const unsafe fn f(&self) -> u32 { self.n }", + ] { + let rejected = validate_value(stream("0"), &stream(item)).expect_err("a const function can carry no mutant"); + + assert!(rejected.contains("no `const fn` body may make"), "`{item}`: {rejected}"); + } + } + + /// An empty body already evaluates to `()`, so a mutant substituting a value for it is the + /// identical program and no test could ever tell the two apart. Reporting it as a survivor + /// would be an accusation against the suite for something nothing could detect, so the + /// attribute is refused rather than silently ignored. + #[test] + fn a_value_stated_on_an_empty_bodied_function_is_rejected() { + for item in ["fn f() {}", "fn f(&self) {}", "fn f(&self) -> () { }"] { + let rejected = validate_value(stream("0"), &stream(item)).expect_err("an empty body has nothing to replace"); + + assert!(rejected.contains("an empty body already evaluates to `()`"), "`{item}`: {rejected}"); + } + } + + /// The two inert forms are refused, but nothing near them is: an ordinary function that merely + /// mentions `const` in its body, and a `const` *item* holding a closure, both stay acceptable. + /// A guard that keyed off the token `const` anywhere in the item would reject the first. + #[test] + fn a_function_that_can_carry_a_mutant_is_still_accepted() { + for item in [ + "fn f() -> u32 { const N: u32 = 1; N }", + "async fn f() -> u32 { 1 }", + "fn f(&self) -> u32 { self.n }", + ] { + assert_eq!(validate_value(stream("0"), &stream(item)), Ok(()), "`{item}` can carry a mutant"); + } + } + /// A rejected value still leaves the item behind, for the same reason a malformed suppression /// does: one diagnostic about the attribute beats a pile about the missing function. #[test] @@ -1111,11 +1454,40 @@ mod tests { ); // A leading group (here a parenthesized list) is neither a literal, a sign, nor an // identifier, so it falls through the positional check's wildcard arm exactly as a - // non-numeric leading identifier does, and the whole attribute is handed to the general - // structural validator instead. That validator only inspects identifier keys and bare - // groups it recurses into, so a lone parenthesized literal — matching none of the - // recognized keys — is accepted. - assert_eq!(validate_timeout_multiplier(&stream("(2.5)")), Ok(())); + // non-numeric leading identifier does, and the argument reaches the fallback validator's + // strict mode. There a bare literal is never a selector that merely was not recognized — + // it is exactly the malformed multiplier this validation exists to catch, so the lone + // parenthesized literal is rejected rather than silently walked past. + assert_eq!( + validate_timeout_multiplier(&stream("(2.5)")), + Err( + "unexpected `2.5`; a timeout multiplier must be written as `test_timeout_multiplier = `, or given alone".to_owned() + ) + ); + // A number that shares an argument with a selector, rather than occupying one of its own, + // is separated from it by nothing at all — no comma the directive parser could split on. + // It is therefore not a positional multiplier but a stray token inside a selector, and the + // strict shape check is what catches it. + assert_eq!( + validate_timeout_multiplier(&stream("arith -1.0")), + Err( + "unexpected signed number; a timeout multiplier must be written as `test_timeout_multiplier = `, or given alone" + .to_owned() + ) + ); + assert_eq!( + validate_timeout_multiplier(&stream("arith 2.5")), + Err( + "unexpected `2.5`; a timeout multiplier must be written as `test_timeout_multiplier = `, or given alone".to_owned() + ) + ); + // Two multiplier keys — even under different aliases — leave which one applies to the + // order they were expanded in, exactly the ambiguity `#[gamma::value]`'s duplicate check + // exists to prevent for its own attribute. + assert_eq!( + validate_timeout_multiplier(&stream("factor = 2.0, multiplier = 3.0")), + Err("`multiplier` states a timeout multiplier a second time; only one may apply to an item".to_owned()) + ); // `inf`, `nan`, and `infinity` arrive as identifiers rather than literals and parse as // non-finite floats, so they slip past the literal check; they must be refused like any // other out-of-range multiplier rather than mistaken for a bare selector and accepted. @@ -1126,6 +1498,116 @@ mod tests { validate_timeout_multiplier(&stream("")), Err("expected a timeout multiplier, as in `#[gamma::test_timeout_multiplier(2.0)]`".to_owned()) ); + // Commas and nothing else state no multiplier either, and reading them as an empty + // selector list would let `#[gamma::test_timeout_multiplier(,)]` compile clean while + // stating nothing at all. + assert_eq!( + validate_timeout_multiplier(&stream(",")), + Err("expected a timeout multiplier, as in `#[gamma::test_timeout_multiplier(2.0)]`".to_owned()) + ); + } + + /// The attribute and the comment directive are deliberately the same text with `//` in front, + /// so an argument list one accepts must not be a compile error to the other. Reading the whole + /// token stream as one `f64` made both of these one: `2.5 ,` and `3.0 , reason = "slow"` parse + /// as no number at all, and the message blamed the numeric bound rather than the comma. + /// + /// `cargo-gamma-lib`'s agreement test drives both channels with this same argument text; this + /// pins the attribute side on its own, so a regression here is reported by the crate that owns + /// the parser rather than only by the crate that compares the two. + #[test] + fn a_positional_multiplier_may_be_followed_by_a_comma_and_by_further_arguments() { + for arguments in [ + "2.5,", + "3.0, reason = \"slow\"", + "3.0, reason = \"slow\",", + "2.5, tag = \"integration\"", + "2.5, arith", + "2.5, arith, reason = \"complex math\"", + // Position carries no meaning: a multiplier stated after its selectors is the same + // directive as one stated before them, and the tool's own test suite has read + // `#[gamma::test_timeout_multiplier(arith, 4.0)]` that way all along. + "arith, 2.5", + "arith, 2.5,", + "arith, 2.5, reason = \"complex math\"", + "reason = \"slow\", 2.5", + "arith, literal, 2.5", + ] { + assert_eq!( + validate_timeout_multiplier(&stream(arguments)), + Ok(()), + "`{arguments}` should have been accepted" + ); + } + } + + /// The multiplier itself is still read from its own argument rather than from everything up to + /// the end of the list, so a bad one followed by a well-formed `reason` is refused for being a + /// bad multiplier — not accepted because something after it parsed. + #[test] + fn a_bad_positional_multiplier_is_still_refused_when_arguments_follow_it() { + for arguments in [ + "-1.0, reason = \"slow\"", + "0, reason = \"slow\"", + "inf, reason = \"slow\"", + "1e300,", + // Late, too: a bad multiplier is a bad multiplier wherever in the list it sits, and + // the arguments before it neither excuse it nor change the message. + "arith, -1.0", + "reason = \"slow\", 0", + "arith, inf", + ] { + assert_eq!( + validate_timeout_multiplier(&stream(arguments)), + Err("timeout multiplier must be a positive number no greater than 1000000".to_owned()), + "`{arguments}` should have been refused" + ); + } + } + + /// A positional multiplier is a stated multiplier, so a second one is the same ambiguity two + /// keyed multipliers are — whichever spelling either arrives in, and in whichever order. + /// + /// The directive parser refuses the same six argument lists, so this is a rejection a user can + /// reach from either channel rather than a rule the attribute alone enforces. + #[test] + fn a_second_multiplier_is_rejected_in_every_spelling_and_order() { + let positional = Err(DUPLICATE_MULTIPLIER.to_owned()); + + assert_eq!(validate_timeout_multiplier(&stream("2.0, 3.0")), positional); + assert_eq!(validate_timeout_multiplier(&stream("2.0, arith, 3.0")), positional); + assert_eq!(validate_timeout_multiplier(&stream("2.0, 3.0, 4.0")), positional); + + assert_eq!( + validate_timeout_multiplier(&stream("2.0, factor = 3.0")), + Err("a timeout multiplier is stated both on its own and as `factor`; only one may apply to an item".to_owned()) + ); + // The mixed form in the other order is the same mistake and gets the same message: which + // of the two was written first does not change that one of them has to go. + assert_eq!( + validate_timeout_multiplier(&stream("factor = 2.0, 3.0")), + Err("a timeout multiplier is stated both on its own and as `factor`; only one may apply to an item".to_owned()) + ); + assert_eq!( + validate_timeout_multiplier(&stream("test_timeout_multiplier = 2.0, arith, 3.0")), + Err( + "a timeout multiplier is stated both on its own and as `test_timeout_multiplier`; only one may apply to an item".to_owned() + ) + ); + } + + /// The arguments after a positional multiplier are read in the same strict mode the keyed + /// spelling is read in, so a malformed `reason` there is caught rather than walked past. + #[test] + fn arguments_after_a_positional_multiplier_are_still_validated() { + assert_eq!( + validate_timeout_multiplier(&stream("2.5, reason = performance")), + Err("`reason` must be a string literal".to_owned()) + ); + assert_eq!( + validate_timeout_multiplier(&stream("2.5, tag(\"x\")")), + Err("`tag` must be written as `tag = \"...\"`".to_owned()) + ); } /// Deeply nested expressions or items are rejected before syn's recursive descent parser can @@ -1181,4 +1663,26 @@ mod tests { assert_eq!(error, "expression nests too deeply to be safely parsed"); } + + /// An `else if` ladder nests no delimiter more deeply than one `if` does — every arm's block + /// closes before the next one opens — yet `syn` parses and drops it as a chain one `ExprIf` + /// deeper per `else`. A guard that only counted delimiter depth would let a long enough ladder + /// reach `syn` and overflow the compiler's stack instead of producing this diagnostic. + #[test] + fn a_long_else_if_ladder_is_rejected_by_the_guard() { + let ladder = format!("if true {{ 1 }}{}", " else if true { 1 }".repeat(NESTING_LIMIT * CHAIN_FACTOR + 1)); + let item = format!("fn f() -> i32 {{ {ladder} else {{ 1 }} }}"); + + let error = validate_value(stream("0"), &stream(&item)).expect_err("a deep else-if ladder must be rejected"); + + assert_eq!(error, "item nests too deeply to be safely parsed"); + } + + /// A short ladder is ordinary code and must not be mistaken for the pathological case above. + #[test] + fn a_short_else_if_ladder_is_accepted() { + let item = "fn f() -> i32 { if true { 1 } else if false { 2 } else { 3 } }"; + + assert_eq!(validate_value(stream("0"), &stream(item)), Ok(())); + } } diff --git a/crates/cargo-gamma-attrs-impl/src/lib.rs b/crates/cargo-gamma-attrs-impl/src/lib.rs index dd9765a6..c7ec141c 100644 --- a/crates/cargo-gamma-attrs-impl/src/lib.rs +++ b/crates/cargo-gamma-attrs-impl/src/lib.rs @@ -46,4 +46,5 @@ mod implementation; -pub use implementation::{CHAIN_FACTOR, MOST_FACTOR, NESTING_LIMIT, inert, inert_timeout, value}; +#[doc(inline)] +pub use implementation::{CHAIN_FACTOR, MOST_FACTOR, NESTING_LIMIT, exceeds_nesting_limit, inert, inert_timeout, value}; diff --git a/crates/cargo-gamma-attrs/src/lib.rs b/crates/cargo-gamma-attrs/src/lib.rs index dcf65246..a4402011 100644 --- a/crates/cargo-gamma-attrs/src/lib.rs +++ b/crates/cargo-gamma-attrs/src/lib.rs @@ -432,8 +432,11 @@ pub fn expect_killed(attr: TokenStream, item: TokenStream) -> TokenStream { /// from an `impl` block or a module, and stating one on either is a compile error: a single /// expression essentially never type-checks as the body of every function beneath it. For the same /// reason it cannot be stated on a trait method that is only declared — there is no body to replace, -/// and the implementations do not inherit it. `const fn` bodies and empty bodies are never mutated, -/// so a value stated on one is honoured by nothing. +/// and the implementations do not inherit it. A `const fn` body and an empty body are refused for +/// the same reason: collection never reaches the stated value on either, so the attribute would be +/// a hint that reads as working and produces nothing. A mutant is spliced in behind a run-time +/// guard call that no `const fn` body may make, and an empty body already evaluates to `()`, so a +/// value substituted for it would be the identical program. /// /// Nothing is taken on trust. The stated expression becomes an ordinary mutant, and if it does not /// type-check it is withdrawn exactly as any other unviable mutant is — one rollback round, no @@ -515,6 +518,22 @@ pub fn expect_killed(attr: TokenStream, item: TokenStream) -> TokenStream { /// fn at(&self) -> usize; /// } /// ``` +/// +/// ```compile_fail +/// // A `const fn` body is a const context throughout, and the guard a mutant is spliced in behind +/// // is a run-time call, so nothing would ever be substituted here. +/// #[gamma::value(0)] +/// const fn budget() -> u32 { +/// 512 +/// } +/// ``` +/// +/// ```compile_fail +/// // An empty body already evaluates to `()`, so substituting a value for it would be the same +/// // program and no test could tell the two apart. +/// #[gamma::value(())] +/// fn nothing() {} +/// ``` #[proc_macro_attribute] pub fn value(attr: TokenStream, item: TokenStream) -> TokenStream { cargo_gamma_attrs_impl::value(attr.into(), item.into()).into() @@ -539,8 +558,26 @@ pub fn value(attr: TokenStream, item: TokenStream) -> TokenStream { /// fn compute_hash(seed: u64) -> u64 { /// seed.wrapping_mul(6364136223846793005).wrapping_add(1) /// } +/// +/// // A positional multiplier is an argument like any other, so selectors, a `reason`, a `tag`, and +/// // a trailing comma may follow it — the same text the equivalent `// gamma:` directive accepts. +/// #[gamma::test_timeout_multiplier(3.0, reason = "hashes a megabyte")] +/// fn digest(data: &[u8]) -> u64 { +/// data.iter().map(|b| u64::from(*b)).sum() +/// } +/// +/// // Position carries no meaning: each argument is read on its own, so a multiplier written after +/// // its selectors states the same thing as one written before them. +/// #[gamma::test_timeout_multiplier(arith, 2.5, reason = "widening arithmetic is slow here")] +/// fn accumulate(data: &[u8]) -> u64 { +/// data.iter().fold(0, |total, b| total + u64::from(*b)) +/// } /// ``` /// +/// Exactly one multiplier applies to an item, so stating a second one — in any spelling, and in +/// either order — is a compile error rather than a silent override. The tool's directive scanner +/// refuses the same text, so commenting the attribute out does not change the verdict. +/// /// A malformed multiplier is a compile error, so a mistyped budget fails loudly at build time /// rather than being quietly rejected later by the tool's directive scanner — or worse, reaching /// `Duration::mul_f64` unbounded. As with the suppression and value macros, these examples are @@ -570,6 +607,57 @@ pub fn value(attr: TokenStream, item: TokenStream) -> TokenStream { /// data.len() /// } /// ``` +/// +/// ```compile_fail +/// // Wherever in the list it sits, a multiplier still has to bound a timeout: the selectors before +/// // it neither excuse it nor turn it into one of their own. +/// #[gamma::test_timeout_multiplier(arith, -1.0)] +/// fn heavy(data: &[u8]) -> usize { +/// data.len() +/// } +/// ``` +/// +/// ```compile_fail +/// // A lone parenthesized number: grouping does not turn a bare literal into a named multiplier. +/// #[gamma::test_timeout_multiplier((2.5))] +/// fn heavy(data: &[u8]) -> usize { +/// data.len() +/// } +/// ``` +/// +/// ```compile_fail +/// // Two multiplier keys, even under different spellings, leave which one applies to the order +/// // they were expanded in. +/// #[gamma::test_timeout_multiplier(factor = 2.0, multiplier = 3.0)] +/// fn heavy(data: &[u8]) -> usize { +/// data.len() +/// } +/// ``` +/// +/// ```compile_fail +/// // Trailing tokens after a named value are not a second argument; the value ends at its literal. +/// #[gamma::test_timeout_multiplier(test_timeout_multiplier = 2.0 + 1.0)] +/// fn heavy(data: &[u8]) -> usize { +/// data.len() +/// } +/// ``` +/// +/// ```compile_fail +/// // A positional multiplier states one, so a named one beside it is the same ambiguity two named +/// // ones are — in either order. +/// #[gamma::test_timeout_multiplier(2.0, factor = 3.0)] +/// fn heavy(data: &[u8]) -> usize { +/// data.len() +/// } +/// ``` +/// +/// ```compile_fail +/// // Two positional multipliers: neither is named, so there is nothing to prefer between them. +/// #[gamma::test_timeout_multiplier(2.0, 3.0)] +/// fn heavy(data: &[u8]) -> usize { +/// data.len() +/// } +/// ``` #[proc_macro_attribute] pub fn test_timeout_multiplier(attr: TokenStream, item: TokenStream) -> TokenStream { cargo_gamma_attrs_impl::inert_timeout("test_timeout_multiplier", &attr.into(), item.into()).into() diff --git a/crates/cargo-gamma-attrs/tests/consumer.rs b/crates/cargo-gamma-attrs/tests/consumer.rs new file mode 100644 index 00000000..4960f2c4 --- /dev/null +++ b/crates/cargo-gamma-attrs/tests/consumer.rs @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Exercises every exported attribute macro from an external consuming crate. +//! +//! A doctest inside this crate proves an expansion compiles; it does not prove the annotated item +//! survived intact, because a valid doctest's example is never called. These tests call the item +//! each macro annotates, so a shim that discarded the item, swapped its delegation for an +//! unrelated validator, or otherwise mangled a valid expansion would fail here even where every +//! existing doctest stays green. + +#[gamma::skip] +fn skipped(x: u32) -> u32 { + x + 1 +} + +#[test] +fn skip_leaves_the_annotated_item_callable() { + assert_eq!(skipped(41), 42); +} + +#[gamma::expect_survived(literal, reason = "consumer test fixture")] +fn survived(n: usize) -> String { + format!("{n} items") +} + +#[test] +fn expect_survived_leaves_the_annotated_item_callable() { + assert_eq!(survived(3), "3 items"); +} + +#[gamma::expect_killed] +fn killed(bytes: &[u8]) -> u32 { + bytes + .iter() + .fold(0_u32, |acc, byte| acc.wrapping_mul(31).wrapping_add(u32::from(*byte))) +} + +#[test] +fn expect_killed_leaves_the_annotated_item_callable() { + assert_eq!(killed(b"abc"), killed(b"abc")); + assert_ne!(killed(b"abc"), killed(b"abd")); +} + +#[gamma::value(u32::MAX)] +fn valued() -> u32 { + 7 +} + +#[test] +fn value_leaves_the_annotated_item_callable() { + assert_eq!(valued(), 7); +} + +#[gamma::test_timeout_multiplier(2.5)] +fn multiplied(data: &[u8]) -> usize { + data.len() * 2 +} + +#[test] +fn test_timeout_multiplier_leaves_the_annotated_item_callable() { + assert_eq!(multiplied(b"abc"), 6); +} + +#[gamma::timeout_multiplier(2.5)] +fn aliased_multiplied(data: &[u8]) -> usize { + data.len() * 3 +} + +#[test] +fn timeout_multiplier_leaves_the_annotated_item_callable() { + assert_eq!(aliased_multiplied(b"ab"), 6); +} + +use gamma::gamma; + +#[gamma(test_timeout_multiplier = 2.0)] +fn generic_gamma(n: usize) -> usize { + n * 2 +} + +#[test] +fn gamma_leaves_the_annotated_item_callable() { + assert_eq!(generic_gamma(4), 8); +} diff --git a/crates/cargo-gamma-attrs/tests/diagnostics.rs b/crates/cargo-gamma-attrs/tests/diagnostics.rs new file mode 100644 index 00000000..5e8c9901 --- /dev/null +++ b/crates/cargo-gamma-attrs/tests/diagnostics.rs @@ -0,0 +1,193 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#![cfg(not(miri))] + +//! Pins the exact diagnostic each exported macro reports for a malformed argument, by compiling a +//! fixture against the real `gamma` proc-macro artifact rather than a stand-in. +//! +//! A `compile_fail` doctest only proves that *some* error occurred; it accepts a diagnostic from +//! any cause, including an unrelated one a regression introduced by accident. Real compilation, +//! checked against a substring every macro's diagnostic carries — its own `#[gamma::]:` +//! prefix — is what actually distinguishes "the tool objected" from "the tool objected under its +//! own name, for the reason it states". + +use std::env; +use std::path::PathBuf; +use std::process::Command; + +fn rustc() -> String { + env::var("RUSTC").unwrap_or_else(|_missing| "rustc".to_owned()) +} + +/// The directory holding every artifact `cargo test` built for this run. +/// +/// The running test binary's own path is used to find it, rather than a guessed `target/...` +/// layout: it is correct regardless of profile, target triple, or a customized target directory, +/// because it is where this very process was actually loaded from. +fn deps_dir() -> PathBuf { + let exe = env::current_exe().expect("a running test binary knows its own path"); + + exe.parent().expect("a test binary is always inside some directory").to_path_buf() +} + +/// Finds the most recently built `gamma` proc-macro artifact. +/// +/// Named by prefix rather than a fixed path, because the exact filename carries a per-build hash +/// this test cannot predict. An explicit `--target` puts this test under the target triple while +/// proc macros remain host artifacts, so both the test's dependency directory and the corresponding +/// host dependency directory are searched. +/// +/// Panics rather than reporting absence, because absence is not a host limitation: cargo builds +/// the dependency before it links this binary, so a missing artifact means the lookup is wrong and +/// every diagnostic below would otherwise be skipped while reporting success. +#[track_caller] +fn gamma_artifact() -> PathBuf { + let directory = deps_dir(); + let profile = directory + .parent() + .expect("a dependency directory is always inside a profile directory"); + let mut directories = vec![directory.clone()]; + + if let (Some(profile_name), Some(target_root)) = (profile.file_name(), profile.parent().and_then(|parent| parent.parent())) { + let host = target_root.join(profile_name).join("deps"); + + if host != directory && host.is_dir() { + directories.push(host); + } + } + + let mut candidates: Vec = directories + .iter() + .flat_map(|directory| { + std::fs::read_dir(directory) + .unwrap_or_else(|error| panic!("artifact directory {} must be readable: {error}", directory.display())) + }) + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| { + let name = path.file_name().and_then(|name| name.to_str()).unwrap_or_default(); + let is_gamma = name.starts_with("libgamma-") || name.starts_with("gamma-"); + let is_dynamic = matches!( + path.extension().and_then(|extension| extension.to_str()), + Some("so" | "dylib" | "dll") + ); + + is_gamma && is_dynamic + }) + .collect(); + + candidates.sort_by_key(|path| std::fs::metadata(path).and_then(|metadata| metadata.modified()).ok()); + + candidates.pop().unwrap_or_else(|| { + panic!( + "no `gamma` proc-macro artifact in {}, which cargo must have built before running this test", + directories + .iter() + .map(|directory| directory.display().to_string()) + .collect::>() + .join(" or ") + ) + }) +} + +/// Compiles `source` against the real `gamma` crate and returns what `rustc` said about it. +/// +/// Every failure is reported as a failure. A skip here would be indistinguishable from a passing +/// diagnostic check, so a lookup that stopped finding the artifact, or a host without a usable +/// `rustc`, would retire every assertion below while the suite stayed green — which is the one +/// outcome a test pinning diagnostics must not produce. Panics if the fixture unexpectedly +/// compiles too: every fixture this file hands to it is deliberately malformed, and a clean +/// compile means the validation this test exists to pin has stopped rejecting it. +#[track_caller] +fn diagnostic_for(name: &str, source: &str) -> String { + let artifact = gamma_artifact(); + let directory = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join(format!("diagnostics-{}", std::process::id())); + + std::fs::create_dir_all(&directory).expect("the scratch directory must be creatable"); + + let path = directory.join(format!("{name}.rs")); + + std::fs::write(&path, source).expect("the fixture source must be writable"); + + let compiler = rustc(); + let output = Command::new(&compiler) + .args(["--edition", "2024", "--crate-type", "lib", "--emit", "metadata"]) + .arg("--extern") + .arg(format!("gamma={}", artifact.display())) + .arg("-o") + .arg(directory.join(format!("{name}.rmeta"))) + .arg(&path) + .output() + .unwrap_or_else(|error| panic!("`{compiler}` must be runnable to pin what it reports for {name}: {error}")); + + assert!( + !output.status.success(), + "a deliberately malformed fixture compiled cleanly\n--- source ---\n{source}" + ); + + String::from_utf8_lossy(&output.stderr).into_owned() +} + +#[test] +fn skip_reports_its_own_name_in_a_malformed_diagnostic() { + let source = "#[gamma::skip(reason = performance)]\nfn scaled(a: i64) -> i64 { a * 2 }\n"; + let reported = diagnostic_for("skip_malformed", source); + + assert!(reported.contains("#[gamma::skip]:"), "{reported}"); + assert!(reported.contains("`reason` must be a string literal"), "{reported}"); +} + +#[test] +fn expect_survived_reports_its_own_name_in_a_malformed_diagnostic() { + let source = "#[gamma::expect_survived(tag = 7)]\nfn describe(n: usize) -> usize { n }\n"; + let reported = diagnostic_for("expect_survived_malformed", source); + + assert!(reported.contains("#[gamma::expect_survived]:"), "{reported}"); + assert!(reported.contains("`tag` must be a string literal"), "{reported}"); +} + +#[test] +fn expect_killed_reports_its_own_name_in_a_malformed_diagnostic() { + let source = "#[gamma::expect_killed(reason = 5)]\nfn checksum(bytes: &[u8]) -> usize { bytes.len() }\n"; + let reported = diagnostic_for("expect_killed_malformed", source); + + assert!(reported.contains("#[gamma::expect_killed]:"), "{reported}"); + assert!(reported.contains("`reason` must be a string literal"), "{reported}"); +} + +#[test] +fn value_reports_its_own_name_in_a_malformed_diagnostic() { + let source = "#[gamma::value()]\nfn budget() -> u32 { 512 }\n"; + let reported = diagnostic_for("value_malformed", source); + + assert!(reported.contains("#[gamma::value]:"), "{reported}"); + assert!(reported.contains("expected one expression"), "{reported}"); +} + +#[test] +fn test_timeout_multiplier_reports_its_own_name_in_a_malformed_diagnostic() { + let source = "#[gamma::test_timeout_multiplier(\"fast\")]\nfn heavy(data: &[u8]) -> usize { data.len() }\n"; + let reported = diagnostic_for("test_timeout_multiplier_malformed", source); + + assert!(reported.contains("#[gamma::test_timeout_multiplier]:"), "{reported}"); + assert!(reported.contains("timeout multiplier must be a positive number"), "{reported}"); +} + +#[test] +fn timeout_multiplier_reports_its_own_name_in_a_malformed_diagnostic() { + let source = "#[gamma::timeout_multiplier(\"fast\")]\nfn heavy(data: &[u8]) -> usize { data.len() }\n"; + let reported = diagnostic_for("timeout_multiplier_malformed", source); + + assert!(reported.contains("#[gamma::timeout_multiplier]:"), "{reported}"); + assert!(reported.contains("timeout multiplier must be a positive number"), "{reported}"); +} + +#[test] +fn gamma_reports_its_own_name_in_a_malformed_diagnostic() { + let source = "#[gamma::gamma(\"fast\")]\nfn heavy(data: &[u8]) -> usize { data.len() }\n"; + let reported = diagnostic_for("gamma_malformed", source); + + assert!(reported.contains("#[gamma::gamma]:"), "{reported}"); + assert!(reported.contains("timeout multiplier must be a positive number"), "{reported}"); +} diff --git a/crates/cargo-gamma-engine/docs/DESIGN.md b/crates/cargo-gamma-engine/docs/DESIGN.md index a82f854e..32aea3ca 100644 --- a/crates/cargo-gamma-engine/docs/DESIGN.md +++ b/crates/cargo-gamma-engine/docs/DESIGN.md @@ -20,6 +20,17 @@ identity, mutator selection, and mutant-schema instrumentation. known not to compile. This includes concrete `Self` and associated types in implementations, standard time types without `Default`, and standard `fmt::Result` aliases. +- Every pre-pass that feeds discovery — the stated-value audit and the + numeric/import indexes, whether run standalone or fused into one walk — is + confined to the code discovery itself would mutate, by the same rule: not + configured out, and not test-only. That gate applies at items, associated + items, struct fields, statements, and expressions, because conditional + compilation inside a body is written on statements. A `#[gamma::value(...)]` + outside that region is therefore not diagnosed by the fused entry point; + `check_stated`, which takes no configuration, still reads the whole file. +- A stated value is reported as an error where discovery would never read it — + on a declaration, a `const fn`, or an empty body — matching the proc macro's + compile-time rejections, so a hint that generates nothing is never silent. - The crate forbids unsafe code. ## Stability diff --git a/crates/cargo-gamma-engine/src/cfg.rs b/crates/cargo-gamma-engine/src/cfg.rs index e59b777f..79ad332d 100644 --- a/crates/cargo-gamma-engine/src/cfg.rs +++ b/crates/cargo-gamma-engine/src/cfg.rs @@ -148,26 +148,49 @@ impl CfgSet { /// might have appeared would be the same unsupported guess this module avoids elsewhere. #[must_use] pub fn holds_for(&self, attrs: &[Attribute]) -> bool { + self.holds_effective(&self.effective(attrs)) + } + + /// Returns whether effective attributes confine an item to test code. + /// + /// `cfg_attr` can add either `cfg(test)` or a test attribute itself. Both have to be read + /// after its condition is evaluated, or the collector can mutate a test helper that rustc + /// treats as a test, while a false condition can hide ordinary production code on a guess. + #[must_use] + pub fn test_gated(&self, attrs: &[Attribute]) -> bool { + self.test_gated_effective(attrs, &self.effective(attrs)) + } + + /// Returns whether `attrs` take an item out of the population: it is gated to test code, or it + /// is behind a configuration predicate that does not hold for this build. + /// + /// Every call site that asks this asks both [`Self::test_gated`] and [`Self::holds_for`] + /// together, and each independently expands `cfg_attr` metadata — cloning every attribute and + /// shifting a vector — to answer its one question. This shares that expansion between both. + #[must_use] + pub fn skip_gate(&self, attrs: &[Attribute]) -> bool { + let effective = self.effective(attrs); + + self.test_gated_effective(attrs, &effective) || !self.holds_effective(&effective) + } + + /// The [`Self::holds_for`] answer, given an already-expanded attribute list. + fn holds_effective(&self, effective: &[Meta]) -> bool { // #[gamma::skip(cond.always_false, reason = "`decide` also returns `Unknown` whenever enforcement is off, so this is only an early return and removing it cannot change an answer")] if !self.enforced { return true; } - self.effective(attrs).iter().all(|attribute| { + effective.iter().all(|attribute| { // An attribute this module cannot parse says nothing about whether the code is built, // so the code stays mutable. cfg_predicate(attribute).is_none_or(|predicate| !is_test_only(&predicate) && self.holds(&predicate)) }) } - /// Returns whether effective attributes confine an item to test code. - /// - /// `cfg_attr` can add either `cfg(test)` or a test attribute itself. Both have to be read - /// after its condition is evaluated, or the collector can mutate a test helper that rustc - /// treats as a test, while a false condition can hide ordinary production code on a guess. - #[must_use] - pub fn test_gated(&self, attrs: &[Attribute]) -> bool { - self.effective(attrs) + /// The [`Self::test_gated`] answer, given an already-expanded attribute list. + fn test_gated_effective(&self, attrs: &[Attribute], effective: &[Meta]) -> bool { + effective .iter() .any(|attribute| cfg_predicate(attribute).is_some_and(|predicate| is_test_only(&predicate)) || is_test_attribute(attribute)) || attrs.iter().any(|attribute| { @@ -412,18 +435,6 @@ fn is_test_attribute(attribute: &Meta) -> bool { attribute.path().segments.last().is_some_and(|segment| segment.ident == "test") } -/// Returns whether direct configuration gates confine an item to a unit-test build. -/// -/// This compatibility helper intentionally has no selected configuration, so it cannot apply -/// `cfg_attr`. Discovery calls [`test_gated_for`] with its target-specific [`CfgSet`]; the -/// collector calls [`CfgSet::test_gated`] for item attributes. -#[must_use] -pub fn test_gated(attrs: &[Attribute]) -> bool { - attrs - .iter() - .any(|attribute| cfg_predicate(&attribute.meta).is_some_and(|predicate| is_test_only(&predicate))) -} - /// Returns whether effective configuration gates confine an item to a unit-test build under `cfg`. /// /// Module discovery needs this narrower answer: `#[test]` belongs to a function, while only @@ -522,6 +533,10 @@ mod tests { item.attrs } + fn test_gated(attrs: &[Attribute]) -> bool { + test_gated_for(&CfgSet::default(), attrs) + } + #[test] fn a_bare_name_is_looked_up() { assert!(set().holds_str("unix")); @@ -807,6 +822,38 @@ mod tests { assert!(!cfg.test_gated(&inactive), "an inactive cfg_attr adds no test gate"); } + /// `skip_gate` shares one `cfg_attr` expansion between the two questions callers otherwise ask + /// separately, so it has to agree with them exactly, on every attribute shape that exercises + /// either question: an inactive predicate, an active `cfg(test)`, an active `cfg_attr` adding + /// `#[test]`, and a plain unconditional attribute list. + #[test] + fn skip_gate_agrees_with_test_gated_or_not_holds_for_on_every_shape() { + let unresolved = set(); + let resolved = test_build(); + + let fixtures: &[&[Attribute]] = &[ + &attribute("#[cfg(unix)]"), + &attribute("#[cfg(windows)]"), + &attribute("#[cfg(test)]"), + &attribute("#[cfg(all(test, unix))]"), + &attribute("#[cfg_attr(unix, cfg(test))]"), + &attribute("#[cfg_attr(unix, test)]"), + &attribute("#[cfg_attr(windows, cfg(test))]"), + &attribute("#[test]"), + &[], + ]; + + for cfg in [&unresolved, &resolved] { + for attrs in fixtures { + assert_eq!( + cfg.skip_gate(attrs), + cfg.test_gated(attrs) || !cfg.holds_for(attrs), + "diverged on {attrs:?}" + ); + } + } + } + /// Module discovery and item collection use the same recursive test-gate rule. Each once read /// only the top-level path of the attribute, saw `all` rather than the `test` inside it, and /// mutated the tests' own helpers. diff --git a/crates/cargo-gamma-engine/src/error.rs b/crates/cargo-gamma-engine/src/error.rs index 0816cdba..02843a06 100644 --- a/crates/cargo-gamma-engine/src/error.rs +++ b/crates/cargo-gamma-engine/src/error.rs @@ -3,6 +3,7 @@ use core::error::Error as StdError; use core::fmt::{self, Display, Formatter}; +use std::backtrace::Backtrace; use std::io; /// An engine error with the coordinator-facing classification preserved. @@ -12,6 +13,15 @@ pub struct Error { cause: Option>, usage: bool, skippable: bool, + + /// Captured at construction, unconditionally. + /// + /// Every path that produces an `Error` funnels through [`Self::new`], so capturing there + /// covers both direct construction and every `From` conversion at its true origin, without a + /// second capture point to keep in sync. Whether frames are actually recorded is controlled + /// the same way the standard library controls it everywhere else, by + /// `RUST_BACKTRACE`/`RUST_LIB_BACKTRACE`, so this costs nothing when they are unset. + backtrace: Backtrace, } impl Error { @@ -22,6 +32,7 @@ impl Error { cause: None, usage: false, skippable: false, + backtrace: Backtrace::capture(), } } @@ -53,12 +64,51 @@ impl Error { self } + /// Returns the backtrace captured when this error was constructed. + pub const fn backtrace(&self) -> &Backtrace { + &self.backtrace + } + + /// Takes this error apart so that a caller can rebuild it as its own type. + /// + /// The backtrace goes with the rest. A conversion that captured a fresh one would record the + /// conversion rather than the failure, which is the one place a backtrace is no use: every + /// engine error crossing into the coordinator would point at the same `From` implementation. #[must_use] - pub fn into_parts(self) -> (String, Option>, bool, bool) { - (self.message, self.cause, self.usage, self.skippable) + pub fn into_parts(self) -> Parts { + Parts { + message: self.message, + cause: self.cause, + usage: self.usage, + skippable: self.skippable, + backtrace: self.backtrace, + } } } +/// Everything an [`Error`] carries, handed over one field at a time. +/// +/// A struct rather than a tuple because four of the five fields are `String`, `bool`, `bool` and an +/// `Option` — a shape in which nothing but position says which is which, and in which swapping the +/// two flags compiles. +#[derive(Debug)] +pub struct Parts { + /// What was being attempted, in the words the user will read. + pub message: String, + + /// The underlying failure, when there was one. + pub cause: Option>, + + /// Whether this is something the user typed rather than something that went wrong. + pub usage: bool, + + /// Whether the caller may step over this and finish the rest of the job. + pub skippable: bool, + + /// Where the error was constructed, captured there rather than here. + pub backtrace: Backtrace, +} + impl Display for Error { #[expect(clippy::renamed_function_params, reason = "`f` is less clear than `formatter`")] fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { @@ -92,6 +142,9 @@ pub(crate) use error; #[cfg(test)] mod tests { + use std::env; + use std::process::Command; + use super::*; /// A freshly built error carries the message given to it and starts out neither a usage error @@ -137,7 +190,7 @@ mod tests { assert_eq!(error.source().expect("a cause was attached").to_string(), "disk exploded"); } - /// `into_parts` hands back exactly the state built up on the error, in the documented order. + /// `into_parts` hands back exactly the state built up on the error. #[test] fn into_parts_returns_the_built_up_state() { let error = Error::new("partial write") @@ -145,7 +198,13 @@ mod tests { .skippable() .caused_by(io::Error::other("truncated")); - let (message, cause, usage, skippable) = error.into_parts(); + let Parts { + message, + cause, + usage, + skippable, + backtrace: _backtrace, + } = error.into_parts(); assert_eq!(message, "partial write"); assert_eq!(cause.expect("a cause was attached").to_string(), "truncated"); @@ -174,4 +233,62 @@ mod tests { assert!(!built.is_usage()); assert!(!built.is_skippable()); } + + /// Every construction path, direct or via a `From` conversion, records real frames when the + /// process asks for them. + /// + /// Run in a child process rather than in this one, because whether a backtrace is captured is + /// decided by the environment the process started with and no test may change that for its + /// neighbours. + /// The parent re-executes the test binary with `RUST_BACKTRACE` and `RUST_LIB_BACKTRACE` set + /// and a marker variable that tells the child it is the child; the child then does the + /// asserting. The parent checks both its exit status and that the requested test actually ran. + /// + /// The child demands [`BacktraceStatus::Captured`], which is the whole point of the isolation: + /// asserting only "not `Unsupported`" passed just as happily against a `Backtrace::disabled()`, + /// because the default environment reports `Disabled` either way. `Unsupported` is still + /// accepted, because a platform with no backtrace support is a fact about the host rather than + /// a regression in this file — and `Backtrace::disabled()` never reports it, so the mutant this + /// test exists to catch is still caught there. + #[test] + fn every_construction_path_captures_a_backtrace() { + use std::backtrace::BacktraceStatus; + + const CHILD: &str = "CARGO_GAMMA_BACKTRACE_CHILD"; + const TEST: &str = "error::tests::every_construction_path_captures_a_backtrace"; + + if env::var_os(CHILD).is_some() { + let direct = Error::new("something went wrong"); + let converted = Error::from(io::Error::other("disk exploded")); + + for error in [&direct, &converted] { + let status = error.backtrace().status(); + + assert!( + matches!(status, BacktraceStatus::Captured | BacktraceStatus::Unsupported), + "a backtrace was requested but not taken: {status:?}" + ); + } + + return; + } + + let executable = env::current_exe().expect("the test executable is known"); + let output = Command::new(executable) + .args(["--exact", TEST, "--nocapture"]) + .env(CHILD, "1") + .env("RUST_BACKTRACE", "1") + .env("RUST_LIB_BACKTRACE", "1") + .output() + .expect("re-run this test with backtraces enabled"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert!( + output.status.success(), + "the isolated run failed: {}\n{stdout}\n{stderr}", + output.status + ); + assert!(stdout.contains(TEST), "the exact filter did not run `{TEST}`\n{stdout}\n{stderr}"); + } } diff --git a/crates/cargo-gamma-engine/src/lib.rs b/crates/cargo-gamma-engine/src/lib.rs index c921bc90..dfb37e0d 100644 --- a/crates/cargo-gamma-engine/src/lib.rs +++ b/crates/cargo-gamma-engine/src/lib.rs @@ -34,5 +34,7 @@ pub mod model; pub mod ops; pub mod parse; pub mod schema; +pub mod text; -pub use error::Error; +#[doc(inline)] +pub use error::{Error, Parts}; diff --git a/crates/cargo-gamma-engine/src/model/identity.rs b/crates/cargo-gamma-engine/src/model/identity.rs index 74d3a06b..8c0b0ee6 100644 --- a/crates/cargo-gamma-engine/src/model/identity.rs +++ b/crates/cargo-gamma-engine/src/model/identity.rs @@ -3,18 +3,195 @@ //! The stable, content-addressed identity of a mutant and its site. +use core::borrow::Borrow; +use core::fmt::{self, Display, Formatter}; +use core::ops::Deref; + use blake3::Hasher; use camino::Utf8Path; use compact_str::CompactString; +use serde::{Deserialize, Serialize}; /// A mutant's compact, content-addressed identity. -pub type MutantId = CompactString; +/// +/// A newtype over the text rather than an alias for it. This is the key that cached verdicts, +/// shard assignments and configured expectations are all stored under, so a mutator name, a +/// package name or a file path reaching one of those maps by mistake would attach one mutant's +/// history to another and nothing downstream could tell the difference. An alias made every one of +/// those the same type. +/// +/// The wrapper is transparent to Serde, so every plan, record and report an earlier version wrote +/// still reads back unchanged, and it dereferences to `str`, so the identity reads as the text it +/// is. +#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct MutantId(CompactString); + +impl MutantId { + /// Wraps text that is already a rendered identity. + /// + /// Deliberately not validating that the text is [`MUTANT_ID_HEX_LEN`] hex characters: the + /// identities in a record written by a future version, or by a test that wants a readable + /// name, are still identities as far as every map keyed on one is concerned, and refusing them + /// would turn a forward-compatible read into a hard failure. + #[must_use] + pub fn new(text: impl AsRef) -> Self { + Self(CompactString::new(text)) + } + + /// The identity as a string slice. + #[must_use] + pub fn as_str(&self) -> &str { + self.0.as_str() + } + + /// Whether the identity spilled out of the inline representation onto the heap. + /// + /// Exposed for the tests that keep an identity within the inline budget, which is the whole + /// reason the underlying representation is a compact string rather than a `String`. + #[must_use] + pub fn is_heap_allocated(&self) -> bool { + self.0.is_heap_allocated() + } +} + +impl Deref for MutantId { + type Target = str; + + fn deref(&self) -> &Self::Target { + self.0.as_str() + } +} + +impl Borrow for MutantId { + fn borrow(&self) -> &str { + self.0.as_str() + } +} + +impl AsRef for MutantId { + fn as_ref(&self) -> &str { + self.0.as_str() + } +} + +impl Display for MutantId { + #[expect(clippy::renamed_function_params, reason = "`f` is less clear than `formatter`")] + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + Display::fmt(&self.0, formatter) + } +} + +impl From for MutantId { + fn from(value: CompactString) -> Self { + Self(value) + } +} + +impl From for MutantId { + fn from(value: String) -> Self { + Self(value.into()) + } +} + +impl From<&str> for MutantId { + fn from(value: &str) -> Self { + Self(value.into()) + } +} + +impl From for CompactString { + fn from(value: MutantId) -> Self { + value.0 + } +} + +impl PartialEq for MutantId { + fn eq(&self, other: &str) -> bool { + self.0.as_str() == other + } +} + +impl PartialEq<&str> for MutantId { + fn eq(&self, other: &&str) -> bool { + self.0.as_str() == *other + } +} + +impl PartialEq for MutantId { + fn eq(&self, other: &String) -> bool { + self.0.as_str() == other.as_str() + } +} + +impl PartialEq for str { + fn eq(&self, other: &MutantId) -> bool { + self == other.0.as_str() + } +} + +impl PartialEq for &str { + fn eq(&self, other: &MutantId) -> bool { + *self == other.0.as_str() + } +} + +impl PartialEq for String { + fn eq(&self, other: &MutantId) -> bool { + self.as_str() == other.0.as_str() + } +} + +/// Which repeat of a mutation site, and which of that site's replacements, an identity names. +/// +/// A named structure rather than two adjacent `u32` parameters. The two counts are +/// indistinguishable at a call site, and swapping them yields a different, entirely valid-looking +/// identity — which silently detaches every cached verdict, shard assignment and configured +/// expectation belonging to that mutant, with no error anywhere to say so. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct SiteIndex { + occurrence: u32, + replacement_index: u32, +} + +impl SiteIndex { + /// Names which repeat of the site, and which of its replacements, this is. + /// + /// Both counts are zero-based and unbounded: a site can repeat as often as the enclosing item + /// contains it, and a mutator may offer any number of replacements, so there is nothing here + /// to reject — only two meanings to keep apart. + #[must_use] + pub const fn new(occurrence: u32, replacement_index: u32) -> Self { + Self { + occurrence, + replacement_index, + } + } + + /// Which repeat of an otherwise identical site within the enclosing item this is. + #[must_use] + pub const fn occurrence(self) -> u32 { + self.occurrence + } + + /// Which of the mutator's replacements for that site this is. + #[must_use] + pub const fn replacement_index(self) -> u32 { + self.replacement_index + } +} /// The identity normalization contract emitted by this version. /// /// Version 4 adds caller-supplied error replacement text to those mutants' identities. Every /// identity whose replacement comes from the registry remains byte-identical. -pub const MUTANT_ID_VERSION: u32 = 4; +/// +/// Version 5 builds the item-path scope of an implementation from the self type's complete source span — +/// qualification, generic arguments, and reference syntax included — instead of only its final +/// path segment. `a::S`, `b::S`, `S`, `S`, `S`, and `&S` now keep distinct item paths and +/// therefore distinct identities; under version 4 they shared one path and fell back to +/// source-order occurrence, which reordering `impl` blocks could reassign to a different type. +pub const MUTANT_ID_VERSION: u32 = 5; /// Computes the stable, content-addressed identity of a mutant. /// @@ -26,15 +203,8 @@ pub const MUTANT_ID_VERSION: u32 = 4; /// paths include both the self type and the implemented trait, so same-named methods from two /// traits never depend on source order for that disambiguation. #[must_use] -pub fn mutant_id( - file: &Utf8Path, - item_path: &str, - mutator: &str, - normalized_site_text: &str, - occurrence: u32, - replacement_index: u32, -) -> MutantId { - mutant_id_with_discriminator(file, item_path, mutator, normalized_site_text, occurrence, replacement_index, None) +pub fn mutant_id(file: &Utf8Path, item_path: &str, mutator: &str, normalized_site_text: &str, site: SiteIndex) -> MutantId { + mutant_id_with_discriminator(file, item_path, mutator, normalized_site_text, site, None) } /// Computes an identity with additional caller-supplied replacement content. @@ -44,8 +214,7 @@ pub(crate) fn mutant_id_with_discriminator( item_path: &str, mutator: &str, normalized_site_text: &str, - occurrence: u32, - replacement_index: u32, + site: SiteIndex, discriminator: Option<&str>, ) -> MutantId { let mut hasher = Hasher::new(); @@ -56,8 +225,8 @@ pub(crate) fn mutant_id_with_discriminator( let _ = hasher.update(field.as_bytes()); } - let _ = hasher.update(&occurrence.to_le_bytes()); - let _ = hasher.update(&replacement_index.to_le_bytes()); + let _ = hasher.update(&site.occurrence().to_le_bytes()); + let _ = hasher.update(&site.replacement_index().to_le_bytes()); if let Some(discriminator) = discriminator { let _ = hasher.update(&(discriminator.len() as u64).to_le_bytes()); let _ = hasher.update(discriminator.as_bytes()); @@ -65,14 +234,14 @@ pub(crate) fn mutant_id_with_discriminator( let digest = hasher.finalize(); let bytes = digest.as_bytes(); - let mut out = MutantId::with_capacity(MUTANT_ID_HEX_LEN); + let mut out = CompactString::with_capacity(MUTANT_ID_HEX_LEN); for byte in bytes.iter().take(MUTANT_ID_BYTES) { out.push(HEX[usize::from(byte >> 4)]); out.push(HEX[usize::from(byte & 0x0f)]); } - out + MutantId(out) } /// How much of the digest a mutant identifier keeps. @@ -182,8 +351,8 @@ mod tests { fn mutant_id_delegates_to_the_discriminated_form_with_no_discriminator() { let file = Utf8Path::new("src/lib.rs"); - let direct = mutant_id(file, "subject::f", "arith.add_to_sub", "1 + 1", 0, 0); - let via_discriminator = mutant_id_with_discriminator(file, "subject::f", "arith.add_to_sub", "1 + 1", 0, 0, None); + let direct = mutant_id(file, "subject::f", "arith.add_to_sub", "1 + 1", SiteIndex::default()); + let via_discriminator = mutant_id_with_discriminator(file, "subject::f", "arith.add_to_sub", "1 + 1", SiteIndex::default(), None); assert_eq!(direct, via_discriminator); assert_eq!(direct.len(), MUTANT_ID_HEX_LEN); @@ -195,15 +364,31 @@ mod tests { #[test] fn each_field_and_the_discriminator_affect_the_identity() { let file = Utf8Path::new("src/lib.rs"); - let baseline = mutant_id_with_discriminator(file, "subject::f", "arith.add_to_sub", "1 + 1", 0, 0, None); - - let other_file = mutant_id_with_discriminator(Utf8Path::new("src/other.rs"), "subject::f", "arith.add_to_sub", "1 + 1", 0, 0, None); - let other_occurrence = mutant_id_with_discriminator(file, "subject::f", "arith.add_to_sub", "1 + 1", 1, 0, None); - let other_replacement_index = mutant_id_with_discriminator(file, "subject::f", "arith.add_to_sub", "1 + 1", 0, 1, None); - let with_discriminator = mutant_id_with_discriminator(file, "subject::f", "arith.add_to_sub", "1 + 1", 0, 0, Some("panic")); - let with_empty_discriminator = mutant_id_with_discriminator(file, "subject::f", "arith.add_to_sub", "1 + 1", 0, 0, Some("")); - let with_other_discriminator = - mutant_id_with_discriminator(file, "subject::f", "arith.add_to_sub", "1 + 1", 0, 0, Some("overflow")); + let baseline = mutant_id_with_discriminator(file, "subject::f", "arith.add_to_sub", "1 + 1", SiteIndex::default(), None); + + let other_file = mutant_id_with_discriminator( + Utf8Path::new("src/other.rs"), + "subject::f", + "arith.add_to_sub", + "1 + 1", + SiteIndex::default(), + None, + ); + let other_occurrence = mutant_id_with_discriminator(file, "subject::f", "arith.add_to_sub", "1 + 1", SiteIndex::new(1, 0), None); + let other_replacement_index = + mutant_id_with_discriminator(file, "subject::f", "arith.add_to_sub", "1 + 1", SiteIndex::new(0, 1), None); + let with_discriminator = + mutant_id_with_discriminator(file, "subject::f", "arith.add_to_sub", "1 + 1", SiteIndex::default(), Some("panic")); + let with_empty_discriminator = + mutant_id_with_discriminator(file, "subject::f", "arith.add_to_sub", "1 + 1", SiteIndex::default(), Some("")); + let with_other_discriminator = mutant_id_with_discriminator( + file, + "subject::f", + "arith.add_to_sub", + "1 + 1", + SiteIndex::default(), + Some("overflow"), + ); for other in [other_file, other_occurrence, other_replacement_index, with_discriminator.clone()] { assert_ne!(baseline, other, "a field change must not collide with the baseline identity"); @@ -213,6 +398,49 @@ mod tests { assert_ne!(baseline, with_empty_discriminator); } + /// Swapping the two counts produces a different identity, which is the whole reason they are + /// named rather than adjacent: the wrong one is not an error anywhere, only a different mutant. + #[test] + fn the_two_site_counts_are_not_interchangeable() { + let file = Utf8Path::new("src/lib.rs"); + let one_way = mutant_id(file, "subject::f", "arith.add_to_sub", "1 + 1", SiteIndex::new(2, 3)); + let other_way = mutant_id(file, "subject::f", "arith.add_to_sub", "1 + 1", SiteIndex::new(3, 2)); + + assert_ne!(one_way, other_way); + + let site = SiteIndex::new(2, 3); + + assert_eq!(site.occurrence(), 2); + assert_eq!(site.replacement_index(), 3); + assert_eq!(SiteIndex::default(), SiteIndex::new(0, 0)); + } + + /// An identity reads, compares and serializes as the text it wraps, so the newtype costs + /// nothing at a call site and nothing on the wire. + #[test] + fn an_identity_behaves_as_the_text_it_wraps() { + let id = MutantId::new("deadbeefcafe"); + + assert!(>::eq(&id, "deadbeefcafe")); + assert!(>::eq(&id, &"deadbeefcafe")); + assert!(>::eq(&id, &String::from("deadbeefcafe"))); + assert!(>::eq("deadbeefcafe", &id)); + assert!(<&str as PartialEq>::eq(&"deadbeefcafe", &id)); + assert!(>::eq(&String::from("deadbeefcafe"), &id)); + assert_eq!(id.as_str(), "deadbeefcafe"); + assert_eq!(id.to_string(), "deadbeefcafe"); + assert_eq!(id.len(), MUTANT_ID_HEX_LEN); + assert!(!id.is_heap_allocated()); + assert_eq!(MutantId::from("deadbeefcafe"), id); + assert_eq!(MutantId::from(String::from("deadbeefcafe")), id); + assert_eq!(CompactString::from(id.clone()), CompactString::new("deadbeefcafe")); + + // Borrowed as `str`, so a map keyed on identities can still be probed with plain text. + let borrowed: &str = &id; + + assert_eq!(borrowed, "deadbeefcafe"); + } + /// Whitespace runs collapse to one space, and a run entirely at the start of the text /// disappears rather than producing a leading space. #[test] diff --git a/crates/cargo-gamma-engine/src/model/interner.rs b/crates/cargo-gamma-engine/src/model/interner.rs index cee5e054..5d15cf33 100644 --- a/crates/cargo-gamma-engine/src/model/interner.rs +++ b/crates/cargo-gamma-engine/src/model/interner.rs @@ -68,6 +68,9 @@ impl Interner { mutation.file = self.path(&mutation.file); mutation.mutator = self.text(&mutation.mutator); mutation.item_path = self.text(&mutation.item_path); + if let Some(trait_impl) = mutation.trait_impl.as_deref() { + mutation.trait_impl = Some(self.text(trait_impl)); + } } } } @@ -113,6 +116,7 @@ mod tests { }), mutator: Arc::from("lit.true_to_false"), item_path: Arc::from("subject::f"), + trait_impl: None, occurrence: 0, replacement_index: 0, replacement: "false".to_owned().into(), diff --git a/crates/cargo-gamma-engine/src/model/mod.rs b/crates/cargo-gamma-engine/src/model/mod.rs index e455fdc2..12d7e424 100644 --- a/crates/cargo-gamma-engine/src/model/mod.rs +++ b/crates/cargo-gamma-engine/src/model/mod.rs @@ -9,7 +9,11 @@ mod mutant_definition; mod mutation_site; pub(crate) use identity::mutant_id_with_discriminator; -pub use identity::{MUTANT_ID_HEX_LEN, MUTANT_ID_VERSION, MutantId, mutant_id, normalize_site_text, site_key}; +#[doc(inline)] +pub use identity::{MUTANT_ID_HEX_LEN, MUTANT_ID_VERSION, MutantId, SiteIndex, mutant_id, normalize_site_text, site_key}; +#[doc(inline)] pub use interner::Interner; +#[doc(inline)] pub use mutant_definition::MutantDefinition; +#[doc(inline)] pub use mutation_site::MutationSite; diff --git a/crates/cargo-gamma-engine/src/model/mutant_definition.rs b/crates/cargo-gamma-engine/src/model/mutant_definition.rs index 775c3ae2..de846000 100644 --- a/crates/cargo-gamma-engine/src/model/mutant_definition.rs +++ b/crates/cargo-gamma-engine/src/model/mutant_definition.rs @@ -19,6 +19,7 @@ pub struct MutantDefinition { pub site: Arc, pub mutator: Arc, pub item_path: Arc, + pub trait_impl: Option>, pub occurrence: u32, pub replacement_index: u32, pub replacement: CompactString, @@ -81,6 +82,7 @@ mod tests { }), mutator: Arc::from("arith.add_to_sub"), item_path: Arc::from("subject::f"), + trait_impl: None, occurrence: 0, replacement_index: 0, replacement: "1 - 1".to_owned().into(), diff --git a/crates/cargo-gamma-engine/src/ops/collect/candidate.rs b/crates/cargo-gamma-engine/src/ops/collect/candidate.rs index be4e820b..a24cef1f 100644 --- a/crates/cargo-gamma-engine/src/ops/collect/candidate.rs +++ b/crates/cargo-gamma-engine/src/ops/collect/candidate.rs @@ -33,6 +33,12 @@ pub struct Candidate { /// candidates, so one allocation per scope and a pointer per candidate is the whole difference. pub item_path: Arc, + /// Terminal name of the enclosing implemented trait, if any. + /// + /// Kept separately from `item_path` so project policy can select trait implementations without + /// parsing a human-readable identity or caring how the trait path was qualified. + pub trait_impl: Option>, + /// How the site must be guarded. pub shape: Shape, } diff --git a/crates/cargo-gamma-engine/src/ops/collect/collector.rs b/crates/cargo-gamma-engine/src/ops/collect/collector.rs index 9d76700a..381708e8 100644 --- a/crates/cargo-gamma-engine/src/ops/collect/collector.rs +++ b/crates/cargo-gamma-engine/src/ops/collect/collector.rs @@ -35,13 +35,13 @@ mod values; #[cfg(test)] mod tests; -use indexes::{Indexes, NumericUses, indexes}; +use indexes::{Indexes, NumericUses, indexes_in}; use noop::is_noop; use predicates::{ binds_a_pattern, boolean_literal, callee_name, callee_type, declared_name, diverges, expr_attrs, is_assign_op, is_capacity_call, is_capacity_result, is_catch_all, is_constant_case, is_default_call, is_diagnostic_message, is_integer_zero_literal, is_numeric_binding, is_numeric_return, is_numeric_type, is_promotable, is_textual, loop_produces_value, returns_numeric, - returns_result, stmt_attrs, type_name, + returns_result, stmt_attrs, }; use tables::{binary_replacements, in_place_reorder, method_renames}; use types::{Types, returns_undefaultable_error, undefaulted_parameters}; @@ -90,6 +90,9 @@ pub(super) struct Collector<'a> { /// The path of a candidate outside any item, shared for the same reason. outermost: Arc, + /// Terminal name of the enclosing implemented trait. + trait_impl: Option>, + candidates: Vec, /// Depth of nesting inside a context where mutation is not possible or not useful. @@ -262,7 +265,7 @@ impl<'a> Collector<'a> { cfg: &'a CfgSet, defaults: &'a Defaults, ) -> Self { - Self::with_indexes(file, selection, errors, cfg, defaults, indexes(&file.ast, selection)) + Self::with_indexes(file, selection, errors, cfg, defaults, indexes_in(&file.ast, selection, cfg)) } /// Creates a collector from indexes a caller already built. @@ -278,13 +281,14 @@ impl<'a> Collector<'a> { defaults: &'a Defaults, indexes: Indexes, ) -> Self { - let default_paths = DefaultPaths::of(&file.ast); + let default_paths = DefaultPaths::of_in(&file.ast, cfg); Self { file, selection, scope: Vec::new(), outermost: Arc::from(""), + trait_impl: None, candidates: Vec::new(), inert_depth: 0, in_default_impl: false, @@ -395,6 +399,7 @@ impl<'a> Collector<'a> { replacement, replacement_index, item_path: self.scope.last().map_or_else(|| Arc::clone(&self.outermost), Arc::clone), + trait_impl: self.trait_impl.as_ref().map(Arc::clone), shape, }); @@ -449,8 +454,17 @@ impl<'a> Collector<'a> { } /// Returns the stable scope name of one inherent or trait implementation. + /// + /// The self type is read from its own source span and compacted rather than reduced to a bare + /// name: a name-only identity conflates `a::S`, `b::S`, `S`, `S`, `S`, and `&S`, so + /// same-named methods on differently qualified, instantiated, or referenced self types would + /// share an item path and fall back to source-order occurrence to tell their mutants apart — + /// which reordering the impl blocks would then silently reassign. Reading the exact source span + /// keeps qualification, generic arguments, and reference syntax intact, the same way the trait + /// path just below is read. fn impl_scope(&self, node: &ItemImpl) -> String { - let self_type = type_name(&node.self_ty); + let self_type = compact_path(self.text_of(node.self_ty.span())); + let self_type = if self_type.is_empty() { "_".to_owned() } else { self_type }; let Some((trait_path, _for)) = &node.trait_ else { return self_type; @@ -760,14 +774,6 @@ impl<'a> Collector<'a> { } } - /// Offers `+ 1` and `- 1` for one expression, in a position where a boundary is being decided. - /// - /// Deliberately not applied to every expression. Doing so would double the population of a - /// large project, duplicate the literal and arithmetic families wherever they already apply, - /// and produce type errors anywhere the expression is generic or not numeric at all. The - /// positions it is applied to are the ones that carry a postcondition somebody could get - /// wrong by one: what a function is handed, what it gives back, what is indexed, and where a - /// range stops. /// Offers a mutant for each element of a `vec!` literal, with that element removed. /// /// The removal sweeps up the separating comma along with the element, so the list that is left @@ -857,6 +863,14 @@ impl<'a> Collector<'a> { } } + /// Offers `+ 1` and `- 1` for one expression, in a position where a boundary is being decided. + /// + /// Deliberately not applied to every expression. Doing so would double the population of a + /// large project, duplicate the literal and arithmetic families wherever they already apply, + /// and produce type errors anywhere the expression is generic or not numeric at all. The + /// positions it is applied to are the ones that carry a postcondition somebody could get + /// wrong by one: what a function is handed, what it gives back, what is indexed, and where a + /// range stops. fn perturb(&mut self, expression: &Expr) { // The veto outranks the proof. Both are read off the source, but only one of them can be // wrong in a way that costs a rollback round: nothing in the allowlist proves an expression @@ -1009,7 +1023,7 @@ impl<'a> Collector<'a> { /// predicate that does not hold for this build. Both mean the same thing to the collector — /// do not descend — so they are asked together at every place that can be entered. fn skipped(&self, attrs: &[Attribute]) -> bool { - is_excluded(self.cfg, attrs) || !self.cfg.holds_for(attrs) + self.cfg.skip_gate(attrs) } /// Runs `body`, treating everything it visits as inert when `constant` holds. @@ -1175,15 +1189,6 @@ impl<'a> Collector<'a> { } } -/// Returns whether any attribute suppresses mutation of the whole item. -fn is_excluded(cfg: &CfgSet, attrs: &[Attribute]) -> bool { - // `#[cfg(test)]` — and any compound gate that implies it, such as `all(test, unix)` — marks - // code that exists only to test other code. Mutating it measures the tests' tests, which - // nobody has. Read by the cfg subsystem's own classifier, so conditional `cfg_attr` gates are - // evaluated under the same target as ordinary `cfg` gates. - cfg.test_gated(attrs) -} - /// Removes trivia from a trait path without changing literal contents. /// /// An item path feeds a stable mutant identity, so formatting `impl Trait < T > for S` must not @@ -1326,6 +1331,13 @@ impl<'ast> Visit<'ast> for Collector<'_> { let depth = self.generics.len(); let defaulted_depth = self.defaulted.len(); let outer = self.in_default_impl; + let outer_trait_impl = core::mem::replace( + &mut self.trait_impl, + node.trait_ + .as_ref() + .and_then(|(path, _for)| path.segments.last()) + .map(|segment| Arc::from(segment.ident.to_string())), + ); let outer_self_type = self.impl_self_type.replace((*node.self_ty).clone()); let outer_associated = core::mem::replace( &mut self.impl_self_associated, @@ -1352,6 +1364,7 @@ impl<'ast> Visit<'ast> for Collector<'_> { self.generics.truncate(depth); self.defaulted.truncate(defaulted_depth); self.in_default_impl = outer; + self.trait_impl = outer_trait_impl; self.impl_self_type = outer_self_type; self.impl_self_associated = outer_associated; } diff --git a/crates/cargo-gamma-engine/src/ops/collect/collector/indexes.rs b/crates/cargo-gamma-engine/src/ops/collect/collector/indexes.rs index 59e0df6b..c767e121 100644 --- a/crates/cargo-gamma-engine/src/ops/collect/collector/indexes.rs +++ b/crates/cargo-gamma-engine/src/ops/collect/collector/indexes.rs @@ -5,11 +5,13 @@ use syn::visit::{self, Visit}; use syn::{ - BinOp, Expr, ExprBinary, ExprForLoop, ExprIndex, ExprMethodCall, File, ImplItemConst, ItemConst, ItemStatic, ItemStruct, ItemUse, - Member, Pat, TraitItemConst, Type, UseTree, + BinOp, Expr, ExprBinary, ExprForLoop, ExprIndex, ExprMethodCall, File, ImplItem, ImplItemConst, Item, ItemConst, ItemStatic, + ItemStruct, ItemUse, Member, Pat, Stmt, TraitItem, TraitItemConst, Type, UseTree, }; -use super::predicates::{is_int_literal, is_numeric_binding, is_numeric_receiver}; +use super::super::defaults::{impl_item_attrs, item_attrs, trait_item_attrs}; +use super::predicates::{expr_attrs, is_int_literal, is_numeric_binding, is_numeric_receiver, stmt_attrs}; +use crate::cfg::CfgSet; use crate::ops::registry::Selection; use crate::{HashMap, HashSet}; @@ -50,13 +52,6 @@ pub(in crate::ops::collect) struct Indexes { pub(super) constants: HashMap, } -/// Builds the indexes a selection actually consults, and skips the walk entirely when it consults -/// none of them. -/// -/// Three of the four exist only to decide whether an expression is a number, which only the -/// perturbation family asks; the fourth exists only to recognise a type that has no `Default`, -/// which only the `fn_value` family asks. A run narrowed to, say, the relational mutators asks -/// neither, and paying for the answers anyway is the whole of this cost. /// Fills whichever indexes were asked for, ignoring scope. pub(super) struct Walk { indexes: Indexes, @@ -66,6 +61,16 @@ pub(super) struct Walk { /// Whether the import paths are wanted. imports: bool, + + /// The configuration predicates that hold for the build this file will be part of. + /// + /// A field, constant, `use`, or numeric use drawn from code the collector will not mutate — + /// because a predicate strips it, or because it is test code — would misinform every active + /// site that later consults the index it feeds. The gates below therefore ask + /// [`CfgSet::skip_gate`], the one question [`Collector::skipped`](super::Collector::skipped) + /// asks, at every place this walk can be entered: items, associated items, struct fields, + /// statements, and expressions. + cfg: CfgSet, } impl Walk { @@ -167,25 +172,37 @@ impl Walk { /// /// Exposed with no continuation of its own so the fused phase-one pass (see /// `collector::phase_one`) can drive this exact per-node logic from its own single traversal. + /// + /// The struct itself is assumed already active — every caller reaches this only through the + /// `visit_item` gate below, which excludes a struct the selected build strips before its + /// fields are ever read. A field can still carry its own `#[cfg(...)]` distinct from the + /// struct's, so each field is checked again here: a field the build does not compile must not + /// inform the numeric guess for a same-named field elsewhere that the build does compile. pub(super) fn on_item_struct(&mut self, node: &ItemStruct) { - if self.numeric { - for field in &node.fields { - let Some(name) = field.ident.as_ref() else { - continue; - }; - - let numeric = is_numeric_binding(&field.ty); - - // Two structs disagreeing about a name means neither answer can be trusted for - // a bare `x.count`, so the name is demoted to unknown rather than won by - // whichever was seen last. - let _known = self - .indexes - .fields - .entry(name.to_string()) - .and_modify(|known| *known = *known && numeric) - .or_insert(numeric); + if !self.numeric { + return; + } + + for field in &node.fields { + if self.cfg.skip_gate(&field.attrs) { + continue; } + + let Some(name) = field.ident.as_ref() else { + continue; + }; + + let numeric = is_numeric_binding(&field.ty); + + // Two structs disagreeing about a name means neither answer can be trusted for + // a bare `x.count`, so the name is demoted to unknown rather than won by + // whichever was seen last. + let _known = self + .indexes + .fields + .entry(name.to_string()) + .and_modify(|known| *known = *known && numeric) + .or_insert(numeric); } } @@ -260,34 +277,69 @@ impl Walk { reason = "syn names every visitor parameter `i`, which says nothing about what it is" )] impl<'ast> Visit<'ast> for Walk { - fn visit_item_struct(&mut self, node: &'ast ItemStruct) { - if self.numeric { - for field in &node.fields { - let Some(name) = field.ident.as_ref() else { - continue; - }; - - let numeric = is_numeric_binding(&field.ty); - - // Two structs disagreeing about a name means neither answer can be trusted for - // a bare `x.count`, so the name is demoted to unknown rather than won by - // whichever was seen last. - let _known = self - .indexes - .fields - .entry(name.to_string()) - .and_modify(|known| *known = *known && numeric) - .or_insert(numeric); - } + /// Gates every item this walk might otherwise index by the same decision the collector reads + /// before it ever offers a mutant. + /// + /// [`CfgSet::skip_gate`] rather than [`CfgSet::holds_for`], because the collector excludes test + /// code as well as configured-out code, and an index built from a `#[cfg(test)]` helper informs + /// guesses about production code the helper is not part of — a field named there can make an + /// active `x.count` look numeric on evidence the measured build never compiles. + /// + /// `visit_item` is the single dispatch point every top-level and nested item passes through — + /// including a local item declared inside a function body — so gating here, rather than + /// separately in each of `visit_item_struct`, `visit_item_use`, `visit_item_const`, and + /// `visit_item_static`, keeps one skipped item from reaching any of them. + fn visit_item(&mut self, node: &'ast Item) { + if !self.cfg.skip_gate(item_attrs(node)) { + visit::visit_item(self, node); + } + } + + /// Gates every associated item inside an `impl` block, for the same reason [`Self::visit_item`] + /// gates top-level items. + fn visit_impl_item(&mut self, node: &'ast ImplItem) { + if !self.cfg.skip_gate(impl_item_attrs(node)) { + visit::visit_impl_item(self, node); + } + } + + /// Gates every associated item inside a `trait` block, for the same reason [`Self::visit_item`] + /// gates top-level items. + fn visit_trait_item(&mut self, node: &'ast TraitItem) { + if !self.cfg.skip_gate(trait_item_attrs(node)) { + visit::visit_trait_item(self, node); } + } + + /// Gates every statement, which no item visitor above ever sees. + /// + /// A `#[cfg(windows)] let n: usize = 0;` inside an active function is discarded by the compiler + /// on a Unix build, and the collector skips it for that reason — but the numeric evidence it + /// carries would otherwise still be indexed, and would then answer for the `n` the build + /// actually has. Statements are the level at which conditional compilation is written inside a + /// body, so this is where that evidence has to be refused. + fn visit_stmt(&mut self, node: &'ast Stmt) { + if !self.cfg.skip_gate(stmt_attrs(node)) { + visit::visit_stmt(self, node); + } + } + + /// Gates every expression, covering the positions `rustc` admits an attribute in today and the + /// ones it does not admit yet, exactly as the collector's own `visit_expr` does. + fn visit_expr(&mut self, node: &'ast Expr) { + if !self.cfg.skip_gate(expr_attrs(node)) { + visit::visit_expr(self, node); + } + } + + fn visit_item_struct(&mut self, node: &'ast ItemStruct) { + self.on_item_struct(node); visit::visit_item_struct(self, node); } fn visit_item_use(&mut self, node: &'ast ItemUse) { - if self.imports { - self.descend(&mut Vec::new(), &node.tree); - } + self.on_item_use(node); visit::visit_item_use(self, node); } @@ -379,8 +431,21 @@ impl<'ast> Visit<'ast> for Walk { } } -pub(super) fn indexes(file: &File, selection: &Selection) -> Indexes { - let mut walk = Walk::new(selection); +/// Builds the indexes a selection actually consults under a build's active configuration, and +/// skips the walk entirely when the selection consults none of them. +/// +/// Three of the four exist only to decide whether an expression is a number, which only the +/// perturbation family asks; the fourth exists only to recognise a type that has no `Default`, +/// which only the `fn_value` family asks. A run narrowed to, say, the relational mutators asks +/// neither, and paying for the answers anyway is the whole of this cost. +/// +/// `cfg` decides which conditionally compiled code the walk is even allowed to learn from: a +/// field, constant, `use`, or numeric use the selected build strips must not inform the guesses +/// made about code the build keeps, any more than the collector would offer a mutant there. Pass +/// [`CfgSet::unconditional`] where the build's configuration is not known, matching every other +/// unconditional entry point in this module. +pub(super) fn indexes_in(file: &File, selection: &Selection, cfg: &CfgSet) -> Indexes { + let mut walk = Walk::new(selection, cfg); if !walk.numeric && !walk.imports { return walk.indexes; @@ -391,12 +456,12 @@ pub(super) fn indexes(file: &File, selection: &Selection) -> Indexes { } impl Walk { - /// Builds an empty index set, gated exactly as [`indexes`] gates its own walk. + /// Builds an empty index set, gated exactly as [`indexes_in`] gates its own walk. /// /// Exposed so the fused phase-one pass (`collector::phase_one`) can build the same starting - /// state `indexes` would, drive it through one combined traversal instead of `indexes`'s own, - /// and read the result back out with [`Walk::into_indexes`]. - pub(super) fn new(selection: &Selection) -> Self { + /// state `indexes_in` would, drive it through one combined traversal instead of + /// `indexes_in`'s own, and read the result back out with [`Walk::into_indexes`]. + pub(super) fn new(selection: &Selection, cfg: &CfgSet) -> Self { Self { indexes: Indexes { fields: HashMap::default(), @@ -411,6 +476,7 @@ impl Walk { // write could be built at all. Missing the second cost five mutants when this gate was // first written, which is what a gate on an index has to be checked against. imports: selection.any_in_family("fn_value") || selection.contains("result.ok_to_err"), + cfg: cfg.clone(), } } @@ -434,6 +500,7 @@ mod tests { }, numeric, imports, + cfg: CfgSet::unconditional(), } } @@ -491,11 +558,135 @@ mod tests { ) .expect("the file parses"); let selection = Selection::parse("expr.increment").expect("the numeric selector resolves"); - let indexes = indexes(&file, &selection); + let indexes = indexes_in(&file, &selection, &CfgSet::unconditional()); assert_eq!(indexes.constants.get("STATIC_LIMIT"), Some(&true)); assert_eq!(indexes.constants.get("TRAIT_LIMIT"), Some(&true)); assert!(indexes.fields.is_empty()); assert!(indexes.numeric_uses.names.contains("limit")); } + + /// A field or a constant behind a predicate the active build does not satisfy must not + /// inform the numeric guess for an active same-named field or constant elsewhere in the file. + /// + /// The set has to be an enforced one: [`CfgSet::unconditional`] answers every predicate `true` + /// by construction, so nothing is stripped under it and the fixture would prove nothing. + #[test] + fn inactive_fields_and_constants_do_not_pollute_the_index() { + let file = syn::parse_file( + r#" + struct Active { + count: u32, + } + + struct Inactive { + #[cfg(windows)] + count: String, + } + + #[cfg(windows)] + const COUNT: &str = "not built"; + + const COUNT: u32 = 1; + "#, + ) + .expect("the file parses"); + let selection = Selection::parse("expr.increment").expect("the numeric selector resolves"); + let indexes = indexes_in(&file, &selection, &CfgSet::parse("unix\n")); + + assert_eq!(indexes.fields.get("count"), Some(&true)); + assert_eq!(indexes.constants.get("COUNT"), Some(&true)); + } + + /// A misplaced or malformed item nested inside an inactive module must not reach this index + /// at all — the module itself never compiles, so nothing inside it should be able to shadow + /// or demote evidence the active build actually relies on. + #[test] + fn items_nested_in_an_inactive_module_are_not_indexed() { + let file = syn::parse_file( + r#" + #[cfg(windows)] + mod inactive { + struct S { + count: String, + } + + const COUNT: &str = "not built"; + } + + struct S { + count: u32, + } + + const COUNT: u32 = 1; + "#, + ) + .expect("the file parses"); + let selection = Selection::parse("expr.increment").expect("the numeric selector resolves"); + let indexes = indexes_in(&file, &selection, &CfgSet::parse("unix\n")); + + assert_eq!(indexes.fields.get("count"), Some(&true)); + assert_eq!(indexes.constants.get("COUNT"), Some(&true)); + } + + /// The collector never offers a mutant in test code, so evidence drawn from test code answers + /// questions about production code it is not part of. The gate is [`CfgSet::skip_gate`], not + /// [`CfgSet::holds_for`], for exactly this: a `#[cfg(test)]` predicate *holds* for the + /// instrumented build, and reading `holds_for` alone let the helper below demote the active + /// `count` and `COUNT` to unknown. + /// + /// Unlike the two fixtures above this needs no enforced set, because the test gate is decided + /// without consulting whether predicates are enforced at all. + #[test] + fn test_gated_fields_and_constants_do_not_pollute_the_index() { + let file = syn::parse_file( + r#" + struct Active { + count: u32, + } + + #[cfg(test)] + mod tests { + struct Helper { + count: String, + } + + const COUNT: &str = "fixture"; + } + + const COUNT: u32 = 1; + "#, + ) + .expect("the file parses"); + let selection = Selection::parse("expr.increment").expect("the numeric selector resolves"); + let indexes = indexes_in(&file, &selection, &CfgSet::unconditional()); + + assert_eq!(indexes.fields.get("count"), Some(&true)); + assert_eq!(indexes.constants.get("COUNT"), Some(&true)); + } + + /// Conditional compilation inside a body is written on statements, which no item visitor ever + /// sees. An inactive `let` says nothing about the binding the build actually has, and a + /// numeric use inside an inactive statement is evidence about code that is not there. + #[test] + fn statements_the_build_discards_are_not_indexed() { + let file = syn::parse_file( + r" + fn f(limit: usize) { + #[cfg(windows)] + let _ = 1 < only_on_windows; + + let _ = limit; + } + ", + ) + .expect("the file parses"); + let selection = Selection::parse("expr.increment").expect("the numeric selector resolves"); + let indexes = indexes_in(&file, &selection, &CfgSet::parse("unix\n")); + + assert!( + !indexes.numeric_uses.names.contains("only_on_windows"), + "a discarded statement must not leave numeric evidence behind" + ); + } } diff --git a/crates/cargo-gamma-engine/src/ops/collect/collector/noop.rs b/crates/cargo-gamma-engine/src/ops/collect/collector/noop.rs index 38984429..9412b76a 100644 --- a/crates/cargo-gamma-engine/src/ops/collect/collector/noop.rs +++ b/crates/cargo-gamma-engine/src/ops/collect/collector/noop.rs @@ -54,6 +54,15 @@ pub(super) fn is_noop(replacement: &str, original: &str, shape: Shape, defaults: /// Deliberately narrow: it answers for this one shape and nothing else, rather than pretending to /// decide equivalence in general. pub(super) fn is_same_leak(replacement: &str, original: &str, defaults: &DefaultPaths, defaulted_types: &[String]) -> bool { + // Every match here requires `leak` as a literal identifier in both expressions' `Box::leak` + // path (checked later by `path_ends_with`), so its absence from the raw text of either side + // rules out a match without tokenizing either one. Almost no replacement or original is this + // shape, so this skips the two token passes below entirely for the overwhelming majority of + // candidates. + if !replacement.contains("leak") || !original.contains("leak") { + return false; + } + let (Some(replacement), Some(original)) = (leaked_value(replacement), leaked_value(original)) else { return false; }; @@ -380,6 +389,23 @@ mod tests { assert_eq!(marker().saturating_sub(second), expected_growth); } + /// The substring pre-check that skips tokenizing non-leak shapes must not change the answer: + /// it has to still recognize a genuine match and still reject anything lacking the literal + /// `leak` text, which is the only case it fast-paths. + #[test] + fn the_leak_prefilter_rejects_and_accepts_the_same_pairs_as_full_tokenization() { + let defaults = defaults(); + + assert!(is_same_leak( + "Box::leak(Box::new(Default::default()))", + "Box::leak(Box::new(T::default()))", + &defaults, + &[String::from("T")], + )); + assert!(!is_same_leak("None", "0", &defaults, &[])); + assert!(!is_same_leak("Box::leak(Box::new(1))", "0", &defaults, &[])); + } + #[test] fn leaked_values_and_call_arguments_reject_non_plain_calls() { let leaked = "&*(Box::leak(Box::new((value))))"; diff --git a/crates/cargo-gamma-engine/src/ops/collect/collector/phase_one.rs b/crates/cargo-gamma-engine/src/ops/collect/collector/phase_one.rs index 2da01463..b418710c 100644 --- a/crates/cargo-gamma-engine/src/ops/collect/collector/phase_one.rs +++ b/crates/cargo-gamma-engine/src/ops/collect/collector/phase_one.rs @@ -3,42 +3,68 @@ //! Fusing the stated-value audit and the numeric/import indexes into one syntax-tree walk. //! -//! [`stated::check`](super::super::stated::check) and [`indexes`](super::indexes::indexes) each -//! drive their own [`syn::visit::Visit`] over the same file, and the [`Collector`](super::Collector) -//! that follows them drives a third. The two pre-passes visit completely disjoint sets of node -//! kinds — `Audit` reads attributes and function-like items, `Walk` reads structs, `use`s, -//! constants and a handful of numeric-looking expressions — so nothing about combining them into -//! one walk changes what either one sees or in what order it sees it: every visit method below is -//! exactly the local update the corresponding standalone type already made, run in the same -//! recursive descent, just not paying for that descent twice. +//! [`stated::check`](super::super::stated::check) and [`indexes_in`](super::indexes::indexes_in) +//! each drive their own [`syn::visit::Visit`] over the same file, and the +//! [`Collector`](super::Collector) that follows them drives a third. The two pre-passes visit +//! completely disjoint sets of node kinds — `Audit` reads attributes and function-like items, +//! `Walk` reads structs, `use`s, constants and a handful of numeric-looking expressions — so +//! nothing about combining them into one walk changes what either one sees or in what order it +//! sees it: every visit method below is exactly the local update the corresponding standalone type +//! already made, run in the same recursive descent, just not paying for that descent twice. +//! +//! Both pre-passes also gate that shared descent by the same decision the collector itself reads +//! before it offers a mutant — [`CfgSet::skip_gate`], which excludes code a false predicate strips +//! *and* code confined to the test build. `visit_item`, `visit_impl_item`, and `visit_trait_item` +//! are this walk's dispatch points for every item and associated item, wherever it is nested, and +//! `visit_stmt` and `visit_expr` cover the levels inside a body that no item visitor sees, so +//! refusing to descend there keeps a skipped region from validating an attribute or indexing a +//! declaration the candidates that follow will never mutate. +//! +//! That makes this pass's stated-value audit narrower than +//! [`stated::check`](super::super::stated::check) run on its own, which reads a whole file and +//! knows nothing about configuration. The narrowing is deliberate in both directions: a malformed +//! `#[gamma::value(...)]` cannot fail a campaign that never compiles the code it sits in, and a +//! `#[gamma::value(...)]` inside a `#[cfg(test)]` module is a hint on code this tool does not +//! mutate — `rustc` still rejects a malformed one when the crate's own tests are built. //! //! [`super::defaults::DefaultPaths`] deliberately stays out of this fusion. It never was a -//! recursive [`syn::visit::Visit`] walk — [`DefaultPaths::of`](super::defaults::DefaultPaths::of) +//! recursive [`syn::visit::Visit`] walk — [`DefaultPaths::of_in`](super::defaults::DefaultPaths::of_in) //! is a single pass over `file.items` — so folding it in here would not remove a traversal, only //! move an already-cheap one. use syn::visit::{self, Visit}; use syn::{ - Attribute, ExprBinary, ExprForLoop, ExprIndex, ExprMethodCall, ImplItemConst, ImplItemFn, ItemConst, ItemFn, ItemStatic, ItemStruct, - ItemUse, TraitItemConst, TraitItemFn, + Attribute, Expr, ExprBinary, ExprForLoop, ExprIndex, ExprMethodCall, ImplItem, ImplItemConst, ImplItemFn, Item, ItemConst, ItemFn, + ItemStatic, ItemStruct, ItemUse, Stmt, TraitItem, TraitItemConst, TraitItemFn, }; +use super::super::defaults::{impl_item_attrs, item_attrs, trait_item_attrs}; use super::super::stated::{self, Audit}; use super::indexes::{Indexes, Walk}; +use super::predicates::{expr_attrs, stmt_attrs}; use crate::Result; +use crate::cfg::CfgSet; use crate::ops::registry::Selection; use crate::parse::SourceFile; /// Runs the stated-value audit and the numeric/import indexes in the same walk over a file, then /// reports the audit's fault exactly as [`stated::check`](super::super::stated::check) would. /// +/// `cfg` decides which code either pre-pass is even allowed to learn from: a malformed or misplaced +/// `#[gamma::value(...)]` behind a predicate the selected build does not satisfy must not fail a +/// campaign that predicate keeps out of the build, and a declaration or numeric use behind the same +/// false predicate must not inform a guess made about active code. Test-gated code is held out for +/// the same reason — see [`Collector::skipped`](super::Collector::skipped), whose rule this shares +/// exactly and which the candidates that follow this pre-pass are already held to. +/// /// Returns the indexes only when there is no fault to report, matching the order the two passes /// already ran in at their one call site: the stated-value check has always run, and had to fail /// the whole file, before the indexes it built were of any use to a collector that would never run. -pub(in crate::ops::collect) fn run(file: &SourceFile, selection: &Selection) -> Result { +pub(in crate::ops::collect) fn run(file: &SourceFile, selection: &Selection, cfg: &CfgSet) -> Result { let mut combined = PhaseOne { audit: Audit::default(), - walk: Walk::new(selection), + walk: Walk::new(selection, cfg), + cfg: cfg.clone(), }; combined.visit_file(&file.ast); @@ -52,10 +78,12 @@ pub(in crate::ops::collect) fn run(file: &SourceFile, selection: &Selection) -> /// /// Holds both sub-visitors' state rather than merging their fields into one type, so each keeps /// exactly the fields, invariants and standalone tests it already had; only the traversal itself is -/// shared. +/// shared. `cfg` is held here rather than in either sub-visitor because it gates the shared +/// traversal itself — see [`Self::visit_item`] — not either sub-visitor's own state. struct PhaseOne { audit: Audit, walk: Walk, + cfg: CfgSet, } #[expect( @@ -68,6 +96,60 @@ impl<'ast> Visit<'ast> for PhaseOne { visit::visit_attribute(self, node); } + /// Gates every item this walk might otherwise audit or index by the same decision the collector + /// reads before it ever offers a mutant. + /// + /// [`CfgSet::skip_gate`] rather than [`CfgSet::holds_for`], because the collector excludes test + /// code as well as configured-out code, and either kind is code no candidate will be offered + /// in — so neither may fail a run over a stated value nor inform a guess about the code that + /// remains. + /// + /// `visit_item` is the single dispatch point every top-level and nested item passes through — + /// including a local item declared inside a function body — so gating here, rather than + /// separately in each of the item-kind methods below, keeps one skipped item from reaching + /// any of them. + fn visit_item(&mut self, node: &'ast Item) { + if !self.cfg.skip_gate(item_attrs(node)) { + visit::visit_item(self, node); + } + } + + /// Gates every associated item inside an `impl` block, for the same reason [`Self::visit_item`] + /// gates top-level items. + fn visit_impl_item(&mut self, node: &'ast ImplItem) { + if !self.cfg.skip_gate(impl_item_attrs(node)) { + visit::visit_impl_item(self, node); + } + } + + /// Gates every associated item inside a `trait` block, for the same reason [`Self::visit_item`] + /// gates top-level items. + fn visit_trait_item(&mut self, node: &'ast TraitItem) { + if !self.cfg.skip_gate(trait_item_attrs(node)) { + visit::visit_trait_item(self, node); + } + } + + /// Gates every statement, which is the level conditional compilation is written at inside a + /// body and which no item visitor above ever sees. + /// + /// The collector descends a block statement by statement for exactly this reason, so without + /// this the pre-pass would index a `#[cfg(windows)] let n: usize = 0;` that a Unix build + /// discards, and that evidence would then answer for the `n` the build actually has. + fn visit_stmt(&mut self, node: &'ast Stmt) { + if !self.cfg.skip_gate(stmt_attrs(node)) { + visit::visit_stmt(self, node); + } + } + + /// Gates every expression, covering the positions `rustc` admits an attribute in today and the + /// ones it does not admit yet, exactly as the collector's own `visit_expr` does. + fn visit_expr(&mut self, node: &'ast Expr) { + if !self.cfg.skip_gate(expr_attrs(node)) { + visit::visit_expr(self, node); + } + } + fn visit_item_fn(&mut self, node: &'ast ItemFn) { self.audit.on_item_fn(node); visit::visit_item_fn(self, node); diff --git a/crates/cargo-gamma-engine/src/ops/collect/collector/predicates.rs b/crates/cargo-gamma-engine/src/ops/collect/collector/predicates.rs index 580a117b..8c7d023f 100644 --- a/crates/cargo-gamma-engine/src/ops/collect/collector/predicates.rs +++ b/crates/cargo-gamma-engine/src/ops/collect/collector/predicates.rs @@ -12,20 +12,6 @@ use syn::{ use super::super::defaults::DefaultPaths; use super::values::{Kind, resolve_type, strip}; -/// Returns a printable name for a type, used to build the enclosing item path for `impl` blocks. -pub(super) fn type_name(ty: &Type) -> String { - match ty { - Type::Path(path) => path - .path - .segments - .last() - .map_or_else(|| "_".to_owned(), |segment| segment.ident.to_string()), - - Type::Reference(reference) => type_name(&reference.elem), - _ => "_".to_owned(), - } -} - /// Returns whether borrowing an expression relies on it being promoted to static storage. /// /// Promotion is a property of const-evaluability, which cannot be decided from syntax alone. What diff --git a/crates/cargo-gamma-engine/src/ops/collect/defaults.rs b/crates/cargo-gamma-engine/src/ops/collect/defaults.rs index 1a39f71d..4925fe2b 100644 --- a/crates/cargo-gamma-engine/src/ops/collect/defaults.rs +++ b/crates/cargo-gamma-engine/src/ops/collect/defaults.rs @@ -26,7 +26,11 @@ pub(super) struct DefaultPaths { } impl DefaultPaths { - /// Collects standard-trait aliases and local shadows from a parsed file. + /// Collects standard-trait aliases and local shadows from a parsed file, ignoring `cfg`. + /// + /// Every production caller threads a real [`CfgSet`] through [`Self::of_in`]; this unconditional + /// form only remains to let tests build a `DefaultPaths` from bare source without a `CfgSet`. + #[cfg(test)] pub(super) fn of(file: &File) -> Self { Self::of_in(file, &CfgSet::unconditional()) } @@ -223,7 +227,12 @@ impl DefaultPaths { } /// Returns the outer attributes of an item. -fn item_attrs(item: &syn::Item) -> &[syn::Attribute] { +/// +/// `pub(in crate::ops::collect)` so every discovery prepass — the stated-value audit, the +/// type/import index, and the fused phase-one pass that drives both — can gate its own descent by +/// the same predicate the collector itself reads before ever offering a mutant, rather than each +/// prepass reaching its own conclusion about which items the selected build actually contains. +pub(in crate::ops::collect) fn item_attrs(item: &syn::Item) -> &[syn::Attribute] { match item { syn::Item::Const(node) => &node.attrs, syn::Item::Enum(node) => &node.attrs, @@ -244,6 +253,34 @@ fn item_attrs(item: &syn::Item) -> &[syn::Attribute] { } } +/// Returns the outer attributes of an associated item inside an `impl` block. +/// +/// Shared with the same discovery prepasses [`item_attrs`] serves, so an inactive associated +/// constant or method is excluded from the stated-value audit and the type/import index exactly as +/// the collector already excludes it from candidate collection. +pub(in crate::ops::collect) fn impl_item_attrs(item: &syn::ImplItem) -> &[syn::Attribute] { + match item { + syn::ImplItem::Const(node) => &node.attrs, + syn::ImplItem::Fn(node) => &node.attrs, + syn::ImplItem::Type(node) => &node.attrs, + syn::ImplItem::Macro(node) => &node.attrs, + _ => &[], + } +} + +/// Returns the outer attributes of an associated item inside a `trait` block. +/// +/// Shared with the same discovery prepasses [`item_attrs`] serves; see its documentation. +pub(in crate::ops::collect) fn trait_item_attrs(item: &syn::TraitItem) -> &[syn::Attribute] { + match item { + syn::TraitItem::Const(node) => &node.attrs, + syn::TraitItem::Fn(node) => &node.attrs, + syn::TraitItem::Type(node) => &node.attrs, + syn::TraitItem::Macro(node) => &node.attrs, + _ => &[], + } +} + /// Names type parameters whose bounds explicitly promise the standard `Default` trait. pub(super) fn standard_defaulted_parameters(generics: &Generics, defaults: &DefaultPaths) -> Vec { let mut names = Vec::new(); @@ -926,6 +963,23 @@ mod tests { ); } + #[test] + fn associated_item_attrs_read_macros_and_fall_back_for_verbatim_tokens() { + let implementation: syn::ImplItem = parse_quote!( + #[allow(dead_code)] + m!(); + ); + let declaration: syn::TraitItem = parse_quote!( + #[allow(dead_code)] + m!(); + ); + + assert_eq!(impl_item_attrs(&implementation).len(), 1); + assert_eq!(trait_item_attrs(&declaration).len(), 1); + assert!(impl_item_attrs(&syn::ImplItem::Verbatim(proc_macro2::TokenStream::new())).is_empty()); + assert!(trait_item_attrs(&syn::TraitItem::Verbatim(proc_macro2::TokenStream::new())).is_empty()); + } + /// A `where` clause is read the same way inline bounds are: a plain type bound reports its /// parameter, and every other predicate shape -- a lifetime bound, a bound on a type this index /// cannot key by a single name, and a bound on a multi-segment path -- is passed over rather than diff --git a/crates/cargo-gamma-engine/src/ops/collect/definitions.rs b/crates/cargo-gamma-engine/src/ops/collect/definitions.rs index 986277b0..2bf0aed4 100644 --- a/crates/cargo-gamma-engine/src/ops/collect/definitions.rs +++ b/crates/cargo-gamma-engine/src/ops/collect/definitions.rs @@ -10,7 +10,7 @@ use compact_str::CompactString; use super::Candidate; use crate::HashMap; -use crate::model::{Interner, MutantDefinition, MutationSite, mutant_id_with_discriminator, normalize_site_text, site_key}; +use crate::model::{Interner, MutantDefinition, MutationSite, SiteIndex, mutant_id_with_discriminator, normalize_site_text, site_key}; use crate::parse::SourceFile; /// Turns candidates into source-level mutant definitions, assigning stable ids. @@ -73,14 +73,14 @@ pub fn into_definitions(file: &SourceFile, candidates: Vec) -> Vec Option> { /// # Errors /// /// Returns an error if a stated value is malformed, duplicated, written on something that is not a -/// function, or written on a function with no body to replace. The proc macro rejects all four at -/// compile time, so a crate that builds cannot reach them — but this tool reads source rather than -/// build output, and `gamma list mutants` runs against trees that have never been compiled. -/// Ignoring one there would leave a hint that reads as if it works and does nothing, which is the -/// failure mode the whole channel exists to avoid. +/// function, written on a function with no body to replace, or written on a function collection +/// never reads a value from — a `const fn`, or one whose body is empty. The proc macro rejects all +/// of them at compile time, so a crate that builds cannot reach them — but this tool reads source +/// rather than build output, and `gamma list mutants` runs against trees that have never been +/// compiled. Ignoring one there would leave a hint that reads as if it works and does nothing, +/// which is the failure mode the whole channel exists to avoid. /// /// Fatal rather than a warning, for the same reason a suppression naming no mutator is: the run /// that swallows it reports a score computed from a population the author did not ask for. @@ -138,7 +147,16 @@ pub(super) struct Audit { impl Audit { /// Records what an item's own attributes get wrong, and which of them a function claimed. - fn item(&mut self, attrs: &[Attribute]) { + /// + /// `inert` is what to say when the function is one collection never reads a stated value from + /// at all — a `const fn`, or a function whose body is empty. Both return before `stated_range` + /// is consulted, so an attribute there produces no mutant and reads as if it does; naming the + /// reason is what turns that silence into a diagnostic. + /// + /// A malformed argument list is reported ahead of an inert position, because it is the more + /// specific mistake: an author who wrote `#[gamma::value(1 +)]` on a `const fn` has two things + /// to fix, and the expression is the one they can see is wrong. + fn item(&mut self, attrs: &[Attribute], inert: Option<&'static str>) { let stated: Vec<&Attribute> = attrs.iter().filter(|attribute| is_stated_value(attribute)).collect(); if let Some(second) = stated.get(1) { @@ -152,6 +170,8 @@ impl Audit { if malformed { self.faults.push((attribute.span().byte_range(), MALFORMED.to_owned())); + } else if let Some(message) = inert { + self.faults.push((attribute.span().byte_range(), message.to_owned())); } } } @@ -165,12 +185,12 @@ impl Audit { /// The local update `visit_item_fn` makes, without its recursive continuation. pub(super) fn on_item_fn(&mut self, node: &ItemFn) { - self.item(&node.attrs); + self.item(&node.attrs, inert_reason(node.sig.constness.is_some(), node.block.stmts.is_empty())); } /// The local update `visit_impl_item_fn` makes, without its recursive continuation. pub(super) fn on_impl_item_fn(&mut self, node: &ImplItemFn) { - self.item(&node.attrs); + self.item(&node.attrs, inert_reason(node.sig.constness.is_some(), node.block.stmts.is_empty())); } /// The local update `visit_trait_item_fn` makes, without its recursive continuation. @@ -178,14 +198,33 @@ impl Audit { // A declaration has no body to replace, and a stated value is not inherited by the // implementations any more than it is inherited from an `impl` block. Left unreported, it // would read as a hint that works and generate nothing anywhere. - if node.default.is_none() { + let Some(default) = node.default.as_ref() else { for attribute in node.attrs.iter().filter(|attribute| is_stated_value(attribute)) { self.faults.push((attribute.span().byte_range(), BODILESS.to_owned())); let _claimed = self.on_functions.insert(attribute.span().byte_range().start); } - } else { - self.item(&node.attrs); - } + + return; + }; + + self.item(&node.attrs, inert_reason(node.sig.constness.is_some(), default.stmts.is_empty())); + } +} + +/// Returns why a function collection reaches would still never read a stated value from, if it is +/// one of those. +/// +/// The two conditions are exactly the early returns collection makes before it consults an item's +/// stated value, kept as one function so the three function grammars cannot drift apart on which +/// of them counts. `const` is checked first because a `const fn` with an empty body is inert for +/// both reasons, and the const one is the one the author must resolve to get a mutant at all. +const fn inert_reason(constant: bool, empty: bool) -> Option<&'static str> { + if constant { + Some(CONSTANT) + } else if empty { + Some(EMPTY) + } else { + None } } @@ -397,6 +436,78 @@ mod tests { assert!(rejected.contains("a declaration has none"), "{rejected}"); } + /// A `const fn` body is a const context throughout, and the guard a mutant is spliced in behind + /// is a run-time call. Collection returns before it reads the stated value there, so silence + /// would leave a hint that reads as working and produces no mutant anywhere. + #[test] + fn a_value_stated_on_a_const_function_is_reported() { + let sources = [ + "#[gamma::value(0)]\nconst fn f() -> u32 { 1 }", + "struct S;\nimpl S {\n#[gamma::value(0)]\nconst fn f(&self) -> u32 { 1 }\n}", + "trait T {\n#[gamma::value(0)]\nconst fn f(&self) -> u32 { 1 }\n}", + ]; + + for source in sources { + let rejected = check(&file(source)).expect_err("a const function can carry no mutant").to_string(); + + assert!(rejected.contains("no `const fn` body may make"), "`{source}`: {rejected}"); + } + } + + /// An empty body already evaluates to `()`, so a mutant substituting a value for it would be + /// the identical program. Collection skips the site for that reason, which makes an attribute + /// there another hint that produces nothing. + #[test] + fn a_value_stated_on_an_empty_bodied_function_is_reported() { + let sources = [ + "#[gamma::value(())]\nfn f() {}", + "struct S;\nimpl S {\n#[gamma::value(())]\nfn f(&self) {}\n}", + "trait T {\n#[gamma::value(())]\nfn f(&self) {}\n}", + ]; + + for source in sources { + let rejected = check(&file(source)).expect_err("an empty body has nothing to replace").to_string(); + + assert!(rejected.contains("already evaluates to `()`"), "`{source}`: {rejected}"); + } + } + + /// A `const fn` with an empty body is inert twice over, and the const reason is the one + /// reported: making the function non-`const` is what an author has to do before a mutant is + /// possible at all, and only then does the empty body become the remaining problem. + #[test] + fn a_doubly_inert_function_reports_the_const_reason() { + let rejected = rejection("#[gamma::value(())]\nconst fn f() {}"); + + assert!(rejected.contains("no `const fn` body may make"), "{rejected}"); + } + + /// A malformed expression is reported ahead of the inert position it sits on, because it is + /// the mistake the author can see — and reporting both would be two diagnostics for one + /// attribute. + #[test] + fn a_malformed_value_on_an_inert_function_reports_the_expression() { + let rejected = rejection("#[gamma::value(1 +)]\nconst fn f() -> u32 { 1 }"); + + assert!(rejected.contains("states one value"), "{rejected}"); + assert!(!rejected.contains("no `const fn` body may make"), "{rejected}"); + } + + /// Neither rule reaches past the function it is about: a `const` *item* inside an ordinary + /// body, and an ordinary function whose body is a single expression, both still state values. + #[test] + fn a_function_that_can_carry_a_mutant_is_still_accepted() { + let sources = [ + "#[gamma::value(0)]\nfn f() -> u32 { const N: u32 = 1; N }", + "#[gamma::value(0)]\nasync fn f() -> u32 { 1 }", + "struct S;\nimpl S {\n#[gamma::value(0)]\nfn f(&self) -> u32 { 1 }\n}", + ]; + + for source in sources { + check(&file(source)).unwrap_or_else(|error| panic!("`{source}` can carry a mutant: {error}")); + } + } + /// A nested function states its own value, at its own site, and is not the enclosing function /// stating a second one. #[test] diff --git a/crates/cargo-gamma-engine/src/ops/collect/tests.rs b/crates/cargo-gamma-engine/src/ops/collect/tests.rs index 50b64231..e4390107 100644 --- a/crates/cargo-gamma-engine/src/ops/collect/tests.rs +++ b/crates/cargo-gamma-engine/src/ops/collect/tests.rs @@ -112,8 +112,7 @@ fn cached_normalization_matches_a_direct_recomputation_for_every_replacement_at_ &candidate.item_path, candidate.mutator, &normalized, - 0, - candidate.replacement_index, + crate::model::SiteIndex::new(0, candidate.replacement_index), (candidate.mutator == "fn_value.err_with").then_some(candidate.replacement.as_str()), ) }) @@ -414,6 +413,7 @@ fn item_paths_include_module_impl_and_method() { let found = candidates(source, "arith.add_to_sub"); assert_eq!(&*found[0].item_path, "m::S::go"); + assert!(found[0].trait_impl.is_none()); } #[test] @@ -430,11 +430,69 @@ fn item_paths_distinguish_trait_defaults_from_each_other_and_free_functions() { } #[test] -fn impl_paths_strip_references_and_generics() { - let source = "struct S(T); impl S { fn go(&self, a: i32) -> i32 { a + 1 } }"; +fn impl_paths_retain_qualification_generics_and_references() { + let source = "struct S; + impl a::S { fn go(&self) -> i32 { 1 + 1 } } + impl b::S { fn go(&self) -> i32 { 2 + 2 } } + impl S { fn go(&self) -> i32 { 3 + 3 } } + impl S { fn go(&self) -> i32 { 4 + 4 } } + impl S { fn go(&self) -> i32 { 5 + 5 } } + trait Go { fn go(&self) -> i32; } + impl Go for &S { fn go(&self) -> i32 { 6 + 6 } } + impl Go for S { fn go(&self) -> i32 { 7 + 7 } }"; let found = candidates(source, "arith.add_to_sub"); + let paths: Vec<&str> = found.iter().map(|candidate| &*candidate.item_path).collect(); + + assert_eq!( + paths, + vec![ + "a::S::go", + "b::S::go", + "S::go", + "S::go", + "S::go", + "<&S as Go>::go", + "::go", + ] + ); +} + +#[test] +fn impl_identities_survive_reordering_across_qualified_instantiated_and_reference_self_types() { + let first = "impl a::S { fn go(&self) -> i32 { 1 + 1 } } + impl S { fn go(&self) -> i32 { 1 + 1 } } + impl S { fn go(&self) -> i32 { 1 + 1 } } + trait Go { fn go(&self) -> i32; } + impl Go for &S { fn go(&self) -> i32 { 1 + 1 } } + impl Go for S { fn go(&self) -> i32 { 1 + 1 } }"; + let second = "trait Go { fn go(&self) -> i32; } + impl Go for S { fn go(&self) -> i32 { 1 + 1 } } + impl Go for &S { fn go(&self) -> i32 { 1 + 1 } } + impl S { fn go(&self) -> i32 { 1 + 1 } } + impl S { fn go(&self) -> i32 { 1 + 1 } } + impl a::S { fn go(&self) -> i32 { 1 + 1 } }"; + + let identities = |source: &str| { + let file = SourceFile::parse("test.rs", source.to_owned()).expect("the fixture parses"); + let selection = Selection::parse("arith.add_to_sub").expect("the mutator exists"); + let mut ids: Vec<(String, String)> = into_definitions(&file, collect(&file, &selection)) + .into_iter() + .map(|mutant| (mutant.item_path.to_string(), mutant.id.to_string())) + .collect(); + + ids.sort_unstable(); + ids + }; + + let before = identities(first); + let after = identities(second); - assert_eq!(&*found[0].item_path, "S::go"); + assert_eq!( + before, after, + "every qualified, instantiated, or reference self type must keep its own id regardless of \ + impl block order" + ); + assert_eq!(before.len(), 5, "each distinct self type must produce its own entry, not share one"); } #[test] @@ -474,6 +532,20 @@ fn trait_implementation_paths_keep_identical_methods_stable_when_reordered() { assert!(paths.contains(&"::f"), "{paths:?}"); } +#[test] +fn trait_implementations_record_the_terminal_trait_name() { + let source = "struct A; struct B; struct C; + impl Debug for A { fn fmt(&self) -> i32 { 1 + 1 } } + impl fmt::Debug for B { fn fmt(&self) -> i32 { 2 + 2 } } + impl core::fmt::Debug for C { fn fmt(&self) -> i32 { 3 + 3 } } + fn outside() -> i32 { 4 + 4 }"; + let found = candidates(source, "arith.add_to_sub"); + + assert_eq!(found.len(), 4); + assert!(found[..3].iter().all(|candidate| candidate.trait_impl.as_deref() == Some("Debug"))); + assert!(found[3].trait_impl.is_none(), "trait context must not escape its implementation"); +} + #[test] fn test_functions_are_not_mutated() { let source = "#[test] fn t() { assert_eq!(1 + 1, 2); }"; @@ -1131,8 +1203,8 @@ fn impl_paths_handle_reference_and_non_path_self_types() { let found = candidates(source, "arith.add_to_sub"); let paths: Vec<&str> = found.iter().map(|candidate| &*candidate.item_path).collect(); - assert!(paths.contains(&"::f"), "{paths:?}"); - assert!(paths.contains(&"<_ as T>::f"), "{paths:?}"); + assert!(paths.contains(&"<&S as T>::f"), "{paths:?}"); + assert!(paths.contains(&"<(S,) as T>::f"), "{paths:?}"); } #[test] @@ -2702,7 +2774,7 @@ fn a_non_ascii_file_instruments_into_source_that_still_parses() { let mutations: Vec<_> = found .iter() .enumerate() - .map(|(ordinal, mutant)| crate::schema::AssignedMutant::new(u32::try_from(ordinal).unwrap(), mutant)) + .map(|(ordinal, mutant)| crate::schema::AssignedMutant::new(crate::schema::Ordinal::new(u32::try_from(ordinal).unwrap()), mutant)) .collect(); let instrumented = crate::schema::instrument(&file.text, &mutations).expect("instruments"); @@ -3298,3 +3370,186 @@ fn the_fused_pass_reports_the_same_fault_as_check_stated_and_collects_nothing() "the fused pass must report the identical fault check_stated would have reported alone" ); } + +/// Runs both entry points over one fixture and returns their candidates side by side, reduced to +/// the fields that identify each. +/// +/// Written once rather than per fixture because the invariant the tests below check is always the +/// same one: the pre-pass the fused entry point runs must not learn anything the pass +/// [`collect_with`] runs internally would not, or the two disagree about which guesses an active +/// site is entitled to. +fn separately_and_fused(source: &str, ops: &str, cfg: &CfgSet) -> (Vec, Vec) { + let file = SourceFile::parse("fixture.rs", source.to_owned()).unwrap(); + let selection = Selection::parse(ops).unwrap(); + let defaults = Defaults::of(&file.ast); + + let separately = collect_with(&file, &selection, cfg, &defaults); + let fused = check_stated_and_collect_with(&file, &selection, cfg, &defaults).expect("the fixture has no fault to report"); + + ( + separately.iter().map(candidate_key).collect(), + fused.iter().map(candidate_key).collect(), + ) +} + +/// The tuple [`candidate_key`] reduces a candidate to, named so the helper above can return two +/// lists of them without spelling it out twice. +type CandidateKey = (Range, &'static str, CompactString, u32, String, Shape); + +/// Code the selected build strips is not code either pass may learn from, and the two passes must +/// strip exactly the same code. +/// +/// The gate is written twice — once in `indexes::Walk`'s own visitor, which `collect_with` drives, +/// and once in `phase_one::PhaseOne`'s, which the fused entry point drives — so the two can drift +/// apart with nothing else noticing. The fixture makes such a drift visible in the candidates +/// rather than only in the indexes: `count` is a number in the active struct and a `String` in the +/// inactive one, so whichever side indexed the inactive field would demote the name to unknown and +/// withhold the perturbation the active site is entitled to. +/// +/// An enforced set is essential — [`CfgSet::unconditional`] answers every predicate `true`, so +/// nothing would be stripped and the fixture would test nothing. +#[test] +fn the_fused_pass_strips_the_same_inactive_code_the_separate_passes_do() { + let source = r#" + struct Inactive { + #[cfg(windows)] + count: String, + } + + struct Active { + count: u32, + } + + #[cfg(windows)] + const LIMIT: &str = "not built"; + + const LIMIT: u32 = 1; + + fn f(record: &Active) -> u32 { + record.count + LIMIT + } + "#; + let cfg = CfgSet::parse("unix\n"); + + let (separately, fused) = separately_and_fused(source, "expr.increment,expr.decrement,arith.add_to_sub", &cfg); + + assert!(!fused.is_empty(), "the fixture is expected to produce candidates"); + assert_eq!( + fused, separately, + "the fused pass must strip exactly what the separate passes strip" + ); +} + +/// The collector offers no mutant in test code, so neither pass may draw evidence from it. This is +/// the case `CfgSet::holds_for` alone got wrong: `cfg(test)` *holds* for the instrumented build, +/// so a pre-pass reading it indexed the helper below while the collector skipped it, and the +/// guesses made about the active `count` were drawn from a struct the measured code has nothing to +/// do with. +/// +/// No enforced set is needed here, because the test gate is decided without consulting whether +/// predicates are enforced at all — which is also why this fixture was wrong for every caller that +/// passes an unconditional set. +#[test] +fn the_fused_pass_strips_the_same_test_gated_code_the_separate_passes_do() { + let source = r#" + #[cfg(test)] + mod tests { + struct Helper { + count: String, + } + + const LIMIT: &str = "fixture"; + } + + struct Active { + count: u32, + } + + const LIMIT: u32 = 1; + + fn f(record: &Active) -> u32 { + record.count + LIMIT + } + "#; + let cfg = CfgSet::unconditional(); + + let (separately, fused) = separately_and_fused(source, "expr.increment,expr.decrement,arith.add_to_sub", &cfg); + + assert!(!fused.is_empty(), "the fixture is expected to produce candidates"); + assert_eq!( + fused, separately, + "the fused pass must strip exactly what the separate passes strip" + ); +} + +/// Conditional compilation inside a body is written on statements and locals, which no item +/// visitor reaches — so this is the level the two visitors are most likely to drift apart at, and +/// the level `indexes_in`'s own unit tests pin against the collector's rule. +#[test] +fn the_fused_pass_strips_the_same_inactive_statements_the_separate_passes_do() { + let source = r" + fn f(data: &[u8]) -> usize { + #[cfg(windows)] + let _ = 1 < only_on_windows; + + let count: usize = 1; + + count + data.len() + } + "; + let cfg = CfgSet::parse("unix\n"); + + let (separately, fused) = separately_and_fused(source, "expr.increment,expr.decrement,arith.add_to_sub", &cfg); + + assert!(!fused.is_empty(), "the fixture is expected to produce candidates"); + assert_eq!( + fused, separately, + "the fused pass must strip exactly what the separate passes strip" + ); +} + +/// Every stated value the two channels accept has to reach a candidate, or the attribute is a hint +/// that reads as working and measures nothing — the failure the `const fn` and empty-body +/// rejections in `stated::check` exist to make impossible. +/// +/// The three positions a function is written in are all covered, because only one of them is an +/// `ItemFn` and a gate applied in one place would leave the other two silent. +#[test] +fn every_accepted_stated_value_emits_a_candidate() { + let sources = [ + "#[gamma::value(7)]\nfn f() -> u32 { 1 }", + "struct S;\nimpl S {\n#[gamma::value(7)]\nfn f(&self) -> u32 { 1 }\n}", + "trait T {\n#[gamma::value(7)]\nfn f(&self) -> u32 { 1 }\n}", + ]; + + for source in sources { + let file = SourceFile::parse("fixture.rs", source.to_owned()).unwrap(); + + check_stated(&file).unwrap_or_else(|error| panic!("`{source}` states a value on a function: {error}")); + + let found = candidates(source, "fn_value.stated"); + + assert_eq!( + found.iter().map(|candidate| candidate.replacement.as_str()).collect::>(), + vec!["7"], + "`{source}` states a value that must become a mutant" + ); + } +} + +/// The converse of the test above: the two positions collection returns from before it reads a +/// stated value emit nothing, which is exactly why `stated::check` refuses the attribute there. +/// If either of these ever started producing a candidate, that refusal would have become wrong. +#[test] +fn the_positions_a_stated_value_is_refused_on_emit_no_candidate() { + for source in ["#[gamma::value(7)]\nconst fn f() -> u32 { 1 }", "#[gamma::value(())]\nfn f() {}"] { + assert!( + candidates(source, "fn_value.stated").is_empty(), + "`{source}` must emit nothing, which is what makes refusing the attribute correct" + ); + + let file = SourceFile::parse("fixture.rs", source.to_owned()).unwrap(); + + let _rejected = check_stated(&file).expect_err("an inert position must be reported rather than silently ignored"); + } +} diff --git a/crates/cargo-gamma-engine/src/ops/collect/traversal.rs b/crates/cargo-gamma-engine/src/ops/collect/traversal.rs index 12c3c7e3..4897dfce 100644 --- a/crates/cargo-gamma-engine/src/ops/collect/traversal.rs +++ b/crates/cargo-gamma-engine/src/ops/collect/traversal.rs @@ -51,10 +51,17 @@ pub fn collect_with(file: &SourceFile, selection: &Selection, cfg: &CfgSet, defa /// Reports a file's stated-value errors and collects its candidates in one walk of the syntax tree, /// rather than the two [`super::check_stated`] and [`collect_with`] would run one after the other. /// -/// Equivalent to calling [`super::check_stated`] and then [`collect_with`]: the same fault, in the -/// same wording, stops candidate collection before it starts, and the candidates returned when there -/// is no fault are the same candidates `collect_with` would have produced from the same inputs. The -/// only difference is that both passes now read the file's syntax tree once between them, instead of +/// The candidates are exactly the candidates [`collect_with`] would have produced from the same +/// inputs, and a fault stops collection before it starts in the same wording [`super::check_stated`] +/// would have used. The one difference is *which* stated values are audited at all: +/// [`super::check_stated`] reads a whole file and knows nothing about configuration, while this +/// pass audits only what `cfg` says the measured build compiles and this tool would mutate — +/// skipping configured-out and test-gated code, the same rule that decides where a candidate may +/// be offered. A stated value there produces no mutant under either entry point, so the fused pass +/// stays silent about it rather than failing a run over code it is not measuring; `rustc` still +/// rejects a malformed one when that code is built. +/// +/// Everything else is shared: both passes read the file's syntax tree once between them, instead of /// [`super::check_stated`]'s own pass, an index-building pass `collect_with` would otherwise run /// internally, and `collect_with`'s own candidate-collecting pass. pub fn check_stated_and_collect_with( @@ -63,7 +70,7 @@ pub fn check_stated_and_collect_with( cfg: &CfgSet, defaults: &Defaults, ) -> Result> { - let indexes = phase_one::run(file, selection)?; + let indexes = phase_one::run(file, selection, cfg)?; let collector = Collector::with_indexes(file, selection, selection.errors(), cfg, defaults, indexes); Ok(finish(file, collector)) diff --git a/crates/cargo-gamma-engine/src/ops/registry/lookup.rs b/crates/cargo-gamma-engine/src/ops/registry/lookup.rs index 58716c5e..6d23a250 100644 --- a/crates/cargo-gamma-engine/src/ops/registry/lookup.rs +++ b/crates/cargo-gamma-engine/src/ops/registry/lookup.rs @@ -40,6 +40,12 @@ pub fn families() -> Vec<&'static str> { /// Expands one selector into the mutator names it matches. /// /// A selector is a full name, a family prefix, an `@preset`, an academic alias, or `all`. +/// +/// # Errors +/// +/// Returns an error if the selector does not match a mutator name, family or sub-family prefix, +/// preset, or alias in the registry. The error suggests the closest known spelling when one is +/// close enough to be a plausible typo. pub fn resolve(selector: &str) -> Result> { if selector == "all" { return Ok(REGISTRY.iter().map(|m| m.name).collect()); diff --git a/crates/cargo-gamma-engine/src/ops/registry/mod.rs b/crates/cargo-gamma-engine/src/ops/registry/mod.rs index 0eafc71b..b09cb4e4 100644 --- a/crates/cargo-gamma-engine/src/ops/registry/mod.rs +++ b/crates/cargo-gamma-engine/src/ops/registry/mod.rs @@ -14,8 +14,13 @@ mod mutator; mod preset; mod selection; +#[doc(inline)] pub use catalog::{PRESETS, REGISTRY}; +#[doc(inline)] pub use lookup::{families, find, find_preset, resolve}; +#[doc(inline)] pub use mutator::Mutator; +#[doc(inline)] pub use preset::Preset; +#[doc(inline)] pub use selection::Selection; diff --git a/crates/cargo-gamma-engine/src/parse/comment.rs b/crates/cargo-gamma-engine/src/parse/comment.rs index 1de3319e..ea4012bf 100644 --- a/crates/cargo-gamma-engine/src/parse/comment.rs +++ b/crates/cargo-gamma-engine/src/parse/comment.rs @@ -30,8 +30,14 @@ pub struct Comment { /// Byte range of the comment including its delimiters. pub span: Range, - /// The comment text with its delimiters and one leading space removed. - pub body: String, + /// Byte range of the comment text with its delimiters and surrounding whitespace removed. + /// + /// Stored as a range into the already-owned source text rather than an owned, trimmed copy: + /// discovery scans every comment in a file to classify and locate it, but only ordinary line + /// comments ever have their text read again downstream, so allocating a trimmed `String` for + /// every documentation and block comment as well would pay for text that is never looked at. + /// Use [`SourceFile::slice`](super::SourceFile::slice) to materialize it on demand. + pub body: Range, /// 1-based line number of the comment's first line. pub line: usize, @@ -132,6 +138,13 @@ fn build_comment(kind: CommentKind, raw: &str, span: Range, text: &str, l .trim_start_matches('!') .trim_end_matches('/') .trim_end_matches('*'); + let trimmed = stripped.trim(); + + // Every `trim*` call above returns a subslice of `raw` rather than a copy, so the pointer + // offset between `trimmed` and `raw` is exactly how many leading bytes were stripped; the + // absolute body range is that offset from `span.start`, spanning `trimmed`'s own length. + let leading = trimmed.as_ptr() as usize - raw.as_ptr() as usize; + let body = span.start + leading..span.start + leading + trimmed.len(); let line_index = match lines.binary_search(&span.start) { Ok(exact) => exact, @@ -144,7 +157,7 @@ fn build_comment(kind: CommentKind, raw: &str, span: Range, text: &str, l Comment { kind, span, - body: stripped.trim().to_owned(), + body, line: line_index + 1, trailing: !before.trim().is_empty(), } @@ -333,6 +346,16 @@ mod tests { "a span is not on a character boundary: {comment:?}" ); + assert!(comment.body.start <= comment.body.end, "a body range runs backwards: {comment:?}"); + assert!( + comment.span.start <= comment.body.start && comment.body.end <= comment.span.end, + "a body range escapes its own span: {comment:?}" + ); + assert!( + text.get(comment.body.clone()).is_some(), + "a body range is not on a character boundary: {comment:?}" + ); + // Comments are found by one forward pass, so they come out in order and cannot // overlap. A scanner that failed to advance past one would break this first. assert!(comment.span.start >= previous, "spans are out of order: {comment:?}"); @@ -358,7 +381,7 @@ mod tests { assert_eq!(file.comments.len(), 1); assert_eq!(file.comments[0].kind, CommentKind::Line); - assert_eq!(file.comments[0].body, "hello"); + assert_eq!(file.slice(&file.comments[0].body), "hello"); assert_eq!(file.comments[0].line, 1); assert!(!file.comments[0].trailing); } @@ -402,7 +425,7 @@ mod tests { let file = parse("fn f() { let _q = '\\''; } // after\n"); assert_eq!(file.comments.len(), 1); - assert_eq!(file.comments[0].body, "after"); + assert_eq!(file.slice(&file.comments[0].body), "after"); } #[test] @@ -431,7 +454,7 @@ mod tests { let file = parse("fn f() -> &'static str { \"a\\\"// no\" }\n// yes\n"); assert_eq!(file.comments.len(), 1); - assert_eq!(file.comments[0].body, "yes"); + assert_eq!(file.slice(&file.comments[0].body), "yes"); } #[test] @@ -439,7 +462,7 @@ mod tests { let file = parse("fn f<'a>(x: &'a str) -> &'a str { x }\n// found me\n"); assert_eq!(file.comments.len(), 1, "{:?}", file.comments); - assert_eq!(file.comments[0].body, "found me"); + assert_eq!(file.slice(&file.comments[0].body), "found me"); } #[test] @@ -447,7 +470,7 @@ mod tests { let file = parse("fn f() -> char { '\\'' }\n// found me\n"); assert_eq!(file.comments.len(), 1, "{:?}", file.comments); - assert_eq!(file.comments[0].body, "found me"); + assert_eq!(file.slice(&file.comments[0].body), "found me"); } #[test] @@ -469,7 +492,7 @@ mod tests { let file = parse("/* outer /* inner */ still outer */\nfn f() {}\n"); assert_eq!(file.comments.len(), 1); - assert!(file.comments[0].body.contains("still outer")); + assert!(file.slice(&file.comments[0].body).contains("still outer")); } #[test] @@ -539,7 +562,7 @@ mod tests { #[test] fn comments_come_back_in_source_order() { let file = parse("// one\nfn f() {}\n// two\nfn g() {}\n// three\n"); - let bodies: Vec<&str> = file.comments.iter().map(|c| c.body.as_str()).collect(); + let bodies: Vec<&str> = file.comments.iter().map(|c| file.slice(&c.body)).collect(); assert_eq!(bodies, vec!["one", "two", "three"]); } diff --git a/crates/cargo-gamma-engine/src/parse/mod.rs b/crates/cargo-gamma-engine/src/parse/mod.rs index 2b2c2335..4489d2f8 100644 --- a/crates/cargo-gamma-engine/src/parse/mod.rs +++ b/crates/cargo-gamma-engine/src/parse/mod.rs @@ -29,8 +29,10 @@ mod comment; pub mod nesting; mod source_file; +#[doc(inline)] pub use comment::{Comment, CommentKind}; pub(crate) use comment::{comment_spans, literal_end}; #[doc(hidden)] pub use source_file::{BOM, strip_bom}; +#[doc(inline)] pub use source_file::{SourceFile, exceeds_nesting_limit, without_bom}; diff --git a/crates/cargo-gamma-engine/src/parse/source_file.rs b/crates/cargo-gamma-engine/src/parse/source_file.rs index 582bdc3d..9f78763e 100644 --- a/crates/cargo-gamma-engine/src/parse/source_file.rs +++ b/crates/cargo-gamma-engine/src/parse/source_file.rs @@ -13,24 +13,33 @@ use super::comment::{self, Comment}; use super::nesting; use crate::Result; use crate::error::error; +use crate::text::encode_controls; /// A parsed source file, with everything downstream stages need to work in byte offsets. #[derive(Debug)] pub struct SourceFile { /// Path as it should appear in reports, relative to the workspace root where possible. - pub path: Utf8PathBuf, + /// + /// `pub(crate)` rather than private: the survey relocates a file read by absolute path to its + /// workspace-relative one once it knows it, through the controlled [`Self::set_path`] rather + /// than a bare field assignment, but every other read stays inside this crate. + pub(crate) path: Utf8PathBuf, /// The exact bytes that were parsed. All spans index into this. - pub text: String, + pub(crate) text: String, /// The syntax tree. - pub ast: File, + /// + /// `pub(crate)` rather than private: this crate's own tests build fixtures by mutating a + /// parsed tree directly, which a getter-only encapsulation cannot express. Every other crate + /// sees this only through the read-only [`Self::ast`] accessor. + pub(crate) ast: File, /// Byte offset of the start of each line. lines: Vec, /// Every comment in the file, in source order. - pub comments: Vec, + pub(crate) comments: Vec, } /// Whether source text is too deeply nested to hand to a recursive parser. @@ -72,7 +81,8 @@ impl SourceFile { // the whole run over it would make a valid workspace unmeasurable, which is a worse // answer than measuring the rest of it and naming what was left out. return Err(error!( - "{path}:{line}: nests deeper than {} levels of brackets, prefix operators, chained operators or postfix expressions", + "{}:{line}: nests deeper than {} levels of brackets, prefix operators, chained operators or postfix expressions", + encode_controls(path.as_str()), nesting::NESTING_LIMIT ) .skippable()); @@ -81,7 +91,16 @@ impl SourceFile { let ast = syn::parse_file(&text).map_err(|cause| { let start = cause.span().start(); - error!("{path}:{}:{}: could not parse: {cause}", start.line, start.column) + // The path is repository-controlled and this message is printed to a terminal, so it is + // encoded here rather than trusted; `cause` is `syn`'s own prose about a token it read + // from that same repository, so it is encoded for the same reason. + error!( + "{}:{}:{}: could not parse: {}", + encode_controls(path.as_str()), + start.line, + start.column, + encode_controls(&cause.to_string()) + ) })?; Ok(Self { @@ -94,10 +113,16 @@ impl SourceFile { } /// Reads and parses a file from disk. + /// + /// # Errors + /// + /// Returns an error if the file cannot be read from `path`, or any error [`Self::parse`] + /// documents for the text once it has been read. pub fn read(path: impl AsRef) -> Result { let path = path.as_ref(); - let text = fs::read_to_string(path).map_err(|cause| error!("could not read `{path}`").caused_by(cause))?; + let text = + fs::read_to_string(path).map_err(|cause| error!("could not read `{}`", encode_controls(path.as_str())).caused_by(cause))?; Self::parse(path.to_owned(), text) } @@ -132,6 +157,39 @@ impl SourceFile { pub fn slice(&self, span: &Range) -> &str { self.text.get(span.start..span.end).unwrap_or("") } + + /// Returns the path as it should appear in reports. + #[must_use] + pub fn path(&self) -> &Utf8Path { + &self.path + } + + /// Relocates the file to a different reporting path, without re-parsing its text. + /// + /// A file is often read from an absolute path and then reported relative to the workspace + /// root once the caller knows it; this is the one controlled way to update that path after + /// parsing, so every other representation field stays untouched and in agreement with `text`. + pub fn set_path(&mut self, path: impl Into) { + self.path = path.into(); + } + + /// Returns the exact bytes that were parsed. All spans index into this. + #[must_use] + pub fn text(&self) -> &str { + &self.text + } + + /// Returns the syntax tree. + #[must_use] + pub fn ast(&self) -> &File { + &self.ast + } + + /// Returns every comment in the file, in source order. + #[must_use] + pub fn comments(&self) -> &[Comment] { + &self.comments + } } /// Drops a leading byte-order mark. diff --git a/crates/cargo-gamma-engine/src/schema.rs b/crates/cargo-gamma-engine/src/schema.rs index 551944a7..02a2a317 100644 --- a/crates/cargo-gamma-engine/src/schema.rs +++ b/crates/cargo-gamma-engine/src/schema.rs @@ -34,6 +34,7 @@ //! any of those three would change what the tests prove. use core::fmt::Write as _; +use core::num::NonZeroU32; use core::ops::Range; use crate::error::Error; @@ -51,35 +52,104 @@ pub const GUARD_PATH: &str = "::gamma_rt::a"; /// agree on a type without it. pub const EITHER_PATH: &str = "::gamma_rt::Either"; +/// Which of a guard's up to four positions one resolved offset belongs to. +#[derive(Clone, Copy)] +enum Slot { + SiteStart, + SiteEnd, + MutatedStart, + MutatedEnd, +} + +/// A guard's positions as they are filled in during the sweep in [`positions`], before every field +/// that the mutant's shape requires is known to be present. +#[derive(Clone, Copy, Default)] +struct Slots { + site_start: Option, + site_end: Option, + mutated_start: Option, + mutated_end: Option, +} + /// Maps each mutant ordinal to where its guard landed in the instrumented text. /// /// A guard emits both the mutated text and the original, so a multi-line site grows and every /// later line shifts. Instrumented text therefore does not line up with its source, and anything /// attributing a compiler diagnostic to a mutant has to use these positions rather than the /// mutant's source line. +/// +/// Every guard needs up to four offsets resolved (`site.start`, `site.end`, and, unless the mutant +/// is a deletion, `mutated.start`/`mutated.end`), and a naive resolver recounts characters from the +/// start of the enclosing line for each one independently — worst case, work proportional to +/// mutant count times line length on a single generated long line. Resolving every offset from +/// every guard in one ascending sweep instead makes the total character-counting work proportional +/// to the text once, with the sweep never re-reading a byte it has already counted. fn positions(text: &str, spans: &HashMap, Range)>) -> HashMap { let mut starts: Vec = Vec::with_capacity(text.len() / 32); starts.push(0); starts.extend(text.match_indices('\n').map(|(at, _matched)| at + 1)); - let at = |offset: usize| -> Position { - let index = starts.partition_point(|start| *start <= offset).saturating_sub(1); - let start = starts.get(index).copied().unwrap_or(0); - let column = text.get(start..offset).map_or(0, |prefix| prefix.chars().count()); + let mut requests: Vec<(usize, u32, Slot)> = Vec::with_capacity(spans.len() * 4); - Position { - line: u32::try_from(index + 1).unwrap_or(u32::MAX), - column: u32::try_from(column + 1).unwrap_or(u32::MAX), + for (ordinal, (site, mutated)) in spans { + requests.push((site.start, *ordinal, Slot::SiteStart)); + requests.push((site.end, *ordinal, Slot::SiteEnd)); + + if !mutated.is_empty() { + requests.push((mutated.start, *ordinal, Slot::MutatedStart)); + requests.push((mutated.end, *ordinal, Slot::MutatedEnd)); } - }; + } + + // Ascending order lets the sweep below only ever move forward: to a later line, or to a later + // byte within the line it is already on. + requests.sort_by_key(|(offset, ..)| *offset); + + let mut resolved: HashMap = HashMap::default(); + let mut line = 0_usize; + let mut cursor = 0_usize; + let mut column = 0_usize; + + for (offset, ordinal, slot) in requests { + while starts.get(line + 1).is_some_and(|next_start| *next_start <= offset) { + line += 1; + cursor = starts[line]; + column = 0; + } + + // `offset` never precedes `cursor`: requests are sorted ascending, so each one is no + // earlier than the last, and the line advance above only ever resets the cursor to a line + // start no later than the offset that triggered it. Both are byte offsets into valid UTF-8 + // spans built by `render`, so both land on character boundaries. + let prefix = text + .get(cursor..offset) + .expect("cursor never exceeds offset and both are char-boundary offsets into this text"); + + column += prefix.chars().count(); + cursor = offset; + + let position = Position::from_zero_based(line, column); + + let slots = resolved.entry(ordinal).or_default(); + + match slot { + Slot::SiteStart => slots.site_start = Some(position), + Slot::SiteEnd => slots.site_end = Some(position), + Slot::MutatedStart => slots.mutated_start = Some(position), + Slot::MutatedEnd => slots.mutated_end = Some(position), + } + } spans - .iter() - .map(|(ordinal, (site, mutated))| { + .keys() + .map(|ordinal| { + let slots = resolved.remove(ordinal).unwrap_or_default(); + let guard = Guard { - site: at(site.start)..at(site.end), - mutated: (!mutated.is_empty()).then(|| at(mutated.start)..at(mutated.end)), + site: slots.site_start.expect("every span above requests its own site.start") + ..slots.site_end.expect("every span above requests its own site.end"), + mutated: slots.mutated_start.zip(slots.mutated_end).map(|(start, end)| start..end), }; (*ordinal, guard) @@ -88,10 +158,61 @@ fn positions(text: &str, spans: &HashMap, Range)>) -> } /// A one-based line and column in instrumented text, ordered as the text reads. +/// +/// The coordinates are private because zero is not a position. Everything that consumes one — a +/// compiler diagnostic, an editor, the blame that decides which mutant a build error belongs to — +/// counts from one, so a zero would compare as strictly before the first character of the file and +/// silently widen every containment test that reaches it. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] pub struct Position { - pub line: u32, - pub column: u32, + line: NonZeroU32, + column: NonZeroU32, +} + +impl Position { + /// Builds a position from one-based coordinates, rejecting a zero in either. + /// + /// Returns `None` rather than clamping, because the callers that reach this hold numbers + /// copied out of someone else's JSON: a zero there means the producer did not mean a position + /// at all, and turning it into line one would attribute a diagnostic to real code. + #[must_use] + pub const fn new(line: u32, column: u32) -> Option { + match (NonZeroU32::new(line), NonZeroU32::new(column)) { + (Some(line), Some(column)) => Some(Self { line, column }), + _other => None, + } + } + + /// Builds a position from zero-based coordinates, as an offset sweep counts them. + /// + /// Total by construction: adding one to a count that starts at zero cannot produce zero, and a + /// count too large for `u32` saturates to the largest representable position rather than + /// wrapping into a small one. + #[must_use] + pub fn from_zero_based(line: usize, column: usize) -> Self { + let one_based = |count: usize| { + let widened = u32::try_from(count).unwrap_or(u32::MAX - 1); + + NonZeroU32::new(widened.saturating_add(1)).expect("a saturating increment of an unsigned count is never zero") + }; + + Self { + line: one_based(line), + column: one_based(column), + } + } + + /// The one-based line. + #[must_use] + pub const fn line(self) -> u32 { + self.line.get() + } + + /// The one-based column. + #[must_use] + pub const fn column(self) -> u32 { + self.column.get() + } } /// Where one mutant's guard landed in instrumented text. @@ -122,10 +243,37 @@ struct Node<'a> { children: Vec, } +/// Which guard, of all the guards in one instrumented tree, a mutant is selected by. +/// +/// A newtype because the number is one of several `u32` counts that travel together and mean +/// entirely different things: an occurrence counts repeats of a site within an item, a replacement +/// index counts a mutator's alternatives for one site, and this counts mutants across the whole +/// run. They are interchangeable to the compiler and to the eye, and swapping two of them produces +/// a tree that builds, runs, and attributes every verdict to the wrong mutant. +/// +/// Zero is not a guard. A mutant an earlier run already settled, or that a shard left out, keeps +/// zero because it was never scheduled, and nothing in the tree ever tests for it. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Ordinal(u32); + +impl Ordinal { + /// Names the guard this ordinal selects. + #[must_use] + pub const fn new(ordinal: u32) -> Self { + Self(ordinal) + } + + /// The number as the instrumented tree spells it. + #[must_use] + pub const fn get(self) -> u32 { + self.0 + } +} + /// A source-level mutant definition paired with the run-local ordinal that selects its guard. #[derive(Clone, Copy, Debug)] pub struct AssignedMutant<'a> { - ordinal: u32, + ordinal: Ordinal, span: &'a Range, replacement: &'a str, shape: Shape, @@ -134,13 +282,13 @@ pub struct AssignedMutant<'a> { impl<'a> AssignedMutant<'a> { #[cfg(test)] #[must_use] - pub(crate) fn new(ordinal: u32, definition: &'a MutantDefinition) -> Self { + pub(crate) fn new(ordinal: Ordinal, definition: &'a MutantDefinition) -> Self { Self::from_parts(ordinal, definition.span(), &definition.replacement, definition.shape) } #[doc(hidden)] #[must_use] - pub const fn from_parts(ordinal: u32, span: &'a Range, replacement: &'a str, shape: Shape) -> Self { + pub const fn from_parts(ordinal: Ordinal, span: &'a Range, replacement: &'a str, shape: Shape) -> Self { Self { ordinal, span, @@ -233,8 +381,10 @@ fn copy(text: &str, range: Range) -> &str { /// Groups sites into a forest ordered by containment. fn build_tree<'a>(sites: &[&'a AssignedMutant<'a>]) -> Result>> { - let mut roots: Vec> = Vec::new(); - let mut stack: Vec> = Vec::new(); + // Neither can ever hold more than one node per site: `roots` gains at most one entry per site + // that closes out to the top level, and `stack` holds at most one open node per site. + let mut roots: Vec> = Vec::with_capacity(sites.len()); + let mut stack: Vec> = Vec::with_capacity(sites.len()); for mutant in sites { // Close every open node this site is not inside. @@ -253,7 +403,7 @@ fn build_tree<'a>(sites: &[&'a AssignedMutant<'a>]) -> Result>> { // spans with different shapes would be spliced with the wrong wrapper — an expression // guard around a statement, say — so they are kept apart and nested instead. if top.span == *mutant.span && top.shape == mutant.shape { - top.mutants.push((mutant.ordinal, mutant.replacement)); + top.mutants.push((mutant.ordinal.get(), mutant.replacement)); continue; } @@ -270,7 +420,7 @@ fn build_tree<'a>(sites: &[&'a AssignedMutant<'a>]) -> Result>> { stack.push(Node { span: (*mutant.span).clone(), shape: mutant.shape, - mutants: vec![(mutant.ordinal, mutant.replacement)], + mutants: vec![(mutant.ordinal.get(), mutant.replacement)], children: Vec::new(), }); } @@ -393,7 +543,7 @@ fn render(text: &str, node: &Node<'_>, out: &mut String, spans: &mut HashMap Position { + Position::new(line, column).expect("a position written into a test is one-based by construction") + } + fn instrument(text: &str, mutants: &[&Mutant]) -> Result { let assigned = assigned_mutants(mutants); @@ -419,7 +574,7 @@ mod tests { fn assigned_mutants<'a>(mutants: &[&'a Mutant]) -> Vec> { mutants .iter() - .map(|mutant| AssignedMutant::from_parts(mutant.ordinal, &mutant.span, &mutant.replacement, mutant.shape)) + .map(|mutant| AssignedMutant::from_parts(Ordinal::new(mutant.ordinal), &mutant.span, &mutant.replacement, mutant.shape)) .collect() } @@ -469,11 +624,79 @@ mod tests { let (_out, guards) = instrument_with_guards(text, &[&first, &second]).expect("instrumented"); - assert_eq!(guards.get(&3).map(|guard| guard.site.start.line), Some(2)); - assert_eq!(guards.get(&9).map(|guard| guard.site.start.line), Some(3)); + assert_eq!(guards.get(&3).map(|guard| guard.site.start.line()), Some(2)); + assert_eq!(guards.get(&9).map(|guard| guard.site.start.line()), Some(3)); assert_eq!(guards.len(), 2); } + /// A byte-counting regression would report the wrong column for anything past a multibyte + /// character, and a missing one-based `+ 1` would report one column short — both silent while + /// every ASCII-only test in this file stays green. `é` (2 bytes) sits before the mutation site + /// and inside it, on the same line, so both the leading-prefix count and the site's own + /// character count are exercised; the expected positions are hand-derived from the exact + /// guard template `render` emits (`(if `, `GUARD_PATH`, `(1u32) { `, the replacement, ` + /// } else { `, the original text, ` })`), not from the code under test. + #[test] + fn guard_positions_count_unicode_characters_not_bytes() { + let text = "fn f() -> i32 {\n let x = 1;\n é_café + 1\n}\n"; + let site = span_of(text, "café + 1"); + let only = mutant(site, 1, "0", Shape::Expr); + + let (_out, guards) = instrument_with_guards(text, &[&only]).expect("instrumented"); + let guard = guards.get(&1).expect("recorded").clone(); + + // " é_" precedes the site: 6 characters, so the site starts at column 7. + // The whole rendered site is `(if ::gamma_rt::a(1u32) { 0 } else { café + 1 })`, which is + // 48 characters long, so the site ends at column 7 + 48 = 55. + assert_eq!(guard.site, at(3, 7)..at(3, 55)); + + // The replacement `0` begins right after the 26-character `(if ::gamma_rt::a(1u32) { ` + // prefix, so at column 7 + 26 = 33, and is 1 character long, ending at column 34. + let mutated = guard.mutated.expect("a replacement was recorded"); + + assert_eq!(mutated, at(3, 33)..at(3, 34)); + } + + /// Zero is not a position, and the constructor that takes one-based numbers has to say so + /// rather than accept a value that would compare as before the first character of the file. + #[test] + fn a_one_based_position_rejects_a_zero_coordinate() { + assert_eq!(Position::new(0, 1), None); + assert_eq!(Position::new(1, 0), None); + assert_eq!(Position::new(0, 0), None); + + let first = Position::new(1, 1).expect("line one column one is a position"); + + assert_eq!(first.line(), 1); + assert_eq!(first.column(), 1); + } + + /// The sweep counts from zero, so the conversion adds one — and a count too large for `u32` + /// has to saturate at the top rather than wrap around to the start of the file. + #[test] + fn a_zero_based_count_becomes_the_position_after_it() { + let origin = Position::from_zero_based(0, 0); + + assert_eq!((origin.line(), origin.column()), (1, 1)); + + let inside = Position::from_zero_based(4, 11); + + assert_eq!((inside.line(), inside.column()), (5, 12)); + + let beyond = Position::from_zero_based(usize::MAX, usize::MAX); + + assert_eq!((beyond.line(), beyond.column()), (u32::MAX, u32::MAX)); + } + + /// Positions order as the text reads: by line first, and only then by column, which is what + /// every containment test in the blame pass relies on. + #[test] + fn positions_order_by_line_before_column() { + assert!(at(1, 99) < at(2, 1)); + assert!(at(2, 1) < at(2, 2)); + assert_eq!(at(7, 3), at(7, 3)); + } + #[test] fn a_guard_reports_the_whole_range_it_spans() { let text = "fn f(a: i32, b: i32) -> bool {\n a\n < b\n}\n"; @@ -484,7 +707,7 @@ mod tests { assert!(out.lines().count() > text.lines().count(), "the site should have grown"); assert!( - span.site.end.line > span.site.start.line, + span.site.end.line() > span.site.start.line(), "a multi-line site should span multiple lines" ); } @@ -529,9 +752,9 @@ mod tests { .position(|line| line.contains("a(2u32)")) .and_then(|at| u32::try_from(at + 1).ok()); - assert_eq!(guards.get(&2).map(|guard| guard.site.start.line), found); + assert_eq!(guards.get(&2).map(|guard| guard.site.start.line()), found); assert_ne!( - guards.get(&2).map(|guard| guard.site.start.line), + guards.get(&2).map(|guard| guard.site.start.line()), Some(5), "the guard should not be on its source line" ); diff --git a/crates/cargo-gamma-engine/src/text.rs b/crates/cargo-gamma-engine/src/text.rs new file mode 100644 index 00000000..861fcb9a --- /dev/null +++ b/crates/cargo-gamma-engine/src/text.rs @@ -0,0 +1,272 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Making repository-controlled text safe to print to a terminal or a CI log. +//! +//! Everything this tool reports on is written by whoever wrote the code under test: file paths, +//! item paths, test names, source fragments, and the diagnostics a build tool produces about them. +//! Those values end up on a console beside status lines the tool draws with real escape sequences, +//! and a terminal cannot tell the two apart. A path containing `\r\x1b[2K` erases the line above +//! it; one containing an OSC 8 sequence attaches a hyperlink of its author's choosing to text a +//! reader will assume the tool wrote; one containing a newline forges a whole line of output. +//! +//! The rule here is that a control character is *shown* rather than *obeyed*. Encoding is visible +//! by design — a reader who sees `\e` or `\u{9b}` in a filename is being told something true about +//! that filename — and it is reversible by eye, which a lossy strip would not be. +//! +//! Two policies exist because two kinds of text arrive here. Values the tool interpolates into its +//! own sentences never legitimately contain an escape, so [`encode_controls`] encodes every one. +//! Output relayed verbatim from another tool does legitimately arrive colored, and color cannot +//! move a cursor, erase a row, or address the terminal, so [`encode_preserving_color`] lets a +//! complete SGR sequence through and encodes everything else — including every other CSI sequence, +//! every operating-system command, and the C1 controls that spell those in a single byte. + +use core::fmt::Write as _; +use std::borrow::Cow; + +/// The escape that introduces every sequence a terminal acts on. +const ESCAPE: u8 = 0x1b; + +/// The lead byte of the UTF-8 encoding of every C1 control. +/// +/// The C1 controls are `U+0080..=U+009F`, which UTF-8 encodes as `0xC2` followed by the code point's +/// own low byte. They matter because a terminal in 8-bit mode reads them as one-byte spellings of +/// the sequences the escape introduces — `U+009B` is CSI and `U+009D` is OSC — so encoding `ESC` +/// alone would leave the same capabilities reachable by another spelling. +const C1_LEAD: u8 = 0xC2; + +/// Encodes every control character, so nothing in `text` can address the terminal. +/// +/// For values the tool interpolates into its own output: paths, identifiers, test names, notes, and +/// source fragments. None of them has a legitimate reason to carry a control character, so the +/// newline that would forge a line and the escape that would erase one are treated alike. +#[must_use] +pub fn encode_controls(text: &str) -> Cow<'_, str> { + encode(text, false) +} + +/// Encodes every control character except a complete color sequence. +/// +/// For output relayed verbatim from another tool, where color is the reason the output is being +/// shown at all. A select-graphic-rendition sequence — `ESC [`, digits, `;` or `:`, then `m` — +/// changes how following text is painted and can do nothing else: it cannot move the cursor, erase +/// anything, resize the window, or speak to the operating system. Every other sequence, including +/// an `ESC [` run that never reaches its `m`, is encoded like any other control. +#[must_use] +pub fn encode_preserving_color(text: &str) -> Cow<'_, str> { + encode(text, true) +} + +/// Encodes `text`, borrowing it unchanged when there was nothing to encode. +/// +/// Borrowing rather than always allocating is what makes this affordable on the path that renders a +/// progress line ten times a second: the overwhelming majority of values are ordinary text, and +/// they cost one scan and no allocation. +fn encode(text: &str, keep_color: bool) -> Cow<'_, str> { + let bytes = text.as_bytes(); + let mut encoded: Option = None; + let mut copied = 0; + let mut cursor = 0; + + while let Some(&byte) = bytes.get(cursor) { + // A UTF-8 continuation or lead byte of anything above the C1 block is ordinary text, and + // stepping over it one byte at a time is safe because no byte of a multi-byte character can + // be confused with an ASCII control. + if byte >= 0x80 { + if byte == C1_LEAD && bytes.get(cursor + 1).is_some_and(|&low| (0x80..=0x9F).contains(&low)) { + let control = char::from(bytes[cursor + 1]); + let out = encoded.get_or_insert_with(|| String::with_capacity(text.len() + ESCAPE_HEADROOM)); + + out.push_str(&text[copied..cursor]); + push_escape(out, control); + + cursor += 2; + copied = cursor; + + continue; + } + + cursor += 1; + + continue; + } + + if !byte.is_ascii_control() { + cursor += 1; + + continue; + } + + if keep_color + && byte == ESCAPE + && let Some(end) = color_sequence_end(bytes, cursor) + { + cursor = end; + + continue; + } + + let out = encoded.get_or_insert_with(|| String::with_capacity(text.len() + ESCAPE_HEADROOM)); + + out.push_str(&text[copied..cursor]); + push_escape(out, char::from(byte)); + + cursor += 1; + copied = cursor; + } + + match encoded { + Some(mut out) => { + out.push_str(&text[copied..]); + + Cow::Owned(out) + } + None => Cow::Borrowed(text), + } +} + +/// Extra capacity reserved once a value is known to need encoding, so the common case of one or two +/// control characters does not grow the buffer again. +const ESCAPE_HEADROOM: usize = 16; + +/// Writes one control character as text a terminal will show rather than obey. +/// +/// The five with a conventional spelling get it, because `\n` in a filename reads as what it is +/// while `\u{0a}` makes a reader look it up. Everything else, including every C1 control, is +/// written as its code point. +fn push_escape(out: &mut String, control: char) { + match control { + '\0' => out.push_str("\\0"), + '\t' => out.push_str("\\t"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\u{1b}' => out.push_str("\\e"), + other => { + // Writing to a `String` cannot fail, and the result is discarded rather than propagated + // because there is no failure to propagate. + let _ = write!(out, "\\u{{{:02x}}}", u32::from(other)); + } + } +} + +/// The index just past a complete SGR sequence beginning at `start`, if there is one. +/// +/// Returns `None` for anything else, including a sequence that runs off the end of the text: an +/// unterminated `ESC [` is exactly what a value would carry to make the *next* thing printed part +/// of its own sequence, so it must be encoded rather than passed on in the hope of a later `m`. +fn color_sequence_end(bytes: &[u8], start: usize) -> Option { + if bytes.get(start + 1) != Some(&b'[') { + return None; + } + + let mut cursor = start + 2; + + while let Some(&byte) = bytes.get(cursor) { + match byte { + b'0'..=b'9' | b';' | b':' => cursor += 1, + b'm' => return Some(cursor + 1), + _ => return None, + } + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ordinary_text_is_borrowed_unchanged() { + let encoded = encode_controls("src/lib.rs::tests::boundary — replace a < b with a <= b"); + + assert!(matches!(encoded, Cow::Borrowed(_)), "ordinary text must not allocate"); + assert_eq!(encoded, "src/lib.rs::tests::boundary — replace a < b with a <= b"); + } + + #[test] + fn a_newline_cannot_forge_a_second_line() { + assert_eq!(encode_controls("a\nb"), "a\\nb"); + assert_eq!(encode_controls("a\r\nb"), "a\\r\\nb"); + } + + #[test] + fn the_erase_sequence_is_shown_rather_than_obeyed() { + assert_eq!(encode_controls("evil\r\u{1b}[2Kforged"), "evil\\r\\e[2Kforged"); + } + + #[test] + fn an_osc_hyperlink_is_encoded_in_both_of_its_spellings() { + assert_eq!( + encode_controls("\u{1b}]8;;https://example.test\u{7}click\u{1b}]8;;\u{7}"), + "\\e]8;;https://example.test\\u{07}click\\e]8;;\\u{07}" + ); + assert_eq!( + encode_controls("\u{9d}8;;https://example.test\u{9c}"), + "\\u{9d}8;;https://example.test\\u{9c}" + ); + } + + #[test] + fn every_c0_control_and_delete_is_encoded() { + for code in (0..=0x1f_u8).chain(core::iter::once(0x7f)) { + let subject = String::from(char::from(code)); + let encoded = encode_controls(&subject); + + assert!(!encoded.chars().any(char::is_control), "`{code:#04x}` survived as a control"); + assert!(encoded.starts_with('\\'), "`{code:#04x}` was not encoded: {encoded}"); + } + } + + #[test] + fn every_c1_control_is_encoded() { + for code in 0x80..=0x9f_u32 { + let control = char::from_u32(code).expect("the C1 block is valid"); + let subject = String::from(control); + let encoded = encode_controls(&subject); + + assert_eq!(encoded, format!("\\u{{{code:02x}}}"), "C1 {code:#04x} was not encoded"); + } + } + + #[test] + fn multibyte_text_around_a_control_survives_intact() { + assert_eq!(encode_controls("héllo→\u{1b}wörld"), "héllo→\\ewörld"); + } + + #[test] + fn color_is_kept_only_by_the_relaying_policy() { + let painted = "\u{1b}[1;32mCompiling\u{1b}[0m gamma"; + + assert_eq!(encode_preserving_color(painted), painted); + assert_eq!(encode_controls(painted), "\\e[1;32mCompiling\\e[0m gamma"); + } + + #[test] + fn relaying_still_refuses_every_sequence_that_is_not_color() { + // Cursor motion, erasure, the private-mode set that hides a cursor, and an OSC are all CSI + // or OSC sequences that a color policy must not mistake for color. + assert_eq!(encode_preserving_color("\u{1b}[2K"), "\\e[2K"); + assert_eq!(encode_preserving_color("\u{1b}[10A"), "\\e[10A"); + assert_eq!(encode_preserving_color("\u{1b}[?25l"), "\\e[?25l"); + assert_eq!(encode_preserving_color("\u{1b}]0;title\u{7}"), "\\e]0;title\\u{07}"); + assert_eq!(encode_preserving_color("line\rline"), "line\\rline"); + } + + #[test] + fn an_unterminated_color_sequence_is_encoded_rather_than_trusted() { + // Passing this through would let the value swallow whatever is printed after it into its + // own sequence, which is the escape it was trying to write in the first place. + assert_eq!(encode_preserving_color("\u{1b}[31"), "\\e[31"); + assert_eq!(encode_preserving_color("\u{1b}[31;"), "\\e[31;"); + assert_eq!(encode_preserving_color("\u{1b}"), "\\e"); + } + + #[test] + fn relayed_color_around_encoded_controls_keeps_both_decisions() { + assert_eq!( + encode_preserving_color("\u{1b}[31merror\u{1b}[0m: src/\u{9b}2K.rs\n"), + "\u{1b}[31merror\u{1b}[0m: src/\\u{9b}2K.rs\\n" + ); + } +} diff --git a/crates/cargo-gamma-lib/Cargo.toml b/crates/cargo-gamma-lib/Cargo.toml index afb297f8..10ab5dcd 100644 --- a/crates/cargo-gamma-lib/Cargo.toml +++ b/crates/cargo-gamma-lib/Cargo.toml @@ -107,6 +107,10 @@ required-features = ["loom"] name = "regressions" required-features = ["internals"] +[[test]] +name = "sarif_contract" +required-features = ["internals"] + [[test]] name = "schema_conformance" required-features = ["internals"] diff --git a/crates/cargo-gamma-lib/src/advise/mod.rs b/crates/cargo-gamma-lib/src/advise/mod.rs index a3489d94..f5ded601 100644 --- a/crates/cargo-gamma-lib/src/advise/mod.rs +++ b/crates/cargo-gamma-lib/src/advise/mod.rs @@ -19,9 +19,15 @@ mod text; mod timing; mod yield_; +#[doc(inline)] pub use analysis::{analyze, analyze_run, yields}; +#[doc(inline)] pub use finding::Finding; +#[doc(inline)] pub use render::{Layout, render_markdown}; +#[doc(inline)] pub use text::human; +#[doc(inline)] pub use timing::Timing; +#[doc(inline)] pub use yield_::Yield; diff --git a/crates/cargo-gamma-lib/src/cfg/mod.rs b/crates/cargo-gamma-lib/src/cfg/mod.rs index 47076ff7..324173c8 100644 --- a/crates/cargo-gamma-lib/src/cfg/mod.rs +++ b/crates/cargo-gamma-lib/src/cfg/mod.rs @@ -69,8 +69,10 @@ mod probe; pub mod features; +#[doc(inline)] pub use build::Build; +pub use cargo_gamma_engine::cfg::CfgSet; pub(crate) use cargo_gamma_engine::cfg::test_gated_for; -pub use cargo_gamma_engine::cfg::{CfgSet, test_gated}; +#[doc(inline)] pub use cfgs::Cfgs; pub(crate) use probe::for_build; diff --git a/crates/cargo-gamma-lib/src/ci/annotations.rs b/crates/cargo-gamma-lib/src/ci/annotations.rs index 7ef8830b..d3bf0294 100644 --- a/crates/cargo-gamma-lib/src/ci/annotations.rs +++ b/crates/cargo-gamma-lib/src/ci/annotations.rs @@ -8,6 +8,7 @@ use clap::ValueEnum; use super::finding::{describe, findings, relative}; use crate::model::Mutant; +use crate::report::encode_controls; /// How much of the CI surfacing to emit. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum)] @@ -90,10 +91,16 @@ fn escape_property(text: &str) -> String { /// Escapes a workflow command message. /// -/// A newline inside a message would end the command and turn the remainder into log noise, so the -/// escaping is not cosmetic. +/// Two layers, because they defend different things. Every control character is first shown rather +/// than obeyed: the log this lands in is a terminal-rendered one, so a path or source fragment +/// carrying `ESC [2K` erases the lines above it and one carrying an OSC 8 sequence hangs a +/// hyperlink of its author's choosing on text a reader takes for workflow output. What is left +/// is then escaped for the workflow command syntax itself, where an unescaped `%`, carriage return, +/// or newline ends the command and turns the remainder into log noise. The second layer no longer +/// has a return or a newline to find, and is kept because it is what the syntax requires rather +/// than a consequence of the first. fn escape_data(text: &str) -> String { - text.replace('%', "%25").replace('\r', "%0D").replace('\n', "%0A") + encode_controls(text).replace('%', "%25").replace('\r', "%0D").replace('\n', "%0A") } #[cfg(test)] @@ -151,7 +158,10 @@ mod tests { fn a_newline_cannot_escape_a_message() { // A raw newline would end the workflow command and turn the rest into log noise. Mutant // text is already flattened before it gets here, so this is the belt to that suspenders. - assert_eq!(escape_data("a\r\nb"), "a%0D%0Ab"); + let escaped = escape_data("a\r\nb"); + + assert_eq!(escaped, "a\\r\\nb"); + assert!(!escaped.contains('\r') && !escaped.contains('\n')); } #[test] @@ -173,7 +183,7 @@ mod tests { let lines = annotations(&mutants, &root()); assert!( - lines[0].starts_with("::warning file=src/a%2Cb%3Ac%0D%0A.rs,line=12,col=5,"), + lines[0].starts_with("::warning file=src/a%2Cb%3Ac\\r\\n.rs,line=12,col=5,"), "{}", lines[0] ); @@ -182,7 +192,25 @@ mod tests { #[test] fn a_percent_is_escaped_before_anything_else() { // Escaping it last would double-escape the escapes. - assert_eq!(escape_data("%0A\n"), "%250A%0A"); + assert_eq!(escape_data("%0A"), "%250A"); + } + + /// A CI log is rendered by a terminal, so a path that erases lines does it there too. + #[test] + fn a_path_cannot_address_the_terminal_the_log_is_read_in() { + let hostile = "/w/src/\r\u{1b}[2K\u{9b}31mforged\u{1b}]8;;https://evil.test\u{7}link.rs"; + let mutants = vec![mutant(hostile, 12, "relational.gt_to_ge", Outcome::Survived)]; + let lines = annotations(&mutants, &root()); + + for line in &lines { + assert!(!line.contains('\u{1b}'), "{line:?}"); + assert!(!line.contains('\u{9b}'), "{line:?}"); + assert!(!line.contains('\u{7}'), "{line:?}"); + assert!(!line.contains('\r'), "{line:?}"); + assert!(!line.contains('\n'), "{line:?}"); + } + + assert!(lines[0].contains("\\r\\e[2K"), "{}", lines[0]); } #[test] diff --git a/crates/cargo-gamma-lib/src/ci/mod.rs b/crates/cargo-gamma-lib/src/ci/mod.rs index e8d407ff..44ce0171 100644 --- a/crates/cargo-gamma-lib/src/ci/mod.rs +++ b/crates/cargo-gamma-lib/src/ci/mod.rs @@ -18,9 +18,14 @@ pub(crate) mod sarif; mod summary; mod truncation; +#[doc(inline)] pub use annotations::{Annotations, annotations, wanted}; +#[doc(inline)] pub use level::Level; +#[doc(inline)] pub use sarif::sarif; pub(crate) use summary::append; +#[doc(inline)] pub use summary::summary; +#[doc(inline)] pub use truncation::Truncation; diff --git a/crates/cargo-gamma-lib/src/ci/sarif.rs b/crates/cargo-gamma-lib/src/ci/sarif.rs index d862475c..d7328fc0 100644 --- a/crates/cargo-gamma-lib/src/ci/sarif.rs +++ b/crates/cargo-gamma-lib/src/ci/sarif.rs @@ -14,17 +14,19 @@ use crate::{HashMap, HashSet, Result}; /// A SARIF 2.1.0 log. #[derive(Debug, Serialize)] -pub(super) struct Log { +pub(super) struct Log<'findings> { pub(super) version: &'static str, #[serde(rename = "$schema")] pub(super) schema: &'static str, - pub(super) runs: Vec, + pub(super) runs: Vec>, } +/// Borrows its results rather than owning them, so that fitting the log to the byte cap can measure +/// one prefix after another over findings that were built once. #[derive(Debug, Serialize)] -pub(super) struct Run { +pub(super) struct Run<'findings> { pub(super) tool: Tool, - pub(super) results: Vec, + pub(super) results: &'findings [Finding], } #[derive(Debug, Serialize)] @@ -118,33 +120,108 @@ const SARIF_BYTES: usize = 10 * 1024 * 1024; /// Rule identifiers are our stable mutator names, which is what makes GitHub's alert grouping and /// dismissal work per operator: a team can permanently dismiss every `literal.int_zero` alert /// without touching anything else, and that decision keeps applying to code written next year. +/// +/// # Errors +/// +/// Returns an error if the log cannot be serialized to JSON. Nothing in the document is caller +/// data of a kind serde can refuse — every value is a string, an integer or a fixed-shape struct — +/// so this reports a failure that should not be reachable rather than a condition to handle. +/// +/// Exceeding either the result-count or the byte cap is not an error: the log is shortened until it +/// fits and the returned [`Truncation`] says what was dropped, because a CI run that uploads +/// nothing is worse than one that uploads the survivors it had room for. pub fn sarif(mutants: &[Mutant], root: &Utf8Path, level: Level) -> Result<(String, Option)> { let survivors = findings(mutants); let found = survivors.len(); - let mut kept: Vec<&Mutant> = survivors.into_iter().take(SARIF_LIMIT).collect(); + let kept: Vec<&Mutant> = survivors.into_iter().take(SARIF_LIMIT).collect(); + let results = results(&kept, root, level); // Shrunk until it fits rather than estimated, because the size of a finding is decided by the // length of a path, a message and an identifier, none of which this can predict. Halving - // converges in a handful of serializations even from the count limit, and the alternative to - // any of it is an upload GitHub refuses whole. + // converges in a handful of measurements even from the count limit, and the alternative to any + // of it is an upload GitHub refuses whole. + // + // What is measured is not what is built. Each candidate prefix is serialized into a writer that + // counts bytes and keeps none of them, so a log that is over the cap costs its length in + // arithmetic rather than in a multi-megabyte `String` that is looked at once and dropped. Only + // the prefix that fits is rendered, exactly once. The sequence of prefixes is the same one the + // repeated-render form walked, so the log that comes out is the same log. + let mut length = results.len(); + loop { - let text = render(&kept, root, level)?; + let rules = rules(&kept[..length], level); + let log = log(&results[..length], rules); - if text.len() <= SARIF_BYTES || kept.is_empty() { - let truncation = (found > kept.len()).then_some(Truncation { - found, - written: kept.len(), - }); + if measure(&log)? <= SARIF_BYTES || length == 0 { + let text = serde_json::to_string_pretty(&log) + .map_err(|cause| crate::error::error!("could not serialize the SARIF log").caused_by(cause))?; + let truncation = (found > length).then_some(Truncation { found, written: length }); return Ok((text, truncation)); } - kept.truncate(kept.len() / 2); + length /= 2; + } +} + +/// The byte length the log would serialize to, without keeping the bytes. +/// +/// # Errors +/// +/// Returns an error if the log cannot be serialized. +fn measure(log: &Log<'_>) -> Result { + let mut counter = Counted::default(); + + serde_json::to_writer_pretty(&mut counter, log) + .map_err(|cause| crate::error::error!("could not serialize the SARIF log").caused_by(cause))?; + + Ok(counter.bytes) +} + +/// A writer that remembers how much was written to it and nothing else. +#[derive(Debug, Default)] +struct Counted { + bytes: usize, +} + +impl std::io::Write for Counted { + #[expect(clippy::renamed_function_params, reason = "`buf` is less clear than `buffer`")] + fn write(&mut self, buffer: &[u8]) -> std::io::Result { + self.bytes += buffer.len(); + + Ok(buffer.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +/// Assembles the log document around results that were built once and rules that were not. +fn log(results: &[Finding], rules: Vec) -> Log<'_> { + Log { + version: "2.1.0", + schema: "https://json.schemastore.org/sarif-2.1.0.json", + runs: vec![Run { + tool: Tool { + driver: Driver { + name: "cargo-gamma", + information_uri: "https://github.com/microsoft/ox-tools/tree/main/crates/cargo-gamma", + version: env!("CARGO_PKG_VERSION"), + rules, + }, + }, + results, + }], } } -/// Serializes one SARIF log over exactly the findings it is given. -fn render(kept: &[&Mutant], root: &Utf8Path, level: Level) -> Result { +/// The rule table for exactly the findings that are being kept. +/// +/// Rebuilt per candidate rather than trimmed, because a rule is shared by every finding that names +/// it and dropping the tail of the results can retire a rule that only they used. There are at most +/// as many rules as there are mutators, so this is small however large the population is. +fn rules(kept: &[&Mutant], level: Level) -> Vec { let mut seen = HashSet::default(); let mut rules = Vec::new(); @@ -172,8 +249,12 @@ fn render(kept: &[&Mutant], root: &Utf8Path, level: Level) -> Result { rules.sort_by(|left, right| left.id.cmp(&right.id)); - let results = kept - .iter() + rules +} + +/// Builds one SARIF result per kept mutant. +fn results(kept: &[&Mutant], root: &Utf8Path, level: Level) -> Vec { + kept.iter() .map(|mutant| { let mut fingerprints = HashMap::default(); @@ -199,25 +280,7 @@ fn render(kept: &[&Mutant], root: &Utf8Path, level: Level) -> Result { partial_fingerprints: fingerprints, } }) - .collect(); - - let log = Log { - version: "2.1.0", - schema: "https://json.schemastore.org/sarif-2.1.0.json", - runs: vec![Run { - tool: Tool { - driver: Driver { - name: "cargo-gamma", - information_uri: "https://github.com/microsoft/ox-tools/tree/main/crates/cargo-gamma", - version: env!("CARGO_PKG_VERSION"), - rules, - }, - }, - results, - }], - }; - - serde_json::to_string_pretty(&log).map_err(|cause| crate::error::error!("could not serialize the SARIF log").caused_by(cause)) + .collect() } #[cfg(test)] @@ -257,7 +320,7 @@ mod tests { assert_eq!(result["level"], "warning"); assert_eq!( - result["partialFingerprints"]["gammaMutantId/v4"], + result["partialFingerprints"][format!("gammaMutantId/v{}", crate::model::MUTANT_ID_VERSION)], "/w/src/a.rs:7:relational.gt_to_ge" ); diff --git a/crates/cargo-gamma-lib/src/commands/clean.rs b/crates/cargo-gamma-lib/src/commands/clean.rs index ec84ed2b..85858b69 100644 --- a/crates/cargo-gamma-lib/src/commands/clean.rs +++ b/crates/cargo-gamma-lib/src/commands/clean.rs @@ -8,18 +8,30 @@ use super::dispatch::EXIT_OK; use super::host::Host; use crate::discover::load_metadata; use crate::exec::{clean_cache, gamma_base}; -use crate::report::Styler; +use crate::report::{Styler, encode_controls}; /// Deletes the external cache belonging to the resolved workspace. pub(super) fn clean(host: &mut H, args: &CleanArgs, styler: Styler) -> crate::Result { let metadata = load_metadata(&args.dir, &FeatureArgs::default())?; let root = camino::Utf8Path::new(metadata.workspace_root.as_str()); let base = gamma_base(root, None); + let cleaned = clean_cache(root)?; - if clean_cache(root)? { - writeln!(host.error(), "{} `{base}`", styler.verb("Cleaned"))?; + if cleaned.is_empty() { + writeln!( + host.error(), + "{} no cached data under `{}`", + styler.verb("Finished"), + encode_controls(base.as_str()) + )?; } else { - writeln!(host.error(), "{} no cached data under `{base}`", styler.verb("Finished"))?; + // Each directory is named rather than only the derived one, because a cache an earlier + // release left behind is somewhere the current name does not describe, and reporting the + // current name for it would tell the user their disk space went from a place it never + // occupied. + for directory in cleaned { + writeln!(host.error(), "{} `{}`", styler.verb("Cleaned"), encode_controls(directory.as_str()))?; + } } Ok(EXIT_OK) diff --git a/crates/cargo-gamma-lib/src/commands/cli.rs b/crates/cargo-gamma-lib/src/commands/cli.rs index ca37b5a4..a35d8404 100644 --- a/crates/cargo-gamma-lib/src/commands/cli.rs +++ b/crates/cargo-gamma-lib/src/commands/cli.rs @@ -2,6 +2,8 @@ // Licensed under the MIT License. use camino::Utf8PathBuf; +use clap::builder::Styles; +use clap::builder::styling::{AnsiColor, Effects}; use clap::{Args, Parser, Subcommand, ValueEnum}; use clap_complete::Shell; @@ -10,6 +12,12 @@ use crate::ci::{Annotations, Level}; use crate::error::error; use crate::ops::registry::Selection; +const CLAP_STYLES: Styles = Styles::styled() + .header(AnsiColor::Green.on_default().effects(Effects::BOLD)) + .usage(AnsiColor::Green.on_default().effects(Effects::BOLD)) + .literal(AnsiColor::Cyan.on_default().effects(Effects::BOLD)) + .placeholder(AnsiColor::Cyan.on_default()); + /// Adapts a [`crate::bounds`] check to clap's parser signature. macro_rules! bounded { ($name:ident) => { @@ -40,11 +48,13 @@ fn size(text: &str) -> Result { bin_name = "cargo gamma", version, propagate_version = true, + author, about = "Fast mutation testing for Rust.", long_about = "Fast mutation testing for Rust.\n\nEvery selected mutant is compiled into one \ set of test binaries and chosen at run time, so a whole workspace is mutated \ without rebuilding it once per mutant.\n\nWith no subcommand, `run` is implied.", - max_term_width = 100 + max_term_width = 100, + styles = CLAP_STYLES )] pub struct Cli { /// The subcommand to run. Defaults to `run`. @@ -237,6 +247,10 @@ pub struct SelectArgs { #[arg(long = "exclude-file", value_name = "GLOB")] pub exclude_files: Vec, + /// Structured mutant exclusions loaded from `gamma.toml`. + #[arg(skip)] + pub exclude_mutants: Vec, + /// Number of shards to divide the mutants into. #[arg(long, value_name = "COUNT")] pub shard_count: Option, @@ -625,12 +639,6 @@ pub struct RunArgs { #[arg(long, help_heading = "Run control")] pub dry_run: bool, - /// Load the report viewer from a CDN instead of embedding it. - /// - /// Produces a much smaller file, at the cost of needing network access to read it. - #[arg(long, help_heading = "Reporting")] - pub html_external: bool, - /// How loudly a survivor is reported to a SARIF consumer. /// /// A surviving mutant is an observation about the test suite rather than a defect in the code, @@ -697,7 +705,6 @@ pub enum ListKind { Files, /// The named mutator presets. - #[value(alias = "profiles")] Presets, } @@ -736,6 +743,7 @@ impl Default for SelectArgs { mutators: None, files: Vec::new(), exclude_files: Vec::new(), + exclude_mutants: Vec::new(), shard_count: None, shard_index: None, in_diff: None, @@ -1026,15 +1034,13 @@ mod tests { } #[test] - fn mutator_presets_are_listed_by_the_new_name_and_the_old_name_remains_an_alias() { - for name in ["presets", "profiles"] { - let cli = Cli::try_parse_from(["cargo-gamma", "list", name]).expect("list kind parses"); - let Command::List(args) = cli.command else { - panic!("expected list"); - }; - - assert_eq!(args.what, ListKind::Presets); - } + fn mutator_presets_are_listed_by_name() { + let cli = Cli::try_parse_from(["cargo-gamma", "list", "presets"]).expect("list kind parses"); + let Command::List(args) = cli.command else { + panic!("expected list"); + }; + + assert_eq!(args.what, ListKind::Presets); } #[test] @@ -1101,7 +1107,14 @@ mod tests { fn the_cli_definition_is_valid() { use clap::CommandFactory as _; - Cli::command().debug_assert(); + let command = Cli::command(); + + assert_eq!(command.get_author(), Some(env!("CARGO_PKG_AUTHORS"))); + assert_eq!(command.get_styles().get_header(), CLAP_STYLES.get_header()); + assert_eq!(command.get_styles().get_usage(), CLAP_STYLES.get_usage()); + assert_eq!(command.get_styles().get_literal(), CLAP_STYLES.get_literal()); + assert_eq!(command.get_styles().get_placeholder(), CLAP_STYLES.get_placeholder()); + command.debug_assert(); } /// The default eligibility is both ceilings, and it is a real parse rather than a string check. @@ -1202,9 +1215,9 @@ mod tests { } } - /// Artifact routing is directory-wide; individual report path flags are not accepted. + /// Artifact routing is directory-wide. #[test] - fn artifact_dir_replaces_individual_report_paths() { + fn artifact_and_cache_directories_parse() { let cli = Cli::try_parse_from(["cargo gamma", "run", "--artifact-dir", "out"]).expect("the directory parses"); match cli.command { @@ -1212,18 +1225,12 @@ mod tests { _ => panic!("expected run"), } - for removed in ["--html-report", "--json-report", "--sarif-report", "--advice", "--diag-bundle"] { - _ = Cli::try_parse_from(["cargo gamma", "run", removed, "out"]).expect_err("individual report paths are gone"); - } - let cli = Cli::try_parse_from(["cargo gamma", "run", "--cache-dir", "cache"]).expect("cache directory parses"); match cli.command { Command::Run(args) => assert_eq!(args.measure.cache_dir.unwrap(), "cache"), _ => panic!("expected run"), } - - _ = Cli::try_parse_from(["cargo gamma", "run", "--scratch-dir", "cache"]).expect_err("the old cache option is gone"); } /// Every flag taking a filesystem path presents the same `` placeholder. diff --git a/crates/cargo-gamma-lib/src/commands/console_events.rs b/crates/cargo-gamma-lib/src/commands/console_events.rs index 1070a585..d063e543 100644 --- a/crates/cargo-gamma-lib/src/commands/console_events.rs +++ b/crates/cargo-gamma-lib/src/commands/console_events.rs @@ -81,8 +81,10 @@ impl crate::exec::Events for ConsoleEvents<'_, H> { } // Written whether or not the display is on. The display goes quiet when output is piped, - // and that is precisely where a user who asked to see the build needs to see it. - self.progress.insist(self.host, &crate::report::continuation(), line); + // and that is precisely where a user who asked to see the build needs to see it. Relayed + // rather than insisted on, because this is cargo's own text: its colour is the reason the + // option exists, and everything else it could carry is not cargo's to say. + self.progress.relay(self.host, &crate::report::continuation(), line); } fn wants_build_output(&self) -> bool { diff --git a/crates/cargo-gamma-lib/src/commands/list.rs b/crates/cargo-gamma-lib/src/commands/list.rs index 5305e30d..2016075e 100644 --- a/crates/cargo-gamma-lib/src/commands/list.rs +++ b/crates/cargo-gamma-lib/src/commands/list.rs @@ -13,7 +13,7 @@ use super::host::Host; use crate::error::error; use crate::exec::CargoOptions; use crate::ops::registry; -use crate::report::Styler; +use crate::report::{Styler, encode_controls}; /// Implements `list`. #[cfg(test)] @@ -34,6 +34,22 @@ pub(super) fn list_with_cargo(host: &mut H, args: &ListArgs, styler: St } } +/// Writes a listing as pretty JSON, straight to the stream. +/// +/// Serializing into a `String` first would hold a second copy of a listing that reaches tens of +/// megabytes on a large workspace, for no benefit: the document is written out whole either way. +/// +/// # Errors +/// +/// Returns an error if `entries` cannot be serialized, or if the stream cannot be written to. +fn write_json_lines(stream: &mut W, entries: &T, what: &str) -> crate::Result<()> { + serde_json::to_writer_pretty(&mut *stream, entries).map_err(|cause| error!("could not write {what} as JSON").caused_by(cause))?; + + writeln!(stream).map_err(|cause| error!("could not write {what} as JSON").caused_by(cause))?; + + Ok(()) +} + /// Lists the named mutator presets. /// /// The selection is resolved against each preset so the listing says which one you are actually @@ -56,11 +72,7 @@ fn list_presets(host: &mut H, args: &ListArgs) -> crate::Result { }) .collect(); - writeln!( - stream, - "{}", - serde_json::to_string_pretty(&entries).map_err(|cause| { error!("could not serialize the presets").caused_by(cause) })? - )?; + write_json_lines(&mut stream, &entries, "the presets")?; return Ok(EXIT_OK); } @@ -100,12 +112,7 @@ fn list_mutators(host: &mut H, args: &ListArgs) -> crate::Result { }) .collect(); - writeln!( - stream, - "{}", - serde_json::to_string_pretty(&entries) - .map_err(|cause| { error!("could not serialize the mutator registry").caused_by(cause) })? - )?; + write_json_lines(&mut stream, &entries, "the mutator registry")?; return Ok(EXIT_OK); } @@ -136,17 +143,13 @@ fn list_files(host: &mut H, args: &ListArgs, styler: Styler, cargo: &Ca if args.json { let paths: Vec<&Utf8PathBuf> = plan.files.iter().map(|file| &file.path).collect(); - writeln!( - stream, - "{}", - serde_json::to_string_pretty(&paths).map_err(|cause| error!("could not serialize the file list").caused_by(cause))? - )?; + write_json_lines(&mut stream, &paths, "the file list")?; return Ok(EXIT_OK); } for file in &plan.files { - writeln!(stream, "{}", file.path)?; + writeln!(stream, "{}", encode_controls(file.path.as_str()))?; } Ok(EXIT_OK) @@ -167,17 +170,13 @@ fn list_mutants(host: &mut H, args: &ListArgs, styler: Styler, cargo: & let mut stream = host.results(); if args.json { - writeln!( - stream, - "{}", - serde_json::to_string_pretty(&plan.mutants).map_err(|cause| error!("could not serialize the mutant list").caused_by(cause))? - )?; + write_json_lines(&mut stream, &plan.mutants, "the mutant list")?; return Ok(EXIT_OK); } for mutant in &plan.mutants { - writeln!(stream, "{}", describe_for_listing(mutant))?; + writeln!(stream, "{}", encode_controls(&describe_for_listing(mutant)))?; } let suppressed = plan @@ -237,7 +236,7 @@ fn write_population( let report = crate::elements::build(plan, crate::elements::Thresholds::default(), Some(info))?; crate::elements::write_json(&report, path)?; - writeln!(host.error(), "Wrote {path}")?; + writeln!(host.error(), "Wrote {}", encode_controls(path.as_str()))?; Ok(()) } @@ -248,7 +247,7 @@ mod tests { use std::fs; use super::*; - use crate::testing::{BrokenHost, Sink}; + use crate::testing::{Broken, BrokenHost, Sink}; fn crate_dir(name: &str) -> tempfile::TempDir { crate::fixtures::crate_dir(name, "pub fn less(a: i32, b: i32) -> bool { a < b }\n").0 @@ -266,6 +265,13 @@ mod tests { } } + #[test] + fn a_stream_failure_is_reported_as_a_write_failure() { + let failure = write_json_lines(&mut Broken, &[1], "the fixture").expect_err("the closed stream must fail"); + + assert!(failure.to_string().contains("could not write the fixture as JSON"), "{failure}"); + } + #[test] fn ops_can_be_listed_as_json() { let mut host = Sink::default(); diff --git a/crates/cargo-gamma-lib/src/commands/merge.rs b/crates/cargo-gamma-lib/src/commands/merge.rs index 2cfa9d9d..05cde81c 100644 --- a/crates/cargo-gamma-lib/src/commands/merge.rs +++ b/crates/cargo-gamma-lib/src/commands/merge.rs @@ -12,7 +12,7 @@ use super::dispatch::{EXIT_GATE_FAILED, EXIT_OK}; use super::host::Host; use crate::elements::Report; use crate::error::error; -use crate::report::{Styler, quantity}; +use crate::report::{Styler, encode_controls, quantity}; /// The most independently produced reports one merge retains. /// @@ -55,12 +55,12 @@ fn merge_at(host: &mut H, args: &MergeArgs, styler: Styler, now: Option if let Some(report) = merged.report.as_ref() { if let Some(path) = args.json_report.as_ref() { crate::elements::write_json(report, path)?; - writeln!(host.error(), "{} {path}", styler.verb("Wrote"))?; + writeln!(host.error(), "{} {}", styler.verb("Wrote"), encode_controls(path.as_str()))?; } if let Some(path) = args.html_report.as_ref() { - crate::html::write_page(report, crate::html::Source::Inline, path)?; - writeln!(host.error(), "{} {path}", styler.verb("Wrote"))?; + crate::html::write_page(report, path)?; + writeln!(host.error(), "{} {}", styler.verb("Wrote"), encode_controls(path.as_str()))?; } } @@ -288,14 +288,16 @@ fn report_merge(host: &mut H, args: &MergeArgs, merged: &crate::merge:: #[cfg(test)] #[cfg(not(miri))] mod tests { + use std::collections::BTreeMap; + use super::*; use crate::elements::{FileResult, RunInfo, ShardInfo}; + use crate::fixtures; use crate::fixtures::mutant_result_at as mutant; use crate::testing::{Sink, fails_at_every_line, workdir}; - use crate::{HashMap, fixtures}; fn report(index: u32, count: u32, status: &str) -> Report { - let mut files = HashMap::default(); + let mut files = BTreeMap::new(); let _ = files.insert( "src/lib.rs".to_owned(), FileResult { @@ -328,7 +330,7 @@ mod tests { /// `populations` only trusts an unsharded report to say what currently exists at a path, so a /// withdrawn mutant can only be produced from a pair of these. fn unsharded_report(started_at: u64, id: &str, line: usize, status: &str) -> Report { - let mut files = HashMap::default(); + let mut files = BTreeMap::new(); let _ = files.insert( "src/lib.rs".to_owned(), FileResult { @@ -690,7 +692,7 @@ mod tests { .map(|(index, status)| mutant(&format!("m{index}"), index + 1, status)) .collect(); - let mut files = HashMap::default(); + let mut files = BTreeMap::new(); let _ = files.insert( "src/lib.rs".to_owned(), FileResult { diff --git a/crates/cargo-gamma-lib/src/commands/mod.rs b/crates/cargo-gamma-lib/src/commands/mod.rs index 73af9f7a..9d225031 100644 --- a/crates/cargo-gamma-lib/src/commands/mod.rs +++ b/crates/cargo-gamma-lib/src/commands/mod.rs @@ -19,10 +19,14 @@ mod unsuppress; mod verdict_log; mod when; +#[doc(inline)] pub use cli::{ - CleanArgs, Cli, Command, CompletionsArgs, ConfigArgs, ExplainArgs, FeatureArgs, HintsArgs, ListArgs, ListKind, MergeArgs, RunArgs, - SelectArgs, SuppressArgs, UnsuppressArgs, + BuildLimitArgs, CleanArgs, Cli, Command, CompletionsArgs, ConfigArgs, ExplainArgs, FeatureArgs, HintsArgs, ListArgs, ListKind, + MeasureArgs, MergeArgs, RunArgs, SelectArgs, SuppressArgs, UnsuppressArgs, }; +#[doc(inline)] pub use dispatch::{EXIT_CANNOT_PROCEED, EXIT_GATE_FAILED, EXIT_INTERNAL, EXIT_OK, EXIT_USAGE, run}; +#[doc(inline)] pub use host::Host; +#[doc(inline)] pub use when::When; diff --git a/crates/cargo-gamma-lib/src/commands/run.rs b/crates/cargo-gamma-lib/src/commands/run.rs index b6308b02..e1fb5cc6 100644 --- a/crates/cargo-gamma-lib/src/commands/run.rs +++ b/crates/cargo-gamma-lib/src/commands/run.rs @@ -172,13 +172,7 @@ fn emit_reports( crate::elements::write_json(&report, &documents.json)?; writeln!(stream, "{} {}", styler.verb("Wrote"), documents.json)?; - let source = if args.html_external { - crate::html::Source::External - } else { - crate::html::Source::Inline - }; - - crate::html::write_page(&report, source, &documents.html)?; + crate::html::write_page(&report, &documents.html)?; writeln!(stream, "{} {}", styler.verb("Wrote"), documents.html)?; drop(stream); @@ -789,6 +783,10 @@ fn measured(host: &mut H, args: &RunArgs, progress_when: When, styler: verdict_log: VerdictLog::default(), }; + #[cfg(feature = "internals")] + if incremental.is_some() && args.measure.cache_dir.is_some() { + crate::testing::pause_after_cache_adoption(&survey.root); + } let outcome = exec::run_with_locks(&survey, &selection, &config, &mut events, cache_locks); // A phase that failed never got to say what it found, so the line it opened is still waiting @@ -1414,7 +1412,6 @@ mod tests { fails_at_every_line(3, |host| { let args = RunArgs { artifact_dir: Some(root.clone()), - html_external: true, annotations: crate::ci::Annotations::None, ..Default::default() }; @@ -1442,7 +1439,6 @@ mod tests { let summary = root.join("summary.md"); let args = RunArgs { artifact_dir: Some(root.join("reports")), - html_external: true, annotations: crate::ci::Annotations::Github, ..Default::default() }; diff --git a/crates/cargo-gamma-lib/src/config.rs b/crates/cargo-gamma-lib/src/config.rs index c4fa60c0..13b51aff 100644 --- a/crates/cargo-gamma-lib/src/config.rs +++ b/crates/cargo-gamma-lib/src/config.rs @@ -53,6 +53,9 @@ pub struct Config { /// Globs excluding files from mutation. pub exclude_files: Vec, + /// Structured rules excluding mutants from trait implementations. + pub exclude_mutants: Vec, + /// Fail the run below this mutation score. pub min_score: Option, @@ -143,10 +146,24 @@ pub struct Config { /// Sharding. #[serde(default)] pub shard: Shard, +} - /// File reports. - #[serde(default)] - pub reporters: Reporters, +/// One structured mutant exclusion from `[[exclude-mutants]]`. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "kebab-case")] +pub struct MutantExclusion { + /// Terminal name of an implemented trait, independent of path qualification. + pub trait_impl: String, + + /// Why this project does not treat mutations in the implementation as useful. + pub reason: String, +} + +impl MutantExclusion { + #[must_use] + pub(crate) fn matches(&self, mutant: &crate::model::Mutant) -> bool { + mutant.trait_impl.as_deref() == Some(self.trait_impl.as_str()) + } } /// Reads a size key that [`Config::validate`] has already accepted. @@ -170,14 +187,6 @@ pub struct Shard { pub index: Option, } -/// The `[reporters]` table. -#[derive(Debug, Default, Clone, Deserialize)] -#[serde(default, deny_unknown_fields, rename_all = "kebab-case")] -pub struct Reporters { - /// Load the viewer from a CDN instead of embedding it. - pub html_external: Option, -} - impl Config { /// The Cargo-only settings discovery must share with the eventual run. #[must_use] @@ -276,6 +285,19 @@ impl Config { } } + for (index, exclusion) in self.exclude_mutants.iter().enumerate() { + if syn::parse_str::(&exclusion.trait_impl).is_err() { + return Err(format!( + "exclude-mutants[{}].trait-impl must be one unqualified Rust identifier", + index + 1 + )); + } + + if exclusion.reason.trim().is_empty() { + return Err(format!("exclude-mutants[{}].reason must not be empty", index + 1)); + } + } + Ok(()) } @@ -330,8 +352,6 @@ impl Config { args.no_baseline = args.no_baseline || self.no_baseline.unwrap_or(false); args.no_confirm = args.no_confirm || self.no_confirm.unwrap_or(false); args.artifact_dir = args.artifact_dir.take().or_else(|| self.artifact_dir.clone()); - args.html_external = args.html_external || self.reporters.html_external.unwrap_or(false); - if !args.measure.test_packages.is_empty() && args.measure.test_workspace { return Err(contradiction( "test-packages", @@ -363,6 +383,7 @@ impl Config { select.files.extend(self.files.iter().cloned()); select.exclude_files.extend(self.exclude_files.iter().cloned()); + select.exclude_mutants.extend(self.exclude_mutants.iter().cloned()); select.packages.extend(self.packages.iter().cloned()); select.errors.extend(self.errors.iter().cloned()); select.features.features.extend(self.features.iter().cloned()); @@ -426,6 +447,7 @@ mod tests { use tempfile::TempDir; use super::*; + use crate::commands::{BuildLimitArgs, FeatureArgs, MeasureArgs}; fn select_args(dir: &Utf8Path) -> SelectArgs { SelectArgs { @@ -732,6 +754,36 @@ mod tests { assert_eq!(config.test_timeout_multiplier, Some(3.0)); } + #[test] + fn a_trait_implementation_exclusion_reaches_mutant_selection() { + let config = Config::parse("[[exclude-mutants]]\ntrait-impl = \"Debug\"\nreason = \"diagnostic text has no stable contract\"\n") + .expect("the structured exclusion parses"); + let mut select = SelectArgs::default(); + + config + .apply_selection(&mut select) + .expect("the exclusion does not contradict another setting"); + + assert_eq!(select.exclude_mutants.len(), 1); + assert_eq!(select.exclude_mutants[0].trait_impl, "Debug"); + assert_eq!(select.exclude_mutants[0].reason, "diagnostic text has no stable contract"); + + let mut mutant = crate::fixtures::mutant(); + mutant.trait_impl = Some("Debug".into()); + + assert!(select.exclude_mutants[0].matches(&mutant)); + } + + #[test] + fn a_trait_exclusion_names_one_terminal_identifier() { + let qualified = + Config::parse("[[exclude-mutants]]\ntrait-impl = \"fmt::Debug\"\nreason = \"diagnostic\"\n").expect_err("must be rejected"); + let empty_reason = Config::parse("[[exclude-mutants]]\ntrait-impl = \"Debug\"\nreason = \" \"\n").expect_err("must be rejected"); + + assert!(qualified.contains("one unqualified Rust identifier"), "{qualified}"); + assert!(empty_reason.contains("reason must not be empty"), "{empty_reason}"); + } + #[test] fn ops_are_joined_into_the_selector_list_the_flag_parses() { // One selector per line, with room for a comment, is the reason this is a list. It has to @@ -812,16 +864,6 @@ mod tests { assert_eq!(args.artifact_dir.as_deref(), Some(Utf8Path::new("cli-out"))); } - #[test] - fn individual_report_destinations_are_not_configurable() { - for key in ["html", "json", "sarif", "advice"] { - let text = format!("[reporters]\n{key} = \"out/report\"\n"); - let failure = Config::parse(&text).expect_err("individual destinations are gone"); - - assert!(failure.contains("unknown field"), "{key}: {failure}"); - } - } - #[test] fn sharding_can_be_set_entirely_from_the_file() { let config = Config::parse("[shard]\ncount = 30\nindex = 7\n").expect("parses"); @@ -994,4 +1036,293 @@ mod tests { assert!(error.to_string().contains(RELATIVE_PATH), "{error}"); } + + /// A configuration with every key set, each to a value nothing else in the merge produces. + /// + /// Sentinel values rather than plausible ones on purpose: an assignment that reached the wrong + /// field, or that was deleted and left the default behind, has to fail rather than coincide. + /// `test_workspace` is the one key held false here, because it and `test-packages` cannot both + /// apply — the pair has a test of its own below. + fn every_key_set() -> Config { + Config { + mutators: Some(vec!["arith".to_owned(), "!arith.add_to_sub".to_owned()]), + files: vec!["file-from-the-file".to_owned()], + exclude_files: vec!["excluded-file-from-the-file".to_owned()], + exclude_mutants: vec![MutantExclusion { + trait_impl: "TraitFromTheFile".to_owned(), + reason: "the file said so".to_owned(), + }], + min_score: Some(61.5), + jobs: Some(62), + test_timeout_multiplier: Some(63.5), + incremental: Some(crate::exec::IncrementalMode::No), + no_baseline: Some(true), + no_confirm: Some(true), + packages: vec!["package-from-the-file".to_owned()], + test_packages: vec!["test-package-from-the-file".to_owned()], + test_workspace: Some(false), + whole_test_binaries: Some(true), + include_tests: vec!["included-test-from-the-file".to_owned()], + exclude_tests: vec!["excluded-test-from-the-file".to_owned()], + features: vec!["feature-from-the-file".to_owned()], + all_features: Some(true), + no_default_features: Some(true), + profile: Some("profile-from-the-file".to_owned()), + cargo_args: vec!["--cargo-argument-from-the-file".to_owned()], + cargo_test_args: vec!["--cargo-test-argument-from-the-file".to_owned()], + errors: vec!["ErrorFromTheFile".to_owned()], + minimum_test_timeout: Some(64.5), + nextest: Some(true), + memory: Some(crate::exec::MemoryControl::Measure), + memory_multiplier: Some(65.5), + memory_headroom: Some("128MiB".to_owned()), + memory_limit: Some("2GiB".to_owned()), + baseline_memory_limit: Some("4GiB".to_owned()), + build_timeout: Some(66.5), + build_timeout_multiplier: Some(67.5), + artifact_dir: Some(Utf8PathBuf::from("artifacts-from-the-file")), + shard: Shard { + count: Some(68), + index: Some(9), + }, + } + } + + /// Every key in the file reaches the setting it names, when the command line said nothing. + /// + /// The one test that is exhaustive over `apply` and `apply_selection` together. The individual + /// tests above each pin one interesting rule; what none of them can catch is an assignment that + /// was simply deleted, or one that was written against the neighbouring field, because a + /// defaulted value looks exactly like a setting nobody configured. + #[test] + fn every_configured_key_reaches_its_setting() { + let config = every_key_set(); + let mut args = RunArgs::default(); + + config + .apply(&mut args) + .expect("no two settings in this file contradict one another"); + + assert_eq!(args.select.mutators.as_deref(), Some("arith,!arith.add_to_sub")); + assert_eq!(args.select.files, ["file-from-the-file"]); + assert_eq!(args.select.exclude_files, ["excluded-file-from-the-file"]); + assert_eq!(args.select.exclude_mutants.len(), 1); + assert_eq!(args.select.exclude_mutants[0].trait_impl, "TraitFromTheFile"); + assert_eq!(args.select.exclude_mutants[0].reason, "the file said so"); + assert_eq!(args.select.packages, ["package-from-the-file"]); + assert_eq!(args.select.errors, ["ErrorFromTheFile"]); + assert_eq!(args.select.features.features, ["feature-from-the-file"]); + assert!(args.select.features.all_features); + assert!(args.select.features.no_default_features); + assert_eq!(args.select.shard_count, Some(68)); + assert_eq!(args.select.shard_index, Some(9)); + + assert_eq!(args.min_score, Some(61.5)); + assert_eq!(args.measure.jobs, Some(62)); + assert_eq!(args.measure.test_timeout_multiplier, Some(63.5)); + assert_eq!(args.measure.minimum_test_timeout, Some(64.5)); + assert!(args.measure.nextest); + assert_eq!(args.measure.memory, Some(crate::exec::MemoryControl::Measure)); + assert_eq!(args.measure.memory_multiplier, Some(65.5)); + assert_eq!(args.measure.memory_headroom, Some(128 * 1024 * 1024)); + assert_eq!(args.measure.memory_limit, Some(2 * 1024 * 1024 * 1024)); + assert_eq!(args.measure.baseline_memory_limit, Some(4 * 1024 * 1024 * 1024)); + assert_eq!(args.limits.build_timeout, Some(66.5)); + assert_eq!(args.limits.build_timeout_multiplier, Some(67.5)); + assert_eq!(args.incremental, Some(crate::exec::IncrementalMode::No)); + assert_eq!(args.measure.profile.as_deref(), Some("profile-from-the-file")); + assert_eq!(args.measure.cargo_args, ["--cargo-argument-from-the-file"]); + assert_eq!(args.measure.cargo_test_args, ["--cargo-test-argument-from-the-file"]); + assert_eq!(args.measure.test_packages, ["test-package-from-the-file"]); + assert!(!args.measure.test_workspace, "the file said false, so nothing may turn it on"); + assert!(args.measure.whole_test_binaries); + assert_eq!(args.measure.include_tests, ["included-test-from-the-file"]); + assert_eq!(args.measure.exclude_tests, ["excluded-test-from-the-file"]); + assert!(args.no_baseline); + assert!(args.no_confirm); + assert_eq!(args.artifact_dir.as_deref(), Some(Utf8Path::new("artifacts-from-the-file"))); + } + + /// The command line wins every scalar, combines every flag with OR, and goes first in every list. + /// + /// The mirror of `every_key_set`: a command line that states every mergeable setting, and + /// states each one differently, so that a value arriving from the file is always visible. + fn every_setting_typed() -> RunArgs { + RunArgs { + select: SelectArgs { + mutators: Some("literal".to_owned()), + files: vec!["file-from-the-command-line".to_owned()], + exclude_files: vec!["excluded-file-from-the-command-line".to_owned()], + packages: vec!["package-from-the-command-line".to_owned()], + errors: vec!["ErrorFromTheCommandLine".to_owned()], + shard_count: Some(3), + shard_index: Some(1), + features: FeatureArgs { + features: vec!["feature-from-the-command-line".to_owned()], + ..FeatureArgs::default() + }, + ..SelectArgs::default() + }, + min_score: Some(11.5), + incremental: Some(crate::exec::IncrementalMode::Build), + artifact_dir: Some(Utf8PathBuf::from("artifacts-from-the-command-line")), + measure: MeasureArgs { + jobs: Some(12), + test_timeout_multiplier: Some(13.5), + minimum_test_timeout: Some(14.5), + memory: Some(crate::exec::MemoryControl::Off), + memory_multiplier: Some(15.5), + memory_headroom: Some(1), + memory_limit: Some(2), + baseline_memory_limit: Some(3), + profile: Some("profile-from-the-command-line".to_owned()), + cargo_args: vec!["--cargo-argument-from-the-command-line".to_owned()], + cargo_test_args: vec!["--cargo-test-argument-from-the-command-line".to_owned()], + test_packages: vec!["test-package-from-the-command-line".to_owned()], + include_tests: vec!["included-test-from-the-command-line".to_owned()], + exclude_tests: vec!["excluded-test-from-the-command-line".to_owned()], + ..MeasureArgs::default() + }, + limits: BuildLimitArgs { + build_timeout: Some(16.5), + build_timeout_multiplier: Some(17.5), + ..BuildLimitArgs::default() + }, + ..RunArgs::default() + } + } + + /// Every scalar the command line states survives the merge unchanged. + /// + /// The half that a `.or(...)` written the wrong way round would pass on its own: a file that + /// overwrote a typed setting is invisible unless both sources state that setting differently, + /// and every value on both sides here does. + #[test] + fn the_command_line_outranks_the_file_for_every_scalar() { + let config = every_key_set(); + let mut args = every_setting_typed(); + + config + .apply(&mut args) + .expect("no two settings in this merge contradict one another"); + + assert_eq!(args.select.mutators.as_deref(), Some("literal")); + assert_eq!(args.select.shard_count, Some(3)); + assert_eq!(args.select.shard_index, Some(1)); + assert_eq!(args.min_score, Some(11.5)); + assert_eq!(args.measure.jobs, Some(12)); + assert_eq!(args.measure.test_timeout_multiplier, Some(13.5)); + assert_eq!(args.measure.minimum_test_timeout, Some(14.5)); + assert_eq!(args.measure.memory_multiplier, Some(15.5)); + assert_eq!(args.measure.memory_headroom, Some(1)); + assert_eq!(args.measure.memory_limit, Some(2)); + assert_eq!(args.measure.baseline_memory_limit, Some(3)); + assert_eq!(args.limits.build_timeout, Some(16.5)); + assert_eq!(args.limits.build_timeout_multiplier, Some(17.5)); + assert_eq!(args.incremental, Some(crate::exec::IncrementalMode::Build)); + assert_eq!(args.measure.profile.as_deref(), Some("profile-from-the-command-line")); + assert_eq!(args.artifact_dir.as_deref(), Some(Utf8Path::new("artifacts-from-the-command-line"))); + + // A stated mode outranks both the file's and the one the typed ceilings imply. + assert_eq!(args.measure.memory, Some(crate::exec::MemoryControl::Off)); + } + + /// Every list keeps what was typed and appends what the file adds, in that order. + /// + /// Concatenation rather than replacement is the contract, and the order is part of it: a + /// project default extends the run that was asked for instead of reordering it. Both halves + /// have to be asserted, since a merge that dropped one source entirely still leaves a + /// non-empty list behind. + #[test] + fn every_list_concatenates_with_the_command_line_first() { + let config = every_key_set(); + let mut args = every_setting_typed(); + + config + .apply(&mut args) + .expect("no two settings in this merge contradict one another"); + + assert_eq!(args.select.files, ["file-from-the-command-line", "file-from-the-file"]); + assert_eq!( + args.select.exclude_files, + ["excluded-file-from-the-command-line", "excluded-file-from-the-file"] + ); + assert_eq!(args.select.packages, ["package-from-the-command-line", "package-from-the-file"]); + assert_eq!(args.select.errors, ["ErrorFromTheCommandLine", "ErrorFromTheFile"]); + assert_eq!( + args.select.features.features, + ["feature-from-the-command-line", "feature-from-the-file"] + ); + assert_eq!( + args.measure.cargo_args, + ["--cargo-argument-from-the-command-line", "--cargo-argument-from-the-file"] + ); + assert_eq!( + args.measure.cargo_test_args, + ["--cargo-test-argument-from-the-command-line", "--cargo-test-argument-from-the-file"] + ); + assert_eq!( + args.measure.test_packages, + ["test-package-from-the-command-line", "test-package-from-the-file"] + ); + assert_eq!( + args.measure.include_tests, + ["included-test-from-the-command-line", "included-test-from-the-file"] + ); + assert_eq!( + args.measure.exclude_tests, + ["excluded-test-from-the-command-line", "excluded-test-from-the-file"] + ); + + // The exclusions have no command-line spelling, so the file's are all there are. + assert_eq!(args.select.exclude_mutants.len(), 1); + } + + /// A ceiling typed on the command line implies a mode, ahead of whatever the file asked for. + /// + /// The one three-way precedence in the merge, and the one an `.or` chain in the wrong order + /// gets subtly wrong: an explicit `--memory` still wins, but a bare `--memory-limit` has to + /// outrank the file rather than lose to it, or a typed ceiling would be measured and not + /// enforced. + #[test] + fn a_typed_ceiling_implies_a_mode_that_outranks_the_file() { + let config = every_key_set(); + let mut args = RunArgs { + measure: MeasureArgs { + memory_limit: Some(9), + ..MeasureArgs::default() + }, + ..RunArgs::default() + }; + + config + .apply(&mut args) + .expect("no two settings in this merge contradict one another"); + + assert_eq!(args.measure.memory, Some(crate::exec::MemoryControl::Enforce)); + } + + /// The file's `test-workspace` is merged with a logical OR, and then contradicts its own + /// `test-packages`. + /// + /// Held out of the exhaustive test because the two cannot both apply, which leaves the `true` + /// branch of this one assignment unreached by it. The error is the assertion: reaching it at + /// all proves the flag was merged, since a deleted assignment would leave it false and the + /// merge would succeed. + #[test] + fn a_configured_test_workspace_is_merged_and_then_contradicts_configured_test_packages() { + let config = Config { + test_workspace: Some(true), + ..every_key_set() + }; + let mut args = RunArgs::default(); + + let error = config + .apply(&mut args) + .expect_err("`test-packages` and `test-workspace` cannot both apply"); + + assert!(error.is_usage(), "{error}"); + assert!(error.to_string().contains("test-packages"), "{error}"); + assert!(error.to_string().contains("test-workspace"), "{error}"); + } } diff --git a/crates/cargo-gamma-lib/src/diag/mod.rs b/crates/cargo-gamma-lib/src/diag/mod.rs index 6752ca21..0b1d292d 100644 --- a/crates/cargo-gamma-lib/src/diag/mod.rs +++ b/crates/cargo-gamma-lib/src/diag/mod.rs @@ -7,12 +7,12 @@ //! to know what to do about it, so it withholds anything they cannot act on. These withhold //! nothing. //! -//! [`render`] is the prose dump behind `--diag`: unstable, undocumented, and written for people +//! [`render()`] is the prose dump behind `--diag`: unstable, undocumented, and written for people //! working on the tool, so that a change to the scheduler, the build sequencing or the mutator //! catalog can be judged against numbers rather than against how the run felt. It goes to the //! diagnostic stream, so it composes with piping the results somewhere. //! -//! [`bundle`] is the same measurements as a versioned document, written for someone else to read. +//! [`bundle()`] is the same measurements as a versioned document, written for someone else to read. //! A user reporting that a run was slow has no way to show us why and we have no way to ask for it, //! so what arrives in an issue is a screenshot or a paraphrase. The bundle is the thing to attach — //! which is why it carries no source text and hashes the identifiers by default. @@ -20,5 +20,7 @@ mod bundle; mod render; +#[doc(inline)] pub use bundle::{Bundle, Context, Redaction, bundle, to_json}; +#[doc(inline)] pub use render::render; diff --git a/crates/cargo-gamma-lib/src/discover/diff.rs b/crates/cargo-gamma-lib/src/discover/diff.rs index 279f55ce..7e4a2601 100644 --- a/crates/cargo-gamma-lib/src/discover/diff.rs +++ b/crates/cargo-gamma-lib/src/discover/diff.rs @@ -30,7 +30,7 @@ impl Diff { /// /// Returns an error if the diff cannot be read. pub fn read(path: &Utf8Path) -> Result { - Self::read_from(path, &mut stdin()) + Self::read_from(path, stdin()) } /// Reads a unified diff, taking `-` from `input` rather than from the real standard input. @@ -41,8 +41,9 @@ impl Diff { /// /// # Errors /// - /// Returns an error if the diff cannot be read. - pub fn read_from(path: &Utf8Path, input: &mut impl Read) -> Result { + /// Returns an error if the diff cannot be read: `-` names a stream that fails part way + /// through or is not UTF-8, and any other path names a file that cannot be opened or read. + pub fn read_from(path: &Utf8Path, mut input: impl Read) -> Result { let text = if path == "-" { let mut buffer = String::new(); @@ -779,12 +780,25 @@ index 1234567..89abcde 100644 // `--in-diff -` is the form `git diff | cargo gamma run --in-diff -` uses, and it has to read // the whole stream rather than the first line of it. + // + // The reader is passed by value here and by `&mut` in the test below, which is the whole point + // of the generic being taken by value: an owned reader needs no adapter, and a caller that + // still wants its reader back afterwards passes a mutable borrow of it. #[test] fn a_diff_is_read_from_standard_input() { + let diff = Diff::read_from(Utf8Path::new("-"), SAMPLE.as_bytes()).expect("could not read the diff"); + + assert!(diff.touches_file(Utf8Path::new("src/lib.rs"))); + } + + // A reader the caller still owns afterwards reaches the same parser through a mutable borrow. + #[test] + fn a_borrowed_reader_is_accepted_and_left_with_its_owner() { let mut input = SAMPLE.as_bytes(); let diff = Diff::read_from(Utf8Path::new("-"), &mut input).expect("could not read the diff"); assert!(diff.touches_file(Utf8Path::new("src/lib.rs"))); + assert!(input.is_empty(), "the borrowed reader was consumed in place rather than copied"); } // A stream that fails half way through must be reported rather than silently truncated into a diff --git a/crates/cargo-gamma-lib/src/discover/hints.rs b/crates/cargo-gamma-lib/src/discover/hints.rs index db910fdf..cb950aaa 100644 --- a/crates/cargo-gamma-lib/src/discover/hints.rs +++ b/crates/cargo-gamma-lib/src/discover/hints.rs @@ -213,7 +213,7 @@ impl Hints { // The one place the admission rule is applied, so that widening it means editing a function // whose name says what it decides. let unviable: HashSet<&str> = record - .entries() + .iter() .filter(|(_id, outcome)| tier_of(*outcome) == Some(Tier::Ordering)) .map(|(id, _outcome)| id) .collect(); diff --git a/crates/cargo-gamma-lib/src/discover/killers.rs b/crates/cargo-gamma-lib/src/discover/killers.rs index 7934dcb0..b6ee0431 100644 --- a/crates/cargo-gamma-lib/src/discover/killers.rs +++ b/crates/cargo-gamma-lib/src/discover/killers.rs @@ -94,7 +94,7 @@ impl Killers { continue; }; - found.absorb(&source.ast.items, "", file); + found.absorb(&source.ast().items, "", file); } found diff --git a/crates/cargo-gamma-lib/src/discover/mod.rs b/crates/cargo-gamma-lib/src/discover/mod.rs index 3e40ca26..094eee59 100644 --- a/crates/cargo-gamma-lib/src/discover/mod.rs +++ b/crates/cargo-gamma-lib/src/discover/mod.rs @@ -17,22 +17,34 @@ mod survey; mod target_file; mod workspace_snapshot; +#[doc(inline)] pub use compile_fail::{CompileFailTarget, advice as compile_fail_advice}; +#[doc(inline)] pub use diff::Diff; pub(crate) use glob::Glob; +#[doc(inline)] pub use glob::matches_glob; +#[doc(inline)] pub use hints::{Hints, Promotion, path as hints_path}; +#[doc(inline)] pub use killers::Killers; pub(crate) use order::stages; +#[doc(inline)] pub use plan::Plan; pub(crate) use record::digest; +#[doc(inline)] pub use record::{ - Context as RecordContext, ContextDigest, Killer, RunRecord, Term, Tier, Trust, context as record_context, rustflags, toolchain, + Context as RecordContext, ContextDigest, Entries as RecordEntries, Killer, RunRecord, Term, Tier, Trust, context as record_context, + rustflags, toolchain, }; +#[doc(inline)] pub use shard::shard_of; #[cfg(test)] +#[doc(inline)] pub use survey::plan; pub(crate) use survey::plan_for_build; +#[doc(inline)] pub use survey::{Scanned, Survey, load_metadata}; +#[doc(inline)] pub use target_file::TargetFile; pub(crate) use workspace_snapshot::WorkspaceSnapshot; diff --git a/crates/cargo-gamma-lib/src/discover/modules.rs b/crates/cargo-gamma-lib/src/discover/modules.rs index da411af4..b6ded2c5 100644 --- a/crates/cargo-gamma-lib/src/discover/modules.rs +++ b/crates/cargo-gamma-lib/src/discover/modules.rs @@ -184,12 +184,15 @@ pub(super) fn excluded_files(roots: &[Utf8PathBuf], declared: &[(Utf8PathBuf, Ve #[cfg(not(miri))] mod tests { use super::*; - use crate::cfg::test_gated; fn parse(text: &str) -> syn::File { syn::parse_file(text).unwrap() } + fn test_gated(attrs: &[Attribute]) -> bool { + test_gated_for(&CfgSet::default(), attrs) + } + #[test] fn a_file_stem_that_is_not_a_module_root_owns_a_subdirectory() { assert_eq!(owned_directory(Utf8Path::new("/a/src/lib.rs")), Some(Utf8PathBuf::from("/a/src"))); diff --git a/crates/cargo-gamma-lib/src/discover/record.rs b/crates/cargo-gamma-lib/src/discover/record.rs index a1592362..fc09fc3b 100644 --- a/crates/cargo-gamma-lib/src/discover/record.rs +++ b/crates/cargo-gamma-lib/src/discover/record.rs @@ -492,6 +492,44 @@ struct Entry { elapsed_ms: u64, } +/// Every recorded verdict in a [`RunRecord`], in file order and then in record order. +/// +/// Returned by [`RunRecord::iter`], and by `IntoIterator` on `&RunRecord`. Named rather than +/// returned as `impl Iterator` so that `&RunRecord` can name it as its `IntoIter`, which is what +/// lets a `for` loop over a record work. +#[derive(Debug, Clone)] +pub struct Entries<'a> { + files: core::slice::Iter<'a, RecordedFile>, + + /// The file currently being drained, absent before the first file is reached. + mutants: Option>, +} + +impl<'a> Iterator for Entries<'a> { + type Item = (&'a str, Outcome); + + fn next(&mut self) -> Option { + loop { + if let Some(mutants) = self.mutants.as_mut() + && let Some(entry) = mutants.next() + { + return Some((entry.id.as_str(), entry.outcome)); + } + + self.mutants = Some(self.files.next()?.mutants.iter()); + } + } +} + +impl<'a> IntoIterator for &'a RunRecord { + type Item = (&'a str, Outcome); + type IntoIter = Entries<'a>; + + fn into_iter(self) -> Self::IntoIter { + self.iter() + } +} + /// Whether an outcome is safe to reuse without executing the mutant. /// /// Test outcomes are observations, not proof that the suite is deterministic. Recording only @@ -554,15 +592,21 @@ impl RunRecord { /// Every verdict the record holds, paired with the mutant it belongs to. /// + /// Named `iter` rather than for what it yields, because that is the spelling a caller looks + /// for first: this is the record's one iteration entry point, and a domain noun would hide it. + /// `&RunRecord` implements [`IntoIterator`] over the same items, so a `for` loop reaches them + /// without naming a method at all. + /// /// Offered without any invalidation of its own, because the callers are the tiers that need /// none: the artifact promotion, which admits only what cannot move a score, and the build /// order. Anything that *believes* a verdict goes through [`Self::settled`] instead, which /// applies both the source digest and the tier's context terms. - pub fn entries(&self) -> impl Iterator { - self.files - .iter() - .flat_map(|file| file.mutants.iter()) - .map(|entry| (entry.id.as_str(), entry.outcome)) + #[must_use] + pub fn iter(&self) -> Entries<'_> { + Entries { + files: self.files.iter(), + mutants: None, + } } /// Whether this record holds anything that the [`Tier::Unviability`] rules govern. @@ -587,7 +631,7 @@ impl RunRecord { #[must_use] pub fn ordering(&self) -> Vec<&str> { let mut ids: Vec<&str> = self - .entries() + .iter() .filter(|(_id, outcome)| *outcome == Outcome::CompileError) .map(|(id, _outcome)| id) .collect(); diff --git a/crates/cargo-gamma-lib/src/discover/survey.rs b/crates/cargo-gamma-lib/src/discover/survey.rs index dbff2f6c..ed5c7eb2 100644 --- a/crates/cargo-gamma-lib/src/discover/survey.rs +++ b/crates/cargo-gamma-lib/src/discover/survey.rs @@ -179,6 +179,7 @@ pub struct Survey { diff: Option, shard: Option<(u32, u32)>, settled: HashMap, + exclude_mutants: Vec, } /// What scanning some part of the workspace yielded. @@ -433,6 +434,7 @@ impl Survey { diff, shard, settled: HashMap::default(), + exclude_mutants: args.exclude_mutants.clone(), source_dirs: sorted(source_dirs), external_inputs: external_inputs.roots, untracked_build_script_inputs: external_inputs.has_build_scripts, @@ -568,12 +570,24 @@ impl Survey { ); let Scan { mut mutants, - suppressed, + mut suppressed, idle, skipped, digests, } = scan(&files, &declaration_files, &roots, selection, &self.cfgs)?; + if !self.exclude_mutants.is_empty() { + let excluded_suppressed = mutants + .iter() + .filter(|mutant| { + mutant.outcome == Outcome::Ignored && self.exclude_mutants.iter().any(|exclusion| exclusion.matches(mutant)) + }) + .count(); + + mutants.retain(|mutant| !self.exclude_mutants.iter().any(|exclusion| exclusion.matches(mutant))); + suppressed = suppressed.saturating_sub(excluded_suppressed); + } + // Within a file the diff still has the last word: a changed line usually sits among many // that were not touched, and mutating those would report on code the change never went // near. A mutant is selected by its whole extent, from the line its site starts on to the @@ -705,7 +719,7 @@ fn mutate(file: &TargetFile, source: &SourceFile, selection: &Selection, cfgs: & // Taken from the tree that was parsed for mutants anyway, so knowing which files exist only for // tests costs a walk over the top-level items rather than a second parse of everything. let cfg = cfgs.for_package(&file.package); - let declared = modules::declarations(&file.absolute, &source.ast, cfg); + let declared = modules::declarations(&file.absolute, source.ast(), cfg); // Before anything is collected, because a stated value that cannot be honoured is a hint the // author believes is working. Reporting it is worth more than the mutants of the file it sits @@ -731,7 +745,7 @@ fn mutate(file: &TargetFile, source: &SourceFile, selection: &Selection, cfgs: & suppressed, idle, declared, - digest: crate::discover::digest(source.text.as_bytes()), + digest: crate::discover::digest(source.text().as_bytes()), }) } @@ -831,10 +845,17 @@ fn scan( .map(|&(path, cfg)| (path, cfg)) .collect(); - if !extra_decl_files.is_empty() { - let extra_declared = parse_declarations_parallel(&extra_decl_files)?; + let declaration_skips: Vec<(Utf8PathBuf, String)> = if extra_decl_files.is_empty() { + Vec::new() + } else { + let Declarations { + declared: extra_declared, + skipped: extra_skipped, + } = parse_declarations_parallel(&extra_decl_files)?; + declared.extend(extra_declared); - } + extra_skipped + }; let excluded = modules::excluded_files(roots, &declared); let total = collected.iter().map(|(_index, parsed)| parsed.mutants.len()).sum(); @@ -857,97 +878,154 @@ fn scan( idle.extend(parsed.idle); } - // Sorted by file order for the same reason the earliest failure is the one reported: which - // worker claimed which file is a race, and a diagnostic that reorders itself between runs is - // one nobody can diff. - let mut unanalyzable = shared.skipped.into_inner().unwrap_or_else(PoisonError::into_inner); + // Keyed by path rather than by claim order for the same reason the earliest failure is the one + // reported: which worker claimed which file is a race, and a diagnostic that reorders itself + // between runs is one nobody can diff. Sorting the selected and declaration-only skips together + // by the same key also makes the list independent of *which* of the two paths read a file, so + // narrowing a selection moves a skip between paths without moving it in the report. + let selected_skips = shared.skipped.into_inner().unwrap_or_else(PoisonError::into_inner); + let mut unanalyzable: Vec<(Utf8PathBuf, String)> = selected_skips + .into_iter() + .map(|(at, message)| { + let path = files.get(at).map_or_else(Utf8PathBuf::new, |file| file.absolute.clone()); - unanalyzable.sort_by_key(|(at, _message)| *at); + (path, message) + }) + .chain(declaration_skips) + .collect(); + + unanalyzable.sort_by(|left, right| left.0.cmp(&right.0).then_with(|| left.1.cmp(&right.1))); + unanalyzable.dedup(); Ok(Scan { mutants, suppressed, idle, - skipped: unanalyzable.into_iter().map(|(_at, message)| message).collect(), + skipped: unanalyzable.into_iter().map(|(_path, message)| message).collect(), digests, }) } -type DeclarationParse = (usize, Result<(Utf8PathBuf, Vec)>); +type DeclarationParse = (usize, Result); + +/// What parsing one declaration-only file yielded: its declarations, or the reason it was skipped. +enum DeclarationOutcome { + /// The file parsed, and declares these modules. + Declared(Utf8PathBuf, Vec), + + /// The file could not be analyzed, and this diagnostic names it and says why. + Skipped(Utf8PathBuf, String), +} + +/// Declarations gathered from files outside the selection, with the ones that could not be read. +struct Declarations { + declared: Vec<(Utf8PathBuf, Vec)>, + skipped: Vec<(Utf8PathBuf, String)>, +} /// Parses declaration-only files with bounded parallelism. /// /// Each file is read and parsed solely to extract module declarations — no mutation is performed. /// The parallelism is bounded by `available_parallelism` to avoid exceeding system thread limits. -/// Results are returned in input order. +/// Results are returned in path order. +/// +/// A file this tool cannot analyze but `rustc` can build is recorded as a skip rather than a +/// failure, exactly as the selected-file path records it. Otherwise narrowing a selection would +/// move such a file from the mutating path to this one and turn a partial measurement of an +/// otherwise valid workspace into a failed run, without a line of the workspace having changed. +/// Its declarations are lost with it, so a module only that file declares is treated as absent — +/// the same shape as the file having been unreadable to a selection that never mentioned it. /// /// # Errors /// -/// Returns the first file-read or parse error encountered, in path order. -fn parse_declarations_parallel(files: &[(&Utf8Path, &CfgSet)]) -> Result)>> { +/// Returns the first non-skippable file-read or parse error encountered, in path order. +fn parse_declarations_parallel(files: &[(&Utf8Path, &CfgSet)]) -> Result { if files.is_empty() { - return Ok(Vec::new()); + return Ok(Declarations { + declared: Vec::new(), + skipped: Vec::new(), + }); } let workers = thread::available_parallelism().map_or(1, NonZero::get).min(files.len()); - if workers <= 1 { + let mut results: Vec = if workers <= 1 { let mut results = Vec::with_capacity(files.len()); - for &(path, cfg) in files { - let source = SourceFile::read(path)?; - results.push(((*path).to_owned(), modules::declarations(path, &source.ast, cfg))); + + for (index, &(path, cfg)) in files.iter().enumerate() { + results.push((index, parse_declarations_of(path, cfg))); } - return Ok(results); - } - let next = AtomicUsize::new(0); + results + } else { + let next = AtomicUsize::new(0); - let mut results: Vec = thread::scope(|scope| { - let handles: Vec<_> = (0..workers) - .map(|_| { - let next = &next; - scope.spawn(move || { - let mut mine = Vec::new(); - loop { - let index = next.fetch_add(1, Ordering::Relaxed); - let Some(&(path, cfg)) = files.get(index) else { - break; - }; - match SourceFile::read(path) { - Ok(source) => { - let decls = modules::declarations(path, &source.ast, cfg); - mine.push((index, Ok(((*path).to_owned(), decls)))); - } - Err(e) => { - mine.push((index, Err(e.into()))); - } + thread::scope(|scope| { + let handles: Vec<_> = (0..workers) + .map(|_worker| { + let next = &next; + + scope.spawn(move || { + let mut mine = Vec::new(); + + loop { + let index = next.fetch_add(1, Ordering::Relaxed); + let Some(&(path, cfg)) = files.get(index) else { + break; + }; + + mine.push((index, parse_declarations_of(path, cfg))); } - } - mine + + mine + }) }) - }) - .collect(); + .collect(); - let mut all = Vec::new(); - for handle in handles { - all.extend(handle.join().unwrap_or_else(|payload| resume_unwind(payload))); - } - all - }); + let mut all = Vec::new(); + + for handle in handles { + all.extend(handle.join().unwrap_or_else(|payload| resume_unwind(payload))); + } + + all + }) + }; results.sort_by_key(|(index, _result)| *index); // Check for errors after restoring input order, so scheduling cannot choose the diagnostic. - let mut ok_results: Vec<(Utf8PathBuf, Vec)> = Vec::with_capacity(results.len()); + let mut gathered = Declarations { + declared: Vec::with_capacity(results.len()), + skipped: Vec::new(), + }; for (_index, result) in results { - ok_results.push(result?); + match result? { + DeclarationOutcome::Declared(path, declarations) => gathered.declared.push((path, declarations)), + DeclarationOutcome::Skipped(path, message) => gathered.skipped.push((path, message)), + } } // Declaration consumers use path order, independent of how the caller ordered its pairs. - ok_results.sort_by(|a, b| a.0.cmp(&b.0)); + gathered.declared.sort_by(|left, right| left.0.cmp(&right.0)); + gathered.skipped.sort_by(|left, right| left.0.cmp(&right.0)); - Ok(ok_results) + Ok(gathered) +} + +/// Reads one declaration-only file, turning a skippable failure into a named skip. +fn parse_declarations_of(path: &Utf8Path, cfg: &CfgSet) -> Result { + match SourceFile::read(path) { + Ok(source) => Ok(DeclarationOutcome::Declared( + path.to_owned(), + modules::declarations(path, source.ast(), cfg), + )), + + Err(error) if error.is_skippable() => Ok(DeclarationOutcome::Skipped(path.to_owned(), error.to_string())), + + Err(error) => Err(error.into()), + } } /// What parsing and mutating a set of files produced. @@ -1020,8 +1098,8 @@ fn work(files: &[&TargetFile], shared: &Shared, selection: &Selection, cfgs: &Cf Ok(mut source) => { // Report paths relative to the workspace root; that is what a user can act on // and what a suppression or an expectation is keyed by. - source.path = file.path.clone(); - index.absorb(collect::Defaults::of_in(&source.ast, cfgs.for_package(&file.package))); + source.set_path(file.path.clone()); + index.absorb(collect::Defaults::of_in(source.ast(), cfgs.for_package(&file.package))); mine.push((at, source)); } @@ -2930,6 +3008,64 @@ mod tests { ); } + /// The same deeply nested file, reached only as a module declaration, must be skipped rather + /// than fail the run. + /// + /// Narrowing `--files` moves a file out of the mutating population and into the + /// declaration-only path, which exists purely to learn which modules are test-only or + /// inactive. If that path propagated the nesting-limit error, the very same buildable + /// workspace would measure partially under a wide selection and refuse to run at all under a + /// narrow one — a selection flag deciding whether the tree is sound. Both selections must + /// complete, and both must name the same file for the same reason. + #[test] + fn a_file_too_deep_to_walk_is_skipped_the_same_way_when_only_its_declarations_are_needed() { + let (_directory, root) = workspace(); + + let depth = 512; + let deep = format!("pub fn deep() -> i32 {{\n {}1{}\n}}\n", "(".repeat(depth), ")".repeat(depth)); + + write(&root, "core/src/deep.rs", &deep); + write( + &root, + "core/src/lib.rs", + "mod deep;\n\npub fn add(a: i32, b: i32) -> i32 {\n a + b\n}\n", + ); + + let scan_with = |args: SelectArgs| { + let survey = survey(&root, args); + let mut ordinals = 0; + + survey + .scan(None, &Selection::parse("all").expect("every mutator resolves"), &mut ordinals) + .expect("a file this tool cannot walk does not make the workspace unmeasurable") + }; + + let wide = scan_with(SelectArgs::default()); + + // Narrow enough that `core/src/deep.rs` is no longer mutated, and so is read only for the + // module declarations it might contain. + let narrow = scan_with(SelectArgs { + files: vec!["core/src/lib.rs".to_owned()], + ..SelectArgs::default() + }); + + assert_eq!(narrow.skipped, wide.skipped, "the same file must be reported either way"); + assert_eq!(narrow.skipped.len(), 1, "{:?}", narrow.skipped); + + let note = narrow.skipped.first().expect("one file was skipped"); + + assert!(note.contains("deep.rs"), "the skip does not name the file: {note}"); + assert!(note.contains("nests deeper"), "the skip does not say why: {note}"); + + // And the narrowed selection still measures what it did select, rather than being emptied + // by the file it could not read. + assert!( + narrow.mutants.iter().any(|mutant| mutant.file.as_str().ends_with("lib.rs")), + "{:?}", + narrow.mutants.iter().map(|mutant| mutant.file.as_str()).collect::>() + ); + } + /// A stated value the tool cannot honour stops the scan for the same reason a misspelled /// suppression does: the author wrote it believing it was working. The compiler catches this /// too, but not until a mutant is built, and a run that quietly collected the site's guessed diff --git a/crates/cargo-gamma-lib/src/elements/mod.rs b/crates/cargo-gamma-lib/src/elements/mod.rs index 4f639eba..7998bbca 100644 --- a/crates/cargo-gamma-lib/src/elements/mod.rs +++ b/crates/cargo-gamma-lib/src/elements/mod.rs @@ -13,11 +13,14 @@ mod digest; mod publication; mod report; +#[doc(inline)] pub use digest::{Digest, FileDigest, FrameworkDigest, MutantDigest, settled_mutants}; pub(crate) use publication::{Publication, remove_if_unchanged, write_if_unchanged, write_streamed}; #[cfg(test)] pub(crate) use publication::{before_next_publication, fail_next_directory_sync, next_scratch_path}; +#[doc(inline)] pub use publication::{publish, write}; +#[doc(inline)] pub use report::{ FileResult, Framework, Location, MergeProvenance, MutantResult, Position, Report, RunInfo, ShardInfo, SourceProvenance, Thresholds, VerdictProvenance, build, to_json, write_json, diff --git a/crates/cargo-gamma-lib/src/elements/report.rs b/crates/cargo-gamma-lib/src/elements/report.rs index d6d37065..06c88a05 100644 --- a/crates/cargo-gamma-lib/src/elements/report.rs +++ b/crates/cargo-gamma-lib/src/elements/report.rs @@ -59,7 +59,12 @@ pub struct Report { pub framework: Framework, /// One entry per mutated file, keyed by workspace-relative path. - pub files: HashMap, + /// + /// Ordered rather than hashed because this is the one map whose iteration order is observable: + /// the document is written straight from these types, so what the map yields is what the file + /// contains. Sorting at the write would answer the same question once per publication and leave + /// every other traversal — merging and digesting — free to differ between runs for no reason. + pub files: BTreeMap, /// Free-form run metadata. /// @@ -438,8 +443,14 @@ fn reason_for(mutant: &Mutant) -> Option { /// /// Every mutated file's full source is embedded, because a report that needs the repository beside /// it to be readable cannot be attached to a CI run or mailed to someone. +/// +/// # Errors +/// +/// Returns an error if a mutated file's source cannot be read back from disk, which is what +/// embedding it requires. The read happens after the run rather than during it, so a file the +/// repository deleted or made unreadable in the meantime fails here. pub fn build(plan: &Plan, thresholds: Thresholds, run: Option) -> Result { - let mut files: HashMap = HashMap::default(); + let mut files: BTreeMap = BTreeMap::new(); // Grouped once rather than rescanned per file: a workspace with many files has many mutants // too, so the pairing is quadratic in exactly the case it needs not to be. @@ -894,7 +905,7 @@ fn render_with_first_line_offset(mutant: &Mutant, source: &SourceFile, first_lin let (end_line, end_column) = source.location(mutant.span.end); MutantResult { - id: mutant.id.clone(), + id: CompactString::new(&mutant.id), mutator_name: CompactString::new(&mutant.mutator), location: Location { start: Position { @@ -916,26 +927,99 @@ fn render_with_first_line_offset(mutant: &Mutant, source: &SourceFile, first_lin } /// Serializes the report as pretty-printed JSON. +/// +/// # Errors +/// +/// Returns an error if the report does not satisfy the schema, or if it cannot be serialized. pub fn to_json(report: &Report) -> Result { - let document = serde_json::to_value(report).map_err(|cause| error!("could not serialize the report").caused_by(cause))?; - - validate_schema(&document).map_err(|cause| error!("could not serialize the report: {cause}"))?; + validate_report(report).map_err(|cause| error!("could not serialize the report: {cause}"))?; - serde_json::to_string_pretty(&document).map_err(|cause| error!("could not serialize the report").caused_by(cause)) + serde_json::to_string_pretty(report).map_err(|cause| error!("could not serialize the report").caused_by(cause)) } /// Writes the report to `path` as pretty-printed JSON. /// -/// Streams through a validated `serde_json::Value` for deterministic key ordering, then -/// directly to the writer without building a full `String` in memory. +/// Serialized straight from the typed report rather than through a `serde_json::Value`. The tree +/// was a second complete copy of a document that embeds every mutated file's whole source, built +/// only so that it could be validated and then thrown away; validating the types themselves asks +/// the same questions of the same data without the copy. Key order is the declaration order of the +/// types rather than the alphabetical order a `Value` imposed, which is just as deterministic — +/// the field order of a `struct` does not vary between runs — and the one map whose order is +/// observable is ordered for exactly this reason. +/// +/// # Errors +/// +/// Returns an error if the report does not satisfy the schema, or if it cannot be serialized or +/// written to `path`. pub fn write_json(report: &Report, path: &Utf8Path) -> Result<()> { - let document = serde_json::to_value(report).map_err(|cause| error!("could not serialize the report").caused_by(cause))?; + validate_report(report).map_err(|cause| error!("could not serialize the report: {cause}"))?; - validate_schema(&document).map_err(|cause| error!("could not serialize the report: {cause}"))?; + crate::elements::write_streamed(path, |writer| serde_json::to_writer_pretty(writer, report).map_err(io::Error::from)) +} - crate::elements::write_streamed(path, |writer| { - serde_json::to_writer_pretty(writer, &document).map_err(io::Error::from) - }) +/// Validates a report this crate built, against the same rules as the untyped document check. +/// +/// The typed form makes most of that check unnecessary: a field that the schema says must be a +/// string is a `String`, one that must be a number is an `f64`, and one that is required is not an +/// `Option`. What is left is everything the type system cannot state — the version pattern, the +/// bounded thresholds, positions that must be at least one, the closed status vocabulary, and the +/// uniqueness of mutants within a file — and those are checked here. +/// +/// [`validate_schema`] stays, and is not implemented in terms of this: it is asked about documents +/// this crate did not write, where the types have not yet been established and the answer must not +/// depend on `serde` having accepted them. +fn validate_report(report: &Report) -> SchemaResult<()> { + if !supported_schema_version(&report.schema_version) { + return Err(format!( + "schema version `{}` at report.schemaVersion must match the supported pattern", + report.schema_version + )); + } + + for (name, threshold) in [("high", report.thresholds.high), ("low", report.thresholds.low)] { + if threshold > 100 { + return Err(format!("report.thresholds.{name} must be at most 100")); + } + } + + for (name, file) in &report.files { + validate_file_result(file, &format!("report.files[{name:?}]"))?; + } + + Ok(()) +} + +/// Validates one file's mutants, and that no two of them are the same mutant. +fn validate_file_result(file: &FileResult, path: &str) -> SchemaResult<()> { + let mut unique = HashSet::default(); + + for (index, mutant) in file.mutants.iter().enumerate() { + let path = format!("{path}.mutants[{index}]"); + + if !unique.insert(mutant.id.as_str()) { + return Err(format!("{path} duplicates another mutant")); + } + + if !matches!( + mutant.status.as_str(), + "Killed" | "Survived" | "NoCoverage" | "CompileError" | "RuntimeError" | "Timeout" | "Ignored" | "Pending" + ) { + return Err(format!( + "{path} mutant `{}` has unknown schema status `{}`", + mutant.id, mutant.status + )); + } + + for (corner, position) in [("start", &mutant.location.start), ("end", &mutant.location.end)] { + for (axis, value) in [("line", position.line), ("column", position.column)] { + if value == 0 { + return Err(format!("{path}.location.{corner}.{axis} must be at least 1")); + } + } + } + } + + Ok(()) } #[cfg(test)] @@ -1027,7 +1111,7 @@ mod tests { #[test] fn a_span_becomes_a_one_based_half_open_location() { let source = SourceFile::parse("src/lib.rs", "fn f() {\n a < b\n}\n".to_owned()).expect("parses"); - let start = source.text.find("a <").expect("present"); + let start = source.text().find("a <").expect("present"); let rendered = render(&mutant(Outcome::Survived, start..start + 5), &source); assert_eq!(rendered.location.start.line, 2); @@ -1057,6 +1141,26 @@ mod tests { assert_eq!(json["replacement"], "(a) <= (b)"); } + #[test] + fn typed_reports_reject_two_results_for_one_mutant_id() { + let source = SourceFile::parse("src/lib.rs", "fn f() { a < b; }".to_owned()).expect("parses"); + let first = render(&mutant(Outcome::Survived, 9..14), &source); + let mut second = first.clone(); + + second.status_reason = Some("a different observation".to_owned()); + + let file = FileResult { + source: source.text().to_owned(), + language: "rust".to_owned(), + mutants: vec![first, second], + }; + + assert_eq!( + validate_file_result(&file, "report.files[\"src/lib.rs\"]").unwrap_err(), + "report.files[\"src/lib.rs\"].mutants[1] duplicates another mutant" + ); + } + #[test] fn a_killing_test_is_named_in_the_status_reason() { let mut subject = mutant(Outcome::Killed, 0..1); @@ -1629,7 +1733,7 @@ mod tests { name: "cargo-gamma".to_owned(), version: "0.1.0".to_owned(), }, - files: HashMap::default(), + files: BTreeMap::new(), config: None, }; let json = to_json(&report).expect("serializes"); diff --git a/crates/cargo-gamma-lib/src/error.rs b/crates/cargo-gamma-lib/src/error.rs index bf3b93a0..8dd40808 100644 --- a/crates/cargo-gamma-lib/src/error.rs +++ b/crates/cargo-gamma-lib/src/error.rs @@ -5,6 +5,7 @@ use core::error::Error as StdError; use core::fmt::{self, Display, Formatter}; +use std::backtrace::Backtrace; use std::io; /// An error carrying a human-readable message and an optional cause. @@ -17,6 +18,15 @@ pub struct Error { cause: Option>, usage: bool, skippable: bool, + + /// Captured at construction, unconditionally. + /// + /// Every path that produces an `Error` funnels through [`Self::new`] or through a conversion + /// that carries the origin's own capture across, so the frames recorded are the ones where the + /// failure happened rather than where it was rewrapped. Whether frames are recorded at all is + /// controlled the way the standard library controls it everywhere else, by + /// `RUST_BACKTRACE`/`RUST_LIB_BACKTRACE`, so this costs nothing when they are unset. + backtrace: Backtrace, } impl Error { @@ -27,6 +37,7 @@ impl Error { cause: None, usage: false, skippable: false, + backtrace: Backtrace::capture(), } } @@ -80,6 +91,16 @@ impl Error { pub fn message(&self) -> &str { &self.message } + + /// Returns the backtrace captured where this error was first constructed. + /// + /// Not printed to the user: a mutation run reports what it could not do and what to do about + /// it, and a stack of this tool's own frames answers neither question. It is here for the case + /// the message cannot cover, a failure that should have been impossible, where the only useful + /// next question is which code path produced it. + pub const fn backtrace(&self) -> &Backtrace { + &self.backtrace + } } impl Display for Error { @@ -109,13 +130,23 @@ impl From for Error { impl From for Error { fn from(value: cargo_gamma_engine::Error) -> Self { - let (message, cause, usage, skippable) = value.into_parts(); + // The engine's capture is carried across rather than replaced. Capturing here would record + // this conversion, which every engine error passes through and which therefore identifies + // nothing. + let cargo_gamma_engine::Parts { + message, + cause, + usage, + skippable, + backtrace, + } = value.into_parts(); Self { message, cause, usage, skippable, + backtrace, } } } diff --git a/crates/cargo-gamma-lib/src/exec/build/blame.rs b/crates/cargo-gamma-lib/src/exec/build/blame.rs index 17efac9f..8593a19a 100644 --- a/crates/cargo-gamma-lib/src/exec/build/blame.rs +++ b/crates/cargo-gamma-lib/src/exec/build/blame.rs @@ -98,7 +98,7 @@ pub(super) fn blame(stdout: &str, root: &Utf8Path, guards: &Guards) -> HashMap { @@ -113,7 +113,7 @@ pub(super) fn blame(stdout: &str, root: &Utf8Path, guards: &Guards) -> HashMap { @@ -260,7 +260,7 @@ pub(super) fn diverted( ); for (ordinal, guard) in here { - if !region.contains(&guard.site.start.line) { + if !region.contains(&guard.site.start.line()) { continue; } @@ -316,13 +316,11 @@ pub(super) fn clamped(number: u64) -> u32 { } /// Reads the region a compiler diagnostic points at out of its JSON span. +/// +/// A span whose line or column is absent, or is zero, yields no region: cargo counts both from +/// one, so a zero is a producer that meant no position at all rather than the first character. pub(super) fn position_range(span: &Span<'_>) -> Option> { - let at = |line: Option, column: Option| { - Some(Position { - line: clamped(line?), - column: clamped(column?), - }) - }; + let at = |line: Option, column: Option| Position::new(clamped(line?), clamped(column?)); Some(at(span.line_start, span.column_start)?..at(span.line_end, span.column_end)?) } diff --git a/crates/cargo-gamma-lib/src/exec/build/invoke.rs b/crates/cargo-gamma-lib/src/exec/build/invoke.rs index 7a8bc817..548fbb85 100644 --- a/crates/cargo-gamma-lib/src/exec/build/invoke.rs +++ b/crates/cargo-gamma-lib/src/exec/build/invoke.rs @@ -21,6 +21,7 @@ use super::messages::cargo_message; use crate::Result; use crate::discover::Plan; use crate::error::{Error, error}; +use crate::report::encode_controls; /// Explains a failed cargo spawn. /// @@ -29,13 +30,15 @@ use crate::error::{Error, error}; /// is not always `cargo` from `PATH`: an inherited `CARGO` wins, and a stale one left over from an /// earlier shell points at a binary that no longer exists. pub(super) fn spawn_failure(program: &str, work: &Workspace, cause: io::Error) -> Error { + let program = encode_controls(program); + let root = encode_controls(work.root.as_str()); + if !work.root.as_std_path().is_dir() { - return error!("the scratch tree at `{}` disappeared while it was being built", work.root).caused_by(cause); + return error!("the scratch tree at `{root}` disappeared while it was being built").caused_by(cause); } error!( - "could not run `{program}` in `{}`. Cargo is taken from the `CARGO` environment variable when it is set, and from `PATH` otherwise", - work.root + "could not run `{program}` in `{root}`. Cargo is taken from the `CARGO` environment variable when it is set, and from `PATH` otherwise" ) .caused_by(cause) } @@ -61,7 +64,7 @@ pub(super) fn compile(work: &Workspace, args: &[String], budget: Option, - events: &mut dyn Events, -) -> Result> { +pub(super) fn supervise(command: Command, work: &Workspace, budget: Option, events: &mut dyn Events) -> Result> { supervise_with_limits(command, work, budget, events, OUTPUT_LIMITS) } /// Runs a build with explicit limits for retained and narrated output. pub(super) fn supervise_with_limits( - command: &mut Command, + command: Command, work: &Workspace, budget: Option, events: &mut dyn Events, limits: OutputLimits, ) -> Result> { let program = command.get_program().to_string_lossy().into_owned(); + let root = encode_controls(work.root.as_str()); + + let mut prepared = prepare(command, MemoryRequest::default()).map_err(|reason| { + let raw_reason = reason.to_string(); + let reason = encode_controls(&raw_reason); - let guard = prepare(command, MemoryRequest::default()) - .map_err(|reason| error!("the cargo build in `{}` could not be contained: {reason}", work.root))?; + error!("the cargo build in `{root}` could not be contained: {reason}") + })?; - let child = command.spawn().map_err(|cause| spawn_failure(&program, work, cause))?; + let child = prepared.spawn().map_err(|cause| spawn_failure(&program, work, cause))?; - let mut subtree = match ProcessTree::adopt(child, guard) { + let mut subtree = match ProcessTree::adopt(child, prepared) { Ok(subtree) => subtree, Err(reason) => { events.build_finished(); + let raw_reason = reason.to_string(); + let reason = encode_controls(&raw_reason); - return Err(error!("the cargo build in `{}` could not be contained: {reason}", work.root)); + return Err(error!("the cargo build in `{root}` could not be contained: {reason}")); } }; @@ -153,7 +158,7 @@ pub(super) fn supervise_with_limits( // build tree running with no handle on it at all. collect(&mut subtree); - break Err(error!("could not wait for cargo in `{}`", work.root).caused_by(cause)); + break Err(error!("could not wait for cargo in `{root}`").caused_by(cause)); } } }; @@ -183,22 +188,20 @@ pub(super) fn supervise_with_limits( // and one that stopped on a read that failed — because the difference is invisible in the bytes. let (Some(stdout), Some(stderr)) = (said, printed) else { return Err(error!( - "cargo in `{}` finished, but its output could not be read to the end, so what it built could not be read", - work.root + "cargo in `{root}` finished, but its output could not be read to the end, so what it built could not be read" )); }; if !stdout.complete || !stderr.complete { return Err(error!( - "cargo in `{}` finished, but its output could not be read to the end, so what it built could not be read", - work.root + "cargo in `{root}` finished, but its output could not be read to the end, so what it built could not be read" )); } if !stdout.within_limits || !stderr.within_limits { return Err(error!( - "cargo in `{}` exceeded the configured {}-byte retained or {}-byte per-line build-output limit, so its truncated output could not be trusted", - work.root, limits.retained, limits.line + "cargo in `{root}` exceeded the configured {}-byte retained or {}-byte per-line build-output limit, so its truncated output could not be trusted", + limits.retained, limits.line )); } diff --git a/crates/cargo-gamma-lib/src/exec/build/tests.rs b/crates/cargo-gamma-lib/src/exec/build/tests.rs index 87f5a132..e871a3af 100644 --- a/crates/cargo-gamma-lib/src/exec/build/tests.rs +++ b/crates/cargo-gamma-lib/src/exec/build/tests.rs @@ -157,7 +157,7 @@ fn a_build_past_its_output_limit_fails_without_retaining_the_excess() { .stderr(Stdio::piped()); let error = supervise_with_limits( - &mut command, + command, &work, Some(Duration::from_secs(30)), &mut crate::testing::Recorder::default(), @@ -1490,7 +1490,7 @@ fn a_build_stopped_by_its_budget_takes_its_descendants_with_it() { let work = Workspace::adopt(root.clone(), root.join("target")); let outcome = supervise( - &mut command, + command, &work, Some(Duration::from_millis(750)), &mut crate::testing::Recorder::default(), @@ -1543,7 +1543,7 @@ fn a_finished_build_is_collected_without_waiting_for_its_survivors() { let began = Instant::now(); let output = supervise( - &mut command, + command, &work, Some(Duration::from_secs(45)), &mut crate::testing::Recorder::default(), @@ -1594,7 +1594,7 @@ fn a_cargo_that_cannot_be_found_names_the_program_and_where_it_came_from() { } fn at(line: u32, column: u32) -> Position { - Position { line, column } + Position::new(line, column).expect("a position written into a test is one-based by construction") } fn guard(site: Range, mutated: Option>) -> Guard { @@ -1652,6 +1652,7 @@ fn mutant() -> Mutant { column: 3, mutator: ("lit.true_to_false".to_owned()).into(), item_path: ("pkg::f".to_owned()).into(), + trait_impl: None, occurrence: 0, replacement_index: 0, original: "true".to_owned().into(), diff --git a/crates/cargo-gamma-lib/src/exec/census.rs b/crates/cargo-gamma-lib/src/exec/census.rs index c0e4a872..ff765ee6 100644 --- a/crates/cargo-gamma-lib/src/exec/census.rs +++ b/crates/cargo-gamma-lib/src/exec/census.rs @@ -46,7 +46,7 @@ use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use core::time::Duration; use std::sync::{Arc, Mutex, mpsc}; use std::time::Instant; -use std::{fs, thread}; +use std::{fs, io, thread}; use camino::{Utf8Path, Utf8PathBuf}; use cargo_gamma_process::{MemoryRequest, ProcessTree, prepare}; @@ -350,6 +350,13 @@ pub(super) fn take( events.phase_progress(completed, total, &unit); let mut listed: Vec<(&TestBinary, Vec>)> = Vec::with_capacity(binaries.len()); + // A proxy for the walk's cost, not a measurement of it: `--list` pays only process startup and + // enumeration, while the walk below pays that once per binary and then, per listed test, the + // loader setup, containment, output supervision, and execution that `walk` (below) actually + // performs. A model closer to the walk's true cost needs a campaign measurement comparing + // current, sampled, and census-disabled admission over test count, duration, mutant count, and + // reach density; scaling this estimate by a guessed per-test multiplier without that data would + // only trade one unmeasured policy for another. let mut estimated_cost = Duration::ZERO; for binary in &binaries { @@ -399,6 +406,9 @@ pub(super) fn take( } /// Whether the estimated census work is smaller than everything it could possibly save. +/// +/// `estimated_cost` is the listing-time proxy described where the caller builds it, not a +/// measurement of the walk it is gating. fn can_repay(estimated_cost: Duration, maximum_savings: Duration) -> bool { !maximum_savings.is_zero() && estimated_cost < maximum_savings } @@ -440,12 +450,12 @@ const LIST_POLL: Duration = Duration::from_millis(5); /// `fn main()` ignores the flags and runs its suite instead. Either way the binary is left without /// a census and therefore run in full, which is the answer this had before the census existed. fn list(work: &Workspace, binary: &TestBinary) -> Option>> { - let mut command = listing_command(work, binary); + let command = listing_command(work, binary); // A successful process with no libtest records may be a custom harness that ignored both // flags. Without a positive record there is no evidence that the output was a complete census, // so leave the binary uncensused and run it whole. - listed(&mut command, LIST_BUDGET).filter(|names| !names.is_empty()) + listed(command, LIST_BUDGET).filter(|names| !names.is_empty()) } /// Builds the direct `--list` invocation for one test binary. @@ -483,19 +493,21 @@ fn listing_command(work: &Workspace, binary: &TestBinary) -> std::process::Comma /// A budget reached is `None` rather than a partial listing: half an enumeration is a census that /// believes some tests do not exist, and a test believed not to exist is one no mutant is ever run /// against. -fn listed(command: &mut std::process::Command, budget: Duration) -> Option>> { +fn listed(mut command: std::process::Command, budget: Duration) -> Option>> { use std::process::Stdio; // Nothing is metered — the question is what the binary is, not what it costs — so the request - // asks for no boundary and the containment reduces to the group and the interrupt slot. + // asks for no measurement and no ceiling. Containment does not follow that request: `prepare` + // seals every launch it can, and the listing of a `harness = false` target is exactly the kind + // of repository-controlled code that spawns things and leaves the process group behind it. let request = MemoryRequest { meter: false, limit: None }; let _ = command.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null()); - let guard = prepare(command, request).ok()?; - let child = command.spawn().ok()?; + let mut prepared = prepare(command, request).ok()?; + let child = prepared.spawn().ok()?; - let mut subtree = match ProcessTree::adopt(child, guard) { + let mut subtree = match ProcessTree::adopt(child, prepared) { Ok(subtree) => subtree, // Adoption already ended and reaped the unwatchable child. @@ -819,22 +831,20 @@ where let wanted = targets.get(&binary.path); let reached = &mut local[binary_at]; + // The runtime normally writes every set bit once. Deduplicate here + // as a protocol boundary too, so malformed input cannot inflate a + // site's saturation count and prematurely stop the walk. + let mut sites = sites; + sites.sort_unstable(); + sites.dedup(); + for site in sites.into_iter().filter(|site| wanted.is_some_and(|wanted| wanted.contains(site))) { let tests = reached.entry(site).or_default(); - if !tests.contains(&test_index) { - tests.push(test_index); - - // `at` is unique to this one worker for this one task, so - // this `(site, test_index)` observation can never be - // produced by any other worker: the fetch_add below cannot - // double-count it. It is also gated on the same - // `!tests.contains` guard as the push above, so a sampler - // returning the same site twice for one test counts it once, - // not twice. - if let Some(&position) = positions.get(&site) { - let _previous = counts[position].fetch_add(1, Ordering::Relaxed); - } + tests.push(test_index); + + if let Some(&position) = positions.get(&site) { + let _previous = counts[position].fetch_add(1, Ordering::Relaxed); } } @@ -965,11 +975,49 @@ fn sample(work: &Workspace, binary: &TestBinary, name: &str, path: &Utf8Path, st // means the census never finished: the open failed, or the process died before it could seal. // That is the binary's problem, not this test's answer, so the sample is spoiled rather than // believed empty. - let bytes = fs::read(path.as_std_path()).ok()?; + let bytes = read_bounded(path).ok()?; decode(&bytes).map(|sites| (sites, elapsed)) } +/// The largest census file this will read into memory. +/// +/// One record per site, plus the [`OVERFLOW`] marker and the [`SEAL`], is the largest whole census +/// [`gamma_rt::write_reached`](gamma_rt) can ever produce: it serializes each set bit of its +/// `MAX_CENSUS_SITES`-sized bitmap at most once, in one pass, so no honest file can hold more +/// records than that. Named from `gamma_rt::MAX_CENSUS_SITES` rather than a second hardcoded +/// number, so the two cannot silently drift apart. +const MAX_CENSUS_BYTES: u64 = (gamma_rt::MAX_CENSUS_SITES as u64 + 2) * 4; + +/// Reads a census file into memory, refusing anything past [`MAX_CENSUS_BYTES`]. +/// +/// The path this opens is disclosed to the censused test through `GAMMA_CENSUS`, and the +/// coordinator reading it back is long-lived, judging every mutant after this one — so a test that +/// replaced the runtime's short record stream with an arbitrarily large or sparse file must not be +/// able to make this allocate on the test's behalf. The cap is enforced by bounding the read itself +/// with [`Read::take`], not by trusting `fs::metadata`'s reported length first: a sparse file can +/// misstate that length, but it cannot make more bytes actually arrive through the pipe this reads. +fn read_bounded(path: &Utf8Path) -> io::Result> { + use std::io::Read as _; + + let file = fs::File::open(path.as_std_path())?; + let mut bytes = Vec::new(); + + // One byte past the cap is asked for, not exactly the cap, so a file that is exactly on the + // boundary is not confused with one that overruns it: reading the cap's worth successfully + // leaves nothing to distinguish "exactly full" from "truncated at the limit" until the extra + // byte either arrives (oversized) or the stream ends first (within bounds). + let _read = file.take(MAX_CENSUS_BYTES.saturating_add(1)).read_to_end(&mut bytes)?; + + if u64::try_from(bytes.len()).is_ok_and(|len| len > MAX_CENSUS_BYTES) { + return Err(io::Error::other( + "the census file is larger than any whole census the runtime protocol can produce", + )); + } + + Ok(bytes) +} + /// Turns the runtime's records into site ordinals, or `None` if the file is not a whole census. /// /// A census is trustworthy only if it ends with the runtime's [`SEAL`]: the file is an append-only @@ -978,6 +1026,10 @@ fn sample(work: &Workspace, binary: &TestBinary, name: &str, path: &Utf8Path, st /// after the seal (a stale census appended to, or two runs sharing a path), and the [`OVERFLOW`] /// marker (the runtime ran out of table) — because each means sites may be missing, and missing is /// the one thing this must never guess at. +/// +/// Callers are expected to bound `bytes` — with [`read_bounded`], in production — before this is +/// reached: the preallocation below is sized from that length, and only a caller that already +/// refused an oversized file makes that sizing safe. fn decode(bytes: &[u8]) -> Option> { if !bytes.len().is_multiple_of(4) { return None; @@ -1013,18 +1065,18 @@ mod tests { fn listing_output_beyond_the_cap_is_not_authoritative() { let (_directory, work) = crate::testing::helper_workspace("census-list-cap", &["flood:4194305", "print:meaningful::killer: test", "exit:0"]); - let mut command = listing_command(&work, &crate::testing::helper()); + let command = listing_command(&work, &crate::testing::helper()); - assert!(listed(&mut command, Duration::from_secs(30)).is_none()); + assert!(listed(command, Duration::from_secs(30)).is_none()); } #[test] fn listing_reader_thread_failure_degrades_to_no_census() { let (_directory, work) = crate::testing::helper_workspace("census-list-thread", &["print:a::test: test", "exit:0"]); - let mut command = listing_command(&work, &crate::testing::helper()); + let command = listing_command(&work, &crate::testing::helper()); let _refused = faults::arm(Fault::Thread); - assert!(listed(&mut command, Duration::from_secs(30)).is_none()); + assert!(listed(command, Duration::from_secs(30)).is_none()); } /// Production workspaces hold an exclusive scratch lock; adopted test workspaces do not. @@ -1064,7 +1116,7 @@ mod tests { let _ = command.args(["-c", "sleep 300"]); let started = Instant::now(); - let names = listed(&mut command, Duration::from_millis(200)); + let names = listed(command, Duration::from_millis(200)); assert!(names.is_none(), "a binary that never listed was believed: {names:?}"); assert!( @@ -1097,7 +1149,7 @@ mod tests { let _ = command.args(["-c", &script]); let started = Instant::now(); - let names = listed(&mut command, Duration::from_millis(100)); + let names = listed(command, Duration::from_millis(100)); for _attempt in 0..20 { if marker.as_std_path().exists() { @@ -1130,7 +1182,7 @@ mod tests { let _ = command.args(["-c", "printf 'suite::first: test\\nsuite::second: test\\n'"]); - let names = listed(&mut command, Duration::from_secs(30)).expect("the listing was answered"); + let names = listed(command, Duration::from_secs(30)).expect("the listing was answered"); assert_eq!(names, vec!["suite::first".into(), "suite::second".into()]); } @@ -1147,7 +1199,7 @@ mod tests { let _ = command.args(["-c", "printf 'suite::first: test\\n'; exit 1"]); - assert!(listed(&mut command, Duration::from_secs(30)).is_none()); + assert!(listed(command, Duration::from_secs(30)).is_none()); } /// A listing sees only the tests the user's own filters allow. @@ -1443,6 +1495,65 @@ mod tests { assert_eq!(decode(&bytes), None); } + /// A censused test controls the file at the path it was handed and can write more to it than + /// any honest run of the runtime protocol ever would. This proves the excess is refused rather + /// than read into memory: a file one record past every site plus both markers is definitely + /// past the protocol's own maximum, and `read_bounded` must say so without this coordinator + /// having trusted the file's own claimed size first. + #[test] + fn read_bounded_refuses_a_file_past_the_protocol_maximum() { + let scratch = crate::testing::workdir("sec2-oversized"); + let path = Utf8PathBuf::from_path_buf(scratch.path().join("census.bin")).expect("a temp path is valid UTF-8"); + + let oversized = + usize::try_from(MAX_CENSUS_BYTES.saturating_add(4)).expect("the bound fits in memory on any host that runs this test"); + fs::write(path.as_std_path(), vec![0_u8; oversized]).expect("the oversized fixture file is writable"); + + let error = read_bounded(&path).expect_err("a file past the protocol's maximum size must be refused"); + + assert!(error.to_string().contains("protocol"), "{error}"); + } + + /// A child that replaces the census file with a sparse one can claim an enormous logical size + /// while writing almost no real data — the attack this bound exists for. `read_bounded` must not + /// allocate anywhere near that claimed size: it is bounded by [`Read::take`], not by trusting + /// `fs::metadata`, so this proves the read is refused promptly rather than after the coordinator + /// tried to hold the whole claimed length in memory. + #[test] + fn read_bounded_refuses_a_sparse_file_without_believing_its_claimed_length() { + use std::io::Write as _; + + let scratch = crate::testing::workdir("sec2-sparse"); + let path = Utf8PathBuf::from_path_buf(scratch.path().join("census.bin")).expect("a temp path is valid UTF-8"); + + let file = fs::File::create(path.as_std_path()).expect("the sparse fixture file is creatable"); + // Claims tens of gigabytes without writing them: a real disk write of that size would make + // this test itself the resource exhaustion it is trying to prove `read_bounded` refuses. + file.set_len(1 << 34) + .expect("the filesystem under the test work directory supports sparse files"); + drop(file); + + let error = read_bounded(&path).expect_err("a sparse file past the protocol's maximum must be refused"); + + assert!(error.to_string().contains("protocol"), "{error}"); + + // The refusal came from the bytes actually read rather than from the claimed length: had + // `read_bounded` trusted `fs::metadata` instead, this would still pass, so the write below + // is what tells the two apart. Reopening confirms the file is still exactly as small as it + // was left, and rereading it with the same bound still refuses it. + let mut confirm = fs::File::options() + .append(true) + .open(path.as_std_path()) + .expect("the sparse fixture file remains open-able"); + confirm + .write_all(&sealed(&[1])) + .expect("appending a small whole census to the sparse claim succeeds"); + drop(confirm); + + let _still_refused = + read_bounded(&path).expect_err("a sparse claim past the bound is refused regardless of what real data follows it"); + } + /// `walked` counts subprocesses that actually launched, not tests that were listed. /// /// A binary spoils the moment one of its tests cannot be trusted, and its remaining tests are diff --git a/crates/cargo-gamma-lib/src/exec/copy.rs b/crates/cargo-gamma-lib/src/exec/copy.rs index 36d71f4f..fce5f33d 100644 --- a/crates/cargo-gamma-lib/src/exec/copy.rs +++ b/crates/cargo-gamma-lib/src/exec/copy.rs @@ -12,14 +12,14 @@ use core::sync::atomic::{AtomicBool, Ordering}; use std::fs::{self, File, FileTimes}; use std::io::ErrorKind; use std::process::{Command, Stdio}; -use std::sync::Mutex; +use std::sync::{Arc, LazyLock, Mutex, PoisonError}; use std::time::SystemTime; use camino::{Utf8Path, Utf8PathBuf}; use ignore::{WalkBuilder, WalkState}; -use crate::Result; use crate::error::{Error, error}; +use crate::{HashMap, Result}; /// Version control directories, which are large, hold nothing a build reads, and are actively /// hazardous in a tree a tool is rewriting: a stray command run from the scratch copy could commit @@ -59,14 +59,72 @@ pub(super) fn visible_vcs_metadata(path: &Utf8Path) -> Vec { found } -/// Whether copy-on-write cloning is still worth attempting. +/// Whether copy-on-write cloning is still worth attempting for one copy destination. /// -/// A filesystem either supports reflinks or does not, so one failure settles it for the whole -/// process and the rest of the copy goes straight to a byte-for-byte read. The check is a latch -/// rather than a per-file probe because the failure is not always reported as -/// [`std::io::ErrorKind::Unsupported`] — some platforms return a plain permission or argument -/// error — so there is nothing reliable to match on. -pub(super) static REFLINK_WORKS: AtomicBool = AtomicBool::new(true); +/// A filesystem either supports reflinks or does not, so one failure settles it for every later +/// file written to the same destination and the rest of the copy goes straight to a byte-for-byte +/// read. The check is a latch rather than a per-file probe because the failure is not always +/// reported as [`std::io::ErrorKind::Unsupported`] — some platforms return a plain permission or +/// argument error — so there is nothing reliable to match on. +/// +/// Scoped to the destination tree rather than to the process, because "can this be cloned" is a +/// property of where the copy is being written and one run writes to more than one place: the +/// default scratch tree lives under the user's cache directory and a `--cache-dir` routinely names +/// another mount. A process-wide latch let the first failure anywhere disable cloning everywhere +/// afterwards, including on destinations that support it. Nothing was ever copied wrongly — the +/// byte-for-byte fallback is exact — but every later copy paid full price for one unrelated mount. +/// +/// The destination tree, rather than the filesystem it sits on, is the key: it is what the caller +/// names, a run has only a handful of them, and two trees on one mount that each learn the same +/// answer cost one redundant clone attempt apiece. Deriving a filesystem identity instead would buy +/// that one attempt back and, since every test's scratch tree is on the same mount as every other +/// test's, would re-couple the capability across the whole test suite. +#[derive(Clone, Debug)] +pub(super) struct Reflinks { + works: Arc, +} + +/// What each copy destination has been observed to support, so a second copy into the same tree +/// does not have to rediscover it. +static CAPABILITIES: LazyLock>>> = LazyLock::new(|| Mutex::new(HashMap::default())); + +impl Reflinks { + /// The capability every copy into `destination` shares. + pub(super) fn for_destination(destination: &Utf8Path) -> Self { + let mut known = CAPABILITIES.lock().unwrap_or_else(PoisonError::into_inner); + let works = Arc::clone( + known + .entry(destination.to_owned()) + .or_insert_with(|| Arc::new(AtomicBool::new(true))), + ); + + Self { works } + } + + /// A capability no other copy — and, in the tests, no other test — shares. + /// + /// Registering nothing is the point: a test that copies onto a destination whose filesystem + /// cannot clone would otherwise decide which branch every later copy in the process takes, and + /// the tests run in parallel, so which branch a test exercises would depend on the order the + /// harness happened to schedule them in. Both branches copy correctly, so nothing failed — the + /// coverage simply moved. + #[cfg(test)] + pub(super) fn isolated() -> Self { + Self { + works: Arc::new(AtomicBool::new(true)), + } + } + + /// Whether a clone is worth attempting for the next file. + pub(super) fn worth_trying(&self) -> bool { + reflink_supported() && self.works.load(Ordering::Relaxed) + } + + /// Records that this destination cannot clone, so the rest of the copy stops asking. + pub(super) fn unsupported(&self) { + self.works.store(false, Ordering::Relaxed); + } +} /// How much of a tree a copy takes. #[derive(Debug, Clone, Copy, Default)] @@ -83,9 +141,12 @@ pub(super) struct CopyOptions { /// /// `skip` is the directory the copy is being written under when that sits inside the source tree, /// which is the default arrangement; without it the copy would try to copy itself. +/// +/// Test-only, and deliberately on an isolated capability: what these tests check is what ends up in +/// the destination, which is the same either way a file gets there. #[cfg(test)] pub(super) fn copy_tree(from: &Utf8Path, to: &Utf8Path, skip: &Utf8Path) -> Result<()> { - copy_tree_with(from, to, skip, CopyOptions::default()) + copy_tree_with(from, to, skip, CopyOptions::default(), &Reflinks::isolated()) } /// Copies a source tree, taking what a run asked for into account. @@ -99,7 +160,10 @@ pub(super) fn copy_tree(from: &Utf8Path, to: &Utf8Path, skip: &Utf8Path) -> Resu /// advice about what to add, not about what to keep, so a tracked file matching one is still part /// of the tree — and mutant discovery, which walks the tree rather than asking git, finds and /// mutates it. Leaving it out of the copy would fail the build over a file the real tree has. -pub(super) fn copy_tree_with(from: &Utf8Path, to: &Utf8Path, skip: &Utf8Path, options: CopyOptions) -> Result<()> { +/// +/// `reflinks` is the cloning capability of `to`; see [`Reflinks`] for why the caller supplies it +/// rather than this reading a process-wide one. +pub(super) fn copy_tree_with(from: &Utf8Path, to: &Utf8Path, skip: &Utf8Path, options: CopyOptions, reflinks: &Reflinks) -> Result<()> { fs::create_dir_all(to.as_std_path()).map_err(|cause| error!("could not create the scratch tree at `{to}`").caused_by(cause))?; let failure: Mutex> = Mutex::new(None); @@ -138,6 +202,7 @@ pub(super) fn copy_tree_with(from: &Utf8Path, to: &Utf8Path, skip: &Utf8Path, op let destination = destination.clone(); let excluded = excluded.clone(); let failure = &failure; + let reflinks = reflinks.clone(); Box::new(move |entry| { let entry = match entry { @@ -176,7 +241,7 @@ pub(super) fn copy_tree_with(from: &Utf8Path, to: &Utf8Path, skip: &Utf8Path, op return WalkState::Skip; } - match copy_entry(source, &destination.join(relative)) { + match copy_entry(source, &destination.join(relative), &reflinks) { Ok(()) => WalkState::Continue, Err(cause) => { record(failure, cause); @@ -200,7 +265,7 @@ pub(super) fn copy_tree_with(from: &Utf8Path, to: &Utf8Path, skip: &Utf8Path, op } } - copy_tracked(from, to, skip) + copy_tracked(from, to, skip, reflinks) } /// Copies the files git tracks that the walk left behind. @@ -208,7 +273,7 @@ pub(super) fn copy_tree_with(from: &Utf8Path, to: &Utf8Path, skip: &Utf8Path, op /// Only files an ignore rule hid are still missing at this point, which is a handful in the trees /// where it happens and none at all in the rest, so each candidate is settled by a single stat /// rather than by re-deriving what the walk decided. -fn copy_tracked(from: &Utf8Path, to: &Utf8Path, skip: &Utf8Path) -> Result<()> { +fn copy_tracked(from: &Utf8Path, to: &Utf8Path, skip: &Utf8Path, reflinks: &Reflinks) -> Result<()> { let Some(tracked) = tracked_files(from)? else { return Ok(()); }; @@ -232,7 +297,7 @@ fn copy_tracked(from: &Utf8Path, to: &Utf8Path, skip: &Utf8Path) -> Result<()> { continue; } - copy_entry(&source, &destination)?; + copy_entry(&source, &destination, reflinks)?; } Ok(()) @@ -326,7 +391,7 @@ pub(super) fn is_pruned(source: &Utf8Path, relative: &Utf8Path, excluded: &Utf8P } /// Copies one entry, preserving what it is. -fn copy_entry(source: &Utf8Path, destination: &Utf8Path) -> Result<()> { +fn copy_entry(source: &Utf8Path, destination: &Utf8Path, reflinks: &Reflinks) -> Result<()> { let metadata = fs::symlink_metadata(source.as_std_path()).map_err(|cause| error!("could not read `{source}`").caused_by(cause))?; if metadata.is_dir() { @@ -341,12 +406,12 @@ fn copy_entry(source: &Utf8Path, destination: &Utf8Path) -> Result<()> { // the parallel-walk safety the unconditional call had — `create_dir_all` is idempotent, so a // parent another thread finished in the meantime is not a conflict, and a parent that is a plain // file surfaces here as the same "could not create" the unconditional call raised. - if place(&metadata, source, destination).is_err() { + if place(&metadata, source, destination, reflinks).is_err() { if let Some(parent) = destination.parent() { fs::create_dir_all(parent.as_std_path()).map_err(|cause| error!("could not create `{parent}`").caused_by(cause))?; } - return place(&metadata, source, destination); + return place(&metadata, source, destination, reflinks); } Ok(()) @@ -354,11 +419,11 @@ fn copy_entry(source: &Utf8Path, destination: &Utf8Path) -> Result<()> { /// Copies one non-directory entry — a symlink verbatim, anything else as a file — assuming its /// parent already exists. -fn place(metadata: &fs::Metadata, source: &Utf8Path, destination: &Utf8Path) -> Result<()> { +fn place(metadata: &fs::Metadata, source: &Utf8Path, destination: &Utf8Path, reflinks: &Reflinks) -> Result<()> { if metadata.is_symlink() { copy_symlink(source, destination) } else { - copy_file(source, destination) + copy_file(source, destination, reflinks) } } @@ -388,8 +453,8 @@ fn copy_symlink(source: &Utf8Path, destination: &Utf8Path) -> Result<()> { } /// Copies one file, cloning it if the filesystem can. -fn copy_file(source: &Utf8Path, destination: &Utf8Path) -> Result<()> { - if reflink_supported() && REFLINK_WORKS.load(Ordering::Relaxed) { +fn copy_file(source: &Utf8Path, destination: &Utf8Path, reflinks: &Reflinks) -> Result<()> { + if reflinks.worth_trying() { match reflink_copy::reflink(source.as_std_path(), destination.as_std_path()) { Ok(()) => { freshen(destination); @@ -404,7 +469,7 @@ fn copy_file(source: &Utf8Path, destination: &Utf8Path) -> Result<()> { return Err(error!("could not copy `{source}` to `{destination}`").caused_by(cause)); } Err(_unsupported) => { - REFLINK_WORKS.store(false, Ordering::Relaxed); + reflinks.unsupported(); // A failed clone can leave a partial destination behind; remove it so the fallback // starts from the caller's precondition that no destination entry exists. @@ -559,7 +624,14 @@ mod tests { return; } - copy_tree_with(&from, &to, Utf8Path::new("/nowhere"), CopyOptions { copy_ignored: true }).unwrap(); + copy_tree_with( + &from, + &to, + Utf8Path::new("/nowhere"), + CopyOptions { copy_ignored: true }, + &Reflinks::isolated(), + ) + .unwrap(); assert!(to.join("src").join("bin").join("helper.rs").as_std_path().exists()); assert_eq!( @@ -918,7 +990,7 @@ mod tests { let (_temporary, from, to) = tree(); let gone = from.join("was-here"); - let cause = copy_entry(&gone, &to.join("was-here")).unwrap_err(); + let cause = copy_entry(&gone, &to.join("was-here"), &Reflinks::isolated()).unwrap_err(); assert!(cause.to_string().contains("could not read"), "{cause}"); } @@ -934,7 +1006,7 @@ mod tests { fs::create_dir_all(to.as_std_path()).unwrap(); fs::write(to.join("adir").as_std_path(), "blocking file").unwrap(); - let cause = copy_entry(&from.join("adir"), &to.join("adir")).unwrap_err(); + let cause = copy_entry(&from.join("adir"), &to.join("adir"), &Reflinks::isolated()).unwrap_err(); assert!(cause.to_string().contains("could not create"), "{cause}"); } @@ -951,7 +1023,7 @@ mod tests { fs::create_dir_all(to.as_std_path()).unwrap(); fs::write(to.join("blocker").as_std_path(), "blocking file").unwrap(); - let cause = copy_entry(&from.join("leaf"), &to.join("blocker").join("leaf")).unwrap_err(); + let cause = copy_entry(&from.join("leaf"), &to.join("blocker").join("leaf"), &Reflinks::isolated()).unwrap_err(); assert!(cause.to_string().contains("could not create"), "{cause}"); } @@ -968,7 +1040,7 @@ mod tests { fs::create_dir_all(to.as_std_path()).unwrap(); // `to/nested` deliberately does not exist yet. - copy_entry(&from.join("leaf"), &to.join("nested").join("leaf")).unwrap(); + copy_entry(&from.join("leaf"), &to.join("nested").join("leaf"), &Reflinks::isolated()).unwrap(); assert_eq!(fs::read_to_string(to.join("nested").join("leaf").as_std_path()).unwrap(), "source"); } @@ -1006,4 +1078,73 @@ mod tests { assert!(cause.to_string().contains("could not recreate the link"), "{cause}"); } + + /// A destination that cannot clone must not decide for a destination that can. + /// + /// A run writes to more than one place — the default scratch tree under the user's cache + /// directory, and a `--cache-dir` that routinely names another mount — and the capability used + /// to be one process-wide latch. The first failure anywhere sent every later copy in the + /// process down the byte-for-byte path, on filesystems that clone perfectly well. Nothing was + /// copied wrongly, which is exactly why it went unnoticed: it only ever cost time. + #[test] + fn a_destination_that_cannot_clone_does_not_disable_cloning_for_another() { + let (_temporary, from, to) = tree(); + let elsewhere = to.parent().expect("the fixture destination has a parent").join("elsewhere"); + + let unsupported = Reflinks::for_destination(&to); + let other = Reflinks::for_destination(&elsewhere); + + unsupported.unsupported(); + + assert!(!unsupported.worth_trying(), "the failing destination must stop asking"); + assert_eq!( + other.worth_trying(), + reflink_supported(), + "one destination's failure must not answer for another" + ); + + // A second copy into the same destination inherits what the first one learned there, which + // is the whole point of remembering it at all. + assert!(!Reflinks::for_destination(&to).worth_trying()); + + // Both destinations still copy, whichever branch they take. + fs::write(from.join("file.rs").as_std_path(), "fn f() {}").unwrap(); + copy_tree_with(&from, &to, Utf8Path::new("/nowhere"), CopyOptions::default(), &unsupported).unwrap(); + copy_tree_with(&from, &elsewhere, Utf8Path::new("/nowhere"), CopyOptions::default(), &other).unwrap(); + + assert_eq!(fs::read_to_string(to.join("file.rs").as_std_path()).unwrap(), "fn f() {}"); + assert_eq!(fs::read_to_string(elsewhere.join("file.rs").as_std_path()).unwrap(), "fn f() {}"); + } + + /// A test that meets an unsupported destination must not change which branch another test runs. + /// + /// The capability was a process-global one-way latch, and the tests run in parallel: one test + /// copying onto a filesystem without cloning silently moved every later test in that process + /// onto the fallback path. Both branches copy correctly, so nothing failed — but which branch + /// any given test covered depended on the order the harness happened to schedule them in, which + /// is not a property a coverage number can be read against. + #[test] + fn an_isolated_capability_shares_nothing_with_the_registry_or_another_test() { + let (_temporary, _from, to) = tree(); + + let registered = Reflinks::for_destination(&to); + let mine = Reflinks::isolated(); + let theirs = Reflinks::isolated(); + + mine.unsupported(); + + assert!(!mine.worth_trying(), "a test's own capability is its own to trip"); + assert_eq!(theirs.worth_trying(), reflink_supported(), "another test must be unaffected"); + assert_eq!( + registered.worth_trying(), + reflink_supported(), + "an isolated capability must not reach the shared registry" + ); + + // And the traffic does not flow the other way either: tripping the registered capability + // for this destination leaves both isolated ones as they were. + registered.unsupported(); + + assert_eq!(theirs.worth_trying(), reflink_supported()); + } } diff --git a/crates/cargo-gamma-lib/src/exec/harness_filters.rs b/crates/cargo-gamma-lib/src/exec/harness_filters.rs index 8381c612..d092769a 100644 --- a/crates/cargo-gamma-lib/src/exec/harness_filters.rs +++ b/crates/cargo-gamma-lib/src/exec/harness_filters.rs @@ -113,7 +113,9 @@ impl<'args> HarnessFilters<'args> { /// carried rather than an allowlist that would silently drop the next such option. The one /// exception is `--format`, which would fight with the one the listing asks for. pub(super) fn selecting(&self) -> Vec<&'args str> { - let mut args: Vec<&str> = Vec::new(); + // Each stored argument is pushed at most once, and the tail appends every filter, so this + // sum is an upper bound available up front. + let mut args: Vec<&str> = Vec::with_capacity(self.flags.len() + self.filters.len()); let mut index = 0; while let Some(flag) = self.flags.get(index) { diff --git a/crates/cargo-gamma-lib/src/exec/incremental_mode.rs b/crates/cargo-gamma-lib/src/exec/incremental_mode.rs index 0fbf177e..aaf45ba4 100644 --- a/crates/cargo-gamma-lib/src/exec/incremental_mode.rs +++ b/crates/cargo-gamma-lib/src/exec/incremental_mode.rs @@ -11,7 +11,6 @@ use serde::{Deserialize, Serialize}; #[serde(rename_all = "kebab-case")] pub enum IncrementalMode { /// Re-run everything from scratch with no caching. - #[value(alias = "none", alias = "off")] No, /// Reuse compiler unviability and checked execution hints. diff --git a/crates/cargo-gamma-lib/src/exec/measure.rs b/crates/cargo-gamma-lib/src/exec/measure.rs index a7df1303..bde83eff 100644 --- a/crates/cargo-gamma-lib/src/exec/measure.rs +++ b/crates/cargo-gamma-lib/src/exec/measure.rs @@ -30,6 +30,7 @@ use crate::error::error; use crate::estimate::project; use crate::model::Outcome; use crate::ops::registry::Selection; +use crate::report::encode_controls; use crate::{HashMap, HashSet, Result}; /// How many groups a "not run, by …" line names before it starts counting the rest. @@ -469,6 +470,10 @@ pub fn measure(survey: &Survey, selection: &Selection, config: &Config, events: measure_with_locks(survey, selection, config, events, None) } +#[expect( + clippy::too_many_lines, + reason = "one ordered pass: admission, copy, build, and baseline share borrowed state" +)] fn measure_with_locks( survey: &Survey, selection: &Selection, @@ -480,6 +485,20 @@ fn measure_with_locks( let (memory, unbounded) = admit_memory_control(config)?; + // Said here, before the tree is copied and long before a build script or a test binary runs. + // Containment is what keeps a test's descendants from outliving the run, and on a host that + // cannot seal a subtree it reduces to a process group any descendant leaves with one + // unprivileged call. That is a fact about the machine rather than about this run, so it is + // reported once, up front, rather than discovered when an orphan holds a scratch tree open. + if let Err(reason) = cargo_gamma_process::containment() { + let reason = reason.to_string(); + + events.warn(&format!( + "this host cannot fully contain a test's descendants, so cleanup is best-effort: {}", + encode_controls(&reason) + )); + } + // Checked against what the workspace declares, before anything is copied or compiled. A typo // here changes which tests get to convict a mutant, so it should cost a second rather than a // full instrumented build. @@ -1018,7 +1037,10 @@ fn take_baseline( /// invisible until it matters. A user who believes their machine is protected and finds out /// otherwise mid-run is worse off than one who was told plainly at the start. fn admit_memory_control(config: &Config) -> Result<(MemoryPolicy, Option)> { - settle_memory_control(config, memory::support()) + // Reduced to prose here rather than carried further: the only thing left to do with a host + // that cannot meter is to quote its reason at the user, and taking prose lets the decision + // below be driven by tests on any machine. + settle_memory_control(config, memory::support().map_err(|reason| reason.to_string())) } /// Decides what memory control a run gets, given what the host can deliver. diff --git a/crates/cargo-gamma-lib/src/exec/memory.rs b/crates/cargo-gamma-lib/src/exec/memory.rs index 352fe195..494aa1a8 100644 --- a/crates/cargo-gamma-lib/src/exec/memory.rs +++ b/crates/cargo-gamma-lib/src/exec/memory.rs @@ -368,6 +368,8 @@ mod tests { // "unsupported" without a cause sends the reader to the source of this tool instead of to // the configuration of their machine. if let Err(reason) = support() { + let reason = reason.to_string(); + assert!(reason.len() > 20, "{reason}"); } } diff --git a/crates/cargo-gamma-lib/src/exec/mod.rs b/crates/cargo-gamma-lib/src/exec/mod.rs index 402ce1cd..2a2fb176 100644 --- a/crates/cargo-gamma-lib/src/exec/mod.rs +++ b/crates/cargo-gamma-lib/src/exec/mod.rs @@ -45,25 +45,37 @@ mod test_binary; mod verdict; mod workspace; +#[doc(inline)] pub use build::{OrderingHints, Round, Withdrawal}; +#[doc(inline)] pub use cargo_options::{BuildLimits, CargoOptions, DEFAULT_ROLLBACK_ROUNDS}; +#[doc(inline)] pub use config::Config; pub(crate) use config::{available_parallelism, resolve_jobs}; +#[doc(inline)] pub use events::Events; +#[doc(inline)] pub use incremental_mode::IncrementalMode; +#[doc(inline)] pub use loader::UNDER_GAMMA_VAR; pub(crate) use manifest::RUNTIME_CRATE; pub(crate) use measure::run_with_locks; +#[doc(inline)] pub use measure::{Built, Measured, Oracle, measure, run}; +#[doc(inline)] pub use memory::{DEFAULT_HEADROOM, DEFAULT_MULTIPLIER, Demand, MemoryControl, MemoryPolicy}; // Named for its subject at this level, where `support` alone would say nothing about what is // supported. The module itself is private unless the `internals` feature exposes it. pub(crate) use memory::{implied_memory_control, support as memory_support}; +#[doc(inline)] pub use session::{CensusCost, Phases, Session, SweepCost}; +#[doc(inline)] pub use test_binary::TestBinary; pub(crate) use verdict::CONFIRM_FACTOR; +#[doc(inline)] pub use verdict::READERS; #[cfg(loom)] pub(crate) use verdict::run_loom_models; +#[doc(inline)] pub use workspace::{Workspace, clean_cache, footprint, gamma_base, scratch_tree}; pub(crate) use workspace::{claim_cache, claim_workspace}; diff --git a/crates/cargo-gamma-lib/src/exec/progress.rs b/crates/cargo-gamma-lib/src/exec/progress.rs index 5bfd1977..f6cceada 100644 --- a/crates/cargo-gamma-lib/src/exec/progress.rs +++ b/crates/cargo-gamma-lib/src/exec/progress.rs @@ -77,15 +77,7 @@ impl Progress { /// Records that the harness produced a line, and how long it had been silent beforehand. pub(super) fn heard(&mut self, line: &str) { - let now = Instant::now(); - - self.quiet = self.quiet.max(now.saturating_duration_since(self.heard)); - - self.heard = now; - self.environment_error |= line - .as_bytes() - .windows(gamma_rt::ENVIRONMENT_ERROR_MARKER.len()) - .any(|window| window == gamma_rt::ENVIRONMENT_ERROR_MARKER); + self.note_activity(line); if let Some(rest) = line.strip_prefix("test ") && let Some((name, verdict)) = rest.split_once(" ... ") @@ -131,6 +123,23 @@ impl Progress { } } + /// Records activity and runtime diagnostics from a non-authoritative output stream. + pub(super) fn heard_diagnostic(&mut self, line: &str) { + self.note_activity(line); + } + + fn note_activity(&mut self, line: &str) { + let now = Instant::now(); + + self.quiet = self.quiet.max(now.saturating_duration_since(self.heard)); + + self.heard = now; + self.environment_error |= line + .as_bytes() + .windows(gamma_rt::ENVIRONMENT_ERROR_MARKER.len()) + .any(|window| window == gamma_rt::ENVIRONMENT_ERROR_MARKER); + } + /// Remembers the first failure announced, leaving any later one alone. fn note(&mut self, name: &str) { if self.failed.is_none() && !name.is_empty() { diff --git a/crates/cargo-gamma-lib/src/exec/sweep.rs b/crates/cargo-gamma-lib/src/exec/sweep.rs index ea611f2b..43d1c676 100644 --- a/crates/cargo-gamma-lib/src/exec/sweep.rs +++ b/crates/cargo-gamma-lib/src/exec/sweep.rs @@ -5,7 +5,7 @@ use core::sync::atomic::{AtomicUsize, Ordering}; use core::time::Duration; -use std::sync::{Arc, Condvar, Mutex, OnceLock, mpsc}; +use std::sync::{Arc, Condvar, Mutex, MutexGuard, OnceLock, PoisonError, mpsc}; use std::thread; use std::time::Instant; @@ -19,7 +19,7 @@ use super::stall::Stall; use super::test_binary::{Reachability, TestBinary}; #[cfg(test)] use super::test_binary::{TestScope, order_reachable, reaches}; -use super::verdict::{Attempt, Only, Verdict, run_binary}; +use super::verdict::{Attempt, Only, Verdict, run_binary, tail}; use super::workspace::Workspace; use crate::Result; use crate::discover::{Killer, Plan}; @@ -101,17 +101,27 @@ fn flaky_note(binary: &Utf8Path, test: Option<&str>) -> String { } /// Describes a mutant that prevents nextest from creating the selected test list. +/// +/// The score-bearing note this returns deliberately never carries nextest's raw output. That +/// output comes from a test process — and anything the test or a nextest extension inherited into +/// its environment — running while a mutant is active, and this note is serialized verbatim into +/// durable JSON, HTML, and SARIF reports. An accidental diagnostic in that output would otherwise +/// extend a secret's retention from one process's stdout into every published artifact of the run. +/// The raw text is still worth an operator's attention locally, so it is raised as a transient +/// diagnostic through [`crate::notes`] — which the tool prints and a captured test can observe, but +/// which is never written into a report — rather than being silently dropped. fn enumeration_note(binary: &Utf8Path, output: &str) -> String { - let mut note = format!( - "`cargo nextest` could not enumerate tests in `{binary}` with this mutant active; the same selection succeeded with no mutant active" - ); - - if !output.is_empty() { - note.push_str(":\n"); - note.push_str(output); + if !output.trim().is_empty() { + crate::notes::note(format!( + "`cargo nextest` could not enumerate tests in `{binary}` with a mutant active; its \ + output, kept out of every published report, was:\n{}", + tail(output, 20) + )); } - note + format!( + "`cargo nextest` could not enumerate tests in `{binary}` with this mutant active; the same selection succeeded with no mutant active" + ) } /// One mutant's result: its index in the plan, what happened, how long it took and any detail. @@ -714,6 +724,10 @@ fn judge_ordered( /// than immediately proceeding without the hint. This avoids redundantly launching the expensive /// cold path for siblings when a killer is about to be published. The bounded wait ensures /// workers are never idled indefinitely if no common killer exists. +/// +/// Both publishing sites go through [`FileLearning::publish`], which never forgets a killer: the +/// lock is not held across the test run, so a worker that started earlier and finished later can +/// arrive here with nothing to report long after a sibling has already found the file's killer. #[expect(clippy::too_many_arguments, reason = "adds one file-local state cell to the verdict path")] fn judge_learning( work: &Workspace, @@ -728,18 +742,13 @@ fn judge_learning( if hint.is_some() { let judged = judge_ordered(work, ordinal, reachable, hint, None, timeout_multiplier, sweep, tally); - if let Judgement::Reached(_outcome, Some(killer), _note) = &judged { - let mut state = observed.state.lock().expect("a file-local killer lock was poisoned"); - *state = Learning::Learned(killer.clone()); - drop(state); - observed.notify.notify_all(); - } + observed.publish(&judged); return judged; } let state = { - let mut learned = observed.state.lock().expect("a file-local killer lock was poisoned"); + let mut learned = observed.locked(); match &*learned { Learning::Learned(killer) => Some(Ok(killer.clone())), @@ -749,15 +758,17 @@ fn judge_learning( } Learning::InProgress => { // Wait for the learner to finish, bounded so workers are never stuck. - let result = observed + let (guard, _timed_out) = observed .notify - .wait_timeout_while(learned, LEARNING_WAIT, |s| matches!(s, Learning::InProgress)) - .expect("a file-local killer lock was poisoned"); - learned = result.0; + .wait_timeout_while(learned, LEARNING_WAIT, |state| matches!(state, Learning::InProgress)) + .unwrap_or_else(PoisonError::into_inner); + + learned = guard; + match &*learned { Learning::Learned(killer) => Some(Ok(killer.clone())), // Timed out or exhausted: proceed without hint. - _ => Some(Err(())), + _unlearned => Some(Err(())), } } Learning::Exhausted => Some(Err(())), @@ -773,15 +784,7 @@ fn judge_learning( let judged = judge_ordered(work, ordinal, reachable, None, None, timeout_multiplier, sweep, tally); - { - let mut state = observed.state.lock().expect("a file-local killer lock was poisoned"); - *state = if let Judgement::Reached(_outcome, Some(killer), _note) = &judged { - Learning::Learned(killer.clone()) - } else { - Learning::Exhausted - }; - observed.notify.notify_all(); - } + observed.publish(&judged); judged } @@ -805,6 +808,54 @@ impl FileLearning { notify: Condvar::new(), } } + + /// Takes the state, recovering from poisoning rather than refusing to go on. + /// + /// This lock guards a scheduling hint and nothing else. Every critical section under it is a + /// read or a whole-value assignment, so a panic in one leaves a `Learning` that is still one of + /// its four legal values, and the worst a stale one can cost is a cold test order for the rest + /// of one file. Propagating the poison instead would turn an optimization into a way for one + /// worker's panic to fail every remaining mutant in that file — and the panic that poisoned the + /// lock is already on its way to the caller on its own thread. + fn locked(&self) -> MutexGuard<'_, Learning> { + self.state.lock().unwrap_or_else(PoisonError::into_inner) + } + + /// Records what one judgement taught about this file, then releases anyone waiting on it. + /// + /// Both publishing branches run with the lock *released* over the test run that produced the + /// judgement, so two workers can reach here having judged different mutants of the same file. + /// A killer is therefore only ever added, never replaced or cleared: a worker that finished a + /// slower mutant without one would otherwise overwrite a killer a faster worker had just found, + /// and every remaining mutant in that file would go down the cold full-order path for no reason. + /// Verdicts do not depend on this either way — a kill is always established by a test that + /// actually failed — so the only thing at stake is how much work the rest of the file costs. + /// + /// Waiters are notified whatever the transition, including the ones this refuses to make: a + /// worker parked on `InProgress` is waiting to be told that the learner is done, and the answer + /// "someone else already learned a killer" ends its wait just as well as a new one would. + fn publish(&self, judged: &Judgement) { + let found = match judged { + Judgement::Reached(_outcome, Some(killer), _note) => Some(killer), + _nothing_learned => None, + }; + + { + let mut state = self.locked(); + + match (&*state, found) { + // The first killer observed for a file wins. A later one is just as valid a hint, + // so replacing it would buy nothing and would churn the value siblings are already + // running against. + (Learning::Learned(_known), _any) => {} + (_unknown, Some(killer)) => *state = Learning::Learned(killer.clone()), + (Learning::Untried | Learning::InProgress, None) => *state = Learning::Exhausted, + (Learning::Exhausted, None) => {} + } + } + + self.notify.notify_all(); + } } #[derive(Debug)] @@ -916,6 +967,7 @@ mod tests { column: 1, mutator: ("relational.gt_to_ge".to_owned()).into(), item_path: ("subject::f".to_owned()).into(), + trait_impl: None, occurrence: 0, replacement_index: 0, original: "a > b".to_owned().into(), @@ -1466,6 +1518,46 @@ mod tests { assert!(note.contains("past the"), "{note}"); } + /// A sentinel a test or nextest extension printed while enumeration failed must never reach + /// the score-bearing note this feeds into `report.rs`'s serialized reason — that note is + /// published verbatim into JSON, HTML, and SARIF artifacts, so anything it carries has left + /// this run for good. It is still worth an operator's attention locally, so the same call + /// raises it through `crate::notes` instead of discarding it outright. + #[test] + fn enumeration_notes_never_carry_the_raw_output_that_produced_them() { + crate::notes::alone(|| { + const SENTINEL: &str = "super-secret-token-3f9a1c"; + let output = format!("error: could not list tests\n{SENTINEL}\n"); + + let note = enumeration_note(Utf8Path::new("/workspace/target/debug/deps/unit-abc"), &output); + + assert!(!note.contains(SENTINEL), "the sentinel leaked into the durable note: {note}"); + assert!(note.contains("unit-abc"), "{note}"); + + let raised = crate::notes::drain(); + + assert!( + raised.iter().any(|line| line.contains(SENTINEL)), + "the raw output was dropped rather than raised as a local diagnostic: {raised:?}" + ); + }); + } + + /// Enumeration output that says nothing raises no diagnostic — there is nothing an operator + /// would be shown, and a note for an empty string would only be noise. + #[test] + fn an_empty_enumeration_output_raises_no_diagnostic() { + crate::notes::alone(|| { + let note = enumeration_note(Utf8Path::new("/workspace/target/debug/deps/unit-abc"), ""); + + assert!(note.contains("unit-abc"), "{note}"); + assert!( + crate::notes::drain().is_empty(), + "an empty output should not have raised a diagnostic" + ); + }); + } + /// A run stopped exactly at its ceiling is described as being at it, not past it. /// /// Regression, issue-023. "Past" and "at" are different findings: the first says the workload @@ -2549,4 +2641,109 @@ mod tests { // The timeout should be roughly 20ms, not blocking indefinitely. assert!(started.elapsed() < Duration::from_millis(100)); } + + fn killer(test: &str) -> Killer { + Killer { + package: "subject".to_owned(), + target: "lib".to_owned(), + test: test.to_owned(), + } + } + + /// A killer another worker found must survive a slower worker publishing nothing. + /// + /// Neither publishing branch holds the lock across the test run that produced its judgement, so + /// the order in which two workers reach the publication is decided by how long their mutants + /// took, not by which one started first. A survivor arriving last used to overwrite the file's + /// learned killer, sending every remaining mutant in that file down the cold full-order path. + /// No verdict was ever wrong — a kill is always established by a test that really failed — but + /// the file paid full price for the rest of the sweep. + #[test] + fn a_learned_killer_is_never_lost_to_a_later_worker_that_found_none() { + let learning = FileLearning::new(); + + // The plain ordering: the killer lands first and a survivor follows it. + learning.publish(&Judgement::Reached(Outcome::Killed, Some(killer("tests::found")), None)); + learning.publish(&Judgement::Reached(Outcome::Survived, None, None)); + + match &*learning.locked() { + Learning::Learned(known) => assert_eq!(known.test, "tests::found"), + other => panic!("a survivor erased the file's killer: {other:?}"), + } + + // And under real contention, whichever order the scheduler chooses. + let contended = FileLearning::new(); + + thread::scope(|scope| { + for worker in 0..8_u32 { + let contended = &contended; + + let _publisher = scope.spawn(move || { + let judged = if worker % 2 == 0 { + Judgement::Reached(Outcome::Killed, Some(killer("tests::found")), None) + } else { + Judgement::Reached(Outcome::Survived, None, None) + }; + + contended.publish(&judged); + }); + } + }); + + match &*contended.locked() { + Learning::Learned(known) => assert_eq!(known.test, "tests::found"), + other => panic!("a concurrent survivor erased the file's killer: {other:?}"), + } + + // A file where nothing killed anything still settles on `Exhausted`, so its siblings stop + // waiting for a hint that is not coming. + let barren = FileLearning::new(); + + barren.publish(&Judgement::Reached(Outcome::Survived, None, None)); + + let settled = barren.locked(); + + assert!(matches!(&*settled, Learning::Exhausted), "{settled:?}"); + drop(settled); + } + + /// Poisoning this lock must not fail the mutants that had nothing to do with it. + /// + /// It guards a scheduling hint, every critical section under it assigns a whole value, and the + /// panic that poisoned it is already on its way to the caller on its own thread. Propagating + /// the poison here would take out every remaining mutant in the file as well. + #[test] + fn a_poisoned_file_local_killer_lock_is_recovered_rather_than_propagated() { + let learning = FileLearning::new(); + + let panicked = thread::scope(|scope| { + scope + .spawn(|| { + let mut state = learning.locked(); + + *state = Learning::InProgress; + + panic!("a worker died holding the hint lock"); + }) + .join() + .is_err() + }); + + assert!(panicked, "the fixture must actually poison the lock"); + assert!(learning.state.is_poisoned(), "the fixture must actually poison the lock"); + + // The state is still one of its legal values, and both the read and the publication go + // through rather than unwinding. + let recovered = learning.locked(); + + assert!(matches!(&*recovered, Learning::InProgress), "{recovered:?}"); + drop(recovered); + + learning.publish(&Judgement::Reached(Outcome::Killed, Some(killer("tests::after")), None)); + + match &*learning.locked() { + Learning::Learned(known) => assert_eq!(known.test, "tests::after"), + other => panic!("a poisoned lock swallowed the publication: {other:?}"), + } + } } diff --git a/crates/cargo-gamma-lib/src/exec/sync.rs b/crates/cargo-gamma-lib/src/exec/sync.rs index 8d54d2d2..48e15b21 100644 --- a/crates/cargo-gamma-lib/src/exec/sync.rs +++ b/crates/cargo-gamma-lib/src/exec/sync.rs @@ -9,7 +9,6 @@ //! place with their original mtimes — preserving Cargo's fingerprint validity for inputs that did //! not change. -use core::sync::atomic::Ordering; use std::collections::HashSet; use std::fs::{self, File, FileTimes}; use std::io::{ErrorKind, Read}; @@ -20,7 +19,7 @@ use camino::{Utf8Path, Utf8PathBuf}; use ignore::{WalkBuilder, WalkState}; use walkdir::WalkDir; -use super::copy::{CopyOptions, copy_tree_with, is_pruned, tracked_files}; +use super::copy::{CopyOptions, Reflinks, copy_tree_with, is_pruned, tracked_files}; use crate::Result; use crate::error::{Error, error}; @@ -61,8 +60,13 @@ fn clear_sentinel(root: &Utf8Path) { /// /// Returns which path was taken so the caller can emit appropriate diagnostics. pub(super) fn sync_or_copy(source: &Utf8Path, root: &Utf8Path, skip: &Utf8Path, options: CopyOptions) -> Result { + // Taken once for the whole operation and shared by both the delta path and the fresh copy it + // may fall back to: they write to the same tree, so what one of them learns about cloning there + // is exactly what the other needs to know. + let reflinks = Reflinks::for_destination(root); + if !root.as_std_path().is_dir() { - copy_tree_with(source, root, skip, options)?; + copy_tree_with(source, root, skip, options, &reflinks)?; mark_consistent(root); return Ok(SyncOutcome::FreshCopy); } @@ -71,7 +75,7 @@ pub(super) fn sync_or_copy(source: &Utf8Path, root: &Utf8Path, skip: &Utf8Path, // Prior run was interrupted — cannot trust what is there. Remove and resync. fs::remove_dir_all(root.as_std_path()) .map_err(|cause| error!("could not clear the inconsistent scratch tree at `{root}`").caused_by(cause))?; - copy_tree_with(source, root, skip, options)?; + copy_tree_with(source, root, skip, options, &reflinks)?; mark_consistent(root); return Ok(SyncOutcome::FreshCopy); } @@ -79,7 +83,7 @@ pub(super) fn sync_or_copy(source: &Utf8Path, root: &Utf8Path, skip: &Utf8Path, // The tree looks consistent — attempt delta sync. clear_sentinel(root); - match delta_sync(source, root, skip, options) { + match delta_sync(source, root, skip, options, &reflinks) { Ok(()) => { mark_consistent(root); Ok(SyncOutcome::Synchronized) @@ -87,7 +91,7 @@ pub(super) fn sync_or_copy(source: &Utf8Path, root: &Utf8Path, skip: &Utf8Path, Err(_cause) => { // Delta sync failed. Remove everything and do a clean sync to restore correctness. let _removed = fs::remove_dir_all(root.as_std_path()); - copy_tree_with(source, root, skip, options)?; + copy_tree_with(source, root, skip, options, &reflinks)?; mark_consistent(root); Ok(SyncOutcome::FreshCopy) } @@ -101,7 +105,7 @@ pub(super) fn sync_or_copy(source: &Utf8Path, root: &Utf8Path, skip: &Utf8Path, /// 1. Copies new entries and replaces changed entries. /// 2. Removes stale entries that no longer exist in the source. /// 3. Leaves unchanged entries untouched (preserving their mtimes for Cargo). -fn delta_sync(source: &Utf8Path, root: &Utf8Path, skip: &Utf8Path, options: CopyOptions) -> Result<()> { +fn delta_sync(source: &Utf8Path, root: &Utf8Path, skip: &Utf8Path, options: CopyOptions, reflinks: &Reflinks) -> Result<()> { // Collect the set of relative paths the source tree produces. let expected = collect_source_entries(source, skip, options)?; @@ -109,7 +113,7 @@ fn delta_sync(source: &Utf8Path, root: &Utf8Path, skip: &Utf8Path, options: Copy for relative in &expected { let src = source.join(relative); let dst = root.join(relative); - sync_entry(&src, &dst)?; + sync_entry(&src, &dst, reflinks)?; } // Remove stale entries from the scratch tree. @@ -214,10 +218,11 @@ fn is_pruned_anywhere(root: &Utf8Path, relative: &Utf8Path, excluded: &Utf8Path) /// Synchronizes one source entry to the scratch tree. /// -/// For files: copies if new or changed (by len + mtime). Unchanged files are left in place. +/// For files: copies if new or if length, permissions, or contents changed. Unchanged files are +/// left in place so their modification times continue to preserve Cargo fingerprints. /// For directories: creates if missing. /// For symlinks: recreates if target differs. -fn sync_entry(source: &Utf8Path, destination: &Utf8Path) -> Result<()> { +fn sync_entry(source: &Utf8Path, destination: &Utf8Path, reflinks: &Reflinks) -> Result<()> { let src_meta = fs::symlink_metadata(source.as_std_path()).map_err(|cause| error!("could not read `{source}`").caused_by(cause))?; if src_meta.is_dir() { @@ -237,11 +242,11 @@ fn sync_entry(source: &Utf8Path, destination: &Utf8Path) -> Result<()> { } // Regular file. - sync_file(source, destination, &src_meta) + sync_file(source, destination, &src_meta, reflinks) } /// Synchronizes a regular file, preserving mtime for unchanged files. -fn sync_file(source: &Utf8Path, destination: &Utf8Path, src_meta: &fs::Metadata) -> Result<()> { +fn sync_file(source: &Utf8Path, destination: &Utf8Path, src_meta: &fs::Metadata, reflinks: &Reflinks) -> Result<()> { let needs_copy = match fs::symlink_metadata(destination.as_std_path()) { Err(_) => true, // Destination does not exist. Ok(dst_meta) => { @@ -271,7 +276,7 @@ fn sync_file(source: &Utf8Path, destination: &Utf8Path, src_meta: &fs::Metadata) // Remove existing destination before copying (reflink requires no existing file). let _removed = fs::remove_file(destination.as_std_path()); - copy_file_for_sync(source, destination)?; + copy_file_for_sync(source, destination, reflinks)?; } // Unchanged files are left in place — their mtime stays as it was, preserving Cargo // fingerprints. @@ -380,10 +385,10 @@ fn sync_symlink(source: &Utf8Path, destination: &Utf8Path) -> Result<()> { Ok(()) } -fn copy_file_for_sync(source: &Utf8Path, destination: &Utf8Path) -> Result<()> { +fn copy_file_for_sync(source: &Utf8Path, destination: &Utf8Path, reflinks: &Reflinks) -> Result<()> { let copied_at = SystemTime::now(); - if reflink_supported() && super::copy::REFLINK_WORKS.load(Ordering::Relaxed) { + if reflinks.worth_trying() { match reflink_copy::reflink(source.as_std_path(), destination.as_std_path()) { Ok(()) => { stamp_mtime(destination, copied_at)?; @@ -393,7 +398,7 @@ fn copy_file_for_sync(source: &Utf8Path, destination: &Utf8Path) -> Result<()> { return Err(error!("could not copy `{source}` to `{destination}`").caused_by(cause)); } Err(_unsupported) => { - super::copy::REFLINK_WORKS.store(false, Ordering::Relaxed); + reflinks.unsupported(); let _removed = fs::remove_file(destination.as_std_path()); } } @@ -416,11 +421,6 @@ fn stamp_mtime(path: &Utf8Path, time: SystemTime) -> Result<()> { .map_err(|cause| error!("could not freshen copied file `{path}`").caused_by(cause)) } -/// Whether cloning is worth trying on this platform at all. -const fn reflink_supported() -> bool { - !cfg!(target_env = "musl") -} - /// Removes entries from the scratch tree that are not in the expected set. /// /// Walks the scratch tree and removes anything not present in the source. Directories are handled diff --git a/crates/cargo-gamma-lib/src/exec/test_binary.rs b/crates/cargo-gamma-lib/src/exec/test_binary.rs index b4ab6222..e750b093 100644 --- a/crates/cargo-gamma-lib/src/exec/test_binary.rs +++ b/crates/cargo-gamma-lib/src/exec/test_binary.rs @@ -968,6 +968,7 @@ mod tests { column: 1, mutator: ("arith.add_to_sub".to_owned()).into(), item_path: ("f".to_owned()).into(), + trait_impl: None, occurrence: 0, replacement_index: 0, original: "a + b".to_owned().into(), @@ -1350,8 +1351,8 @@ mod tests { /// Without a baseline every duration is zero because nothing was measured, and scaling that /// hands every binary the floor as though it had been derived. On a suite slower than the floor - /// each mutant then runs out of time, and a timeout scores as a detection — a near-perfect score - /// made entirely of mutants no test ever exercised. + /// each mutant then runs out of time, and a timeout scores as an undetected mutant — a score of + /// nearly zero made entirely of mutants no test ever got the chance to exercise. #[test] fn an_uncalibrated_run_gets_no_budget_rather_than_the_floor() { let mut binaries = vec![binary("a"), binary("b")]; diff --git a/crates/cargo-gamma-lib/src/exec/verdict.rs b/crates/cargo-gamma-lib/src/exec/verdict.rs index 93d45043..e632a409 100644 --- a/crates/cargo-gamma-lib/src/exec/verdict.rs +++ b/crates/cargo-gamma-lib/src/exec/verdict.rs @@ -2,6 +2,7 @@ // Licensed under the MIT License. use core::time::Duration; +use std::borrow::Cow; use std::io::{self, BufReader, Read}; use std::process::{Child, ChildStderr, ChildStdout, Command, ExitStatus, Stdio}; use std::sync::mpsc::{Receiver, RecvTimeoutError}; @@ -10,13 +11,14 @@ use std::thread; use std::time::Instant; use camino::Utf8Path; -use cargo_gamma_process::{MemoryRequest, MemoryUsage, ProcessTree, SpawnGuard, prepare}; +use cargo_gamma_process::{MemoryRequest, MemoryUsage, PreparedCommand, ProcessTree, prepare}; mod hubs; #[cfg(test)] use cargo_gamma_process::faults::{self as process_faults, Fault as ProcessFault}; use hubs::Pulse; +#[doc(inline)] pub use hubs::READERS; #[cfg(test)] #[cfg(not(loom))] @@ -65,11 +67,20 @@ pub(super) enum Only<'name> { impl<'name> Only<'name> { /// The tests to run, empty when the whole binary runs. - fn names(self) -> Vec<&'name str> { + /// + /// Borrowed rather than copied wherever a borrow reaches far enough: a census's [`Self::These`] + /// selection can already name most of a large suite, and every reachable censused binary + /// launches at least one attempt against it, so cloning that slice into a second vector on every + /// one of those launches would be an allocation with no reason behind it — `launcher` only ever + /// reads through the result, once, to build one command. [`Self::One`] still allocates: a name + /// held by value here has nowhere with lifetime `'name` to be borrowed from, so there is no + /// slice of that lifetime to hand back without first storing the name somewhere that outlives + /// this call. + fn names(self) -> Cow<'name, [&'name str]> { match self { - Self::All => Vec::new(), - Self::One(name) => vec![name], - Self::These(names) => names.to_vec(), + Self::All => Cow::Owned(Vec::new()), + Self::One(name) => Cow::Owned(vec![name]), + Self::These(names) => Cow::Borrowed(names), } } } @@ -147,7 +158,10 @@ pub(super) enum Verdict { /// Nextest could not enumerate the selected tests while a mutant was active. /// /// This is only a suspicion until the identical selection succeeds with no mutant active. - /// Carries nextest's output because enumeration failures usually explain themselves there. + /// Carries nextest's output because enumeration failures usually explain themselves there — + /// but that output comes from a test process and its inherited environment, so anything that + /// turns this into a durable, published record must not repeat it verbatim; see + /// `sweep::enumeration_note`, the one place that does. TestEnumerationFailed(String), /// The budget ran out while the binary was still making progress. @@ -269,15 +283,20 @@ enum StartError { Spawn(io::Error), } -fn spawn_patiently(command: &mut Command, request: MemoryRequest) -> Result<(Child, SpawnGuard), StartError> { +fn spawn_patiently(command: Command, request: MemoryRequest) -> Result<(Child, PreparedCommand), StartError> { let mut waited = SPAWN_BACKOFF; - let mut guard = prepare(command, request).map_err(StartError::Containment)?; + + // Prepared once, outside the loop, and carried through every wait: a retry re-spawns the launch + // it already has rather than building a second one, which is the only shape `prepare` supports. + let mut prepared = prepare(command, request).map_err(|cause| StartError::Containment(cause.to_string()))?; for _attempt in 1..SPAWN_ATTEMPTS { - match spawn_once(command) { - Ok(child) => return Ok((child, guard)), + match spawn_once(&mut prepared) { + Ok(child) => return Ok((child, prepared)), Err(cause) if transient(&cause) => { - guard = guard.backoff(waited).map_err(StartError::Containment)?; + prepared = prepared + .backoff(waited) + .map_err(|cause| StartError::Containment(cause.to_string()))?; } Err(cause) => return Err(StartError::Spawn(cause)), } @@ -285,17 +304,17 @@ fn spawn_patiently(command: &mut Command, request: MemoryRequest) -> Result<(Chi waited = waited.saturating_mul(2); } - spawn_once(command).map(|child| (child, guard)).map_err(StartError::Spawn) + spawn_once(&mut prepared).map(|child| (child, prepared)).map_err(StartError::Spawn) } /// The spawn itself, named so the fault seam can stand in for the kernel's refusal. -fn spawn_once(command: &mut Command) -> io::Result { +fn spawn_once(prepared: &mut PreparedCommand) -> io::Result { #[cfg(test)] if faults::fired(Fault::Spawn) { return Err(io::Error::new(io::ErrorKind::WouldBlock, "the process table is full")); } - command.spawn() + prepared.spawn() } /// Whether a spawn refusal is the machine being momentarily out of something. @@ -460,9 +479,10 @@ fn confirm_enumeration(work: &Workspace, binary: &TestBinary, attempt: Attempt<' Verdict::Unjudged(reason) => Verdict::Unjudged(reason), // Whatever the exoneration ran into, it ran into it with no mutant active, so it is not a - // verdict about the mutant and must not be handed back as one: `judge` scores a `TimedOut` - // or a `MemoryLimit` as a detection, which would credit a mutant that was switched off in - // the run that produced it. Nothing was established, and that is what this says. + // verdict about the mutant and must not be handed back as one: a `TimedOut` or a + // `MemoryLimit` is scored as an undetected mutant, so returning one here would record the + // suite as having missed a mutant that was switched off for the entire run that produced + // the evidence. Nothing was established, and that is what this says. _unestablished => Verdict::Flaky(None), } } @@ -519,7 +539,7 @@ fn launcher(work: &Workspace, binary: &TestBinary, only: Only<'_>) -> Result = names.into_iter().filter(|name| user.admits(name)).collect(); + let allowed: Vec<&str> = names.iter().copied().filter(|name| user.admits(name)).collect(); if allowed.is_empty() { return Err(format!( @@ -694,7 +714,7 @@ fn run_with(work: &Workspace, binary: &TestBinary, attempt: Attempt<'_>, progres configure(&mut command, binary, launch, work.harness_threads(), active, attempt.census); - let (child, guard) = match spawn_patiently(&mut command, request) { + let (child, prepared) = match spawn_patiently(command, request) { Ok(started) => started, // A spawn fails for reasons of the machine — descriptors, processes, address space, a @@ -728,19 +748,29 @@ fn run_with(work: &Workspace, binary: &TestBinary, attempt: Attempt<'_>, progres // Taken before anything is read from the child, so that the window in which a grandchild could // start outside the containment is as short as it can be made without owning the spawn. - let mut subtree = match ProcessTree::adopt(child, guard) { + let mut subtree = match ProcessTree::adopt(child, prepared) { Ok(subtree) => subtree, // The child is already live, so this is one run that cannot be accounted for rather than a // boundary the host will never provide — `prepare` above answers that question, and it // succeeded. Err(reason) => { - return (Verdict::Unjudged(reason), MemoryUsage::default()); + return (Verdict::Unjudged(reason.to_string()), MemoryUsage::default()); } }; let pulse = Arc::new(Pulse::default()); - let drained = readers(&mut subtree, progress, &pulse); + let drained = match readers(&mut subtree, progress, &pulse, under_nextest) { + Ok(drained) => drained, + Err(cause) => { + let (usage, _ceiling) = cut_short(&mut subtree, request); + + return ( + Verdict::Unjudged(format!("`{}` output could not be supervised: {cause}", binary.path)), + usage, + ); + } + }; let deadline = timeout.map(deadline_after); @@ -1164,19 +1194,31 @@ const OUTPUT_CAP: usize = 4 * 1024 * 1024; /// before draining, which closes those ends in the ordinary case — but a descendant that escaped /// the containment entirely is still possible, so the wait stays bounded and the readers stay /// abandonable rather than joined. -fn readers(subtree: &mut ProcessTree, progress: &Arc>, pulse: &Arc) -> Receiver<(Vec, bool)> { +/// +/// Two threads and two buffers per child, rather than one multiplexing supervisor. Substituting one +/// would mean redesigning the abandonable-reader and authoritative/diagnostic split above against a +/// ceiling nobody has measured, trading a known-correct shape for an unmeasured one. The targeted +/// process-launch measurement that would justify or rule it out — across quiet, chatty, +/// mixed-stream, capped, inherited-pipe, and fast-exit children — remains outstanding. +fn readers( + subtree: &mut ProcessTree, + progress: &Arc>, + pulse: &Arc, + under_nextest: bool, +) -> io::Result, bool)>> { let (sink, drained) = mpsc::channel::<(Vec, bool)>(); - // Both streams are piped. Nextest reports everything on stderr, while a directly run binary - // ordinarily reports on stdout but may carry a runtime protocol marker on stderr. - let pipes = [subtree.take_stdout().map(Either::Out), subtree.take_stderr().map(Either::Err)]; + // Only the harness's authoritative stream may settle a verdict. The other stream still keeps + // the stall clock alive and can carry the runtime's startup-error marker. + let pipes = [ + subtree.take_stdout().map(|pipe| (Either::Out(pipe), !under_nextest)), + subtree.take_stderr().map(|pipe| (Either::Err(pipe), under_nextest)), + ]; - for pipe in pipes.into_iter().flatten() { + for (pipe, authoritative) in pipes.into_iter().flatten() { let published = Arc::clone(progress); let pulse = Arc::clone(pulse); let sink = sink.clone(); - let failed = sink.clone(); - let failed_pulse = Arc::clone(&pulse); #[cfg(test)] let refused = faults::fired(Fault::Thread); @@ -1190,8 +1232,8 @@ fn readers(subtree: &mut ProcessTree, progress: &Arc>, pulse: &A READERS.started(); let collected = match pipe { - Either::Out(pipe) => drain(pipe, &published, &pulse), - Either::Err(pipe) => drain(pipe, &published, &pulse), + Either::Out(pipe) => drain(pipe, &published, &pulse, authoritative), + Either::Err(pipe) => drain(pipe, &published, &pulse, authoritative), }; let _sent = sink.send(collected); @@ -1208,20 +1250,14 @@ fn readers(subtree: &mut ProcessTree, progress: &Arc>, pulse: &A }) }; - match spawned { - Ok(_handle) => {} - Err(_cause) => { - let _sent = failed.send((Vec::new(), false)); - failed_pulse.signal(); - } - } + let _handle = spawned?; } // Dropped so the receiver sees the readers' senders as the only ones left, and disconnects as // soon as they finish rather than waiting out the grace period on a sender nobody is using. drop(sink); - drained + Ok(drained) } /// One of the two streams a child may be read from, so both can be started by the same loop. @@ -1243,7 +1279,7 @@ enum Either { /// /// An interrupted read is not truncation and does not reach the failing arm: `read_until` retries /// `EINTR` itself, and nothing was taken out of the pipe when it fired. -fn drain(pipe: R, progress: &Mutex, pulse: &Pulse) -> (Vec, bool) { +fn drain(pipe: R, progress: &Mutex, pulse: &Pulse, authoritative: bool) -> (Vec, bool) { use std::io::BufRead as _; let mut reader = BufReader::new(pipe); @@ -1271,7 +1307,12 @@ fn drain(pipe: R, progress: &Mutex, pulse: &Pulse) -> (Vec assert!(reason.contains("output could not be supervised"), "{reason}"), + other => panic!("expected an unjudged attempt, got {other:?}"), + } } /// Only the refusals the machine recovers from on its own are waited out. diff --git a/crates/cargo-gamma-lib/src/exec/workspace.rs b/crates/cargo-gamma-lib/src/exec/workspace.rs index 9181acb4..bf87b4f0 100644 --- a/crates/cargo-gamma-lib/src/exec/workspace.rs +++ b/crates/cargo-gamma-lib/src/exec/workspace.rs @@ -1,12 +1,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -use core::hash::{Hash, Hasher}; use core::num::NonZeroUsize; use core::sync::atomic::{AtomicBool, Ordering}; -use std::collections::hash_map::DefaultHasher; use std::ffi::OsString; use std::fs::{self, File, TryLockError}; +use std::io::{Read as _, Write as _}; use std::process::{Command, Output}; use std::sync::OnceLock; use std::{env, io, thread}; @@ -41,7 +40,7 @@ const RUNTIME_SOURCES: [(&str, &str); 3] = [ /// The workspace package contract inherited by the real runtime crate. const WORKSPACE_MANIFEST: &str = include_str!("../../../../Cargo.toml"); -/// Identifies the workspace allowed to reuse a redirected cache. +/// Identifies the workspace allowed to reuse a cache directory. const CACHE_OWNER: &str = ".cargo-gamma-owner"; /// A scratch copy of the workspace, instrumented and ready to build. @@ -234,9 +233,6 @@ impl Workspace { #[cfg(feature = "internals")] crate::testing::pause_during_workspace_preparation(source); - migrate_legacy_directory(&base, "tree", "workspace")?; - migrate_legacy_directory(&base, "build", "target")?; - events.testing_log(&base)?; let _outcome = sync_or_copy( @@ -681,69 +677,121 @@ pub fn footprint(base: &Utf8Path) -> u64 { /// against the absolute paths it walks, and cargo resolves a path dependency against the manifest /// holding it, so a relative base would make the copy descend into its own output and the /// instrumented tree point its runtime dependency at itself. +/// +/// The default directory's name comes from `workspace_identity`, a digest this crate pins rather than +/// one the standard library is free to change: the lock every source-changing command shares lives +/// under this path, so two binaries that derive different names for one workspace would rewrite the +/// same tree at the same time while each believing it held the only lock. #[must_use] pub fn gamma_base(root: &Utf8Path, cache: Option<&Utf8Path>) -> Utf8PathBuf { let base = cache.map_or_else( - || { - let mut identity = DefaultHasher::new(); - absolute(root).hash(&mut identity); - - env::var_os("XDG_CACHE_HOME") - .or_else(|| env::var_os("LOCALAPPDATA")) - .and_then(|path| Utf8PathBuf::from_path_buf(path.into()).ok()) - .or_else(|| { - env::var_os("HOME") - .and_then(|path| Utf8PathBuf::from_path_buf(path.into()).ok()) - .map(|home| home.join(".cache")) - }) - .unwrap_or_else(|| absolute(root).parent().unwrap_or(root).join(".cargo-gamma-cache")) - .join("cargo-gamma") - .join(format!("{:016x}", identity.finish())) - }, + || default_cache_home(root).join(workspace_identity(&absolute(root))), Utf8Path::to_owned, ); absolute(&base) } -/// Deletes every cached entry for a workspace while preserving its concurrency lock. +/// The directory every default per-workspace cache is a child of. +fn default_cache_home(root: &Utf8Path) -> Utf8PathBuf { + env::var_os("XDG_CACHE_HOME") + .or_else(|| env::var_os("LOCALAPPDATA")) + .and_then(|path| Utf8PathBuf::from_path_buf(path.into()).ok()) + .or_else(|| { + env::var_os("HOME") + .and_then(|path| Utf8PathBuf::from_path_buf(path.into()).ok()) + .map(|home| home.join(".cache")) + }) + .unwrap_or_else(|| absolute(root).parent().unwrap_or(root).join(".cargo-gamma-cache")) + .join("cargo-gamma") +} + +/// Names the default cache directory of one absolute workspace root. /// -/// Returns whether any cached data existed. The lock file remains so an active run and a clean -/// cannot race over the workspace or Cargo artifacts. -pub fn clean_cache(root: &Utf8Path) -> Result { - let base = gamma_base(root, None); +/// BLAKE3 rather than `DefaultHasher`: the algorithm behind `DefaultHasher` is explicitly not a +/// stable contract across standard-library releases, and this name is not a private detail of one +/// process. It is where the advisory lock that serializes every command able to publish source or +/// configuration changes for this workspace lives, so two binaries built against different standard +/// libraries would take two different locks over one tree and copy or rewrite it concurrently while +/// each of them observed no contention. Pinning the algorithm here is what makes the documented +/// single lock domain true. +/// +/// Truncated to 64 bits because this is a directory name a user reads and occasionally types, and +/// because a collision is caught rather than trusted: the owner marker under the directory records +/// the root that owns it, and a second root landing on the same name is refused instead of quietly +/// sharing the tree. +fn workspace_identity(root: &Utf8Path) -> String { + let digest = blake3::hash(root.as_str().as_bytes()); + let mut identity = [0_u8; 8]; - if !base.exists() { - return Ok(false); - } + identity.copy_from_slice(digest.as_bytes().get(..8).expect("a BLAKE3 digest is 32 bytes long")); - let _lock = claim(&base)?; - let entries = - fs::read_dir(base.as_std_path()).map_err(|cause| error!("could not read cargo-gamma's cache at `{base}`").caused_by(cause))?; - let mut removed = false; + format!("{:016x}", u64::from_be_bytes(identity)) +} - for entry in entries { - let entry = entry.map_err(|cause| error!("could not read an entry in cargo-gamma's cache at `{base}`").caused_by(cause))?; +/// Deletes every cached entry for a workspace while preserving its concurrency lock and identity. +/// +/// Returns the cache directories data was actually removed from, in the order they were visited, +/// so the caller can name them rather than announce a directory it only guessed at. An empty answer +/// means there was nothing cached. The lock file remains so an active run and a clean cannot race +/// over the workspace or Cargo artifacts, and the owner marker remains because it says which +/// workspace this directory is for rather than holding any cached data. +/// +/// # Errors +/// +/// Returns an error if the cache is unmarked or belongs to another workspace, if the cache +/// directory cannot be read, or if an entry cannot be removed — a permission denial, a file held +/// open by another process on Windows, or a path that changed under the walk. +/// +/// Only the workspace's own cache is cleaned. A cache redirected elsewhere with `--cache-dir` is +/// not reachable from the workspace root alone and is the caller's to remove. +pub fn clean_cache(root: &Utf8Path) -> Result> { + let base = gamma_base(root, None); + let mut cleaned = Vec::new(); - if entry.file_name() == "lock" { - continue; - } + if base.exists() { + reject_linked_cache(&base)?; + let _lock = claim(&base)?; + validate_cache_owner(root, &base, CacheKind::Default)?; + let entries = + fs::read_dir(base.as_std_path()).map_err(|cause| error!("could not read cargo-gamma's cache at `{base}`").caused_by(cause))?; + let mut removed = false; + + for entry in entries { + let entry = entry.map_err(|cause| error!("could not read an entry in cargo-gamma's cache at `{base}`").caused_by(cause))?; + + if entry.file_name() == "lock" || entry.file_name() == CACHE_OWNER { + continue; + } - let path = entry.path(); - let file_type = entry - .file_type() - .map_err(|cause| error!("could not inspect cached data at `{}`", path.display()).caused_by(cause))?; - let result = if file_type.is_dir() && !file_type.is_symlink() { - fs::remove_dir_all(&path) - } else { - fs::remove_file(&path) - }; + remove_cached(&entry)?; + removed = true; + } - result.map_err(|cause| error!("could not remove cached data at `{}`", path.display()).caused_by(cause))?; - removed = true; + if removed { + cleaned.push(base); + } } - Ok(removed) + Ok(cleaned) +} + +/// Removes one entry of a cache directory, whatever kind of thing it is. +/// +/// A symbolic link is unlinked rather than followed, so a link planted in a cache cannot turn a +/// clean into a recursive delete of whatever it points at. +fn remove_cached(entry: &fs::DirEntry) -> Result<()> { + let path = entry.path(); + let file_type = entry + .file_type() + .map_err(|cause| error!("could not inspect cached data at `{}`", path.display()).caused_by(cause))?; + let result = if file_type.is_dir() && !file_type.is_symlink() { + fs::remove_dir_all(&path) + } else { + fs::remove_file(&path) + }; + + result.map_err(|cause| error!("could not remove cached data at `{}`", path.display()).caused_by(cause)) } /// Resolves a path against the current directory and removes the components that name nothing. @@ -977,31 +1025,6 @@ pub fn scratch_tree(root: &Utf8Path, scratch: Option<&Utf8Path>) -> Utf8PathBuf gamma_base(root, scratch).join("workspace") } -/// Renames an older cache directory, or removes it when its replacement already exists. -fn migrate_legacy_directory(base: &Utf8Path, legacy: &str, current: &str) -> Result<()> { - let from = base.join(legacy); - let to = base.join(current); - - if !from.exists() { - return Ok(()); - } - - if to.exists() { - let metadata = fs::symlink_metadata(from.as_std_path()) - .map_err(|cause| error!("could not inspect the legacy cache directory at `{from}`").caused_by(cause))?; - let removed = if metadata.is_dir() { - fs::remove_dir_all(from.as_std_path()) - } else { - fs::remove_file(from.as_std_path()) - }; - - return removed.map_err(|cause| error!("could not remove the legacy cache directory at `{from}`").caused_by(cause)); - } - - fs::rename(from.as_std_path(), to.as_std_path()) - .map_err(|cause| error!("could not rename the legacy cache directory from `{from}` to `{to}`").caused_by(cause)) -} - /// Takes and validates a cache chosen independently of the workspace. /// /// The caller has already taken the stable workspace lock. This second lock protects the inverse @@ -1009,6 +1032,8 @@ fn migrate_legacy_directory(base: &Utf8Path, legacy: &str, current: &str) -> Res /// for an empty directory; existing unmarked contents are never adopted as cargo-gamma state. fn claim_redirected_cache(source: &Utf8Path, base: &Utf8Path) -> Result { if base.exists() { + reject_linked_cache(base)?; + match fs::symlink_metadata(base.join(CACHE_OWNER).as_std_path()) { Ok(_metadata) => {} Err(cause) if cause.kind() == io::ErrorKind::NotFound => { @@ -1029,34 +1054,188 @@ fn claim_redirected_cache(source: &Utf8Path, base: &Utf8Path) -> Result { fs::create_dir_all(base.as_std_path()) .map_err(|cause| error!("could not create the redirected cargo-gamma cache at `{base}`").caused_by(cause))?; + reject_linked_cache(base)?; + reject_foreign_writers(base)?; + let lock = claim(base)?; - validate_cache_owner(source, base)?; + validate_cache_owner(source, base, CacheKind::Redirected)?; Ok(lock) } -/// Refuses a redirected directory unless it is empty or already belongs to this workspace. -fn validate_cache_owner(source: &Utf8Path, base: &Utf8Path) -> Result<()> { +/// Refuses a cache directory that is reached through a link. +/// +/// A redirected cache is named by the user and everything under it is later handed to cargo as +/// `CARGO_TARGET_DIR` and executed from. A symbolic link — or, on Windows, any other reparse point +/// — means the name that was checked and the directory that is used are two different things, and +/// whoever can rewrite the link chooses the second one after the first has been approved. Only the +/// final component is checked here; the ancestry above it is covered by +/// [`reject_foreign_writers`] on the platforms where ownership can be established at all. +fn reject_linked_cache(base: &Utf8Path) -> Result<()> { + let metadata = fs::symlink_metadata(base.as_std_path()) + .map_err(|cause| error!("could not inspect the redirected cargo-gamma cache at `{base}`").caused_by(cause))?; + + if metadata.file_type().is_symlink() { + return Err(error!( + "the redirected cache at `{base}` is a link.\n\ + Pass the directory itself to --cache-dir: a link can be repointed after it has been checked, \ + at a directory whose contents this tool would then build and run." + ) + .usage()); + } + + if !metadata.is_dir() { + return Err(error!( + "the redirected cache at `{base}` is not a directory.\n\ + Choose an empty directory for --cache-dir." + ) + .usage()); + } + + Ok(()) +} + +/// Refuses a cache whose directory, or any directory above it, another local user can write to. +/// +/// Everything the run builds is placed here and later executed, so write access to any directory on +/// the path to it is enough to choose what this tool runs as the invoking user. The check walks the +/// physical path — the one with every link already resolved — from the cache to the root, and +/// refuses a directory that is owned by anyone but the invoking user or `root`, or that is writable +/// by group or other without the sticky bit that stops one user replacing another's entries. +/// +/// This is an ownership check, not a race-free one. It says the directories are not writable by +/// another identity now, which is what makes a later substitution require the invoking user's own +/// credentials; it does not, and cannot, freeze the path for the duration of the run. A user who +/// can write to their own cache can still change it under a run they started themselves, and no +/// check here would be about a trust boundary. +#[cfg(unix)] +fn reject_foreign_writers(base: &Utf8Path) -> Result<()> { + use std::os::unix::fs::MetadataExt as _; + + /// The bits that let a group member or anyone else write to a directory. + const SHARED_WRITE: u32 = 0o022; + + /// The bit that stops one user removing or renaming another's entries in a shared directory. + /// It is what makes a world-writable `/tmp` an acceptable place to keep a private subdirectory. + const STICKY: u32 = 0o1000; + + /// The user id the kernel treats as unrestricted, and so the only foreign owner that could not + /// gain anything by substituting a file this run will execute. + const ROOT: u32 = 0; + + let user = cargo_gamma_unsafe::identity::effective_user(); + let physical = physical(base); + + for directory in physical.ancestors() { + // A relative cache path whose prefix could not be resolved ends its ancestry at the empty + // path, which names no directory and is not a step towards the root. + if directory.as_str().is_empty() { + break; + } + + let metadata = fs::symlink_metadata(directory.as_std_path()) + .map_err(|cause| error!("could not inspect `{directory}` on the way to the redirected cache").caused_by(cause))?; + + if metadata.uid() != user && metadata.uid() != ROOT { + return Err(error!( + "`{directory}`, on the way to the redirected cache at `{base}`, belongs to another user.\n\ + Choose a directory only you can write to for --cache-dir: this run builds test executables \ + there and then runs them as you." + ) + .usage()); + } + + let mode = metadata.mode(); + + if mode & SHARED_WRITE != 0 && mode & STICKY == 0 { + return Err(error!( + "`{directory}`, on the way to the redirected cache at `{base}`, is writable by other users.\n\ + Choose a directory only you can write to for --cache-dir: this run builds test executables \ + there and then runs them as you." + ) + .usage()); + } + } + + Ok(()) +} + +/// Ownership of a cache directory cannot be established here. +/// +/// The check this replaces asks who owns a directory and who may write to it. On Windows those are +/// answers about a discretionary access-control list, which `std` does not expose and which cannot +/// be read without the Win32 security interface; the closest thing to it that is reachable, the +/// read-only attribute, says nothing about other principals. Rather than run a check that would +/// pass on an unsafe directory and read as if something had been verified, nothing is claimed: a +/// redirected cache on this platform is trusted to the extent that the directory the user named is +/// one they control. The link check in [`reject_linked_cache`] still applies, and is the part that +/// does not depend on identity. +#[cfg(not(unix))] +#[expect(clippy::unnecessary_wraps, reason = "matches the Unix signature this stands in for")] +fn reject_foreign_writers(_base: &Utf8Path) -> Result<()> { + Ok(()) +} + +/// Which cache directory a marker is being checked for, so the refusal names something the user +/// can change. +#[derive(Clone, Copy, PartialEq, Eq)] +enum CacheKind { + /// The per-workspace directory [`gamma_base`] derives when no `--cache-dir` was given. + Default, + + /// A directory the user named with `--cache-dir`. + Redirected, +} + +impl CacheKind { + /// What to tell a user whose cache directory already belongs to another workspace. + const fn collision_advice(self) -> &'static str { + match self { + // The name is derived from the workspace root, so a mismatch is a digest collision + // rather than a choice the user made — the only thing they can do is stop sharing it. + Self::Default => "Pass --cache-dir to give one of the two workspaces a cache of its own.", + Self::Redirected => "Choose a different directory for --cache-dir.", + } + } +} + +/// Refuses a cache directory unless it is unclaimed or already belongs to this workspace. +/// +/// The marker is opened once and both inspected and read through that one handle, so the file whose +/// type is checked is the file whose contents decide the answer. Reopening it by name between the +/// two would leave an interval in which the checked file could be replaced by another. +fn validate_cache_owner(source: &Utf8Path, base: &Utf8Path, kind: CacheKind) -> Result<()> { let owner = base.join(CACHE_OWNER); - match fs::symlink_metadata(owner.as_std_path()) { - Ok(metadata) => { + match open_cache_owner(&owner) { + Ok(mut marker) => { + let metadata = marker + .metadata() + .map_err(|cause| error!("could not inspect the cargo-gamma cache owner marker at `{owner}`").caused_by(cause))?; + if !metadata.is_file() { return Err(error!( "the cargo-gamma cache owner marker at `{owner}` is not a regular file.\n\ - Choose an empty directory for --cache-dir." + {}", + kind.collision_advice() ) .usage()); } - let recorded = fs::read_to_string(owner.as_std_path()) + let mut recorded = String::new(); + + marker + .read_to_string(&mut recorded) .map_err(|cause| error!("could not read the cargo-gamma cache owner marker at `{owner}`").caused_by(cause))?; + let source = physical(source); if recorded != source.as_str() { return Err(error!( - "the redirected cache at `{base}` belongs to the workspace at `{recorded}`, not `{source}`.\n\ - Choose a different directory for --cache-dir." + "the cargo-gamma cache at `{base}` belongs to the workspace at `{}`, not `{source}`.\n\ + {}", + crate::report::encode_controls(&recorded), + kind.collision_advice() ) .usage()); } @@ -1066,7 +1245,17 @@ fn validate_cache_owner(source: &Utf8Path, base: &Utf8Path) -> Result<()> { return Err(unowned_cache(base)); } - fs::write(owner.as_std_path(), physical(source).as_str()) + // Created rather than truncated, so that this cannot adopt a marker another principal + // planted between the check above and this write: a marker that appears in that window + // makes the claim fail rather than silently overwriting whatever is there. + let mut marker = File::options() + .create_new(true) + .write(true) + .open(owner.as_std_path()) + .map_err(|cause| error!("could not write the cargo-gamma cache owner marker at `{owner}`").caused_by(cause))?; + + marker + .write_all(physical(source).as_str().as_bytes()) .map_err(|cause| error!("could not write the cargo-gamma cache owner marker at `{owner}`").caused_by(cause))?; } Err(cause) => { @@ -1077,6 +1266,30 @@ fn validate_cache_owner(source: &Utf8Path, base: &Utf8Path) -> Result<()> { Ok(()) } +#[cfg(unix)] +fn open_cache_owner(owner: &Utf8Path) -> io::Result { + use std::os::unix::fs::OpenOptionsExt; + + File::options() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK) + .open(owner.as_std_path()) +} + +#[cfg(not(unix))] +fn open_cache_owner(owner: &Utf8Path) -> io::Result { + let metadata = fs::symlink_metadata(owner.as_std_path())?; + + if !metadata.is_file() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "cache owner marker is not a regular file", + )); + } + + File::open(owner.as_std_path()) +} + /// Says whether a directory already contains anything at all. fn has_any_entries(base: &Utf8Path) -> Result { let mut entries = @@ -1091,11 +1304,10 @@ fn has_any_entries(base: &Utf8Path) -> Result { /// Says whether a directory contains anything cargo-gamma has not just created to lock it. fn has_unowned_entries(base: &Utf8Path) -> Result { - let entries = - fs::read_dir(base.as_std_path()).map_err(|cause| error!("could not inspect the redirected cache at `{base}`").caused_by(cause))?; + let entries = fs::read_dir(base.as_std_path()).map_err(|cause| error!("could not inspect the cache at `{base}`").caused_by(cause))?; for entry in entries { - let entry = entry.map_err(|cause| error!("could not inspect an entry in the redirected cache at `{base}`").caused_by(cause))?; + let entry = entry.map_err(|cause| error!("could not inspect an entry in the cache at `{base}`").caused_by(cause))?; if entry.file_name() != "lock" { return Ok(true); } @@ -1106,8 +1318,8 @@ fn has_unowned_entries(base: &Utf8Path) -> Result { fn unowned_cache(base: &Utf8Path) -> crate::error::Error { error!( - "the redirected cache at `{base}` is not empty and is not marked as cargo-gamma state.\n\ - Choose an empty directory for --cache-dir; existing contents will not be adopted or removed." + "the cache at `{base}` is not empty and is not marked as cargo-gamma state.\n\ + Existing contents will not be adopted or removed; pass --cache-dir to use an empty directory instead." ) .usage() } @@ -1156,12 +1368,22 @@ fn claim(base: &Utf8Path) -> Result { /// /// Deliberately ignores `--cache-dir`: separate build caches may run independently, but commands /// that can publish source or configuration changes must still agree on one lock domain. +/// +/// The identity marker is verified under the lock, so a second workspace root that happened to +/// derive the same directory name is refused rather than handed a lock domain and a scratch tree +/// belonging to the first. Checking it after the lock is taken is what makes the check meaningful: +/// before it, two colliding runs could both read an absent marker and both write one. pub(crate) fn claim_workspace(root: &Utf8Path) -> Result { let base = gamma_base(root, None); fs::create_dir_all(base.as_std_path()) .map_err(|cause| error!("could not create cargo-gamma's workspace cache at `{base}`").caused_by(cause))?; - claim(&base) + + let lock = claim(&base)?; + + validate_cache_owner(root, &base, CacheKind::Default)?; + + Ok(lock) } /// Takes every lock needed to use one workspace's reusable state. @@ -1414,33 +1636,8 @@ mod tests { ); } - /// A scratch tree blocked by a plain file is reported rather than silently reused. - #[test] - fn a_scratch_tree_blocked_by_a_file_is_a_reported_failure() { - let directory = crate::testing::workdir("stale-tree"); - let source = Utf8PathBuf::from_path_buf(directory.path().to_path_buf()).expect("the scratch path is UTF-8"); - - fs::write(source.join("Cargo.toml").as_std_path(), "[workspace]\nmembers = []\n").expect("a manifest"); - - // A leftover named `tree` that is a file rather than a directory cannot be copied into. - // Reusing it would instrument nothing and report a perfect score, so the run has to stop. - let base = gamma_base(&source, None); - - fs::create_dir_all(base.as_std_path()).expect("the scratch base"); - fs::write(base.join("tree").as_std_path(), "not a directory").expect("the stale entry"); - - let config = Config::default(); - let mut events = crate::testing::Recorder::default(); - let failure = Workspace::prepare(&source, &config, &mut events).expect_err("the tree must fail to prepare"); - - assert!(failure.to_string().contains("could not create the scratch tree"), "{failure}"); - } - - /// The one existing `prepare` test only exercises the failure path where a stale entry blocks - /// the copy; every other step of preparing a scratch tree — creating the base, claiming the - /// lock, copying, vendoring the runtime, and anchoring manifests — was otherwise only reachable - /// through a real mutation-testing run. If any one of them silently broke, nothing here would - /// have caught it before a user's run failed on it instead. + /// Exercises every step of preparing a scratch tree — creating the base, claiming the lock, + /// copying, vendoring the runtime, and anchoring manifests. #[test] fn preparing_a_fresh_workspace_produces_a_usable_scratch_tree() { let directory = crate::testing::workdir("prepare-happy-"); @@ -1469,28 +1666,6 @@ mod tests { ); } - #[test] - fn legacy_cache_directories_are_renamed_without_losing_their_contents() { - let directory = crate::testing::workdir("legacy-cache-layout-"); - let base = Utf8PathBuf::from_path_buf(directory.path().to_path_buf()).expect("the cache path is UTF-8"); - - fs::create_dir_all(base.join("tree")).expect("legacy workspace"); - fs::create_dir_all(base.join("build")).expect("legacy target"); - fs::write(base.join("tree/source"), "source").expect("workspace marker"); - fs::write(base.join("build/artifact"), "artifact").expect("target marker"); - - migrate_legacy_directory(&base, "tree", "workspace").expect("workspace migration"); - migrate_legacy_directory(&base, "build", "target").expect("target migration"); - - assert_eq!( - fs::read_to_string(base.join("workspace/source")).expect("workspace marker"), - "source" - ); - assert_eq!(fs::read_to_string(base.join("target/artifact")).expect("target marker"), "artifact"); - assert!(!base.join("tree").exists()); - assert!(!base.join("build").exists()); - } - #[test] fn an_unowned_redirected_cache_is_refused_without_touching_its_contents() { let directory = crate::testing::workdir("unowned-cache-"); @@ -1564,6 +1739,125 @@ mod tests { assert!(failure.to_string().contains(absolute(&first).as_str()), "{failure}"); } + /// A cache reached through a link is a cache whose identity can be changed after it has been + /// approved, so the name that was checked is not the directory that gets built into and run + /// from. + #[test] + fn a_linked_redirected_cache_is_refused() { + let directory = crate::testing::workdir("linked-cache-"); + let source = Utf8PathBuf::from_path_buf(directory.path().join("source")).expect("the source path is UTF-8"); + let real = Utf8PathBuf::from_path_buf(directory.path().join("real")).expect("the real path is UTF-8"); + let base = Utf8PathBuf::from_path_buf(directory.path().join("cache")).expect("the cache path is UTF-8"); + + fs::create_dir_all(&real).expect("the directory the link points at"); + + #[cfg(unix)] + std::os::unix::fs::symlink(real.as_std_path(), base.as_std_path()).expect("the link"); + + // Creating a link needs a privilege or developer mode on Windows, and a runner without it + // cannot exercise this at all. Skipped rather than failed: the check being tested is the + // same one either way, and a test that demands a privilege reports the runner's + // configuration rather than this tool's behaviour. + #[cfg(windows)] + if std::os::windows::fs::symlink_dir(real.as_std_path(), base.as_std_path()).is_err() { + return; + } + + let failure = claim_redirected_cache(&source, &base).expect_err("a linked cache must be refused"); + + assert!(failure.is_usage()); + assert!(failure.to_string().contains("is a link"), "{failure}"); + assert!(!real.join(CACHE_OWNER).exists(), "nothing may be written through the link"); + } + + /// A regular file where a cache directory was named is not a cache, and the marker checks would + /// otherwise be asked about a directory that does not exist. + #[test] + fn a_redirected_cache_that_is_not_a_directory_is_refused() { + let directory = crate::testing::workdir("file-cache-"); + let source = Utf8PathBuf::from_path_buf(directory.path().join("source")).expect("the source path is UTF-8"); + let base = Utf8PathBuf::from_path_buf(directory.path().join("cache")).expect("the cache path is UTF-8"); + + fs::write(base.as_std_path(), "not a directory").expect("the user's file"); + + let failure = claim_redirected_cache(&source, &base).expect_err("a file is not a cache directory"); + + assert!(failure.is_usage()); + assert_eq!( + fs::read_to_string(base.as_std_path()).expect("the user's file remains"), + "not a directory" + ); + } + + /// Everything a run builds is placed in the cache and then executed, so a directory anywhere + /// above it that another local user can write to is a directory from which they choose what + /// this tool runs as the invoking user. + #[cfg(unix)] + #[test] + fn a_redirected_cache_under_a_world_writable_directory_is_refused() { + use std::os::unix::fs::PermissionsExt as _; + + let directory = crate::testing::workdir("shared-cache-"); + let source = Utf8PathBuf::from_path_buf(directory.path().join("source")).expect("the source path is UTF-8"); + let shared = Utf8PathBuf::from_path_buf(directory.path().join("shared")).expect("the shared path is UTF-8"); + let base = shared.join("cache"); + + fs::create_dir_all(&base).expect("the cache directory"); + fs::set_permissions(shared.as_std_path(), fs::Permissions::from_mode(0o777)).expect("make the parent world-writable"); + + let failure = claim_redirected_cache(&source, &base).expect_err("a world-writable ancestor must be refused"); + + assert!(failure.is_usage()); + assert!(failure.to_string().contains("is writable by other users"), "{failure}"); + assert!(!base.join(CACHE_OWNER).exists()); + assert!(!base.join("lock").exists(), "refusal must happen before anything is written"); + + // Restored so that the temporary directory can be removed by the harness that made it. + fs::set_permissions(shared.as_std_path(), fs::Permissions::from_mode(0o755)).expect("restore the parent"); + } + + /// The sticky bit is what makes a shared directory safe to keep a private one inside: it stops + /// one user removing or renaming another's entries, which is the substitution being defended + /// against. Refusing it would rule out every cache under `/tmp` for no gain. + #[cfg(unix)] + #[test] + fn a_redirected_cache_under_a_sticky_shared_directory_is_allowed() { + use std::os::unix::fs::PermissionsExt as _; + + let directory = crate::testing::workdir("sticky-cache-"); + let source = Utf8PathBuf::from_path_buf(directory.path().join("source")).expect("the source path is UTF-8"); + let shared = Utf8PathBuf::from_path_buf(directory.path().join("shared")).expect("the shared path is UTF-8"); + let base = shared.join("cache"); + + fs::create_dir_all(&base).expect("the cache directory"); + fs::set_permissions(shared.as_std_path(), fs::Permissions::from_mode(0o1777)).expect("make the parent sticky and shared"); + + let claimed = claim_redirected_cache(&source, &base); + + fs::set_permissions(shared.as_std_path(), fs::Permissions::from_mode(0o755)).expect("restore the parent"); + + let _lock = claimed.expect("a sticky shared ancestor is not a foreign writer"); + } + + /// The owner marker is a path a would-be attacker controls the contents of, and it is read + /// back into a message printed to a terminal. + #[test] + fn a_hostile_owner_marker_cannot_address_the_terminal_it_is_reported_on() { + let directory = crate::testing::workdir("hostile-marker-cache-"); + let source = Utf8PathBuf::from_path_buf(directory.path().join("source")).expect("the source path is UTF-8"); + let base = Utf8PathBuf::from_path_buf(directory.path().join("cache")).expect("the cache path is UTF-8"); + + fs::create_dir_all(&base).expect("the cache directory"); + fs::write(base.join(CACHE_OWNER).as_std_path(), "/w\r\u{1b}[2Kforged").expect("the planted marker"); + + let failure = claim_redirected_cache(&source, &base).expect_err("a foreign marker must be refused"); + let text = failure.to_string(); + + assert!(!text.contains('\u{1b}'), "{text:?}"); + assert!(!text.contains('\r'), "{text:?}"); + assert!(text.contains("\\r\\e[2Kforged"), "{text:?}"); + } + #[test] fn two_workspaces_cannot_use_one_redirected_cache_concurrently() { let directory = crate::testing::workdir("contended-cache-"); @@ -1596,19 +1890,36 @@ mod tests { let base = gamma_base(&root, None); let report = root.join("target/cargo-gamma/gamma-report.json"); - fs::create_dir_all(base.join("workspace")).expect("cached workspace"); + fs::create_dir_all(&base).expect("cache"); + validate_cache_owner(&root, &base, CacheKind::Default).expect("claim cache"); + fs::create_dir(base.join("workspace")).expect("cached workspace"); fs::create_dir_all(base.join("target")).expect("cached target"); fs::write(base.join("last-gamma-run.json"), "{}").expect("run record"); fs::create_dir_all(report.parent().expect("report directory")).expect("report directory"); fs::write(&report, "{}").expect("published report"); - assert!(clean_cache(&root).expect("clean cache")); + assert_eq!(clean_cache(&root).expect("clean cache"), vec![base.clone()]); assert!(!base.join("workspace").exists()); assert!(!base.join("target").exists()); assert!(!base.join("last-gamma-run.json").exists()); assert!(base.join("lock").exists(), "the concurrency lock remains"); assert!(report.exists(), "published output is not cache data"); - assert!(!clean_cache(&root).expect("cleaning an empty cache")); + assert!(clean_cache(&root).expect("cleaning an empty cache").is_empty()); + } + + #[test] + fn cleaning_refuses_an_unmarked_populated_cache() { + let directory = crate::testing::workdir("clean-unmarked-cache-"); + let root = Utf8PathBuf::from_path_buf(directory.path().to_path_buf()).expect("the workspace path is UTF-8"); + let base = gamma_base(&root, None); + + fs::create_dir_all(base.join("workspace")).expect("unowned contents"); + + let failure = clean_cache(&root).expect_err("unowned contents must not be removed"); + + assert!(failure.is_usage(), "{failure}"); + assert!(base.join("workspace").exists(), "unowned contents must survive"); + assert!(!base.join(CACHE_OWNER).exists(), "the cache must not be adopted"); } #[test] @@ -2391,6 +2702,83 @@ mod tests { ); } + /// The default cache directory's name is a cross-release contract, not a private detail. + /// + /// Every command that can publish source or configuration changes for a workspace shares one + /// advisory lock, and that lock lives under this name. Two binaries that derived different + /// names for one workspace would each take a lock nobody else holds and rewrite the same tree + /// at the same time, which is precisely the situation the lock exists to prevent — and nothing + /// would report contention, because there is none to report. So the digest is pinned here + /// against a literal, and any change to the algorithm has to be a deliberate one made with + /// that consequence in view. + #[test] + fn the_default_cache_directory_name_is_pinned_to_this_crate() { + assert_eq!(workspace_identity(Utf8Path::new("/workspace")), "155d8208f4c61a79"); + assert_eq!(workspace_identity(Utf8Path::new("/workspace/one")), "85dacdefd093e7c1"); + + // Distinct roots get distinct directories, which is the whole reason to hash at all. + assert_ne!( + workspace_identity(Utf8Path::new("/workspace/one")), + workspace_identity(Utf8Path::new("/workspace/two")) + ); + + // And it is the name the default base actually uses. + let expected = workspace_identity(&absolute(Utf8Path::new("/workspace"))); + assert!( + gamma_base(Utf8Path::new("/workspace"), None).as_str().ends_with(&expected), + "{}", + gamma_base(Utf8Path::new("/workspace"), None) + ); + } + + /// A truncated digest can collide, so the directory says which workspace it belongs to and a + /// second one is refused rather than handed the first one's lock domain and scratch tree. + #[test] + fn a_default_cache_directory_claimed_by_another_workspace_is_refused() { + let directory = crate::testing::workdir("default-owner"); + let root = Utf8PathBuf::from_path_buf(directory.path().to_path_buf()).expect("the scratch path is UTF-8"); + let base = root.join("cache"); + let mine = root.join("mine"); + let theirs = root.join("theirs"); + + fs::create_dir_all(base.as_std_path()).expect("an empty cache"); + fs::create_dir_all(mine.as_std_path()).expect("one workspace root"); + fs::create_dir_all(theirs.as_std_path()).expect("another workspace root"); + + // An unclaimed directory is adopted, and says so afterwards. + validate_cache_owner(&mine, &base, CacheKind::Default).expect("an unclaimed default cache is adopted"); + assert_eq!( + fs::read_to_string(base.join(CACHE_OWNER).as_std_path()).expect("the owner marker"), + physical(&mine).as_str() + ); + + // The owner may keep using it. + validate_cache_owner(&mine, &base, CacheKind::Default).expect("the owning workspace reuses its cache"); + + let failure = validate_cache_owner(&theirs, &base, CacheKind::Default).expect_err("a colliding workspace must be refused"); + + assert!(failure.to_string().contains(physical(&mine).as_str()), "{failure}"); + assert!(failure.to_string().contains("--cache-dir"), "{failure}"); + assert!(failure.is_usage(), "{failure}"); + } + + #[test] + fn an_unmarked_populated_cache_is_refused_for_default_and_redirected_paths() { + let directory = crate::testing::workdir("unmarked-cache"); + let base = Utf8PathBuf::from_path_buf(directory.path().to_path_buf()).expect("the scratch path is UTF-8"); + let source = base.join("source"); + + fs::create_dir_all(source.as_std_path()).expect("a workspace root"); + fs::create_dir_all(base.join("target").as_std_path()).expect("cached build output"); + + for kind in [CacheKind::Redirected, CacheKind::Default] { + let failure = validate_cache_owner(&source, &base, kind).expect_err("existing contents are unowned"); + + assert!(failure.is_usage(), "{failure}"); + assert!(!base.join(CACHE_OWNER).exists(), "nothing may be claimed on the refused path"); + } + } + #[test] fn scratch_tree_is_derived_from_the_same_base_as_prepare() { // Callers report this path after deliberately leaking the workspace, so it has to match diff --git a/crates/cargo-gamma-lib/src/fix/mod.rs b/crates/cargo-gamma-lib/src/fix/mod.rs index a673a2a6..fcc168da 100644 --- a/crates/cargo-gamma-lib/src/fix/mod.rs +++ b/crates/cargo-gamma-lib/src/fix/mod.rs @@ -27,10 +27,17 @@ mod removal; mod verification; mod verify; +#[doc(inline)] pub use diff::diff; +#[doc(inline)] pub use edit::Edit; +#[doc(inline)] pub use eligible::Eligible; +#[doc(inline)] pub use plan::{apply, plan, today}; +#[doc(inline)] pub use removal::{removable, remove}; +#[doc(inline)] pub use verification::Verification; +#[doc(inline)] pub use verify::verify; diff --git a/crates/cargo-gamma-lib/src/fixtures.rs b/crates/cargo-gamma-lib/src/fixtures.rs index 0afcf341..b514d81f 100644 --- a/crates/cargo-gamma-lib/src/fixtures.rs +++ b/crates/cargo-gamma-lib/src/fixtures.rs @@ -17,7 +17,7 @@ //! Gated on `cfg(test)` rather than living in [`crate::testing`]: that module is `pub`, so anything //! added to it joins the crate's API, and these are only ever wanted from a `mod tests` block. -use std::collections::HashMap; +use std::collections::BTreeMap; use camino::Utf8PathBuf; @@ -49,6 +49,7 @@ pub(crate) fn mutant() -> Mutant { column: 1, mutator: "relational.lt_to_le".to_owned().into(), item_path: "f".to_owned().into(), + trait_impl: None, occurrence: 0, replacement_index: 0, original: "a".to_owned().into(), @@ -115,13 +116,13 @@ pub(crate) fn report() -> Report { name: "cargo-gamma".to_owned(), version: "0.1.0".to_owned(), }, - files: HashMap::default(), + files: BTreeMap::new(), config: None, } } pub(crate) fn report_with(shard: Option<(u32, u32)>, started_at: u64, mutants: Vec) -> Report { - let mut files = HashMap::default(); + let mut files = BTreeMap::new(); let _ = files.insert( "src/lib.rs".to_owned(), diff --git a/crates/cargo-gamma-lib/src/html.rs b/crates/cargo-gamma-lib/src/html.rs index 26aa499d..85ce3f24 100644 --- a/crates/cargo-gamma-lib/src/html.rs +++ b/crates/cargo-gamma-lib/src/html.rs @@ -9,6 +9,14 @@ //! //! The recipe is the one every Stryker implementation uses: inline the viewer bundle and assign //! the report as a JavaScript property rather than fetching it. +//! +//! There is deliberately no second mode that loads the viewer from somewhere else. A report +//! carries the whole mutated source of the code it describes, and a page that fetches its viewer +//! hands all of it to whatever that script turns out to be on the day the file is opened. The +//! bytes here were reviewed and committed once; a remote artifact cannot be, unless its exact +//! content is pinned by a digest this repository can establish without trusting the fetch — which +//! it cannot, because the vendored bundle is built here rather than copied byte-for-byte from a +//! published one. Rather than ship an unverified `` in a string literal cannot /// terminate the element carrying it, matching the escaping the whole-string form used. Splitting /// the page this way is what keeps neither the report JSON nor the page held whole in memory. -fn stream(report: &Report, source: Source, writer: &mut dyn io::Write) -> io::Result<()> { +fn stream(report: &Report, writer: &mut dyn io::Write) -> io::Result<()> { write!( writer, "\n\ @@ -76,10 +83,7 @@ fn stream(report: &Report, source: Source, writer: &mut dyn io::Write) -> io::Re \n" )?; - match source { - Source::Inline => write!(writer, "")?, - Source::External => write!(writer, "")?, - } + write!(writer, "")?; write!( writer, @@ -193,24 +197,46 @@ mod tests { #[test] fn the_inline_page_carries_the_whole_viewer() { - let page = render(&report(), Source::Inline).expect("renders"); + let page = render(&report()).expect("renders"); assert!(page.contains(" VIEWER.len(), "the viewer was not inlined"); assert!(!page.contains("cdn.jsdelivr.net"), "the offline report must not reference a CDN"); } + /// No rendered page may load code from anywhere but itself. + /// + /// This is the whole security property of the report format, so it is asserted over the + /// executable markup rather than over an option. URL strings embedded in the vendored + /// JavaScript are inert and do not imply a network load. #[test] - fn the_external_page_is_small_and_references_the_cdn() { - let page = render(&report(), Source::External).expect("renders"); + fn the_page_loads_no_code_from_anywhere_else() { + let page = render(&report()).expect("renders"); + let head = page.split("app.report =").next().expect("the page has a prefix"); + + assert!(!head.contains("