1use std::{collections::BTreeMap, ffi::OsString};
9
10pub const MINIMUM_NEXTEST_ATTEMPT_VERSION: (u64, u64, u64) = (0, 9, 138);
13pub 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
122pub 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 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 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 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}