1use 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}