Skip to main content

supercov_engine/
rust_compiler_selection.rs

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(&current));
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
397/// Resolve the rustdoc executable adjacent to Cargo's exact rustc and require
398/// the same compiler commit, release and host. Rustdoc intentionally has no
399/// independent driver digest; the already-authenticated companion remains
400/// bound to rustc's exact driver bytes.
401pub 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}