Skip to main content

supercov_engine/
rust_runner_attempt.rs

1//! Exact attempt identity exposed by Rust test runners.
2//!
3//! Runner identity is an input contract, not an output heuristic. Standard
4//! Cargo/libtest has one attempt. Nextest exposes exact list/run and retry
5//! identity to target runners; partial identity and unsupported stress axes
6//! fail before user test execution.
7
8use std::{collections::BTreeMap, ffi::OsString};
9
10/// The first release that tells a target runner which test and attempt it
11/// was started for. An older one cannot be measured test by test.
12pub const MINIMUM_NEXTEST_ATTEMPT_VERSION: (u64, u64, u64) = (0, 9, 138);
13/// The newest release the gates run under. A later one runs too: Supercov
14/// used to accept only the releases it had been checked with, so every new
15/// nextest was refused until a Supercov release named it (0.9.148 was, the
16/// week it came out). What a runner is told is checked for each attempt, and
17/// a release that told it less would stop there, saying what is missing.
18pub const NEWEST_CHECKED_NEXTEST_VERSION: (u64, u64, u64) = (0, 9, 148);
19
20const EXECUTION_IDENTITY_KEYS: &[&str] = &[
21    "NEXTEST_RUN_ID",
22    "NEXTEST_VERSION",
23    "NEXTEST_EXECUTION_MODE",
24    "NEXTEST_BINARY_ID",
25    "NEXTEST_TEST_NAME",
26    "NEXTEST_ATTEMPT",
27    "NEXTEST_TOTAL_ATTEMPTS",
28    "NEXTEST_ATTEMPT_ID",
29    "NEXTEST_STRESS_CURRENT",
30    "NEXTEST_STRESS_TOTAL",
31];
32
33const ATTEMPT_KEYS: &[&str] = &[
34    "NEXTEST_TEST_NAME",
35    "NEXTEST_ATTEMPT",
36    "NEXTEST_TOTAL_ATTEMPTS",
37    "NEXTEST_ATTEMPT_ID",
38    "NEXTEST_STRESS_CURRENT",
39    "NEXTEST_STRESS_TOTAL",
40];
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct NextestInvocationIdentity {
44    pub run_id: String,
45    pub version: String,
46    pub binary_id: String,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct NextestAttemptIdentity {
51    pub invocation: NextestInvocationIdentity,
52    pub test_name: String,
53    pub retry: usize,
54    pub total_attempts: usize,
55    pub runner_attempt_id: String,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub enum RustRunnerInvocationIdentity {
60    CargoSingleAttempt,
61    NextestList(NextestInvocationIdentity),
62    NextestAttempt(NextestAttemptIdentity),
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct RustRunnerAttemptError(pub String);
67
68impl std::fmt::Display for RustRunnerAttemptError {
69    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70        formatter.write_str(&self.0)
71    }
72}
73
74impl std::error::Error for RustRunnerAttemptError {}
75
76fn value(
77    environment: &BTreeMap<OsString, OsString>,
78    key: &str,
79) -> Result<Option<String>, RustRunnerAttemptError> {
80    environment
81        .get(&OsString::from(key))
82        .map(|value| {
83            value.clone().into_string().map_err(|_| {
84                RustRunnerAttemptError(format!("{key} contains non-UTF-8 runner identity"))
85            })
86        })
87        .transpose()
88}
89
90fn required(
91    environment: &BTreeMap<OsString, OsString>,
92    key: &str,
93) -> Result<String, RustRunnerAttemptError> {
94    let value = value(environment, key)?
95        .ok_or_else(|| RustRunnerAttemptError(format!("nextest attempt identity lacks {key}")))?;
96    if value.is_empty() || value.len() > 4_096 || value.contains(['\r', '\n', '\0']) {
97        return Err(RustRunnerAttemptError(format!(
98            "{key} is empty, oversized, or not a single-line identity"
99        )));
100    }
101    Ok(value)
102}
103
104fn parse_version(value: &str) -> Result<(u64, u64, u64), RustRunnerAttemptError> {
105    let mut parts = value.split('.');
106    let mut parse = |name: &str| {
107        parts
108            .next()
109            .ok_or_else(|| RustRunnerAttemptError(format!("nextest version lacks {name}")))?
110            .parse::<u64>()
111            .map_err(|_| RustRunnerAttemptError(format!("nextest version has invalid {name}")))
112    };
113    let version = (parse("major")?, parse("minor")?, parse("patch")?);
114    if parts.next().is_some() {
115        return Err(RustRunnerAttemptError(
116            "nextest version must have exactly three numeric components".into(),
117        ));
118    }
119    Ok(version)
120}
121
122/// Whether a release is newer than any the gates run under.
123pub fn newer_than_checked(value: &str) -> bool {
124    parse_version(value).is_ok_and(|version| version > NEWEST_CHECKED_NEXTEST_VERSION)
125}
126
127pub fn validate_nextest_version(value: &str) -> Result<(), RustRunnerAttemptError> {
128    let version = parse_version(value)?;
129    if version < MINIMUM_NEXTEST_ATTEMPT_VERSION {
130        return Err(RustRunnerAttemptError(format!(
131            "nextest {value} is older than 0.9.138, the first release that tells a target runner which test and attempt it runs"
132        )));
133    }
134    Ok(())
135}
136
137pub fn parse_nextest_version_output(output: &[u8]) -> Result<String, RustRunnerAttemptError> {
138    let output = std::str::from_utf8(output)
139        .map_err(|_| RustRunnerAttemptError("nextest --version output is not UTF-8".into()))?;
140    if output.contains('\r') {
141        return Err(RustRunnerAttemptError(
142            "nextest --version output contains a carriage return".into(),
143        ));
144    }
145    let mut lines = output.trim_end_matches('\n').split('\n');
146    let first_line = lines.next().unwrap_or_default();
147    let mut fields = first_line.split_ascii_whitespace();
148    if fields.next() != Some("cargo-nextest") {
149        return Err(RustRunnerAttemptError(
150            "nextest --version output lacks the cargo-nextest product identity".into(),
151        ));
152    }
153    let version = fields
154        .next()
155        .ok_or_else(|| RustRunnerAttemptError("nextest --version output lacks a version".into()))?;
156    validate_nextest_version(version)?;
157    let first_line_metadata = fields.collect::<Vec<_>>();
158    let release = lines.next().ok_or_else(|| {
159        RustRunnerAttemptError("nextest --version output lacks release metadata".into())
160    })?;
161    if release != format!("release: {version}") {
162        return Err(RustRunnerAttemptError(
163            "nextest --version release metadata disagrees with its product version".into(),
164        ));
165    }
166    let remaining = lines.collect::<Vec<_>>();
167    let (commit_hash, commit_date, host) = match remaining.as_slice() {
168        [host] => (None, None, *host),
169        [commit_hash, commit_date, host] => (Some(*commit_hash), Some(*commit_date), *host),
170        _ => {
171            return Err(RustRunnerAttemptError(
172                "nextest --version output has an unknown metadata layout".into(),
173            ));
174        }
175    };
176    let host = host
177        .strip_prefix("host: ")
178        .filter(|host| !host.is_empty())
179        .ok_or_else(|| {
180            RustRunnerAttemptError("nextest --version output lacks a build host".into())
181        })?;
182    if host.len() > 255 || !host.is_ascii() {
183        return Err(RustRunnerAttemptError(
184            "nextest --version build host is oversized or non-ASCII".into(),
185        ));
186    }
187    match (commit_hash, commit_date) {
188        (Some(commit_hash), Some(commit_date)) => {
189            let commit_hash = commit_hash
190                .strip_prefix("commit-hash: ")
191                .filter(|hash| {
192                    hash.len() >= 9
193                        && hash.len() <= 64
194                        && hash
195                            .bytes()
196                            .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
197                })
198                .ok_or_else(|| {
199                    RustRunnerAttemptError(
200                        "nextest --version output has an invalid commit hash".into(),
201                    )
202                })?;
203            let commit_date = commit_date
204                .strip_prefix("commit-date: ")
205                .filter(|date| {
206                    date.len() == 10
207                        && date.bytes().enumerate().all(|(index, byte)| {
208                            if matches!(index, 4 | 7) {
209                                byte == b'-'
210                            } else {
211                                byte.is_ascii_digit()
212                            }
213                        })
214                })
215                .ok_or_else(|| {
216                    RustRunnerAttemptError(
217                        "nextest --version output has an invalid commit date".into(),
218                    )
219                })?;
220            if first_line_metadata.len() != 2
221                || first_line_metadata[0] != format!("({}", &commit_hash[..9])
222                || first_line_metadata[1] != format!("{commit_date})")
223            {
224                return Err(RustRunnerAttemptError(
225                    "nextest --version short build identity disagrees with its metadata".into(),
226                ));
227            }
228        }
229        (None, None) if first_line_metadata.is_empty() => {}
230        _ => {
231            return Err(RustRunnerAttemptError(
232                "nextest --version build identity is only partially present".into(),
233            ));
234        }
235    }
236    Ok(version.to_owned())
237}
238
239fn canonical_uuid(value: &str) -> bool {
240    value.len() == 36
241        && value.bytes().enumerate().all(|(index, byte)| {
242            if matches!(index, 8 | 13 | 18 | 23) {
243                byte == b'-'
244            } else {
245                byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()
246            }
247        })
248}
249
250fn parse_positive_usize(key: &str, value: &str) -> Result<usize, RustRunnerAttemptError> {
251    value
252        .parse::<usize>()
253        .ok()
254        .filter(|number| *number > 0)
255        .ok_or_else(|| RustRunnerAttemptError(format!("{key} must be a positive integer")))
256}
257
258pub fn classify_rust_runner_environment()
259-> Result<RustRunnerInvocationIdentity, RustRunnerAttemptError> {
260    classify_rust_runner_environment_from(std::env::vars_os())
261}
262
263pub fn classify_rust_runner_environment_from(
264    environment: impl IntoIterator<Item = (OsString, OsString)>,
265) -> Result<RustRunnerInvocationIdentity, RustRunnerAttemptError> {
266    let environment = environment.into_iter().collect::<BTreeMap<_, _>>();
267    let nextest = value(&environment, "NEXTEST")?;
268    if nextest.is_none() {
269        if let Some(key) = EXECUTION_IDENTITY_KEYS
270            .iter()
271            .find(|key| environment.contains_key(&OsString::from(**key)))
272        {
273            return Err(RustRunnerAttemptError(format!(
274                "runner environment contains {key} without NEXTEST=1"
275            )));
276        }
277        return Ok(RustRunnerInvocationIdentity::CargoSingleAttempt);
278    }
279    if nextest.as_deref() != Some("1") {
280        return Err(RustRunnerAttemptError(
281            "NEXTEST must equal 1 when nextest identity is present".into(),
282        ));
283    }
284
285    let run_id = required(&environment, "NEXTEST_RUN_ID")?;
286    if !canonical_uuid(&run_id) {
287        return Err(RustRunnerAttemptError(
288            "NEXTEST_RUN_ID is not a canonical lowercase UUID".into(),
289        ));
290    }
291    let version = required(&environment, "NEXTEST_VERSION")?;
292    validate_nextest_version(&version)?;
293    let execution_mode = required(&environment, "NEXTEST_EXECUTION_MODE")?;
294    if execution_mode != "process-per-test" {
295        return Err(RustRunnerAttemptError(format!(
296            "unsupported nextest execution mode: {execution_mode}"
297        )));
298    }
299    let binary_id = required(&environment, "NEXTEST_BINARY_ID")?;
300    let invocation = NextestInvocationIdentity {
301        run_id,
302        version,
303        binary_id,
304    };
305
306    let present_attempt_keys = ATTEMPT_KEYS
307        .iter()
308        .filter(|key| environment.contains_key(&OsString::from(**key)))
309        .count();
310    if present_attempt_keys == 0 {
311        return Ok(RustRunnerInvocationIdentity::NextestList(invocation));
312    }
313    if present_attempt_keys != ATTEMPT_KEYS.len() {
314        return Err(RustRunnerAttemptError(
315            "nextest supplied only part of the frozen attempt identity".into(),
316        ));
317    }
318
319    let test_name = required(&environment, "NEXTEST_TEST_NAME")?;
320    let attempt = parse_positive_usize(
321        "NEXTEST_ATTEMPT",
322        &required(&environment, "NEXTEST_ATTEMPT")?,
323    )?;
324    let total_attempts = parse_positive_usize(
325        "NEXTEST_TOTAL_ATTEMPTS",
326        &required(&environment, "NEXTEST_TOTAL_ATTEMPTS")?,
327    )?;
328    if attempt > total_attempts {
329        return Err(RustRunnerAttemptError(
330            "NEXTEST_ATTEMPT exceeds NEXTEST_TOTAL_ATTEMPTS".into(),
331        ));
332    }
333    let runner_attempt_id = required(&environment, "NEXTEST_ATTEMPT_ID")?;
334    if !runner_attempt_id.starts_with(&format!("{}:", invocation.run_id)) {
335        return Err(RustRunnerAttemptError(
336            "NEXTEST_ATTEMPT_ID does not belong to NEXTEST_RUN_ID".into(),
337        ));
338    }
339    let stress_current = required(&environment, "NEXTEST_STRESS_CURRENT")?;
340    let stress_total = required(&environment, "NEXTEST_STRESS_TOTAL")?;
341    if stress_current != "none" || stress_total != "none" {
342        return Err(RustRunnerAttemptError(
343            "nextest stress iterations require a distinct identity axis and are not yet supported"
344                .into(),
345        ));
346    }
347    Ok(RustRunnerInvocationIdentity::NextestAttempt(
348        NextestAttemptIdentity {
349            invocation,
350            test_name,
351            retry: attempt - 1,
352            total_attempts,
353            runner_attempt_id,
354        },
355    ))
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361
362    fn environment(values: &[(&str, &str)]) -> Vec<(OsString, OsString)> {
363        values
364            .iter()
365            .map(|(key, value)| (OsString::from(key), OsString::from(value)))
366            .collect()
367    }
368
369    fn nextest_base() -> Vec<(&'static str, &'static str)> {
370        vec![
371            ("NEXTEST", "1"),
372            ("NEXTEST_RUN_ID", "2ae19189-240a-433a-a31d-acc411fe8e1f"),
373            ("NEXTEST_VERSION", "0.9.140"),
374            ("NEXTEST_EXECUTION_MODE", "process-per-test"),
375            ("NEXTEST_BINARY_ID", "fixture::integration"),
376        ]
377    }
378
379    #[test]
380    fn standard_cargo_is_one_exact_attempt() {
381        assert_eq!(
382            classify_rust_runner_environment_from(environment(&[("NEXTEST_RETRIES", "3")]))
383                .unwrap(),
384            RustRunnerInvocationIdentity::CargoSingleAttempt
385        );
386    }
387
388    #[test]
389    fn nextest_list_is_distinct_and_has_no_attempt() {
390        let values = nextest_base();
391        assert!(matches!(
392            classify_rust_runner_environment_from(environment(&values)).unwrap(),
393            RustRunnerInvocationIdentity::NextestList(_)
394        ));
395    }
396
397    #[test]
398    fn nextest_attempt_derives_zero_based_retry_without_parsing_attempt_id() {
399        let mut values = nextest_base();
400        values.extend([
401            ("NEXTEST_TEST_NAME", "tests::flaky"),
402            ("NEXTEST_ATTEMPT", "2"),
403            ("NEXTEST_TOTAL_ATTEMPTS", "3"),
404            (
405                "NEXTEST_ATTEMPT_ID",
406                "2ae19189-240a-433a-a31d-acc411fe8e1f:fixture::integration$tests::flaky#2",
407            ),
408            ("NEXTEST_STRESS_CURRENT", "none"),
409            ("NEXTEST_STRESS_TOTAL", "none"),
410        ]);
411        let RustRunnerInvocationIdentity::NextestAttempt(attempt) =
412            classify_rust_runner_environment_from(environment(&values)).unwrap()
413        else {
414            panic!("expected a nextest attempt");
415        };
416        assert_eq!(attempt.retry, 1);
417        assert_eq!(attempt.total_attempts, 3);
418        assert_eq!(attempt.test_name, "tests::flaky");
419    }
420
421    #[test]
422    fn partial_or_cross_run_nextest_identity_is_fatal() {
423        let mut partial = nextest_base();
424        partial.push(("NEXTEST_ATTEMPT", "1"));
425        assert!(
426            classify_rust_runner_environment_from(environment(&partial))
427                .unwrap_err()
428                .to_string()
429                .contains("only part")
430        );
431
432        let mut cross_run = nextest_base();
433        cross_run.extend([
434            ("NEXTEST_TEST_NAME", "tests::flaky"),
435            ("NEXTEST_ATTEMPT", "1"),
436            ("NEXTEST_TOTAL_ATTEMPTS", "2"),
437            (
438                "NEXTEST_ATTEMPT_ID",
439                "11111111-1111-1111-1111-111111111111:fixture$tests::flaky",
440            ),
441            ("NEXTEST_STRESS_CURRENT", "none"),
442            ("NEXTEST_STRESS_TOTAL", "none"),
443        ]);
444        assert!(
445            classify_rust_runner_environment_from(environment(&cross_run))
446                .unwrap_err()
447                .to_string()
448                .contains("does not belong")
449        );
450    }
451
452    #[test]
453    fn stress_and_future_execution_modes_fail_closed() {
454        let mut stress = nextest_base();
455        stress.extend([
456            ("NEXTEST_TEST_NAME", "tests::stress"),
457            ("NEXTEST_ATTEMPT", "1"),
458            ("NEXTEST_TOTAL_ATTEMPTS", "1"),
459            (
460                "NEXTEST_ATTEMPT_ID",
461                "2ae19189-240a-433a-a31d-acc411fe8e1f:fixture$tests::stress@stress-0",
462            ),
463            ("NEXTEST_STRESS_CURRENT", "0"),
464            ("NEXTEST_STRESS_TOTAL", "3"),
465        ]);
466        assert!(
467            classify_rust_runner_environment_from(environment(&stress))
468                .unwrap_err()
469                .to_string()
470                .contains("distinct identity axis")
471        );
472
473        let mut future = nextest_base();
474        future[3].1 = "in-process";
475        assert!(
476            classify_rust_runner_environment_from(environment(&future))
477                .unwrap_err()
478                .to_string()
479                .contains("unsupported nextest execution mode")
480        );
481    }
482
483    #[test]
484    fn nextest_version_handshake_accepts_every_release_that_names_the_attempt() {
485        assert_eq!(
486            parse_nextest_version_output(
487                b"cargo-nextest 0.9.138 (fc97e97bb 2026-06-21)\nrelease: 0.9.138\ncommit-hash: fc97e97bbe0a3927482a694247da00c099f4269e\ncommit-date: 2026-06-21\nhost: aarch64-apple-darwin\n"
488            )
489            .unwrap(),
490            "0.9.138"
491        );
492        assert_eq!(
493            parse_nextest_version_output(
494                b"cargo-nextest 0.9.140 (a9fef2964 2026-07-05)\nrelease: 0.9.140\ncommit-hash: a9fef2964e34f64ed4fceeee7c0c3559ce560920\ncommit-date: 2026-07-05\nhost: aarch64-apple-darwin\n"
495            )
496            .unwrap(),
497            "0.9.140"
498        );
499        // A release between two the gates run under is not refused.
500        assert_eq!(
501            parse_nextest_version_output(
502                b"cargo-nextest 0.9.139\nrelease: 0.9.139\nhost: aarch64-apple-darwin\n"
503            )
504            .unwrap(),
505            "0.9.139"
506        );
507        assert!(
508            parse_nextest_version_output(
509                b"cargo-nextest 0.9.137\nrelease: 0.9.137\nhost: aarch64-apple-darwin\n"
510            )
511            .unwrap_err()
512            .to_string()
513            .contains("older than 0.9.138")
514        );
515        // The release that was current when this was written, refused until
516        // Supercov named it: a project on that day's nextest could not be
517        // measured.
518        assert_eq!(
519            parse_nextest_version_output(
520                b"cargo-nextest 0.9.148 (cd1d6d546 2026-10-07)\nrelease: 0.9.148\ncommit-hash: cd1d6d5467322dbc0d39a80cb671d000ac302798\ncommit-date: 2026-10-07\nhost: aarch64-apple-darwin\n"
521            )
522            .unwrap(),
523            "0.9.148"
524        );
525        // And one that does not exist yet runs, where it was refused.
526        for later in ["0.9.149", "0.10.0", "1.0.0"] {
527            assert_eq!(
528                parse_nextest_version_output(
529                    format!(
530                        "cargo-nextest {later}\nrelease: {later}\nhost: aarch64-apple-darwin\n"
531                    )
532                    .as_bytes()
533                )
534                .unwrap(),
535                later
536            );
537            assert!(newer_than_checked(later), "{later}");
538        }
539        assert!(!newer_than_checked("0.9.148"));
540        assert!(!newer_than_checked("0.9.141"));
541        assert!(
542            parse_nextest_version_output(
543                b"cargo 0.9.140\nrelease: 0.9.140\nhost: aarch64-apple-darwin\n"
544            )
545            .is_err()
546        );
547        assert!(
548            parse_nextest_version_output(
549                b"cargo-nextest 0.9.140\nrelease: 0.9.140\nextra: value\nhost: aarch64-apple-darwin\n"
550            )
551            .is_err()
552        );
553    }
554}