1use std::{
2 env, fs,
3 path::{Path, PathBuf},
4 process::{Command, Stdio},
5};
6
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9use supercov_contracts::{
10 RustCompilerCompanionError, RustCompilerCompanionHandshake, RustCompilerIdentity,
11 require_matching_rust_compiler_companion,
12};
13
14#[derive(Debug)]
15pub enum RustCompilerSelectionError {
16 Io {
17 operation: &'static str,
18 path: PathBuf,
19 source: std::io::Error,
20 },
21 CommandFailed {
22 program: PathBuf,
23 operation: &'static str,
24 status: Option<i32>,
25 },
26 UnexpectedStderr {
27 program: PathBuf,
28 operation: &'static str,
29 },
30 NonUtf8Output {
31 program: PathBuf,
32 operation: &'static str,
33 },
34 InvalidRustcVerbose(String),
35 RustdocCompilerMismatch {
36 path: PathBuf,
37 },
38 InvalidSysroot(String),
39 InvalidDriverDirectory {
40 path: PathBuf,
41 count: usize,
42 },
43 NonRegularFile(PathBuf),
44 MalformedHandshake {
45 path: PathBuf,
46 reason: String,
47 },
48 InvalidHandshake {
49 path: PathBuf,
50 source: RustCompilerCompanionError,
51 },
52 CompanionBuildIdMismatch {
53 path: PathBuf,
54 },
55 NoMatchingCompanion,
56 MultipleMatchingCompanions(Vec<PathBuf>),
57}
58
59impl std::fmt::Display for RustCompilerSelectionError {
60 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61 match self {
62 Self::Io {
63 operation,
64 path,
65 source,
66 } => {
67 write!(
68 formatter,
69 "could not {operation} {}: {source}",
70 path.display()
71 )
72 }
73 Self::CommandFailed {
74 program,
75 operation,
76 status,
77 } => write!(
78 formatter,
79 "{} failed while {operation} with status {}",
80 program.display(),
81 status.map_or_else(|| "signal".into(), |value| value.to_string())
82 ),
83 Self::UnexpectedStderr { program, operation } => write!(
84 formatter,
85 "{} wrote unexpected stderr while {operation}",
86 program.display()
87 ),
88 Self::NonUtf8Output { program, operation } => write!(
89 formatter,
90 "{} produced non-UTF-8 output while {operation}",
91 program.display()
92 ),
93 Self::InvalidRustcVerbose(reason) => {
94 write!(formatter, "invalid rustc -vV output: {reason}")
95 }
96 Self::RustdocCompilerMismatch { path } => write!(
97 formatter,
98 "rustdoc {} does not match Cargo's exact rustc commit, release and host",
99 path.display()
100 ),
101 Self::InvalidSysroot(reason) => write!(formatter, "invalid rustc sysroot: {reason}"),
102 Self::InvalidDriverDirectory { path, count } => write!(
103 formatter,
104 "expected exactly one rustc driver in {}, found {count}",
105 path.display()
106 ),
107 Self::NonRegularFile(path) => {
108 write!(
109 formatter,
110 "expected a non-symlink regular file: {}",
111 path.display()
112 )
113 }
114 Self::MalformedHandshake { path, reason } => write!(
115 formatter,
116 "invalid compiler companion handshake from {}: {reason}",
117 path.display()
118 ),
119 Self::InvalidHandshake { path, source } => write!(
120 formatter,
121 "compiler companion {} was rejected: {source}",
122 path.display()
123 ),
124 Self::CompanionBuildIdMismatch { path } => write!(
125 formatter,
126 "compiler companion {} reported a build ID that does not match its bytes",
127 path.display()
128 ),
129 Self::NoMatchingCompanion => {
130 formatter.write_str("no exact compiler companion matches the selected rustc")
131 }
132 Self::MultipleMatchingCompanions(paths) => write!(
133 formatter,
134 "multiple exact compiler companions match the selected rustc: {}",
135 paths
136 .iter()
137 .map(|path| path.display().to_string())
138 .collect::<Vec<_>>()
139 .join(", ")
140 ),
141 }
142 }
143}
144
145impl std::error::Error for RustCompilerSelectionError {}
146
147#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
148#[serde(rename_all = "camelCase", deny_unknown_fields)]
149pub struct SelectedRustCompilerCompanion {
150 pub rustc_path: PathBuf,
151 pub compiler_library_directory: PathBuf,
152 pub companion_path: PathBuf,
153 pub compiler: RustCompilerIdentity,
154 pub handshake: RustCompilerCompanionHandshake,
155}
156
157fn resolve_program(program: &Path) -> Result<PathBuf, RustCompilerSelectionError> {
158 let has_parent = program
159 .parent()
160 .is_some_and(|parent| !parent.as_os_str().is_empty());
161 let candidate = if program.is_absolute() || has_parent {
162 if program.is_absolute() {
163 program.to_path_buf()
164 } else {
165 env::current_dir()
166 .map_err(io_error("resolve current directory for", program))?
167 .join(program)
168 }
169 } else {
170 let path = env::var_os("PATH").ok_or_else(|| {
171 RustCompilerSelectionError::InvalidSysroot(
172 "PATH is unavailable while resolving rustc".into(),
173 )
174 })?;
175 env::split_paths(&path)
176 .map(|directory| directory.join(program))
177 .find(|candidate| {
178 fs::symlink_metadata(candidate).is_ok_and(|metadata| {
179 metadata.file_type().is_file() || metadata.file_type().is_symlink()
180 })
181 })
182 .ok_or_else(|| RustCompilerSelectionError::Io {
183 operation: "resolve executable",
184 path: program.to_path_buf(),
185 source: std::io::Error::new(std::io::ErrorKind::NotFound, "not found on PATH"),
186 })?
187 };
188 let metadata =
189 fs::symlink_metadata(&candidate).map_err(io_error("inspect executable", &candidate))?;
190 if !metadata.file_type().is_file() && !metadata.file_type().is_symlink() {
191 return Err(RustCompilerSelectionError::NonRegularFile(candidate));
192 }
193 Ok(candidate)
194}
195
196pub fn configure_companion_loader_environment(
197 command: &mut Command,
198 compiler_library_directory: &Path,
199) -> Result<(), RustCompilerSelectionError> {
200 let variable = if cfg!(target_os = "macos") {
201 "DYLD_LIBRARY_PATH"
202 } else if cfg!(windows) {
203 "PATH"
204 } else {
205 "LD_LIBRARY_PATH"
206 };
207 let mut paths = vec![compiler_library_directory.to_path_buf()];
208 if let Some(current) = env::var_os(variable) {
209 paths.extend(env::split_paths(¤t));
210 }
211 let paths = env::join_paths(paths).map_err(|source| {
212 RustCompilerSelectionError::InvalidSysroot(format!(
213 "could not construct {variable}: {source}"
214 ))
215 })?;
216 command.env(variable, paths);
217 Ok(())
218}
219
220fn io_error(
221 operation: &'static str,
222 path: &Path,
223) -> impl FnOnce(std::io::Error) -> RustCompilerSelectionError {
224 let path = path.to_path_buf();
225 move |source| RustCompilerSelectionError::Io {
226 operation,
227 path,
228 source,
229 }
230}
231
232fn sha256_file(path: &Path) -> Result<String, RustCompilerSelectionError> {
233 let metadata = fs::symlink_metadata(path).map_err(io_error("inspect", path))?;
234 if !metadata.file_type().is_file() {
235 return Err(RustCompilerSelectionError::NonRegularFile(
236 path.to_path_buf(),
237 ));
238 }
239 let bytes = fs::read(path).map_err(io_error("read", path))?;
240 Ok(format!("{:x}", Sha256::digest(bytes)))
241}
242
243fn command_output(
244 program: &Path,
245 args: &[&str],
246 operation: &'static str,
247 require_empty_stderr: bool,
248 compiler_library_directory: Option<&Path>,
249) -> Result<Vec<u8>, RustCompilerSelectionError> {
250 let mut command = Command::new(program);
251 command.args(args).stdin(Stdio::null());
252 if let Some(directory) = compiler_library_directory {
253 configure_companion_loader_environment(&mut command, directory)?;
254 }
255 let output = command.output().map_err(io_error("execute", program))?;
256 if !output.status.success() {
257 return Err(RustCompilerSelectionError::CommandFailed {
258 program: program.to_path_buf(),
259 operation,
260 status: output.status.code(),
261 });
262 }
263 if require_empty_stderr && !output.stderr.is_empty() {
264 return Err(RustCompilerSelectionError::UnexpectedStderr {
265 program: program.to_path_buf(),
266 operation,
267 });
268 }
269 Ok(output.stdout)
270}
271
272fn unique_verbose_field(
273 verbose: &str,
274 prefix: &'static str,
275) -> Result<String, RustCompilerSelectionError> {
276 let values = verbose
277 .lines()
278 .filter_map(|line| line.strip_prefix(prefix).map(str::trim))
279 .collect::<Vec<_>>();
280 let [value] = values.as_slice() else {
281 return Err(RustCompilerSelectionError::InvalidRustcVerbose(format!(
282 "expected one {prefix} field, found {}",
283 values.len()
284 )));
285 };
286 if value.is_empty() {
287 return Err(RustCompilerSelectionError::InvalidRustcVerbose(format!(
288 "{prefix} field was empty"
289 )));
290 }
291 Ok((*value).to_owned())
292}
293
294fn parse_rustc_verbose(
295 verbose: &[u8],
296) -> Result<(String, String, String), RustCompilerSelectionError> {
297 let verbose =
298 std::str::from_utf8(verbose).map_err(|_| RustCompilerSelectionError::NonUtf8Output {
299 program: PathBuf::from("rustc"),
300 operation: "inspecting compiler identity",
301 })?;
302 Ok((
303 unique_verbose_field(verbose, "commit-hash:")?,
304 unique_verbose_field(verbose, "release:")?,
305 unique_verbose_field(verbose, "host:")?,
306 ))
307}
308
309fn driver_file(directory: &Path) -> Result<PathBuf, RustCompilerSelectionError> {
310 let entries =
311 fs::read_dir(directory).map_err(io_error("read rustc driver directory", directory))?;
312 let mut drivers = entries
313 .map(|entry| {
314 entry
315 .map(|entry| entry.path())
316 .map_err(io_error("read rustc driver entry", directory))
317 })
318 .collect::<Result<Vec<_>, _>>()?;
319 drivers.retain(|path| {
320 path.file_name()
321 .and_then(|name| name.to_str())
322 .is_some_and(|name| {
323 name.starts_with("librustc_driver-")
324 && matches!(
325 path.extension().and_then(|extension| extension.to_str()),
326 Some("so" | "dylib")
327 )
328 })
329 });
330 drivers.sort();
331 drivers.dedup();
332 let [driver] = drivers.as_slice() else {
333 return Err(RustCompilerSelectionError::InvalidDriverDirectory {
334 path: directory.to_path_buf(),
335 count: drivers.len(),
336 });
337 };
338 Ok(driver.clone())
339}
340
341struct ProbedRustc {
342 identity: RustCompilerIdentity,
343 driver_directory: PathBuf,
344}
345
346fn probe_rustc(rustc_path: &Path) -> Result<ProbedRustc, RustCompilerSelectionError> {
347 let rustc_path = resolve_program(rustc_path)?;
348 let verbose = command_output(
349 &rustc_path,
350 &["-vV"],
351 "inspecting compiler identity",
352 false,
353 None,
354 )?;
355 let (rustc_commit_hash, rustc_release, host_triple) = parse_rustc_verbose(&verbose)?;
356 let sysroot = command_output(
357 &rustc_path,
358 &["--print", "sysroot"],
359 "inspecting compiler sysroot",
360 false,
361 None,
362 )?;
363 let sysroot =
364 std::str::from_utf8(&sysroot).map_err(|_| RustCompilerSelectionError::NonUtf8Output {
365 program: rustc_path.clone(),
366 operation: "inspecting compiler sysroot",
367 })?;
368 let sysroot = sysroot.trim();
369 if sysroot.is_empty() || sysroot.lines().count() != 1 {
370 return Err(RustCompilerSelectionError::InvalidSysroot(
371 "expected one non-empty path".into(),
372 ));
373 }
374 let directory = Path::new(sysroot)
375 .join("lib/rustlib")
376 .join(&host_triple)
377 .join("lib");
378 let driver = driver_file(&directory)?;
379 let rustc_driver_sha256 = sha256_file(&driver)?;
380 Ok(ProbedRustc {
381 identity: RustCompilerIdentity {
382 rustc_commit_hash,
383 rustc_release,
384 host_triple,
385 rustc_driver_sha256,
386 },
387 driver_directory: directory,
388 })
389}
390
391pub fn probe_rustc_identity(
392 rustc_path: &Path,
393) -> Result<RustCompilerIdentity, RustCompilerSelectionError> {
394 Ok(probe_rustc(rustc_path)?.identity)
395}
396
397pub fn resolve_matching_rustdoc(
402 selection: &SelectedRustCompilerCompanion,
403) -> Result<PathBuf, RustCompilerSelectionError> {
404 #[cfg(windows)]
405 let executable = "rustdoc.exe";
406 #[cfg(not(windows))]
407 let executable = "rustdoc";
408 let rustdoc = resolve_program(&selection.rustc_path.with_file_name(executable))?;
409 let verbose = command_output(
410 &rustdoc,
411 &["-vV"],
412 "inspecting rustdoc identity",
413 false,
414 None,
415 )?;
416 let (commit, release, host) = parse_rustc_verbose(&verbose)?;
417 if commit != selection.compiler.rustc_commit_hash
418 || release != selection.compiler.rustc_release
419 || host != selection.compiler.host_triple
420 {
421 return Err(RustCompilerSelectionError::RustdocCompilerMismatch { path: rustdoc });
422 }
423 Ok(rustdoc)
424}
425
426fn inspect_candidate(
427 path: &Path,
428 compiler: &RustCompilerIdentity,
429 compiler_library_directory: &Path,
430 require_public_capabilities: bool,
431) -> Result<Option<(PathBuf, RustCompilerCompanionHandshake)>, RustCompilerSelectionError> {
432 let path = fs::canonicalize(path).map_err(io_error("resolve compiler companion", path))?;
433 let build_id = sha256_file(&path)?;
434 let output = command_output(
435 &path,
436 &["--supercov-handshake"],
437 "reading compiler companion handshake",
438 true,
439 Some(compiler_library_directory),
440 )?;
441 let handshake: RustCompilerCompanionHandshake =
442 serde_json::from_slice(&output).map_err(|error| {
443 RustCompilerSelectionError::MalformedHandshake {
444 path: path.clone(),
445 reason: error.to_string(),
446 }
447 })?;
448 if handshake.companion_build_id != build_id {
449 return Err(RustCompilerSelectionError::CompanionBuildIdMismatch { path });
450 }
451 match require_matching_rust_compiler_companion(
452 &handshake,
453 compiler,
454 require_public_capabilities,
455 ) {
456 Ok(()) => Ok(Some((path, handshake))),
457 Err(RustCompilerCompanionError::CompilerMismatch) => Ok(None),
458 Err(source) => Err(RustCompilerSelectionError::InvalidHandshake { path, source }),
459 }
460}
461
462pub fn select_rust_compiler_companion(
463 rustc_path: &Path,
464 candidates: &[PathBuf],
465 require_public_capabilities: bool,
466) -> Result<SelectedRustCompilerCompanion, RustCompilerSelectionError> {
467 let rustc_path = resolve_program(rustc_path)?;
468 let probed = probe_rustc(&rustc_path)?;
469 let compiler = probed.identity;
470 let mut matches = Vec::new();
471 for candidate in candidates {
472 if let Some(candidate) = inspect_candidate(
473 candidate,
474 &compiler,
475 &probed.driver_directory,
476 require_public_capabilities,
477 )? {
478 matches.push(candidate);
479 }
480 }
481 match matches.as_slice() {
482 [] => Err(RustCompilerSelectionError::NoMatchingCompanion),
483 [(companion_path, handshake)] => Ok(SelectedRustCompilerCompanion {
484 rustc_path,
485 compiler_library_directory: probed.driver_directory,
486 companion_path: companion_path.clone(),
487 compiler,
488 handshake: handshake.clone(),
489 }),
490 _ => Err(RustCompilerSelectionError::MultipleMatchingCompanions(
491 matches.into_iter().map(|(path, _)| path).collect(),
492 )),
493 }
494}
495
496#[cfg(test)]
497mod tests {
498 use super::*;
499 use supercov_contracts::RustCompilerCompanionCapabilities;
500
501 fn identity() -> RustCompilerIdentity {
502 RustCompilerIdentity {
503 rustc_commit_hash: "a".repeat(40),
504 rustc_release: "1.95.0".into(),
505 host_triple: "aarch64-apple-darwin".into(),
506 rustc_driver_sha256: "b".repeat(64),
507 }
508 }
509
510 fn handshake() -> RustCompilerCompanionHandshake {
511 RustCompilerCompanionHandshake {
512 protocol_version: 1,
513 frontend_id: "rust".into(),
514 coverage_model_variant: "rust-source-v1".into(),
515 evidence_schema_version: 3,
516 companion_build_id: "c".repeat(64),
517 compiler: identity(),
518 capabilities: RustCompilerCompanionCapabilities {
519 expanded_hir_provenance: true,
520 runtime_mir_probe_insertion: true,
521 generated_source_provenance: true,
522 ctfe_path_tracing: false,
523 rustdoc_doctest_tracing: false,
524 exact_test_harness_attribution: true,
525 },
526 }
527 }
528
529 #[test]
530 fn verbose_identity_requires_exactly_one_of_each_field() {
531 assert_eq!(
532 parse_rustc_verbose(
533 b"rustc 1.95.0\nbinary: rustc\ncommit-hash: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\ncommit-date: 2026-01-01\nhost: aarch64-apple-darwin\nrelease: 1.95.0\nLLVM version: 22.0.0\n"
534 )
535 .unwrap(),
536 (
537 "a".repeat(40),
538 "1.95.0".into(),
539 "aarch64-apple-darwin".into()
540 )
541 );
542 assert!(parse_rustc_verbose(b"commit-hash: a\nrelease: x\n").is_err());
543 assert!(
544 parse_rustc_verbose(b"commit-hash: a\ncommit-hash: b\nrelease: x\nhost: y\n").is_err()
545 );
546 }
547
548 #[test]
549 fn public_readiness_is_not_inferred_from_partial_private_capabilities() {
550 let handshake = handshake();
551 require_matching_rust_compiler_companion(&handshake, &identity(), false).unwrap();
552 assert!(require_matching_rust_compiler_companion(&handshake, &identity(), true).is_err());
553 }
554}