Skip to main content

mbx_cache_rustc/
lib.rs

1//! Conservative parsing and action-key construction for `rustc` invocations.
2//!
3//! The adapter deliberately rejects any compiler option whose effect on the
4//! action key is unknown. Callers should treat [`BypassReason`] as a safe cache
5//! bypass, run the real compiler, and avoid publishing an action result.
6//!
7//! A typical integration parses an invocation, discovers precise inputs from
8//! rustc dep-info with [`RustcInvocation::discover_inputs`], adds them to an
9//! [`ActionContext`], and finally calls [`RustcInvocation::action`]. Path
10//! mappings make workspace-local absolute paths stable across machines.
11//!
12//! ```
13//! use mbx_cache_rustc::{PathMapping, normalize_mapped_path};
14//! use std::path::Path;
15//!
16//! let mappings = PathMapping::ordered(&[
17//!     PathMapping::new("/work/project", "workspace"),
18//! ]);
19//! assert_eq!(
20//!     normalize_mapped_path(
21//!         Path::new("src/lib.rs"),
22//!         Path::new("/work/project"),
23//!         &mappings,
24//!     )?,
25//!     "${workspace}/src/lib.rs",
26//! );
27//! # Ok::<(), mbx_cache_rustc::BypassReason>(())
28//! ```
29#![deny(missing_docs)]
30
31use mbx_cache_core::{CacheDigest, canonical_json};
32use serde::{Deserialize, Serialize};
33use std::collections::{BTreeMap, BTreeSet};
34use std::ffi::OsString;
35use std::path::{Component, Path, PathBuf};
36use thiserror::Error;
37
38mod dep_info;
39
40pub use dep_info::{DepInfoCommand, DiscoveredInputs, RustcDepInfo};
41
42/// Schema version embedded in canonical rustc action descriptors.
43pub const ACTION_SCHEMA_VERSION: u8 = 1;
44/// Version of the rustc argument and input model used to construct keys.
45///
46/// Bumped to 2 when the dep-info and diagnostics stored with a result stopped
47/// carrying the publishing checkout's absolute paths. Entries written before
48/// that hold the old spelling, and nothing in them says so, so they are retired
49/// by the key rather than restored into a checkout they do not describe.
50pub const ADAPTER_VERSION: u8 = 2;
51
52impl BypassReason {
53    /// A stable, low-cardinality name for this reason.
54    ///
55    /// Many variants carry a path or a flag, so `Display` text cannot be
56    /// aggregated; statistics group by this instead.
57    pub fn kind(&self) -> &'static str {
58        self.into()
59    }
60}
61
62const SUPPORTED_CODEGEN_OPTIONS: &[&str] = &[
63    "codegen-units",
64    "control-flow-guard",
65    "debug-assertions",
66    "debuginfo",
67    "default-linker-libraries",
68    "embed-bitcode",
69    "extra-filename",
70    "force-frame-pointers",
71    "force-unwind-tables",
72    "instrument-coverage",
73    "link-dead-code",
74    "link-self-contained",
75    "lto",
76    "metadata",
77    "no-prepopulate-passes",
78    "opt-level",
79    "overflow-checks",
80    "panic",
81    "prefer-dynamic",
82    "relocation-model",
83    "rpath",
84    "save-temps",
85    "soft-float",
86    "split-debuginfo",
87    "split-dwarf-kind",
88    "strip",
89    "symbol-mangling-version",
90    "target-cpu",
91    "target-feature",
92    "tls-model",
93];
94
95const NATIVE_DIRECTORY_PREDICTION_PREFIX: &str = "@native-directory:";
96const MAX_PREDICTED_INPUTS: usize = 16 * 1024;
97const MAX_NATIVE_INPUT_BYTES: u64 = 2 * 1024 * 1024 * 1024;
98
99/// Built-in WebAssembly targets whose default linkers and CRT inputs ship in
100/// the Rust toolchain. Custom target specs never enter this list.
101const COMPILER_BUNDLED_WASM_TARGETS: &[&str] = &[
102    "wasm32-unknown-unknown",
103    "wasm32-wasip1",
104    "wasm32-wasip1-threads",
105    "wasm32-wasip2",
106    "wasm32v1-none",
107    "wasm64-unknown-unknown",
108];
109
110#[derive(Debug, Clone, PartialEq, Eq, Error, strum::IntoStaticStr)]
111#[strum(serialize_all = "kebab-case")]
112/// Reason an invocation cannot safely use the action cache.
113///
114/// A bypass is an expected conservative outcome, not necessarily a compiler
115/// error. Variants carry diagnostic context while [`BypassReason::kind`]
116/// provides a stable aggregation key.
117///
118/// This set exists to shrink: every invocation the adapter learns to model
119/// retires a variant, and every construct it learns to reject adds one. Match
120/// on [`BypassReason::kind`] for aggregation rather than on the variants.
121#[non_exhaustive]
122pub enum BypassReason {
123    /// An argument cannot be represented in the canonical UTF-8 key.
124    #[error("rustc argument {index} is not valid UTF-8")]
125    NonUtf8Argument {
126        /// Zero-based index in the argument slice.
127        index: usize,
128    },
129    /// A rustc response file could not be read or parsed exactly.
130    #[error("could not model rustc response file: {0}")]
131    ResponseFile(String),
132    /// A compiler flag is not modeled by this adapter version.
133    #[error("rustc flag is not modeled by the cache adapter: {0}")]
134    UnknownFlag(String),
135    /// A `-C` option is not modeled by this adapter version.
136    #[error("rustc codegen option is not modeled by the cache adapter: {0}")]
137    UnknownCodegenOption(String),
138    /// A recognized flag was not followed by its required value.
139    #[error("rustc flag requires a value: {0}")]
140    MissingValue(String),
141    /// The invocation queries compiler information instead of compiling.
142    #[error("rustc invocation is a compiler query, not a compilation")]
143    CompilerQuery,
144    /// Source would be read from standard input and cannot be rediscovered.
145    #[error("rustc invocation reads source from standard input")]
146    StandardInput,
147    /// No Rust source input was supplied.
148    #[error("rustc invocation has no source input")]
149    MissingInput,
150    /// More than one source input was supplied.
151    #[error("rustc invocation has multiple source inputs")]
152    MultipleInputs,
153    /// Incremental state makes the outputs unsuitable for action caching.
154    #[error("incremental compilation cannot be combined with action caching")]
155    Incremental,
156    /// The requested crate type is outside the supported cacheability tier.
157    #[error("rustc crate type is not cacheable yet: {0}")]
158    UnsupportedCrateType(String),
159    /// The requested emit kind is outside the supported cacheability tier.
160    #[error("rustc output type is not cacheable yet: {0}")]
161    UnsupportedEmit(String),
162    /// The invocation emits no artifact in the supported cacheability tier.
163    #[error("rustc invocation does not emit a cacheable artifact")]
164    NoCacheableOutput,
165    /// The invocation does not emit the dep-info needed for input discovery.
166    #[error("rustc invocation does not emit dependency information")]
167    NoDepInfo,
168    /// Outputs cannot be represented as one cache directory.
169    #[error("rustc output paths do not share one directory")]
170    SplitOutputDirectories,
171    /// An output path does not name a file.
172    #[error("rustc output path has no file name: {0}")]
173    InvalidOutputPath(PathBuf),
174    /// `-o` leaves the name of an implicit emit ambiguous.
175    #[error("rustc -o with an emit that has no explicit path is not modeled: {0}")]
176    ImplicitEmitWithOutputFile(PathBuf),
177    /// Native-library lookup is not modeled as a precise input.
178    #[error("native library lookup is not cacheable yet")]
179    NativeLibrary,
180    /// An output's name does not say whether it is a program or a library.
181    #[error("rustc output name does not distinguish a program from a library: {0}")]
182    AmbiguousOutputName(PathBuf),
183    /// A native link would embed something no other checkout can reproduce.
184    #[error("native link is not reproducible across checkouts: {0}")]
185    UnportableNativeLink(String),
186    /// A library search-path kind is not modeled.
187    #[error("rustc search path kind is not cacheable yet: {0}")]
188    UnsupportedSearchPath(String),
189    /// An extern name does not resolve to a concrete input artifact.
190    #[error("rustc extern does not identify an input artifact: {0}")]
191    UnresolvedExtern(String),
192    /// An absolute path has no stable placeholder mapping.
193    #[error("absolute path has no stable cache mapping: {0}")]
194    UnmappedAbsolutePath(PathBuf),
195    /// A path cannot be represented in a canonical UTF-8 action key.
196    #[error("cache key paths must be valid UTF-8: {0}")]
197    NonUtf8Path(PathBuf),
198    /// The action working directory is not absolute.
199    #[error("cache action working directory must be absolute: {0}")]
200    RelativeWorkingDirectory(PathBuf),
201    /// A path-mapping root is not absolute.
202    #[error("cache path mapping must use an absolute root: {0}")]
203    RelativePathMapping(PathBuf),
204    /// A mapping placeholder is empty or contains unsafe characters.
205    #[error("cache path mapping placeholder is invalid: {0}")]
206    InvalidPathPlaceholder(String),
207    /// An input referenced by compiler arguments is missing from the context.
208    #[error("required compiler input was not provided: {0}")]
209    MissingRequiredInput(String),
210    /// A supplied compiler-input digest is malformed.
211    #[error("compiler input has an invalid digest: {0}")]
212    InvalidInputDigest(String),
213    /// One normalized input path has multiple distinct digests.
214    #[error("compiler input appears more than once with different content: {0}")]
215    ConflictingInput(String),
216    /// rustc dep-info does not follow the supported format.
217    #[error("rustc dep-info is malformed: {0}")]
218    MalformedDepInfo(String),
219    /// A dep-info file could not be read as UTF-8 text.
220    #[error("failed to read rustc dep-info {path}: {message}")]
221    DepInfoRead {
222        /// Dep-info file path.
223        path: PathBuf,
224        /// Underlying I/O or decoding error.
225        message: String,
226    },
227    /// The requested dep-info output path is not absolute.
228    #[error("rustc dep-info output path must be absolute: {0}")]
229    RelativeDepInfoPath(PathBuf),
230    /// A dep-info output path contains a comma and cannot be safely rendered.
231    #[error("rustc dep-info output path cannot contain a comma: {0}")]
232    UnsafeDepInfoPath(PathBuf),
233    /// A discovered compiler input could not be read or was not a file.
234    #[error("failed to read compiler input {path}: {message}")]
235    InputRead {
236        /// Compiler-input path.
237        path: PathBuf,
238        /// Underlying filesystem error.
239        message: String,
240    },
241    /// Input contents changed after they were hashed.
242    #[error("compiler input changed after discovery: {0}")]
243    InputChanged(PathBuf),
244    /// An input's modification time overlaps the compiler execution.
245    #[error("compiler input was modified during compilation: {0}")]
246    InputModifiedDuringCompilation(PathBuf),
247    /// Discovered inputs and the action use different working directories.
248    #[error("discovered inputs were collected from a different working directory")]
249    DiscoveryWorkingDirectory,
250    /// One observed environment input has conflicting values.
251    #[error("compiler environment input has conflicting values: {0}")]
252    ConflictingEnvironment(String),
253    /// Canonical action serialization failed.
254    #[error("failed to serialize the rustc action: {0}")]
255    Serialization(String),
256    /// The stored prediction uses an unsupported version or exceeds limits.
257    #[error("rustc action prediction is unsupported")]
258    UnsupportedPrediction,
259    /// A normalized predicted path cannot be mapped back to the host.
260    #[error("rustc action prediction contains an invalid input path: {0}")]
261    InvalidPredictedInput(String),
262}
263
264#[derive(Debug, Clone, PartialEq, Eq)]
265enum Argument {
266    Plain(String),
267    Path { flag: String, path: PathBuf },
268    SearchPath { kind: String, path: PathBuf },
269    Extern { name: String, path: Option<PathBuf> },
270    Emit(Vec<Emit>),
271    RemapPath { from: PathBuf, to: String },
272}
273
274#[derive(Debug, Clone, PartialEq, Eq)]
275struct Emit {
276    kind: String,
277    path: Option<PathBuf>,
278}
279
280#[derive(Debug, Clone, PartialEq, Eq)]
281/// Parsed, cache-safe model of one `rustc` command line.
282///
283/// Fields are intentionally private so new modeled flags can be added without
284/// exposing the adapter's internal representation.
285pub struct RustcInvocation {
286    arguments: Vec<Argument>,
287    source: PathBuf,
288    required_inputs: Vec<PathBuf>,
289    crate_name: String,
290    extra_filename: String,
291    out_dir: Option<PathBuf>,
292    explicit_output: Option<PathBuf>,
293    emits: Vec<Emit>,
294    target: Option<String>,
295    link_output: LinkOutput,
296}
297
298#[derive(Debug, Clone, Copy, PartialEq, Eq)]
299enum LinkOutput {
300    Library,
301    WasmExecutable,
302    NativeExecutable,
303}
304
305/// What the caller is prepared to model beyond the default tier.
306///
307/// The parser itself stays pure: whether the host can describe its linker
308/// precisely enough to key a native link is the caller's question, and the
309/// answer arrives here rather than being read out of the environment.
310#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
311#[non_exhaustive]
312pub struct ParseOptions {
313    /// Admit natively linked test binaries and executables, given a linker
314    /// identity in the action key. Off by default.
315    pub cache_native_links: bool,
316}
317
318impl ParseOptions {
319    /// Options that admit native links when `enabled`.
320    pub fn caching_native_links(enabled: bool) -> Self {
321        Self {
322            cache_native_links: enabled,
323        }
324    }
325}
326
327/// The cacheable files and dependency manifest produced by a rustc invocation.
328#[derive(Debug, Clone, PartialEq, Eq)]
329pub struct RustcOutputs {
330    /// Common directory containing all modeled outputs.
331    pub directory: PathBuf,
332    /// Cacheable library, metadata, and/or linked program files.
333    pub files: Vec<PathBuf>,
334    /// Dep-info file used for precise input discovery.
335    pub dep_info: PathBuf,
336}
337
338impl RustcInvocation {
339    /// Parse rustc's arguments, excluding the compiler executable supplied as
340    /// the first argument to `RUSTC_WRAPPER`.
341    ///
342    /// Any flag whose cache semantics are not modeled returns a bypass reason
343    /// instead of guessing. A successful parse only admits the initial
344    /// rlib/rmeta tier plus binaries linked by compiler-bundled WebAssembly
345    /// toolchains.
346    pub fn parse(arguments: &[OsString]) -> Result<Self, BypassReason> {
347        Self::parse_with(arguments, ParseOptions::default())
348    }
349
350    /// Parse as [`RustcInvocation::parse`] does, admitting what `options`
351    /// says the caller can model.
352    pub fn parse_with(arguments: &[OsString], options: ParseOptions) -> Result<Self, BypassReason> {
353        let expanded = expand_response_files(arguments)?;
354        Parser::new(&expanded.arguments, options).parse()
355    }
356
357    /// Whether this invocation links a native program, whose key must therefore
358    /// describe the linker that produced it.
359    pub fn links_natively(&self) -> bool {
360        self.link_output == LinkOutput::NativeExecutable
361    }
362
363    /// Whether the contents of native search directories cannot reach this
364    /// invocation's outputs.
365    ///
366    /// A library emit never runs a linker, so a `-L native` directory is only
367    /// read for a static library named by `-l` -- and any `-l` already bypasses
368    /// the whole invocation. On Windows every crate downstream of a `cc`-built
369    /// dependency carries the MSVC toolset's `-L native` directories, which sit
370    /// outside every checkout root; treating them as content inputs would leave
371    /// all of those compilations permanently uncacheable. A `#[link]` attribute
372    /// naming a bundled static library could in principle reach through such a
373    /// directory without an `-l` flag, but toolchain directories are
374    /// version-stamped by their path, which the key still carries verbatim.
375    fn native_search_is_inert(&self) -> bool {
376        self.link_output == LinkOutput::Library
377    }
378
379    /// Return the source input passed to rustc.
380    pub fn source(&self) -> &Path {
381        &self.source
382    }
383
384    /// Return the explicitly selected compilation target, if any.
385    pub fn target(&self) -> Option<&str> {
386        self.target.as_deref()
387    }
388
389    /// Return the crate name rustc will assign to this compilation.
390    pub fn crate_name(&self) -> &str {
391        &self.crate_name
392    }
393
394    /// Digest of the inputs this compilation owns: its sources and whatever
395    /// they read, but not the artifacts it merely links against.
396    ///
397    /// This is what separates a crate someone is editing from one sitting
398    /// above it in the graph. Rebuilding a dependency changes the action key of
399    /// every crate that links it, because their keys hash its artifact; it does
400    /// not change this. A caller watching for churn has to watch this instead,
401    /// or a single edited crate would drag its whole dependent cone along.
402    ///
403    /// Host paths enter the digest as they are. This describes one checkout to
404    /// itself rather than to the cache, so there is nothing here to make
405    /// portable.
406    pub fn source_fingerprint(&self, discovered: &DiscoveredInputs) -> CacheDigest {
407        let linked = self
408            .arguments
409            .iter()
410            .filter_map(|argument| match argument {
411                Argument::Extern {
412                    path: Some(path), ..
413                } => Some(path.as_path()),
414                _ => None,
415            })
416            .collect::<BTreeSet<_>>();
417        let owned = discovered
418            .inputs
419            .iter()
420            .filter(|input| !linked.contains(input.path.as_path()))
421            .map(|input| (input.path.as_path(), &input.digest))
422            .collect::<BTreeMap<_, _>>();
423        let mut bytes = Vec::new();
424        for (path, digest) in owned {
425            bytes.extend_from_slice(path.as_os_str().as_encoded_bytes());
426            bytes.push(0);
427            bytes.extend_from_slice(digest.key().as_bytes());
428            bytes.push(0);
429        }
430        CacheDigest::blake3(&bytes)
431    }
432
433    /// Resolve the files produced by this invocation.
434    ///
435    /// The initial cache tier requires one output directory so its artifact can
436    /// be represented by one protocol directory and restored atomically later.
437    pub fn outputs(&self, working_dir: &Path) -> Result<RustcOutputs, BypassReason> {
438        if !working_dir.is_absolute() {
439            return Err(BypassReason::RelativeWorkingDirectory(
440                working_dir.to_path_buf(),
441            ));
442        }
443        let explicit_output = self
444            .explicit_output
445            .as_deref()
446            .map(|path| absolute_path(path, working_dir));
447        let output_directory = explicit_output
448            .as_deref()
449            .and_then(Path::parent)
450            .map(Path::to_path_buf)
451            .or_else(|| {
452                self.out_dir
453                    .as_deref()
454                    .map(|path| absolute_path(path, working_dir))
455            })
456            .unwrap_or_else(|| normalize_components(working_dir));
457        // rustc applies `-o` to every emit that has no path of its own, so the
458        // file names cannot be derived from the crate name here. Cargo always
459        // uses --out-dir instead, so refusing to model this costs nothing.
460        if let Some(output) = &explicit_output
461            && self.emits.iter().any(|emit| {
462                emit.path.is_none()
463                    && matches!(emit.kind.as_str(), "dep-info" | "link" | "metadata")
464            })
465        {
466            return Err(BypassReason::ImplicitEmitWithOutputFile(output.clone()));
467        }
468        let mut files = BTreeSet::new();
469        let mut dep_info = None;
470        for emit in &self.emits {
471            if emit.kind == "dep-info" {
472                let path = emit.path.as_ref().map_or_else(
473                    || {
474                        explicit_output.clone().map_or_else(
475                            || {
476                                output_directory
477                                    .join(format!("{}{}.d", self.crate_name, self.extra_filename))
478                            },
479                            |path| path.with_extension("d"),
480                        )
481                    },
482                    |path| absolute_path(path, working_dir),
483                );
484                if path.file_name().is_none() {
485                    return Err(BypassReason::InvalidOutputPath(path));
486                }
487                dep_info = Some(path);
488                continue;
489            }
490            let (prefix, extension) = match emit.kind.as_str() {
491                "link" => match self.link_output {
492                    LinkOutput::Library => ("lib", "rlib"),
493                    LinkOutput::WasmExecutable => ("", "wasm"),
494                    // A linked native program has no extension of its own on
495                    // the platforms this tier admits.
496                    LinkOutput::NativeExecutable => ("", ""),
497                },
498                "metadata" => ("lib", "rmeta"),
499                _ => continue,
500            };
501            let path = if let Some(path) = &emit.path {
502                absolute_path(path, working_dir)
503            } else {
504                let name = format!("{prefix}{}{}", self.crate_name, self.extra_filename);
505                output_directory.join(if extension.is_empty() {
506                    name
507                } else {
508                    format!("{name}.{extension}")
509                })
510            };
511            if path.file_name().is_none() {
512                return Err(BypassReason::InvalidOutputPath(path));
513            }
514            if path.parent() != Some(output_directory.as_path()) {
515                return Err(BypassReason::SplitOutputDirectories);
516            }
517            // Whether a restored output is a program is read back off its name,
518            // so a program that answers to a library's name would be restored
519            // without the permission that makes it runnable. Nothing cargo
520            // emits looks like this; a hand-built invocation could.
521            if emit.kind == "link"
522                && !matches!(self.link_output, LinkOutput::Library)
523                && matches!(
524                    path.extension().and_then(|extension| extension.to_str()),
525                    Some("rlib" | "rmeta")
526                )
527            {
528                return Err(BypassReason::AmbiguousOutputName(path));
529            }
530            files.insert(path);
531        }
532        let dep_info = dep_info.ok_or(BypassReason::NoDepInfo)?;
533        if dep_info.parent() != Some(output_directory.as_path()) {
534            return Err(BypassReason::SplitOutputDirectories);
535        }
536        Ok(RustcOutputs {
537            directory: output_directory,
538            files: files.into_iter().collect(),
539            dep_info,
540        })
541    }
542
543    /// Build canonical action bytes after precise input discovery has run.
544    ///
545    /// `context.inputs` must contain the source, every explicit extern, and
546    /// every additional source or environment-generated input discovered from
547    /// dep-info.
548    pub fn action(&self, context: ActionContext) -> Result<RustcAction, BypassReason> {
549        self.action_linked_by(context, None)
550    }
551
552    /// Build canonical action bytes for an invocation that links a native
553    /// program.
554    ///
555    /// A `linker` is required whenever [`RustcInvocation::links_natively`]
556    /// holds: the linker, its startup objects, and the platform SDK are inputs
557    /// rustc dep-info does not enumerate, so a key without them would claim
558    /// more than it can support. Passing one for anything else is ignored,
559    /// since nothing else depends on a linker.
560    pub fn action_linked_by(
561        &self,
562        context: ActionContext,
563        linker: Option<LinkerIdentity>,
564    ) -> Result<RustcAction, BypassReason> {
565        ActionBuilder::new(self, context).linked_by(linker).build()
566    }
567
568    /// Fingerprint the modeled invocation before dependency contents are known.
569    pub fn invocation_digest(&self, context: &ActionContext) -> Result<CacheDigest, BypassReason> {
570        let descriptor = ActionBuilder::new(self, context.clone()).invocation_descriptor()?;
571        let bytes = canonical_json(&descriptor)
572            .map_err(|error| BypassReason::Serialization(error.to_string()))?;
573        Ok(CacheDigest::blake3(&bytes))
574    }
575
576    /// Capture normalized dependency paths for a future invocation that has no
577    /// dep-info file yet.
578    pub fn prediction(
579        &self,
580        context: &ActionContext,
581        discovered: &DiscoveredInputs,
582    ) -> Result<RustcInputPrediction, BypassReason> {
583        let builder = ActionBuilder::new(self, context.clone());
584        builder.validate_mappings()?;
585        let mut inputs = discovered
586            .inputs
587            .iter()
588            .map(|input| builder.normalize_path(&input.path))
589            .collect::<Result<BTreeSet<_>, _>>()?;
590        let mut has_native_directory = false;
591        for argument in &self.arguments {
592            if let Argument::SearchPath { kind, path } = argument
593                && kind == "native"
594            {
595                match builder.normalize_path(path) {
596                    Ok(normalized) => {
597                        has_native_directory = true;
598                        inputs.insert(format!("{NATIVE_DIRECTORY_PREDICTION_PREFIX}{normalized}"));
599                    }
600                    // Inert and outside every mapped root: the directory
601                    // contributes no content inputs, so the prediction has
602                    // nothing to replay for it. Input discovery skips it the
603                    // same way, which is what keeps the predicted action key
604                    // equal to the one dep-info discovery builds.
605                    Err(BypassReason::UnmappedAbsolutePath(_)) if self.native_search_is_inert() => {
606                    }
607                    Err(error) => return Err(error),
608                }
609            }
610        }
611        Ok(RustcInputPrediction {
612            version: if has_native_directory { 3 } else { 1 },
613            inputs: inputs.into_iter().collect(),
614            environment: discovered.environment.keys().cloned().collect(),
615            compiler_duration_ns: 0,
616            crate_name: String::new(),
617        })
618    }
619}
620
621impl RustcOutputs {
622    /// Whether `path` is a linked program whose executable permission is part
623    /// of the declared output contract.
624    /// A program is whatever this invocation emitted that is not a library
625    /// artifact. Every tier the adapter admits distinguishes the two by
626    /// extension -- `rlib` and `rmeta` are the compiler's own, and a linked
627    /// program carries either the target's (`wasm`) or none at all -- so the
628    /// name is enough and no separate list has to be carried alongside.
629    pub fn is_executable(&self, path: &Path) -> bool {
630        self.files.iter().any(|output| output == path)
631            && !matches!(
632                path.extension().and_then(|extension| extension.to_str()),
633                Some("rlib" | "rmeta")
634            )
635    }
636}
637
638#[derive(Debug, Clone, PartialEq, Eq)]
639/// Mapping from a host-specific absolute root to a stable key placeholder.
640pub struct PathMapping {
641    /// Absolute host path to replace.
642    pub root: PathBuf,
643    /// Placeholder name without the surrounding `${...}` syntax.
644    pub placeholder: String,
645}
646
647impl PathMapping {
648    /// Map an absolute host path to a stable cache-key placeholder.
649    pub fn new(root: impl Into<PathBuf>, placeholder: impl Into<String>) -> Self {
650        Self {
651            root: root.into(),
652            placeholder: placeholder.into(),
653        }
654    }
655
656    /// Order mappings deepest root first, which is what normalization needs:
657    /// a target directory inside the workspace has to win over the workspace.
658    pub fn ordered(mappings: &[PathMapping]) -> Vec<PathMapping> {
659        let mut ordered = mappings.to_vec();
660        ordered.sort_by_key(|mapping| std::cmp::Reverse(mapping.root.components().count()));
661        ordered
662    }
663}
664
665/// Map an absolute path to its cache-key placeholder form.
666///
667/// `mappings` must already be ordered by [`PathMapping::ordered`]. Exposed for
668/// callers that need the placeholder text before an action exists -- notably to
669/// build the `--remap-path-prefix` flag that makes a compilation independent of
670/// a path in its environment.
671pub fn normalize_mapped_path(
672    path: &Path,
673    working_dir: &Path,
674    mappings: &[PathMapping],
675) -> Result<String, BypassReason> {
676    let mappings = mappings
677        .iter()
678        .map(|mapping| PathMapping {
679            root: resolve_mapping_root(&mapping.root),
680            placeholder: mapping.placeholder.clone(),
681        })
682        .collect::<Vec<_>>();
683    normalize_resolved_mapped_path(path, working_dir, &mappings)
684}
685
686fn normalize_resolved_mapped_path(
687    path: &Path,
688    working_dir: &Path,
689    mappings: &[PathMapping],
690) -> Result<String, BypassReason> {
691    let absolute = if path.is_absolute() {
692        normalize_components(path)
693    } else {
694        normalize_components(&working_dir.join(path))
695    };
696    let resolved = if absolute.is_absolute() {
697        resolve_path_aliases(&absolute)
698    } else {
699        absolute.clone()
700    };
701    for mapping in mappings {
702        if let Ok(relative) = resolved.strip_prefix(&mapping.root) {
703            let suffix = slash_path(relative)?;
704            return Ok(if suffix.is_empty() {
705                format!("${{{}}}", mapping.placeholder)
706            } else {
707                format!("${{{}}}/{suffix}", mapping.placeholder)
708            });
709        }
710    }
711    Err(BypassReason::UnmappedAbsolutePath(absolute))
712}
713
714/// Resolve aliases in the existing prefix while preserving a not-yet-created
715/// output suffix. Cargo and rustc may spell the same macOS temporary directory
716/// as `/var/...` and `/private/var/...`; comparing only lexical paths makes a
717/// target mapping miss even though both names identify the same directory.
718#[cfg(unix)]
719fn resolve_path_aliases(path: &Path) -> PathBuf {
720    let mut existing = path;
721    let mut missing = Vec::new();
722    loop {
723        match std::fs::canonicalize(existing) {
724            Ok(mut resolved) => {
725                for component in missing.iter().rev() {
726                    resolved.push(component);
727                }
728                return normalize_components(&resolved);
729            }
730            Err(_) => {
731                let Some(name) = existing.file_name() else {
732                    return path.to_path_buf();
733                };
734                missing.push(name.to_os_string());
735                let Some(parent) = existing.parent() else {
736                    return path.to_path_buf();
737                };
738                existing = parent;
739            }
740        }
741    }
742}
743
744#[cfg(not(unix))]
745fn resolve_path_aliases(path: &Path) -> PathBuf {
746    path.to_path_buf()
747}
748
749fn resolve_mapping_root(root: &Path) -> PathBuf {
750    let root = normalize_components(root);
751    if root.is_absolute() {
752        resolve_path_aliases(&root)
753    } else {
754        root
755    }
756}
757
758#[derive(Debug, Clone, PartialEq, Eq)]
759/// Compiler properties that distinguish incompatible action outputs.
760pub struct CompilerIdentity {
761    /// Toolchain selector or installation identity.
762    pub toolchain: String,
763    /// Complete verbose rustc version string.
764    pub rustc_version: String,
765    /// Compiler host target triple.
766    pub host: String,
767}
768
769/// One file input paired with the digest used in the action key.
770#[derive(Debug, Clone, PartialEq, Eq)]
771pub struct ActionInput {
772    /// Absolute host path used to read and verify the input.
773    pub path: PathBuf,
774    /// Digest of the input contents.
775    pub digest: CacheDigest,
776}
777
778/// External information needed to construct a canonical rustc action.
779#[derive(Debug, Clone, PartialEq, Eq)]
780pub struct ActionContext {
781    /// Identity of the compiler that produces the outputs.
782    pub compiler: CompilerIdentity,
783    /// Absolute directory in which rustc runs.
784    pub working_dir: PathBuf,
785    /// Host roots replaced with stable placeholders in the key.
786    pub path_mappings: Vec<PathMapping>,
787    /// Environment inputs and their observed values.
788    pub environment: BTreeMap<String, Option<String>>,
789    /// Environment inputs whose absolute values the compilation has been made
790    /// independent of, and whose values the key therefore normalizes.
791    ///
792    /// Naming one here is a claim about the compilation, not a preference: the
793    /// caller must both neutralize the value inside it (with
794    /// `--remap-path-prefix`) and confirm no output carries the value anyway.
795    pub portable_environment: BTreeSet<String>,
796    /// Complete set of direct and discovered file inputs.
797    pub inputs: Vec<ActionInput>,
798}
799
800/// What produced a linked native program, beyond the compiler itself.
801///
802/// The fields are identity rather than content wherever a compiler's own
803/// identity is: a driver's version output names its toolchain more cheaply than
804/// hashing a hundred megabytes of it, and matches how rustc is identified. The
805/// CRT objects are hashed, because nothing else pins the libc a link resolves
806/// against. Nothing here is placeholder-mapped -- these paths are host
807/// locations rather than checkout locations, and two hosts that differ should
808/// miss rather than share.
809#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
810#[serde(deny_unknown_fields)]
811pub struct LinkerIdentity {
812    /// Resolved absolute path of the linker driver rustc will invoke.
813    pub driver: String,
814    /// Version output of that driver.
815    pub driver_version: String,
816    /// Version of the linker the driver selects.
817    pub linker_version: String,
818    /// Startup objects and libc the driver resolves, by probe name.
819    #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
820    pub crt_objects: BTreeMap<String, CacheDigest>,
821    /// Platform SDK identity, where the platform has one.
822    #[serde(skip_serializing_if = "Option::is_none", default)]
823    pub sdk: Option<String>,
824    /// Deployment target the link was made against, where one applies.
825    #[serde(skip_serializing_if = "Option::is_none", default)]
826    pub deployment_target: Option<String>,
827}
828
829/// Canonical action descriptor and its content digest.
830#[derive(Debug, Clone, PartialEq, Eq)]
831pub struct RustcAction {
832    /// Digest of `bytes`, used as the action-cache key.
833    pub digest: CacheDigest,
834    /// Canonical serialized action descriptor.
835    pub bytes: Vec<u8>,
836}
837
838/// Normalized input names from the last successful execution of one modeled
839/// rustc invocation.
840#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
841#[serde(deny_unknown_fields)]
842pub struct RustcInputPrediction {
843    /// Prediction schema version.
844    pub version: u8,
845    /// Normalized input paths observed during the successful invocation.
846    pub inputs: Vec<String>,
847    /// Names of environment variables read by the compilation.
848    pub environment: Vec<String>,
849    /// Compiler wall time from the successful invocation that produced this
850    /// prediction. Zero means no timing hint was recorded.
851    #[serde(default, skip_serializing_if = "is_zero")]
852    pub compiler_duration_ns: u64,
853    /// Crate name associated with the timing hint.
854    #[serde(default, skip_serializing_if = "String::is_empty")]
855    pub crate_name: String,
856}
857
858fn is_zero(value: &u64) -> bool {
859    *value == 0
860}
861
862impl RustcInputPrediction {
863    /// Rehash the predicted paths and read the current environment. The caller
864    /// still recomputes the full action digest, so changed inputs are misses.
865    pub fn discover(
866        &self,
867        working_dir: &Path,
868        path_mappings: &[PathMapping],
869    ) -> Result<DiscoveredInputs, BypassReason> {
870        if !matches!(self.version, 1..=3) {
871            return Err(BypassReason::UnsupportedPrediction);
872        }
873        if self.inputs.len() > MAX_PREDICTED_INPUTS || self.environment.len() > 4 * 1024 {
874            return Err(BypassReason::UnsupportedPrediction);
875        }
876        let mut paths = BTreeSet::new();
877        let admitted_roots = dep_info::native_input_roots(working_dir, path_mappings);
878        let mut native_bytes = 0_u64;
879        for path in &self.inputs {
880            if self.version >= 3
881                && let Some(path) = path.strip_prefix(NATIVE_DIRECTORY_PREDICTION_PREFIX)
882            {
883                let directory = denormalize_path(path, path_mappings)?;
884                dep_info::collect_native_directory(
885                    &directory,
886                    &admitted_roots,
887                    &mut paths,
888                    &mut native_bytes,
889                )?;
890            } else {
891                paths.insert(denormalize_path(path, path_mappings)?);
892            }
893        }
894        let environment = self
895            .environment
896            .iter()
897            .map(|name| {
898                if name.is_empty() || name.contains(['=', '\0']) {
899                    return Err(BypassReason::UnsupportedPrediction);
900                }
901                let value = std::env::var_os(name)
902                    .map(|value| {
903                        value
904                            .into_string()
905                            .map_err(|_| BypassReason::UnsupportedPrediction)
906                    })
907                    .transpose()?;
908                Ok((name.clone(), value))
909            })
910            .collect::<Result<BTreeMap<_, _>, _>>()?;
911        DiscoveredInputs::from_paths(working_dir, paths, environment)
912    }
913}
914
915#[derive(Serialize)]
916struct ActionDescriptor {
917    version: u8,
918    kind: &'static str,
919    adapter_version: u8,
920    compiler: CompilerDescriptor,
921    arguments: Vec<String>,
922    environment: BTreeMap<String, Option<String>>,
923    inputs: Vec<InputDescriptor>,
924    /// Omitted entirely unless the invocation links natively, so every key
925    /// written before this field existed still serializes to the same bytes.
926    #[serde(skip_serializing_if = "Option::is_none")]
927    linker: Option<LinkerIdentity>,
928}
929
930#[derive(Serialize)]
931struct InvocationDescriptor {
932    version: u8,
933    kind: &'static str,
934    adapter_version: u8,
935    compiler: CompilerDescriptor,
936    arguments: Vec<String>,
937    required_inputs: Vec<String>,
938}
939
940#[derive(Serialize)]
941struct CompilerDescriptor {
942    toolchain: String,
943    rustc_version: String,
944    host: String,
945}
946
947#[derive(Debug, Serialize, PartialEq, Eq, PartialOrd, Ord)]
948struct InputDescriptor {
949    path: String,
950    digest: CacheDigest,
951}
952
953struct Parser<'a> {
954    arguments: &'a [OsString],
955    index: usize,
956    parsed: Vec<Argument>,
957    source: Option<PathBuf>,
958    crate_types: Vec<String>,
959    emits: Vec<Emit>,
960    required_inputs: Vec<PathBuf>,
961    test: bool,
962    crate_name: Option<String>,
963    extra_filename: String,
964    out_dir: Option<PathBuf>,
965    explicit_output: Option<PathBuf>,
966    target: Option<String>,
967    options: ParseOptions,
968}
969
970struct ExpandedArguments {
971    arguments: Vec<OsString>,
972}
973
974#[derive(Default)]
975struct ResponseExpander {
976    shell_argfiles: bool,
977    next_is_unstable_option: bool,
978    arguments: Vec<OsString>,
979}
980
981impl ResponseExpander {
982    fn push(&mut self, argument: String) {
983        if self.next_is_unstable_option {
984            self.shell_argfiles |= argument == "shell-argfiles";
985            self.next_is_unstable_option = false;
986        } else if let Some(option) = argument.strip_prefix("-Z") {
987            if option.is_empty() {
988                self.next_is_unstable_option = true;
989            } else {
990                self.shell_argfiles |= option == "shell-argfiles";
991            }
992        }
993        self.arguments.push(argument.into());
994    }
995}
996
997/// Match rustc's argfile expansion: UTF-8, one option per line, no recursive
998/// expansion, with the nightly shell form enabled only after its `-Z` flag.
999fn expand_response_files(arguments: &[OsString]) -> Result<ExpandedArguments, BypassReason> {
1000    let mut expanded = ResponseExpander::default();
1001    for (index, argument) in arguments.iter().enumerate() {
1002        let argument = argument
1003            .to_str()
1004            .ok_or(BypassReason::NonUtf8Argument { index })?;
1005        let Some(argfile) = argument.strip_prefix('@') else {
1006            expanded.push(argument.to_string());
1007            continue;
1008        };
1009        let (path, shell) = match argfile.split_once(':') {
1010            Some(("shell", path)) if expanded.shell_argfiles => (path, true),
1011            _ => (argfile, false),
1012        };
1013        let contents = std::fs::read_to_string(path).map_err(|error| {
1014            BypassReason::ResponseFile(format!("{}: {error}", Path::new(path).display()))
1015        })?;
1016        if shell {
1017            let arguments = shlex::split(&contents).ok_or_else(|| {
1018                BypassReason::ResponseFile(format!(
1019                    "invalid shell-style arguments in {}",
1020                    Path::new(path).display()
1021                ))
1022            })?;
1023            for argument in arguments {
1024                expanded.push(argument);
1025            }
1026        } else {
1027            for argument in contents.lines() {
1028                expanded.push(argument.to_string());
1029            }
1030        }
1031    }
1032    Ok(ExpandedArguments {
1033        arguments: expanded.arguments,
1034    })
1035}
1036
1037impl<'a> Parser<'a> {
1038    fn new(arguments: &'a [OsString], options: ParseOptions) -> Self {
1039        Self {
1040            arguments,
1041            options,
1042            index: 0,
1043            parsed: Vec::new(),
1044            source: None,
1045            crate_types: Vec::new(),
1046            emits: Vec::new(),
1047            required_inputs: Vec::new(),
1048            test: false,
1049            crate_name: None,
1050            extra_filename: String::new(),
1051            out_dir: None,
1052            explicit_output: None,
1053            target: None,
1054        }
1055    }
1056
1057    fn parse(mut self) -> Result<RustcInvocation, BypassReason> {
1058        while self.index < self.arguments.len() {
1059            let value = self.current()?.to_string();
1060            self.index += 1;
1061            if let Some(long) = value.strip_prefix("--") {
1062                self.parse_long(long)?;
1063            } else if value.starts_with('-') && value != "-" {
1064                self.parse_short(&value)?;
1065            } else {
1066                self.parse_input(&value)?;
1067            }
1068        }
1069
1070        let source = self.source.clone().ok_or(BypassReason::MissingInput)?;
1071        let link_output = self.classify()?;
1072        let crate_name = self.crate_name.clone().map_or_else(
1073            || {
1074                source
1075                    .file_stem()
1076                    .and_then(|name| name.to_str())
1077                    .map(|name| name.replace('-', "_"))
1078                    .ok_or_else(|| BypassReason::NonUtf8Path(source.clone()))
1079            },
1080            Ok,
1081        )?;
1082        self.required_inputs.push(source.clone());
1083        Ok(RustcInvocation {
1084            arguments: self.parsed,
1085            source,
1086            required_inputs: self.required_inputs,
1087            crate_name,
1088            extra_filename: self.extra_filename,
1089            out_dir: self.out_dir,
1090            explicit_output: self.explicit_output,
1091            emits: self.emits,
1092            target: self.target,
1093            link_output,
1094        })
1095    }
1096
1097    fn current(&self) -> Result<&str, BypassReason> {
1098        self.arguments[self.index]
1099            .to_str()
1100            .ok_or(BypassReason::NonUtf8Argument { index: self.index })
1101    }
1102
1103    fn take_value(&mut self, flag: &str, inline: Option<&str>) -> Result<String, BypassReason> {
1104        if let Some(value) = inline {
1105            if value.is_empty() {
1106                return Err(BypassReason::MissingValue(flag.into()));
1107            }
1108            return Ok(value.into());
1109        }
1110        if self.index >= self.arguments.len() {
1111            return Err(BypassReason::MissingValue(flag.into()));
1112        }
1113        let value = self.current()?.to_string();
1114        self.index += 1;
1115        Ok(value)
1116    }
1117
1118    fn parse_long(&mut self, value: &str) -> Result<(), BypassReason> {
1119        let (flag, inline) = value
1120            .split_once('=')
1121            .map_or((value, None), |(flag, value)| (flag, Some(value)));
1122        let rendered_flag = format!("--{flag}");
1123        match flag {
1124            "help" | "version" | "explain" | "print" => Err(BypassReason::CompilerQuery),
1125            "test" => {
1126                self.test = true;
1127                self.parsed.push(Argument::Plain(rendered_flag));
1128                Ok(())
1129            }
1130            "verbose" => {
1131                self.parsed.push(Argument::Plain(rendered_flag));
1132                Ok(())
1133            }
1134            "crate-name" => {
1135                let value = self.take_value(&rendered_flag, inline)?;
1136                self.crate_name = Some(value.clone());
1137                self.parsed
1138                    .push(Argument::Plain(format!("{rendered_flag}={value}")));
1139                Ok(())
1140            }
1141            "cfg" | "check-cfg" | "edition" | "error-format" | "json" | "color"
1142            | "diagnostic-width" | "remap-path-scope" | "allow" | "warn" | "force-warn"
1143            | "deny" | "forbid" | "cap-lints" => {
1144                let value = self.take_value(&rendered_flag, inline)?;
1145                self.parsed
1146                    .push(Argument::Plain(format!("{rendered_flag}={value}")));
1147                Ok(())
1148            }
1149            "target" => {
1150                let value = self.take_value(&rendered_flag, inline)?;
1151                self.target = Some(value.clone());
1152                if value.ends_with(".json") || value.contains(['/', '\\']) {
1153                    let path = PathBuf::from(value);
1154                    self.required_inputs.push(path.clone());
1155                    self.parsed.push(Argument::Path {
1156                        flag: rendered_flag,
1157                        path,
1158                    });
1159                } else {
1160                    self.target = Some(value.clone());
1161                    self.parsed
1162                        .push(Argument::Plain(format!("{rendered_flag}={value}")));
1163                }
1164                Ok(())
1165            }
1166            "crate-type" => {
1167                let value = self.take_value(&rendered_flag, inline)?;
1168                self.crate_types
1169                    .extend(value.split(',').map(ToOwned::to_owned));
1170                self.parsed
1171                    .push(Argument::Plain(format!("{rendered_flag}={value}")));
1172                Ok(())
1173            }
1174            "emit" => {
1175                let value = self.take_value(&rendered_flag, inline)?;
1176                let emits = parse_emits(&value);
1177                self.emits.extend(emits.clone());
1178                self.parsed.push(Argument::Emit(emits));
1179                Ok(())
1180            }
1181            "out-dir" => {
1182                let path = PathBuf::from(self.take_value(&rendered_flag, inline)?);
1183                self.out_dir = Some(path.clone());
1184                self.parsed.push(Argument::Path {
1185                    flag: rendered_flag,
1186                    path,
1187                });
1188                Ok(())
1189            }
1190            "sysroot" => {
1191                let path = self.take_value(&rendered_flag, inline)?;
1192                self.parsed.push(Argument::Path {
1193                    flag: rendered_flag,
1194                    path: path.into(),
1195                });
1196                Ok(())
1197            }
1198            "extern" => {
1199                let value = self.take_value(&rendered_flag, inline)?;
1200                let (name, path) = value
1201                    .split_once('=')
1202                    .map_or((value.as_str(), None), |(name, path)| {
1203                        (name, Some(PathBuf::from(path)))
1204                    });
1205                if let Some(path) = &path {
1206                    self.required_inputs.push(path.clone());
1207                }
1208                self.parsed.push(Argument::Extern {
1209                    name: name.into(),
1210                    path,
1211                });
1212                Ok(())
1213            }
1214            "remap-path-prefix" => {
1215                let value = self.take_value(&rendered_flag, inline)?;
1216                let Some((from, to)) = value.split_once('=') else {
1217                    return Err(BypassReason::MissingValue(rendered_flag));
1218                };
1219                self.parsed.push(Argument::RemapPath {
1220                    from: from.into(),
1221                    to: to.into(),
1222                });
1223                Ok(())
1224            }
1225            "codegen" => {
1226                let value = self.take_value(&rendered_flag, inline)?;
1227                self.parse_codegen(&value)
1228            }
1229            _ => Err(BypassReason::UnknownFlag(rendered_flag)),
1230        }
1231    }
1232
1233    fn parse_short(&mut self, value: &str) -> Result<(), BypassReason> {
1234        if let Some(attached) = value.strip_prefix("-Z") {
1235            let option = self.take_value("-Z", (!attached.is_empty()).then_some(attached))?;
1236            if option == "shell-argfiles" {
1237                self.parsed.push(Argument::Plain("-Zshell-argfiles".into()));
1238                return Ok(());
1239            }
1240            return Err(BypassReason::UnknownFlag(format!("-Z{option}")));
1241        }
1242        match value {
1243            // `-vV` is how cargo and build scripts ask for the verbose
1244            // version, so it is a query rather than a flag left unmodeled.
1245            "-h" | "-V" | "-vV" => return Err(BypassReason::CompilerQuery),
1246            "-g" | "-O" | "-v" => {
1247                self.parsed.push(Argument::Plain(value.into()));
1248                return Ok(());
1249            }
1250            _ => {}
1251        }
1252        for (short, long) in [
1253            ("-A", "--allow"),
1254            ("-W", "--warn"),
1255            ("-D", "--deny"),
1256            ("-F", "--forbid"),
1257        ] {
1258            if let Some(attached) = value.strip_prefix(short) {
1259                let lint = self.take_value(short, (!attached.is_empty()).then_some(attached))?;
1260                self.parsed.push(Argument::Plain(format!("{long}={lint}")));
1261                return Ok(());
1262            }
1263        }
1264        if let Some(attached) = value.strip_prefix("-C") {
1265            let option = self.take_value("-C", (!attached.is_empty()).then_some(attached))?;
1266            return self.parse_codegen(&option);
1267        }
1268        if let Some(attached) = value.strip_prefix("-L") {
1269            let search = self.take_value("-L", (!attached.is_empty()).then_some(attached))?;
1270            let (kind, path) = search
1271                .split_once('=')
1272                .map_or(("all", search.as_str()), |(kind, path)| (kind, path));
1273            if !matches!(kind, "dependency" | "native") {
1274                return Err(BypassReason::UnsupportedSearchPath(kind.into()));
1275            }
1276            self.parsed.push(Argument::SearchPath {
1277                kind: kind.into(),
1278                path: path.into(),
1279            });
1280            return Ok(());
1281        }
1282        if value == "-l" || value.starts_with("-l") {
1283            return Err(BypassReason::NativeLibrary);
1284        }
1285        if let Some(attached) = value.strip_prefix("-o") {
1286            let path = self.take_value("-o", (!attached.is_empty()).then_some(attached))?;
1287            self.explicit_output = Some(path.clone().into());
1288            self.parsed.push(Argument::Path {
1289                flag: "-o".into(),
1290                path: path.into(),
1291            });
1292            return Ok(());
1293        }
1294        Err(BypassReason::UnknownFlag(value.into()))
1295    }
1296
1297    fn parse_codegen(&mut self, value: &str) -> Result<(), BypassReason> {
1298        let name = value.split_once('=').map_or(value, |(name, _)| name);
1299        if name == "incremental" {
1300            return Err(BypassReason::Incremental);
1301        }
1302        if SUPPORTED_CODEGEN_OPTIONS.binary_search(&name).is_err() {
1303            return Err(BypassReason::UnknownCodegenOption(name.into()));
1304        }
1305        self.parsed
1306            .push(Argument::Plain(format!("--codegen={value}")));
1307        if name == "extra-filename" {
1308            self.extra_filename = value
1309                .split_once('=')
1310                .map_or(String::new(), |(_, value)| value.to_string());
1311        }
1312        Ok(())
1313    }
1314
1315    fn parse_input(&mut self, value: &str) -> Result<(), BypassReason> {
1316        if value == "-" {
1317            return Err(BypassReason::StandardInput);
1318        }
1319        if self.source.replace(value.into()).is_some() {
1320            return Err(BypassReason::MultipleInputs);
1321        }
1322        Ok(())
1323    }
1324
1325    fn classify(&self) -> Result<LinkOutput, BypassReason> {
1326        let link_output = if !self.test
1327            && !self.crate_types.is_empty()
1328            && self
1329                .crate_types
1330                .iter()
1331                .all(|crate_type| matches!(crate_type.as_str(), "lib" | "rlib"))
1332        {
1333            LinkOutput::Library
1334        } else if self
1335            .target
1336            .as_deref()
1337            .is_some_and(compiler_bundled_wasm_target)
1338            && ((self.test && self.crate_types.is_empty())
1339                || matches!(self.crate_types.as_slice(), [kind] if kind == "bin" || kind == "cdylib"))
1340        {
1341            if self.parsed.iter().any(|argument| match argument {
1342                Argument::Plain(value) if value == "--codegen=link-self-contained" => false,
1343                Argument::Plain(value) if value.starts_with("--codegen=link-self-contained=") => {
1344                    !matches!(
1345                        value.rsplit_once('=').map(|(_, value)| value),
1346                        Some("y" | "yes" | "on" | "true")
1347                    )
1348                }
1349                _ => false,
1350            }) {
1351                return Err(BypassReason::UnknownCodegenOption(
1352                    "link-self-contained".into(),
1353                ));
1354            }
1355            if self.target.as_deref().is_some_and(|target| target.contains("wasi"))
1356                && self.parsed.iter().any(|argument| {
1357                    matches!(argument, Argument::Plain(value) if value.strip_prefix("--codegen=target-feature=").is_some_and(|features| features.split(',').any(|feature| feature == "-crt-static")))
1358                })
1359            {
1360                return Err(BypassReason::UnknownCodegenOption(
1361                    "target-feature=-crt-static".into(),
1362                ));
1363            }
1364            // These targets use a linker and, where applicable, CRT objects
1365            // and libc shipped in the Rust toolchain. Unlike native linking,
1366            // there are no implicit host inputs outside compiler identity.
1367            LinkOutput::WasmExecutable
1368        } else if self.options.cache_native_links && self.links_a_native_program() {
1369            self.check_native_link_is_portable()?;
1370            LinkOutput::NativeExecutable
1371        } else if self.test {
1372            return Err(BypassReason::UnsupportedCrateType("test".into()));
1373        } else {
1374            return Err(BypassReason::UnsupportedCrateType(
1375                self.crate_types
1376                    .iter()
1377                    .find(|crate_type| !matches!(crate_type.as_str(), "lib" | "rlib"))
1378                    .cloned()
1379                    .unwrap_or_else(|| "bin".into()),
1380            ));
1381        };
1382        if let Some(name) = self.parsed.iter().find_map(|argument| match argument {
1383            Argument::Extern { name, path: None } if name != "proc_macro" => Some(name),
1384            _ => None,
1385        }) {
1386            return Err(BypassReason::UnresolvedExtern(name.clone()));
1387        }
1388        if let Some(emit) = self
1389            .emits
1390            .iter()
1391            .find(|emit| !matches!(emit.kind.as_str(), "dep-info" | "link" | "metadata"))
1392        {
1393            return Err(BypassReason::UnsupportedEmit(emit.kind.clone()));
1394        }
1395        if !self
1396            .emits
1397            .iter()
1398            .any(|emit| matches!(emit.kind.as_str(), "link" | "metadata"))
1399        {
1400            return Err(BypassReason::NoCacheableOutput);
1401        }
1402        Ok(link_output)
1403    }
1404}
1405
1406impl Parser<'_> {
1407    /// Whether this invocation links a program for the host.
1408    ///
1409    /// An explicit `--target` bypasses even when it spells the host triple:
1410    /// rustc without one links for the host by construction, which is the
1411    /// cheapest description of "the linker this adapter can identify", and
1412    /// cargo omits it for host builds anyway.
1413    fn links_a_native_program(&self) -> bool {
1414        self.target.is_none()
1415            // A compilation that emits no linked artifact did not link, so it
1416            // needs no linker to describe it. `cargo check --tests` asks for
1417            // metadata alone, and reading that as a link would send it looking
1418            // for a linker identity and refusing flags no linker ever saw.
1419            && self.emits.iter().any(|emit| emit.kind == "link")
1420            && ((self.test && self.crate_types.is_empty())
1421                || matches!(self.crate_types.as_slice(), [kind] if kind == "bin"))
1422    }
1423
1424    /// Reject a native link whose result depends on something the key cannot
1425    /// describe, or that leaves artifacts beside the ones mbx would store.
1426    ///
1427    /// Each check names a value rather than a flag: an option absent from the
1428    /// invocation keeps rustc's default, which the compiler identity already
1429    /// pins, while any other spelling is refused rather than guessed at.
1430    fn check_native_link_is_portable(&self) -> Result<(), BypassReason> {
1431        for argument in &self.parsed {
1432            let Argument::Plain(value) = argument else {
1433                continue;
1434            };
1435            // `-g` is rustc's shorthand for debug info, and arrives as itself
1436            // rather than as a codegen option.
1437            let (name, value) = if value == "-g" {
1438                ("debuginfo", Some("2"))
1439            } else if let Some(option) = value.strip_prefix("--codegen=") {
1440                match option.split_once('=') {
1441                    Some((name, value)) => (name, Some(value)),
1442                    // A codegen flag with no value asks for its enabled form,
1443                    // which is what cargo passes for `-Crpath`. Reading it as
1444                    // "nothing to check" is how these slipped through.
1445                    None => (option, None),
1446                }
1447            } else {
1448                continue;
1449            };
1450            let unportable = match name {
1451                // Packed debug info leaves a .dSYM bundle or .dwp file beside
1452                // the binary, and mbx stores neither.
1453                "split-debuginfo" => value != Some("off"),
1454                // ld64 records absolute object paths and their timestamps in
1455                // the binary's debug map, so the same source links to
1456                // different bytes in another checkout -- or the same one
1457                // twice.
1458                "debuginfo" if cfg!(target_os = "macos") => !matches!(value, Some("0" | "none")),
1459                // Both embed this checkout's absolute target directory.
1460                "rpath" | "prefer-dynamic" => is_enabled(value),
1461                // The CRT objects a self-contained link uses come from
1462                // somewhere other than where the driver reports.
1463                "link-self-contained" => true,
1464                _ => false,
1465            };
1466            if unportable {
1467                return Err(BypassReason::UnportableNativeLink(match value {
1468                    Some(value) => format!("{name}={value}"),
1469                    None => name.to_owned(),
1470                }));
1471            }
1472        }
1473        Ok(())
1474    }
1475}
1476
1477/// Whether a boolean codegen option is asking for its enabled form. Absent a
1478/// value, rustc reads the flag itself as the request.
1479fn is_enabled(value: Option<&str>) -> bool {
1480    matches!(value, None | Some("y" | "yes" | "on" | "true"))
1481}
1482
1483fn compiler_bundled_wasm_target(target: &str) -> bool {
1484    COMPILER_BUNDLED_WASM_TARGETS.binary_search(&target).is_ok()
1485}
1486
1487fn parse_emits(value: &str) -> Vec<Emit> {
1488    value
1489        .split(',')
1490        .map(|emit| {
1491            let (kind, path) = emit
1492                .split_once('=')
1493                .map_or((emit, None), |(kind, path)| (kind, Some(path.into())));
1494            Emit {
1495                kind: kind.into(),
1496                path,
1497            }
1498        })
1499        .collect()
1500}
1501
1502struct ActionBuilder<'a> {
1503    invocation: &'a RustcInvocation,
1504    context: ActionContext,
1505    mappings: Vec<PathMapping>,
1506    linker: Option<LinkerIdentity>,
1507}
1508
1509impl<'a> ActionBuilder<'a> {
1510    fn new(invocation: &'a RustcInvocation, mut context: ActionContext) -> Self {
1511        context.path_mappings = PathMapping::ordered(&context.path_mappings);
1512        let mappings = context
1513            .path_mappings
1514            .iter()
1515            .map(|mapping| PathMapping {
1516                root: resolve_mapping_root(&mapping.root),
1517                placeholder: mapping.placeholder.clone(),
1518            })
1519            .collect();
1520        Self {
1521            linker: None,
1522            invocation,
1523            mappings,
1524            context,
1525        }
1526    }
1527
1528    fn linked_by(mut self, linker: Option<LinkerIdentity>) -> Self {
1529        self.linker = linker;
1530        self
1531    }
1532
1533    fn build(self) -> Result<RustcAction, BypassReason> {
1534        self.validate_mappings()?;
1535        let invocation = self.invocation_descriptor()?;
1536        let environment = self.environment_descriptor()?;
1537
1538        let mut inputs = BTreeMap::<String, CacheDigest>::new();
1539        for input in &self.context.inputs {
1540            input
1541                .digest
1542                .validate()
1543                .map_err(|_| BypassReason::InvalidInputDigest(input.path.display().to_string()))?;
1544            let path = self.normalize_path(&input.path)?;
1545            if inputs
1546                .insert(path.clone(), input.digest.clone())
1547                .is_some_and(|existing| existing != input.digest)
1548            {
1549                return Err(BypassReason::ConflictingInput(path));
1550            }
1551        }
1552        let required = self
1553            .invocation
1554            .required_inputs
1555            .iter()
1556            .map(|path| self.normalize_path(path))
1557            .collect::<Result<BTreeSet<_>, _>>()?;
1558        if let Some(missing) = required.iter().find(|path| !inputs.contains_key(*path)) {
1559            return Err(BypassReason::MissingRequiredInput(missing.clone()));
1560        }
1561        let inputs = inputs
1562            .into_iter()
1563            .map(|(path, digest)| InputDescriptor { path, digest })
1564            .collect();
1565        // A native link without a linker identity would be keyed as though the
1566        // host did not matter. Refuse rather than publish that claim.
1567        if self.invocation.links_natively() && self.linker.is_none() {
1568            return Err(BypassReason::UnportableNativeLink(
1569                "linker identity is unknown".into(),
1570            ));
1571        }
1572        let descriptor = ActionDescriptor {
1573            version: ACTION_SCHEMA_VERSION,
1574            kind: "rustc",
1575            adapter_version: ADAPTER_VERSION,
1576            compiler: invocation.compiler,
1577            arguments: invocation.arguments,
1578            environment,
1579            inputs,
1580            linker: self.linker.clone(),
1581        };
1582        let bytes = canonical_json(&descriptor)
1583            .map_err(|error| BypassReason::Serialization(error.to_string()))?;
1584        let digest = CacheDigest::blake3(&bytes);
1585        Ok(RustcAction { digest, bytes })
1586    }
1587
1588    fn invocation_descriptor(&self) -> Result<InvocationDescriptor, BypassReason> {
1589        self.validate_mappings()?;
1590        let arguments = self
1591            .invocation
1592            .arguments
1593            .iter()
1594            .map(|argument| self.normalize_argument(argument))
1595            .collect::<Result<Vec<_>, _>>()?;
1596        let required_inputs = self
1597            .invocation
1598            .required_inputs
1599            .iter()
1600            .map(|path| self.normalize_path(path))
1601            .collect::<Result<BTreeSet<_>, _>>()?
1602            .into_iter()
1603            .collect();
1604        Ok(InvocationDescriptor {
1605            version: ACTION_SCHEMA_VERSION,
1606            kind: "rustc",
1607            adapter_version: ADAPTER_VERSION,
1608            compiler: CompilerDescriptor {
1609                toolchain: self.context.compiler.toolchain.clone(),
1610                rustc_version: self.context.compiler.rustc_version.clone(),
1611                host: self.context.compiler.host.clone(),
1612            },
1613            arguments,
1614            required_inputs,
1615        })
1616    }
1617
1618    fn validate_mappings(&self) -> Result<(), BypassReason> {
1619        if !self.context.working_dir.is_absolute() {
1620            return Err(BypassReason::RelativeWorkingDirectory(
1621                self.context.working_dir.clone(),
1622            ));
1623        }
1624        let mut roots = BTreeSet::new();
1625        let mut placeholders = BTreeSet::new();
1626        for mapping in &self.mappings {
1627            if !mapping.root.is_absolute() {
1628                return Err(BypassReason::RelativePathMapping(mapping.root.clone()));
1629            }
1630            if mapping.placeholder.is_empty()
1631                || !mapping
1632                    .placeholder
1633                    .bytes()
1634                    .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
1635                || !roots.insert(normalize_components(&mapping.root))
1636                || !placeholders.insert(&mapping.placeholder)
1637            {
1638                return Err(BypassReason::InvalidPathPlaceholder(
1639                    mapping.placeholder.clone(),
1640                ));
1641            }
1642        }
1643        Ok(())
1644    }
1645
1646    fn normalize_argument(&self, argument: &Argument) -> Result<String, BypassReason> {
1647        match argument {
1648            Argument::Plain(value) => Ok(value.clone()),
1649            Argument::Path { flag, path } => Ok(format!("{flag}={}", self.normalize_path(path)?)),
1650            Argument::SearchPath { kind, path } => {
1651                let text = match self.normalize_path(path) {
1652                    Ok(text) => text,
1653                    // A native directory outside every mapped root is a host
1654                    // toolchain installation, not a checkout location. Its
1655                    // literal path is the key material: the path is
1656                    // version-stamped on the platforms that pass one (the MSVC
1657                    // toolset, the Windows SDK), so hosts that differ miss,
1658                    // which is the same identity-over-content stance
1659                    // [`LinkerIdentity`] takes.
1660                    Err(BypassReason::UnmappedAbsolutePath(absolute))
1661                        if kind == "native" && self.invocation.native_search_is_inert() =>
1662                    {
1663                        absolute
1664                            .to_str()
1665                            .ok_or(BypassReason::NonUtf8Path(absolute.clone()))?
1666                            .to_string()
1667                    }
1668                    Err(error) => return Err(error),
1669                };
1670                Ok(format!("-L{kind}={text}"))
1671            }
1672            Argument::Extern { name, path } => match path {
1673                Some(path) => Ok(format!("--extern={name}={}", self.normalize_path(path)?)),
1674                None => Ok(format!("--extern={name}")),
1675            },
1676            Argument::Emit(emits) => Ok(format!(
1677                "--emit={}",
1678                emits
1679                    .iter()
1680                    .map(|emit| match &emit.path {
1681                        Some(path) => self
1682                            .normalize_path(path)
1683                            .map(|path| format!("{}={path}", emit.kind)),
1684                        None => Ok(emit.kind.clone()),
1685                    })
1686                    .collect::<Result<Vec<_>, _>>()?
1687                    .join(",")
1688            )),
1689            Argument::RemapPath { from, to } => Ok(format!(
1690                "--remap-path-prefix={}={}",
1691                self.normalize_path(from)?,
1692                to
1693            )),
1694        }
1695    }
1696
1697    /// Environment values enter the key verbatim, because rustc may embed one
1698    /// through `env!`: unlike a path used to locate an input, changing the value
1699    /// changes the artifact.
1700    ///
1701    /// A name in `portable_environment` is the exception the caller has earned.
1702    /// Its value normalizes like any other path, so two checkouts agree on it.
1703    fn environment_descriptor(&self) -> Result<BTreeMap<String, Option<String>>, BypassReason> {
1704        self.context
1705            .environment
1706            .iter()
1707            .map(|(name, value)| {
1708                let value = match value {
1709                    Some(value) if self.context.portable_environment.contains(name) => {
1710                        Some(self.normalize_path(Path::new(value))?)
1711                    }
1712                    value => value.clone(),
1713                };
1714                Ok((name.clone(), value))
1715            })
1716            .collect()
1717    }
1718
1719    fn normalize_path(&self, path: &Path) -> Result<String, BypassReason> {
1720        normalize_resolved_mapped_path(path, &self.context.working_dir, &self.mappings)
1721    }
1722}
1723
1724fn denormalize_path(value: &str, mappings: &[PathMapping]) -> Result<PathBuf, BypassReason> {
1725    for mapping in mappings {
1726        let prefix = format!("${{{}}}", mapping.placeholder);
1727        let suffix = if value == prefix {
1728            ""
1729        } else if let Some(suffix) = value.strip_prefix(&format!("{prefix}/")) {
1730            suffix
1731        } else {
1732            continue;
1733        };
1734        if !mapping.root.is_absolute()
1735            || (!suffix.is_empty()
1736                && suffix.split('/').any(|component| {
1737                    component.is_empty()
1738                        || matches!(component, "." | "..")
1739                        || component.contains('\\')
1740                }))
1741        {
1742            return Err(BypassReason::InvalidPredictedInput(value.into()));
1743        }
1744        let mut path = normalize_components(&mapping.root);
1745        path.extend(suffix.split('/').filter(|component| !component.is_empty()));
1746        return Ok(path);
1747    }
1748    Err(BypassReason::InvalidPredictedInput(value.into()))
1749}
1750
1751fn normalize_components(path: &Path) -> PathBuf {
1752    let mut normalized = PathBuf::new();
1753    for component in path.components() {
1754        match component {
1755            Component::CurDir => {}
1756            Component::ParentDir => {
1757                normalized.pop();
1758            }
1759            component => normalized.push(component.as_os_str()),
1760        }
1761    }
1762    normalized
1763}
1764
1765fn absolute_path(path: &Path, working_dir: &Path) -> PathBuf {
1766    if path.is_absolute() {
1767        normalize_components(path)
1768    } else {
1769        normalize_components(&working_dir.join(path))
1770    }
1771}
1772
1773fn slash_path(path: &Path) -> Result<String, BypassReason> {
1774    path.components()
1775        .filter_map(|component| match component {
1776            Component::Normal(value) => Some(
1777                value
1778                    .to_str()
1779                    .map(ToOwned::to_owned)
1780                    .ok_or_else(|| BypassReason::NonUtf8Path(path.to_path_buf())),
1781            ),
1782            _ => None,
1783        })
1784        .collect::<Result<Vec<_>, _>>()
1785        .map(|components| components.join("/"))
1786}
1787
1788#[cfg(test)]
1789#[path = "rustc_cache_tests.rs"]
1790mod tests;