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