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::{
32    CacheDigest, FileDigestCache, PathAliases, PathMapping as SharedPathMapping,
33    PathNormalizationError, canonical_json, normalize_mapped_path as normalize_shared_path,
34    normalize_resolved_mapped_path_with as normalize_resolved_shared_path_with,
35    resolve_path_mappings,
36};
37use serde::{Deserialize, Deserializer, Serialize, Serializer};
38use std::collections::{BTreeMap, BTreeSet};
39use std::ffi::OsString;
40use std::path::{Component, Path, PathBuf};
41use thiserror::Error;
42
43mod dep_info;
44
45pub use dep_info::{DepInfoCommand, DiscoveredInputs, RustcDepInfo};
46
47/// Schema version embedded in canonical rustc action descriptors.
48pub const ACTION_SCHEMA_VERSION: u8 = 1;
49/// Version of the rustc argument and input model used to construct keys.
50///
51/// Bumped to 2 when the dep-info and diagnostics stored with a result stopped
52/// carrying the publishing checkout's absolute paths. Entries written before
53/// that hold the old spelling, and nothing in them says so, so they are retired
54/// by the key rather than restored into a checkout they do not describe.
55pub const ADAPTER_VERSION: u8 = 2;
56
57impl BypassReason {
58    /// A stable, low-cardinality name for this reason.
59    ///
60    /// Many variants carry a path or a flag, so `Display` text cannot be
61    /// aggregated; statistics group by this instead.
62    pub fn kind(&self) -> &'static str {
63        self.into()
64    }
65
66    /// A concrete change that can make this invocation cacheable, when one is
67    /// available.
68    ///
69    /// Expected probes and failures that require adapter support return
70    /// `None`; callers can still explain those from [`BypassReason::kind`].
71    pub fn remediation(&self) -> Option<&'static str> {
72        match self {
73            Self::Incremental => Some(
74                "Set `MBX_INCREMENTAL=0`; mbx will then disable Cargo incremental state and cache the compilation.",
75            ),
76            Self::UnportableNativeLink(detail) if detail.contains("linker") => Some(
77                "Make the native linker resolvable on `PATH`, or configure a linker mbx can identify for this target.",
78            ),
79            Self::UnportableNativeLink(_) => Some(
80                "Remove the reported `-C` option from the active Cargo profile or `RUSTFLAGS` to make these links cacheable.",
81            ),
82            Self::UnknownFlag(_) | Self::UnknownCodegenOption(_) => Some(
83                "Remove the reported compiler option, or upgrade mbx if the option should be modeled.",
84            ),
85            Self::UnmodeledLinkArgument(_) => Some(
86                "Remove the reported linker argument, or keep these links uncached if the argument is required.",
87            ),
88            Self::UnmappedAbsolutePath(_) => Some(
89                "Move the input under the workspace, target, Cargo, toolchain, or home roots so mbx can give it a portable cache name.",
90            ),
91            _ => None,
92        }
93    }
94}
95
96impl From<PathNormalizationError> for BypassReason {
97    fn from(reason: PathNormalizationError) -> Self {
98        match reason {
99            PathNormalizationError::UnmappedAbsolutePath(path) => Self::UnmappedAbsolutePath(path),
100            PathNormalizationError::NonUtf8Path(path) => Self::NonUtf8Path(path),
101        }
102    }
103}
104
105const SUPPORTED_CODEGEN_OPTIONS: &[&str] = &[
106    "codegen-units",
107    "control-flow-guard",
108    "debug-assertions",
109    "debuginfo",
110    "default-linker-libraries",
111    "embed-bitcode",
112    "extra-filename",
113    "force-frame-pointers",
114    "force-unwind-tables",
115    "instrument-coverage",
116    "link-arg",
117    "link-args",
118    "link-dead-code",
119    "link-self-contained",
120    "linker-plugin-lto",
121    "lto",
122    "metadata",
123    "no-prepopulate-passes",
124    "opt-level",
125    "overflow-checks",
126    "panic",
127    "prefer-dynamic",
128    "relocation-model",
129    "rpath",
130    "save-temps",
131    "soft-float",
132    "split-debuginfo",
133    "split-dwarf-kind",
134    "strip",
135    "symbol-mangling-version",
136    "target-cpu",
137    "target-feature",
138    "tls-model",
139];
140
141const NATIVE_DIRECTORY_PREDICTION_PREFIX: &str = "@native-directory:";
142const MAX_PREDICTED_INPUTS: usize = 16 * 1024;
143// A compact payload is capped on the wire by the protocol. Bound its expanded
144// form too, so a hostile prefix chain cannot turn a small manifest into an
145// unreasonable allocation while it is being decoded.
146const MAX_DECODED_PREDICTION_BYTES: usize = 16 * 1024 * 1024;
147const MAX_NATIVE_INPUT_BYTES: u64 = 2 * 1024 * 1024 * 1024;
148
149/// Built-in WebAssembly targets whose default linkers and CRT inputs ship in
150/// the Rust toolchain. Custom target specs never enter this list.
151const COMPILER_BUNDLED_WASM_TARGETS: &[&str] = &[
152    "wasm32-unknown-unknown",
153    "wasm32-wasip1",
154    "wasm32-wasip1-threads",
155    "wasm32-wasip2",
156    "wasm32v1-none",
157    "wasm64-unknown-unknown",
158];
159
160#[derive(Debug, Clone, PartialEq, Eq, Error, strum::IntoStaticStr)]
161#[strum(serialize_all = "kebab-case")]
162/// Reason an invocation cannot safely use the action cache.
163///
164/// A bypass is an expected conservative outcome, not necessarily a compiler
165/// error. Variants carry diagnostic context while [`BypassReason::kind`]
166/// provides a stable aggregation key.
167///
168/// This set exists to shrink: every invocation the adapter learns to model
169/// retires a variant, and every construct it learns to reject adds one. Match
170/// on [`BypassReason::kind`] for aggregation rather than on the variants.
171#[non_exhaustive]
172pub enum BypassReason {
173    /// An argument cannot be represented in the canonical UTF-8 key.
174    #[error("rustc argument {index} is not valid UTF-8")]
175    NonUtf8Argument {
176        /// Zero-based index in the argument slice.
177        index: usize,
178    },
179    /// A rustc response file could not be read or parsed exactly.
180    #[error("could not model rustc response file: {0}")]
181    ResponseFile(String),
182    /// A compiler flag is not modeled by this adapter version.
183    #[error("rustc flag is not modeled by the cache adapter: {0}")]
184    UnknownFlag(String),
185    /// A `-C` option is not modeled by this adapter version.
186    #[error("rustc codegen option is not modeled by the cache adapter: {0}")]
187    UnknownCodegenOption(String),
188    /// A recognized flag was not followed by its required value.
189    #[error("rustc flag requires a value: {0}")]
190    MissingValue(String),
191    /// The invocation queries compiler information instead of compiling.
192    #[error("rustc invocation is a compiler query, not a compilation")]
193    CompilerQuery,
194    /// Source would be read from standard input and cannot be rediscovered.
195    #[error("rustc invocation reads source from standard input")]
196    StandardInput,
197    /// No Rust source input was supplied.
198    #[error("rustc invocation has no source input")]
199    MissingInput,
200    /// More than one source input was supplied.
201    #[error("rustc invocation has multiple source inputs")]
202    MultipleInputs,
203    /// Incremental state makes the outputs unsuitable for action caching.
204    #[error("incremental compilation cannot be combined with action caching")]
205    Incremental,
206    /// The requested crate type is outside the supported cacheability tier.
207    #[error("rustc crate type is not cacheable yet: {0}")]
208    UnsupportedCrateType(String),
209    /// The requested emit kind is outside the supported cacheability tier.
210    #[error("rustc output type is not cacheable yet: {0}")]
211    UnsupportedEmit(String),
212    /// The invocation emits no artifact in the supported cacheability tier.
213    #[error("rustc invocation does not emit a cacheable artifact")]
214    NoCacheableOutput,
215    /// The invocation does not emit the dep-info needed for input discovery.
216    #[error("rustc invocation does not emit dependency information")]
217    NoDepInfo,
218    /// Outputs cannot be represented as one cache directory.
219    #[error("rustc output paths do not share one directory")]
220    SplitOutputDirectories,
221    /// An output path does not name a file.
222    #[error("rustc output path has no file name: {0}")]
223    InvalidOutputPath(PathBuf),
224    /// `-o` leaves the name of an implicit emit ambiguous.
225    #[error("rustc -o with an emit that has no explicit path is not modeled: {0}")]
226    ImplicitEmitWithOutputFile(PathBuf),
227    /// Native-library lookup is not modeled as a precise input.
228    #[error("native library lookup is not cacheable yet")]
229    NativeLibrary,
230    /// An output's name does not say whether it is a program or a library.
231    #[error("rustc output name does not distinguish a program from a library: {0}")]
232    AmbiguousOutputName(PathBuf),
233    /// A native link would embed something no other checkout can reproduce.
234    #[error("native link is not reproducible across checkouts: {0}")]
235    UnportableNativeLink(String),
236    /// A linked output was given an argument to pass on to its linker.
237    #[error("rustc link argument is not modeled by the cache adapter: {0}")]
238    UnmodeledLinkArgument(String),
239    /// A library search-path kind is not modeled.
240    #[error("rustc search path kind is not cacheable yet: {0}")]
241    UnsupportedSearchPath(String),
242    /// An extern name does not resolve to a concrete input artifact.
243    #[error("rustc extern does not identify an input artifact: {0}")]
244    UnresolvedExtern(String),
245    /// An absolute path has no stable placeholder mapping.
246    #[error("absolute path has no stable cache mapping: {0}")]
247    UnmappedAbsolutePath(PathBuf),
248    /// A path cannot be represented in a canonical UTF-8 action key.
249    #[error("cache key paths must be valid UTF-8: {0}")]
250    NonUtf8Path(PathBuf),
251    /// The action working directory is not absolute.
252    #[error("cache action working directory must be absolute: {0}")]
253    RelativeWorkingDirectory(PathBuf),
254    /// A path-mapping root is not absolute.
255    #[error("cache path mapping must use an absolute root: {0}")]
256    RelativePathMapping(PathBuf),
257    /// A mapping placeholder is empty or contains unsafe characters.
258    #[error("cache path mapping placeholder is invalid: {0}")]
259    InvalidPathPlaceholder(String),
260    /// An input referenced by compiler arguments is missing from the context.
261    #[error("required compiler input was not provided: {0}")]
262    MissingRequiredInput(String),
263    /// A supplied compiler-input digest is malformed.
264    #[error("compiler input has an invalid digest: {0}")]
265    InvalidInputDigest(String),
266    /// One normalized input path has multiple distinct digests.
267    #[error("compiler input appears more than once with different content: {0}")]
268    ConflictingInput(String),
269    /// rustc dep-info does not follow the supported format.
270    #[error("rustc dep-info is malformed: {0}")]
271    MalformedDepInfo(String),
272    /// A dep-info file could not be read as UTF-8 text.
273    #[error("failed to read rustc dep-info {path}: {message}")]
274    DepInfoRead {
275        /// Dep-info file path.
276        path: PathBuf,
277        /// Underlying I/O or decoding error.
278        message: String,
279    },
280    /// The requested dep-info output path is not absolute.
281    #[error("rustc dep-info output path must be absolute: {0}")]
282    RelativeDepInfoPath(PathBuf),
283    /// A dep-info output path contains a comma and cannot be safely rendered.
284    #[error("rustc dep-info output path cannot contain a comma: {0}")]
285    UnsafeDepInfoPath(PathBuf),
286    /// A discovered compiler input could not be read or was not a file.
287    #[error("failed to read compiler input {path}: {message}")]
288    InputRead {
289        /// Compiler-input path.
290        path: PathBuf,
291        /// Underlying filesystem error.
292        message: String,
293    },
294    /// Input contents changed after they were hashed.
295    #[error("compiler input changed after discovery: {0}")]
296    InputChanged(PathBuf),
297    /// An input's modification time overlaps the compiler execution.
298    #[error("compiler input was modified during compilation: {0}")]
299    InputModifiedDuringCompilation(PathBuf),
300    /// Discovered inputs and the action use different working directories.
301    #[error("discovered inputs were collected from a different working directory")]
302    DiscoveryWorkingDirectory,
303    /// One observed environment input has conflicting values.
304    #[error("compiler environment input has conflicting values: {0}")]
305    ConflictingEnvironment(String),
306    /// Canonical action serialization failed.
307    #[error("failed to serialize the rustc action: {0}")]
308    Serialization(String),
309    /// The stored prediction uses an unsupported version or exceeds limits.
310    #[error("rustc action prediction is unsupported")]
311    UnsupportedPrediction,
312    /// A normalized predicted path cannot be mapped back to the host.
313    #[error("rustc action prediction contains an invalid input path: {0}")]
314    InvalidPredictedInput(String),
315}
316
317#[derive(Debug, Clone, PartialEq, Eq)]
318enum Argument {
319    Plain(String),
320    Path {
321        flag: String,
322        path: PathBuf,
323    },
324    SearchPath {
325        kind: String,
326        path: PathBuf,
327    },
328    Extern {
329        name: String,
330        path: Option<PathBuf>,
331    },
332    Emit(Vec<Emit>),
333    RemapPath {
334        from: PathBuf,
335        to: String,
336    },
337    /// A single @rpath filename used as a macOS proc macro install name.
338    InstallName(String),
339    /// An `-oso_prefix` handed to ld64, whose path strips checkout-specific
340    /// prefixes from the debug map the linker records.
341    OsoPrefix {
342        path: PathBuf,
343        trailing_slash: bool,
344    },
345    /// A `-fuse-ld=<name>` handed to the driver, selecting which linker a
346    /// link invokes. Modeled for native links only, where the linker identity
347    /// probe resolves and pins the linker it names.
348    FuseLd(String),
349}
350
351#[derive(Debug, Clone, PartialEq, Eq)]
352struct Emit {
353    kind: String,
354    path: Option<PathBuf>,
355}
356
357#[derive(Debug, Clone, PartialEq, Eq)]
358/// Parsed, cache-safe model of one `rustc` command line.
359///
360/// Fields are intentionally private so new modeled flags can be added without
361/// exposing the adapter's internal representation.
362pub struct RustcInvocation {
363    arguments: Vec<Argument>,
364    source: PathBuf,
365    required_inputs: Vec<PathBuf>,
366    crate_name: String,
367    extra_filename: String,
368    out_dir: Option<PathBuf>,
369    explicit_output: Option<PathBuf>,
370    emits: Vec<Emit>,
371    target: Option<String>,
372    link_output: LinkOutput,
373}
374
375#[derive(Debug, Clone, Copy, PartialEq, Eq)]
376enum LinkOutput {
377    Library,
378    WasmExecutable,
379    NativeExecutable,
380    NativeProcMacro,
381}
382
383/// What the caller is prepared to model beyond the default tier.
384///
385/// The parser itself stays pure: whether the host can describe its linker
386/// precisely enough to key a native link is the caller's question, and the
387/// answer arrives here rather than being read out of the environment.
388#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
389#[non_exhaustive]
390pub struct ParseOptions {
391    /// Admit natively linked test binaries, executables, and proc macros,
392    /// given a linker identity in the action key. Off by default.
393    pub cache_native_links: bool,
394}
395
396impl ParseOptions {
397    /// Options that admit native links when `enabled`.
398    pub fn caching_native_links(enabled: bool) -> Self {
399        Self {
400            cache_native_links: enabled,
401        }
402    }
403}
404
405/// The cacheable files and dependency manifest produced by a rustc invocation.
406#[derive(Debug, Clone, PartialEq, Eq)]
407pub struct RustcOutputs {
408    /// Common directory containing all modeled outputs.
409    pub directory: PathBuf,
410    /// Cacheable library, metadata, and/or linked program files.
411    pub files: Vec<PathBuf>,
412    /// Dep-info file used for precise input discovery.
413    pub dep_info: PathBuf,
414}
415
416impl RustcInvocation {
417    /// Parse rustc's arguments, excluding the compiler executable supplied as
418    /// the first argument to `RUSTC_WRAPPER`.
419    ///
420    /// Any flag whose cache semantics are not modeled returns a bypass reason
421    /// instead of guessing. A successful parse admits the rlib/rmeta tier,
422    /// every compilation that links nothing at all -- what `cargo check` and
423    /// clippy run, whatever the crate type -- and binaries linked by
424    /// compiler-bundled WebAssembly toolchains.
425    pub fn parse(arguments: &[OsString]) -> Result<Self, BypassReason> {
426        Self::parse_with(arguments, ParseOptions::default())
427    }
428
429    /// Parse as [`RustcInvocation::parse`] does, admitting what `options`
430    /// says the caller can model.
431    pub fn parse_with(arguments: &[OsString], options: ParseOptions) -> Result<Self, BypassReason> {
432        let expanded = expand_response_files(arguments)?;
433        Parser::new(&expanded.arguments, options).parse()
434    }
435
436    /// Whether this invocation links a native artifact, whose key must
437    /// therefore describe the linker that produced it.
438    pub fn links_natively(&self) -> bool {
439        matches!(
440            self.link_output,
441            LinkOutput::NativeExecutable | LinkOutput::NativeProcMacro
442        )
443    }
444
445    /// Stable Mach-O identity for a cached macOS proc macro. The compiler loads
446    /// these by filename; embedding the checkout's output path only makes the
447    /// bytes (and dependent action keys) vary between otherwise identical builds.
448    pub fn portable_install_name(&self, outputs: &RustcOutputs) -> Option<String> {
449        if !cfg!(target_os = "macos")
450            || self.link_output != LinkOutput::NativeProcMacro
451            || self
452                .arguments
453                .iter()
454                .any(|arg| matches!(arg, Argument::InstallName(_)))
455        {
456            return None;
457        }
458        let file = outputs
459            .files
460            .iter()
461            .find(|path| path.extension().is_some_and(|ext| ext == "dylib"))?;
462        let name = file.file_name()?.to_str()?;
463        if !safe_install_name(name) {
464            return None;
465        }
466        Some(format!("-Clink-arg=-Wl,-install_name,@rpath/{name}"))
467    }
468
469    /// Linker selected with `-C linker`, if the invocation overrides rustc's
470    /// platform default.
471    pub fn linker_override(&self) -> Option<&Path> {
472        self.arguments.iter().find_map(|argument| {
473            let Argument::Plain(value) = argument else {
474                return None;
475            };
476            value.strip_prefix("--codegen=linker=").map(Path::new)
477        })
478    }
479
480    /// Linker selected with `-C link-arg=-fuse-ld=<name>`, if the invocation
481    /// passes one the adapter models.
482    pub fn fuse_ld(&self) -> Option<&str> {
483        self.arguments.iter().find_map(|argument| {
484            let Argument::FuseLd(selection) = argument else {
485                return None;
486            };
487            Some(selection.as_str())
488        })
489    }
490
491    fn emits_windows_pdb(&self) -> bool {
492        if !cfg!(windows) || !self.links_natively() {
493            return false;
494        }
495        let mut enabled = false;
496        for argument in &self.arguments {
497            let Argument::Plain(value) = argument else {
498                continue;
499            };
500            if value == "-g" {
501                enabled = true;
502            } else if let Some(value) = value.strip_prefix("--codegen=debuginfo=") {
503                enabled = !matches!(value, "0" | "none");
504            }
505        }
506        enabled
507    }
508
509    /// Whether the contents of native search directories cannot reach this
510    /// invocation's outputs.
511    ///
512    /// A library emit never runs a linker, so a `-L native` directory is only
513    /// read for a static library named by `-l` -- and any `-l` already bypasses
514    /// the whole invocation. On Windows every crate downstream of a `cc`-built
515    /// dependency carries the MSVC toolset's `-L native` directories, which sit
516    /// outside every checkout root; treating them as content inputs would leave
517    /// all of those compilations permanently uncacheable. A `#[link]` attribute
518    /// naming a bundled static library could in principle reach through such a
519    /// directory without an `-l` flag, but toolchain directories are
520    /// version-stamped by their path, which the key still carries verbatim.
521    fn native_search_is_inert(&self) -> bool {
522        self.link_output == LinkOutput::Library
523    }
524
525    /// Return the source input passed to rustc.
526    pub fn source(&self) -> &Path {
527        &self.source
528    }
529
530    /// Absolute files named directly by the invocation that rustc must read.
531    ///
532    /// This includes the source, explicit extern artifacts, and custom target
533    /// specifications. Callers can snapshot these before rustc starts even
534    /// though the complete input set is only known from dep-info afterwards.
535    pub fn required_inputs_in(&self, working_dir: &Path) -> Vec<PathBuf> {
536        self.required_inputs
537            .iter()
538            .map(|path| absolute_path(path, working_dir))
539            .collect()
540    }
541
542    /// Return the explicitly selected compilation target, if any.
543    pub fn target(&self) -> Option<&str> {
544        self.target.as_deref()
545    }
546
547    /// Return the crate name rustc will assign to this compilation.
548    pub fn crate_name(&self) -> &str {
549        &self.crate_name
550    }
551
552    /// Digest of the inputs this compilation owns: its sources and whatever
553    /// they read, but not the artifacts it merely links against.
554    ///
555    /// This is what separates a crate someone is editing from one sitting
556    /// above it in the graph. Rebuilding a dependency changes the action key of
557    /// every crate that links it, because their keys hash its artifact; it does
558    /// not change this. A caller watching for churn has to watch this instead,
559    /// or a single edited crate would drag its whole dependent cone along.
560    ///
561    /// Host paths enter the digest as they are. This describes one checkout to
562    /// itself rather than to the cache, so there is nothing here to make
563    /// portable.
564    pub fn source_fingerprint(&self, discovered: &DiscoveredInputs) -> CacheDigest {
565        let linked = self
566            .arguments
567            .iter()
568            .filter_map(|argument| match argument {
569                Argument::Extern {
570                    path: Some(path), ..
571                } => Some(path.as_path()),
572                _ => None,
573            })
574            .collect::<BTreeSet<_>>();
575        let owned = discovered
576            .inputs
577            .iter()
578            .filter(|input| !linked.contains(input.path.as_path()))
579            .map(|input| (input.path.as_path(), &input.digest))
580            .collect::<BTreeMap<_, _>>();
581        let mut bytes = Vec::new();
582        for (path, digest) in owned {
583            bytes.extend_from_slice(path.as_os_str().as_encoded_bytes());
584            bytes.push(0);
585            bytes.extend_from_slice(digest.key().as_bytes());
586            bytes.push(0);
587        }
588        CacheDigest::blake3(&bytes)
589    }
590
591    /// Resolve the files produced by this invocation.
592    ///
593    /// The initial cache tier requires one output directory so its artifact can
594    /// be represented by one protocol directory and restored atomically later.
595    pub fn outputs(&self, working_dir: &Path) -> Result<RustcOutputs, BypassReason> {
596        if !working_dir.is_absolute() {
597            return Err(BypassReason::RelativeWorkingDirectory(
598                working_dir.to_path_buf(),
599            ));
600        }
601        let explicit_output = self
602            .explicit_output
603            .as_deref()
604            .map(|path| absolute_path(path, working_dir));
605        let output_directory = explicit_output
606            .as_deref()
607            .and_then(Path::parent)
608            .map(Path::to_path_buf)
609            .or_else(|| {
610                self.out_dir
611                    .as_deref()
612                    .map(|path| absolute_path(path, working_dir))
613            })
614            .unwrap_or_else(|| normalize_components(working_dir));
615        // rustc applies `-o` to every emit that has no path of its own, so the
616        // file names cannot be derived from the crate name here. Cargo always
617        // uses --out-dir instead, so refusing to model this costs nothing.
618        if let Some(output) = &explicit_output
619            && self.emits.iter().any(|emit| {
620                emit.path.is_none()
621                    && matches!(emit.kind.as_str(), "dep-info" | "link" | "metadata")
622            })
623        {
624            return Err(BypassReason::ImplicitEmitWithOutputFile(output.clone()));
625        }
626        let mut files = BTreeSet::new();
627        let mut dep_info = None;
628        for emit in &self.emits {
629            if emit.kind == "dep-info" {
630                let path = emit.path.as_ref().map_or_else(
631                    || {
632                        explicit_output.clone().map_or_else(
633                            || {
634                                output_directory
635                                    .join(format!("{}{}.d", self.crate_name, self.extra_filename))
636                            },
637                            |path| path.with_extension("d"),
638                        )
639                    },
640                    |path| absolute_path(path, working_dir),
641                );
642                if path.file_name().is_none() {
643                    return Err(BypassReason::InvalidOutputPath(path));
644                }
645                dep_info = Some(path);
646                continue;
647            }
648            let (prefix, extension) = match emit.kind.as_str() {
649                "link" => match self.link_output {
650                    LinkOutput::Library => ("lib", "rlib"),
651                    LinkOutput::WasmExecutable => ("", "wasm"),
652                    LinkOutput::NativeExecutable => ("", std::env::consts::EXE_EXTENSION),
653                    LinkOutput::NativeProcMacro => (
654                        std::env::consts::DLL_PREFIX,
655                        std::env::consts::DLL_SUFFIX.trim_start_matches('.'),
656                    ),
657                },
658                "metadata" => ("lib", "rmeta"),
659                _ => continue,
660            };
661            let path = if let Some(path) = &emit.path {
662                absolute_path(path, working_dir)
663            } else {
664                let name = format!("{prefix}{}{}", self.crate_name, self.extra_filename);
665                output_directory.join(if extension.is_empty() {
666                    name
667                } else {
668                    format!("{name}.{extension}")
669                })
670            };
671            if path.file_name().is_none() {
672                return Err(BypassReason::InvalidOutputPath(path));
673            }
674            if path.parent() != Some(output_directory.as_path()) {
675                return Err(BypassReason::SplitOutputDirectories);
676            }
677            // Whether a restored output is a program is read back off its name,
678            // so a program that answers to a library's name would be restored
679            // without the permission that makes it runnable. Nothing cargo
680            // emits looks like this; a hand-built invocation could.
681            let has_library_extension = path
682                .extension()
683                .and_then(|extension| extension.to_str())
684                .is_some_and(|extension| matches!(extension, "rlib" | "rmeta"))
685                || (cfg!(windows)
686                    && path
687                        .file_stem()
688                        .and_then(|stem| Path::new(stem).extension())
689                        .and_then(|extension| extension.to_str())
690                        .is_some_and(|extension| matches!(extension, "rlib" | "rmeta")));
691            if emit.kind == "link"
692                && !matches!(self.link_output, LinkOutput::Library)
693                && has_library_extension
694            {
695                return Err(BypassReason::AmbiguousOutputName(path));
696            }
697            if emit.kind == "link" && self.emits_windows_pdb() {
698                files.insert(path.with_extension("pdb"));
699            }
700            files.insert(path);
701        }
702        let dep_info = dep_info.ok_or(BypassReason::NoDepInfo)?;
703        if dep_info.parent() != Some(output_directory.as_path()) {
704            return Err(BypassReason::SplitOutputDirectories);
705        }
706        Ok(RustcOutputs {
707            directory: output_directory,
708            files: files.into_iter().collect(),
709            dep_info,
710        })
711    }
712
713    /// Build canonical action bytes after precise input discovery has run.
714    ///
715    /// `context.inputs` must contain the source, every explicit extern, and
716    /// every additional source or environment-generated input discovered from
717    /// dep-info.
718    pub fn action(&self, context: ActionContext) -> Result<RustcAction, BypassReason> {
719        self.action_linked_by(context, None)
720    }
721
722    /// Build canonical action bytes for an invocation that links a native
723    /// program.
724    ///
725    /// A `linker` is required whenever [`RustcInvocation::links_natively`]
726    /// holds: the linker, its startup objects, and the platform SDK are inputs
727    /// rustc dep-info does not enumerate, so a key without them would claim
728    /// more than it can support. Passing one for anything else is ignored,
729    /// since nothing else depends on a linker.
730    pub fn action_linked_by(
731        &self,
732        context: ActionContext,
733        linker: Option<LinkerIdentity>,
734    ) -> Result<RustcAction, BypassReason> {
735        ActionBuilder::new(self, context).linked_by(linker).build()
736    }
737
738    /// Fingerprint the modeled invocation before dependency contents are known.
739    pub fn invocation_digest(&self, context: &ActionContext) -> Result<CacheDigest, BypassReason> {
740        let descriptor = ActionBuilder::new(self, context.clone()).invocation_descriptor()?;
741        let bytes = canonical_json(&descriptor)
742            .map_err(|error| BypassReason::Serialization(error.to_string()))?;
743        Ok(CacheDigest::blake3(&bytes))
744    }
745
746    /// Capture normalized dependency paths for a future invocation that has no
747    /// dep-info file yet.
748    pub fn prediction(
749        &self,
750        context: &ActionContext,
751        discovered: &DiscoveredInputs,
752    ) -> Result<RustcInputPrediction, BypassReason> {
753        let builder = ActionBuilder::new(self, context.clone());
754        builder.validate_mappings()?;
755        let mut native_directories = BTreeSet::new();
756        for argument in &self.arguments {
757            if let Argument::SearchPath { kind, path } = argument
758                && kind == "native"
759            {
760                match builder.normalize_path(path) {
761                    Ok(normalized) => {
762                        native_directories.insert(normalized);
763                    }
764                    // Inert and outside every mapped root: the directory
765                    // contributes no content inputs, so the prediction has
766                    // nothing to replay for it. Input discovery skips it the
767                    // same way, which is what keeps the predicted action key
768                    // equal to the one dep-info discovery builds.
769                    Err(BypassReason::UnmappedAbsolutePath(_)) if self.native_search_is_inert() => {
770                    }
771                    Err(error) => return Err(error),
772                }
773            }
774        }
775        // A file beneath a recorded directory is rediscovered by walking that
776        // directory, so naming it as well only makes the payload grow with the
777        // object tree a C dependency leaves in its `OUT_DIR`. `aws-lc-sys`
778        // leaves thousands of files there, which was enough to push the
779        // serialized prediction past the protocol's payload limit and lose the
780        // prediction entirely for the crate that most needed one.
781        let mut inputs = BTreeSet::new();
782        for input in &discovered.inputs {
783            let normalized = builder.normalize_path(&input.path)?;
784            if !under_any_directory(&normalized, &native_directories) {
785                inputs.insert(normalized);
786            }
787        }
788        inputs.extend(
789            native_directories
790                .into_iter()
791                .map(|directory| format!("{NATIVE_DIRECTORY_PREDICTION_PREFIX}{directory}")),
792        );
793        Ok(RustcInputPrediction {
794            version: 4,
795            inputs: inputs.into_iter().collect(),
796            environment: discovered.environment.keys().cloned().collect(),
797            compiler_duration_ns: 0,
798            crate_name: String::new(),
799        })
800    }
801}
802
803impl RustcOutputs {
804    /// The linked executable Cargo will run as a build script, when this is a
805    /// build-script compilation.
806    pub fn build_script_executable(&self, crate_name: &str) -> Option<&Path> {
807        (crate_name == "build_script_build")
808            .then(|| self.files.iter().find(|path| self.is_executable(path)))
809            .flatten()
810            .map(PathBuf::as_path)
811    }
812
813    /// Whether `path` is a linked program whose executable permission is part
814    /// of the declared output contract.
815    /// A program is whatever this invocation emitted that is not a library
816    /// artifact. Every tier the adapter admits distinguishes the two by
817    /// extension -- `rlib` and `rmeta` are the compiler's own, and a linked
818    /// program carries either the target's (`wasm`) or none at all -- so the
819    /// name is enough and no separate list has to be carried alongside.
820    pub fn is_executable(&self, path: &Path) -> bool {
821        self.files.iter().any(|output| output == path)
822            && !matches!(
823                path.extension().and_then(|extension| extension.to_str()),
824                Some("rlib" | "rmeta")
825            )
826    }
827}
828
829#[derive(Debug, Clone, PartialEq, Eq)]
830/// Mapping from a host-specific absolute root to a stable key placeholder.
831pub struct PathMapping {
832    /// Absolute host path to replace.
833    pub root: PathBuf,
834    /// Placeholder name without the surrounding `${...}` syntax.
835    pub placeholder: String,
836}
837
838impl PathMapping {
839    /// Map an absolute host path to a stable cache-key placeholder.
840    pub fn new(root: impl Into<PathBuf>, placeholder: impl Into<String>) -> Self {
841        Self {
842            root: root.into(),
843            placeholder: placeholder.into(),
844        }
845    }
846
847    /// Order mappings deepest root first, which is what normalization needs:
848    /// a target directory inside the workspace has to win over the workspace.
849    pub fn ordered(mappings: &[PathMapping]) -> Vec<PathMapping> {
850        let mut ordered = mappings.to_vec();
851        ordered.sort_by_key(|mapping| std::cmp::Reverse(mapping.root.components().count()));
852        ordered
853    }
854}
855
856fn shared_path_mappings(mappings: &[PathMapping]) -> Vec<SharedPathMapping> {
857    mappings
858        .iter()
859        .map(|mapping| SharedPathMapping::new(&mapping.root, &mapping.placeholder))
860        .collect()
861}
862
863/// Map an absolute path to its cache-key placeholder form.
864///
865/// `mappings` must already be ordered by [`PathMapping::ordered`]. Exposed for
866/// callers that need the placeholder text before an action exists -- notably to
867/// build the `--remap-path-prefix` flag that makes a compilation independent of
868/// a path in its environment.
869pub fn normalize_mapped_path(
870    path: &Path,
871    working_dir: &Path,
872    mappings: &[PathMapping],
873) -> Result<String, BypassReason> {
874    normalize_shared_path(path, working_dir, &shared_path_mappings(mappings)).map_err(Into::into)
875}
876
877#[derive(Debug, Clone, PartialEq, Eq)]
878/// Compiler properties that distinguish incompatible action outputs.
879pub struct CompilerIdentity {
880    /// Toolchain selector or installation identity.
881    pub toolchain: String,
882    /// Complete verbose rustc version string.
883    pub rustc_version: String,
884    /// Compiler host target triple.
885    pub host: String,
886    /// Identity of a compiler driver layered over rustc, when one is in use.
887    pub driver: Option<String>,
888}
889
890/// One file input paired with the digest used in the action key.
891#[derive(Debug, Clone, PartialEq, Eq)]
892pub struct ActionInput {
893    /// Absolute host path used to read and verify the input.
894    pub path: PathBuf,
895    /// Digest of the input contents.
896    pub digest: CacheDigest,
897}
898
899/// External information needed to construct a canonical rustc action.
900#[derive(Debug, Clone, PartialEq, Eq)]
901pub struct ActionContext {
902    /// Identity of the compiler that produces the outputs.
903    pub compiler: CompilerIdentity,
904    /// Absolute directory in which rustc runs.
905    pub working_dir: PathBuf,
906    /// Host roots replaced with stable placeholders in the key.
907    pub path_mappings: Vec<PathMapping>,
908    /// Environment inputs and their observed values.
909    pub environment: BTreeMap<String, Option<String>>,
910    /// Environment inputs whose absolute values the compilation has been made
911    /// independent of, and whose values the key therefore normalizes.
912    ///
913    /// Naming one here is a claim about the compilation, not a preference: the
914    /// caller must both neutralize the value inside it (with
915    /// `--remap-path-prefix`) and confirm no output carries the value anyway.
916    pub portable_environment: BTreeSet<String>,
917    /// Complete set of direct and discovered file inputs.
918    pub inputs: Vec<ActionInput>,
919}
920
921/// What produced a linked native program, beyond the compiler itself.
922///
923/// The fields are identity rather than content wherever a compiler's own
924/// identity is: a driver's version output names its toolchain more cheaply than
925/// hashing a hundred megabytes of it, and matches how rustc is identified. The
926/// CRT objects are hashed, because nothing else pins the libc a link resolves
927/// against. Nothing here is placeholder-mapped -- these paths are host
928/// locations rather than checkout locations, and two hosts that differ should
929/// miss rather than share.
930#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
931#[serde(deny_unknown_fields)]
932pub struct LinkerIdentity {
933    /// Resolved absolute path of the linker driver rustc will invoke.
934    pub driver: String,
935    /// Version output of that driver.
936    pub driver_version: String,
937    /// Version of the linker the driver selects.
938    pub linker_version: String,
939    /// Startup objects and libc the driver resolves, by probe name.
940    #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
941    pub crt_objects: BTreeMap<String, CacheDigest>,
942    /// Platform SDK identity, where the platform has one.
943    #[serde(skip_serializing_if = "Option::is_none", default)]
944    pub sdk: Option<String>,
945    /// Deployment target the link was made against, where one applies.
946    #[serde(skip_serializing_if = "Option::is_none", default)]
947    pub deployment_target: Option<String>,
948}
949
950/// Canonical action descriptor and its content digest.
951#[derive(Debug, Clone, PartialEq, Eq)]
952pub struct RustcAction {
953    /// Digest of `bytes`, used as the action-cache key.
954    pub digest: CacheDigest,
955    /// Canonical serialized action descriptor.
956    pub bytes: Vec<u8>,
957}
958
959/// Normalized input names from the last successful execution of one modeled
960/// rustc invocation.
961#[derive(Debug, Clone, PartialEq, Eq)]
962pub struct RustcInputPrediction {
963    /// Prediction schema version.
964    pub version: u8,
965    /// Normalized input paths observed during the successful invocation.
966    pub inputs: Vec<String>,
967    /// Names of environment variables read by the compilation.
968    pub environment: Vec<String>,
969    /// Compiler wall time from the successful invocation that produced this
970    /// prediction. Zero means no timing hint was recorded.
971    pub compiler_duration_ns: u64,
972    /// Crate name associated with the timing hint.
973    pub crate_name: String,
974}
975
976#[derive(Serialize, Deserialize)]
977#[serde(deny_unknown_fields)]
978struct RustcInputPredictionWire {
979    version: u8,
980    inputs: Vec<String>,
981    environment: Vec<String>,
982    #[serde(default, skip_serializing_if = "is_zero")]
983    compiler_duration_ns: u64,
984    #[serde(default, skip_serializing_if = "String::is_empty")]
985    crate_name: String,
986}
987
988impl Serialize for RustcInputPrediction {
989    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
990    where
991        S: Serializer,
992    {
993        RustcInputPredictionWire {
994            version: self.version,
995            inputs: if self.version == 4 {
996                compact_prediction_inputs(&self.inputs)
997            } else {
998                self.inputs.clone()
999            },
1000            environment: self.environment.clone(),
1001            compiler_duration_ns: self.compiler_duration_ns,
1002            crate_name: self.crate_name.clone(),
1003        }
1004        .serialize(serializer)
1005    }
1006}
1007
1008impl<'de> Deserialize<'de> for RustcInputPrediction {
1009    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1010    where
1011        D: Deserializer<'de>,
1012    {
1013        let wire = RustcInputPredictionWire::deserialize(deserializer)?;
1014        let inputs = if wire.version == 4 {
1015            expand_prediction_inputs(&wire.inputs).map_err(serde::de::Error::custom)?
1016        } else {
1017            wire.inputs
1018        };
1019        Ok(Self {
1020            version: wire.version,
1021            inputs,
1022            environment: wire.environment,
1023            compiler_duration_ns: wire.compiler_duration_ns,
1024            crate_name: wire.crate_name,
1025        })
1026    }
1027}
1028
1029fn compact_prediction_inputs(inputs: &[String]) -> Vec<String> {
1030    let mut previous = "";
1031    inputs
1032        .iter()
1033        .map(|input| {
1034            let mut shared = previous
1035                .bytes()
1036                .zip(input.bytes())
1037                .take_while(|(left, right)| left == right)
1038                .count();
1039            while !input.is_char_boundary(shared) {
1040                shared -= 1;
1041            }
1042            let compact = format!("{shared}:{}", &input[shared..]);
1043            previous = input;
1044            compact
1045        })
1046        .collect()
1047}
1048
1049fn expand_prediction_inputs(inputs: &[String]) -> Result<Vec<String>, &'static str> {
1050    let mut expanded: Vec<String> = Vec::with_capacity(inputs.len());
1051    let mut decoded_bytes = 0_usize;
1052    for input in inputs {
1053        let (shared_text, suffix) = input
1054            .split_once(':')
1055            .ok_or("compact rustc prediction input has no prefix length")?;
1056        let shared: usize = shared_text
1057            .parse()
1058            .map_err(|_| "compact rustc prediction prefix length is invalid")?;
1059        if shared.to_string() != shared_text
1060            || shared > expanded.last().map_or(0, String::len)
1061            || expanded
1062                .last()
1063                .is_some_and(|previous| !previous.is_char_boundary(shared))
1064        {
1065            return Err("compact rustc prediction prefix is not canonical");
1066        }
1067        let mut path = expanded
1068            .last()
1069            .map_or_else(String::new, |previous| previous[..shared].to_string());
1070        path.push_str(suffix);
1071        decoded_bytes = decoded_bytes
1072            .checked_add(path.len())
1073            .ok_or("compact rustc prediction is too large")?;
1074        if decoded_bytes > MAX_DECODED_PREDICTION_BYTES {
1075            return Err("compact rustc prediction is too large");
1076        }
1077        expanded.push(path);
1078    }
1079    Ok(expanded)
1080}
1081
1082fn is_zero(value: &u64) -> bool {
1083    *value == 0
1084}
1085
1086impl RustcInputPrediction {
1087    /// Rehash the predicted paths and read the current environment. The caller
1088    /// still recomputes the full action digest, so changed inputs are misses.
1089    pub fn discover(
1090        &self,
1091        working_dir: &Path,
1092        path_mappings: &[PathMapping],
1093        digests: &dyn FileDigestCache,
1094    ) -> Result<DiscoveredInputs, BypassReason> {
1095        if !matches!(self.version, 2..=4) {
1096            return Err(BypassReason::UnsupportedPrediction);
1097        }
1098        if self.inputs.len() > MAX_PREDICTED_INPUTS || self.environment.len() > 4 * 1024 {
1099            return Err(BypassReason::UnsupportedPrediction);
1100        }
1101        let mut paths = BTreeSet::new();
1102        let admitted_roots = dep_info::native_input_roots(working_dir, path_mappings);
1103        let mut native_bytes = 0_u64;
1104        for path in &self.inputs {
1105            if self.version >= 3
1106                && let Some(path) = path.strip_prefix(NATIVE_DIRECTORY_PREDICTION_PREFIX)
1107            {
1108                let directory = denormalize_path(path, path_mappings)?;
1109                dep_info::collect_native_directory(
1110                    &directory,
1111                    &admitted_roots,
1112                    &mut paths,
1113                    &mut native_bytes,
1114                )?;
1115            } else {
1116                paths.insert(denormalize_path(path, path_mappings)?);
1117            }
1118        }
1119        let environment = self
1120            .environment
1121            .iter()
1122            .map(|name| {
1123                if name.is_empty() || name.contains(['=', '\0']) {
1124                    return Err(BypassReason::UnsupportedPrediction);
1125                }
1126                let value = std::env::var_os(name)
1127                    .map(|value| {
1128                        value
1129                            .into_string()
1130                            .map_err(|_| BypassReason::UnsupportedPrediction)
1131                    })
1132                    .transpose()?;
1133                Ok((name.clone(), value))
1134            })
1135            .collect::<Result<BTreeMap<_, _>, _>>()?;
1136        DiscoveredInputs::from_paths(working_dir, paths, environment, digests)
1137    }
1138}
1139
1140#[derive(Serialize)]
1141struct ActionDescriptor {
1142    version: u8,
1143    kind: &'static str,
1144    adapter_version: u8,
1145    compiler: CompilerDescriptor,
1146    arguments: Vec<String>,
1147    environment: BTreeMap<String, Option<String>>,
1148    inputs: Vec<InputDescriptor>,
1149    /// Omitted entirely unless the invocation links natively, so every key
1150    /// written before this field existed still serializes to the same bytes.
1151    #[serde(skip_serializing_if = "Option::is_none")]
1152    linker: Option<LinkerIdentity>,
1153}
1154
1155#[derive(Serialize)]
1156struct InvocationDescriptor {
1157    version: u8,
1158    kind: &'static str,
1159    adapter_version: u8,
1160    compiler: CompilerDescriptor,
1161    arguments: Vec<String>,
1162    required_inputs: Vec<String>,
1163}
1164
1165#[derive(Serialize)]
1166struct CompilerDescriptor {
1167    toolchain: String,
1168    rustc_version: String,
1169    host: String,
1170    #[serde(skip_serializing_if = "Option::is_none")]
1171    driver: Option<String>,
1172}
1173
1174#[derive(Debug, Serialize, PartialEq, Eq, PartialOrd, Ord)]
1175struct InputDescriptor {
1176    path: String,
1177    digest: CacheDigest,
1178}
1179
1180struct Parser<'a> {
1181    arguments: &'a [OsString],
1182    index: usize,
1183    parsed: Vec<Argument>,
1184    source: Option<PathBuf>,
1185    crate_types: Vec<String>,
1186    emits: Vec<Emit>,
1187    required_inputs: Vec<PathBuf>,
1188    test: bool,
1189    crate_name: Option<String>,
1190    extra_filename: String,
1191    out_dir: Option<PathBuf>,
1192    explicit_output: Option<PathBuf>,
1193    target: Option<String>,
1194    options: ParseOptions,
1195}
1196
1197struct ExpandedArguments {
1198    arguments: Vec<OsString>,
1199}
1200
1201#[derive(Default)]
1202struct ResponseExpander {
1203    shell_argfiles: bool,
1204    next_is_unstable_option: bool,
1205    arguments: Vec<OsString>,
1206}
1207
1208impl ResponseExpander {
1209    fn push(&mut self, argument: String) {
1210        if self.next_is_unstable_option {
1211            self.shell_argfiles |= argument == "shell-argfiles";
1212            self.next_is_unstable_option = false;
1213        } else if let Some(option) = argument.strip_prefix("-Z") {
1214            if option.is_empty() {
1215                self.next_is_unstable_option = true;
1216            } else {
1217                self.shell_argfiles |= option == "shell-argfiles";
1218            }
1219        }
1220        self.arguments.push(argument.into());
1221    }
1222}
1223
1224/// Match rustc's argfile expansion: UTF-8, one option per line, no recursive
1225/// expansion, with the nightly shell form enabled only after its `-Z` flag.
1226fn expand_response_files(arguments: &[OsString]) -> Result<ExpandedArguments, BypassReason> {
1227    let mut expanded = ResponseExpander::default();
1228    for (index, argument) in arguments.iter().enumerate() {
1229        let argument = argument
1230            .to_str()
1231            .ok_or(BypassReason::NonUtf8Argument { index })?;
1232        let Some(argfile) = argument.strip_prefix('@') else {
1233            expanded.push(argument.to_string());
1234            continue;
1235        };
1236        let (path, shell) = match argfile.split_once(':') {
1237            Some(("shell", path)) if expanded.shell_argfiles => (path, true),
1238            _ => (argfile, false),
1239        };
1240        let contents = std::fs::read_to_string(path).map_err(|error| {
1241            BypassReason::ResponseFile(format!("{}: {error}", Path::new(path).display()))
1242        })?;
1243        if shell {
1244            let arguments = shlex::split(&contents).ok_or_else(|| {
1245                BypassReason::ResponseFile(format!(
1246                    "invalid shell-style arguments in {}",
1247                    Path::new(path).display()
1248                ))
1249            })?;
1250            for argument in arguments {
1251                expanded.push(argument);
1252            }
1253        } else {
1254            for argument in contents.lines() {
1255                expanded.push(argument.to_string());
1256            }
1257        }
1258    }
1259    Ok(ExpandedArguments {
1260        arguments: expanded.arguments,
1261    })
1262}
1263
1264impl<'a> Parser<'a> {
1265    fn new(arguments: &'a [OsString], options: ParseOptions) -> Self {
1266        Self {
1267            arguments,
1268            options,
1269            index: 0,
1270            parsed: Vec::new(),
1271            source: None,
1272            crate_types: Vec::new(),
1273            emits: Vec::new(),
1274            required_inputs: Vec::new(),
1275            test: false,
1276            crate_name: None,
1277            extra_filename: String::new(),
1278            out_dir: None,
1279            explicit_output: None,
1280            target: None,
1281        }
1282    }
1283
1284    fn parse(mut self) -> Result<RustcInvocation, BypassReason> {
1285        while self.index < self.arguments.len() {
1286            let value = self.current()?.to_string();
1287            self.index += 1;
1288            if let Some(long) = value.strip_prefix("--") {
1289                self.parse_long(long)?;
1290            } else if value.starts_with('-') && value != "-" {
1291                self.parse_short(&value)?;
1292            } else {
1293                self.parse_input(&value)?;
1294            }
1295        }
1296
1297        let source = self.source.clone().ok_or(BypassReason::MissingInput)?;
1298        let link_output = self.classify()?;
1299        let crate_name = self.crate_name.clone().map_or_else(
1300            || {
1301                source
1302                    .file_stem()
1303                    .and_then(|name| name.to_str())
1304                    .map(|name| name.replace('-', "_"))
1305                    .ok_or_else(|| BypassReason::NonUtf8Path(source.clone()))
1306            },
1307            Ok,
1308        )?;
1309        self.required_inputs.push(source.clone());
1310        Ok(RustcInvocation {
1311            arguments: self.parsed,
1312            source,
1313            required_inputs: self.required_inputs,
1314            crate_name,
1315            extra_filename: self.extra_filename,
1316            out_dir: self.out_dir,
1317            explicit_output: self.explicit_output,
1318            emits: self.emits,
1319            target: self.target,
1320            link_output,
1321        })
1322    }
1323
1324    fn current(&self) -> Result<&str, BypassReason> {
1325        self.arguments[self.index]
1326            .to_str()
1327            .ok_or(BypassReason::NonUtf8Argument { index: self.index })
1328    }
1329
1330    fn take_value(&mut self, flag: &str, inline: Option<&str>) -> Result<String, BypassReason> {
1331        if let Some(value) = inline {
1332            if value.is_empty() {
1333                return Err(BypassReason::MissingValue(flag.into()));
1334            }
1335            return Ok(value.into());
1336        }
1337        if self.index >= self.arguments.len() {
1338            return Err(BypassReason::MissingValue(flag.into()));
1339        }
1340        let value = self.current()?.to_string();
1341        self.index += 1;
1342        Ok(value)
1343    }
1344
1345    fn parse_long(&mut self, value: &str) -> Result<(), BypassReason> {
1346        let (flag, inline) = value
1347            .split_once('=')
1348            .map_or((value, None), |(flag, value)| (flag, Some(value)));
1349        let rendered_flag = format!("--{flag}");
1350        match flag {
1351            "help" | "version" | "explain" | "print" => Err(BypassReason::CompilerQuery),
1352            "test" => {
1353                self.test = true;
1354                self.parsed.push(Argument::Plain(rendered_flag));
1355                Ok(())
1356            }
1357            "verbose" => {
1358                self.parsed.push(Argument::Plain(rendered_flag));
1359                Ok(())
1360            }
1361            "crate-name" => {
1362                let value = self.take_value(&rendered_flag, inline)?;
1363                self.crate_name = Some(value.clone());
1364                self.parsed
1365                    .push(Argument::Plain(format!("{rendered_flag}={value}")));
1366                Ok(())
1367            }
1368            "cfg" | "check-cfg" | "edition" | "error-format" | "json" | "color"
1369            | "diagnostic-width" | "remap-path-scope" | "allow" | "warn" | "force-warn"
1370            | "deny" | "forbid" | "cap-lints" => {
1371                let value = self.take_value(&rendered_flag, inline)?;
1372                self.parsed
1373                    .push(Argument::Plain(format!("{rendered_flag}={value}")));
1374                Ok(())
1375            }
1376            "target" => {
1377                let value = self.take_value(&rendered_flag, inline)?;
1378                self.target = Some(value.clone());
1379                if value.ends_with(".json") || value.contains(['/', '\\']) {
1380                    let path = PathBuf::from(value);
1381                    self.required_inputs.push(path.clone());
1382                    self.parsed.push(Argument::Path {
1383                        flag: rendered_flag,
1384                        path,
1385                    });
1386                } else {
1387                    self.target = Some(value.clone());
1388                    self.parsed
1389                        .push(Argument::Plain(format!("{rendered_flag}={value}")));
1390                }
1391                Ok(())
1392            }
1393            "crate-type" => {
1394                let value = self.take_value(&rendered_flag, inline)?;
1395                self.crate_types
1396                    .extend(value.split(',').map(ToOwned::to_owned));
1397                self.parsed
1398                    .push(Argument::Plain(format!("{rendered_flag}={value}")));
1399                Ok(())
1400            }
1401            "emit" => {
1402                let value = self.take_value(&rendered_flag, inline)?;
1403                let emits = parse_emits(&value);
1404                self.emits.extend(emits.clone());
1405                self.parsed.push(Argument::Emit(emits));
1406                Ok(())
1407            }
1408            "out-dir" => {
1409                let path = PathBuf::from(self.take_value(&rendered_flag, inline)?);
1410                self.out_dir = Some(path.clone());
1411                self.parsed.push(Argument::Path {
1412                    flag: rendered_flag,
1413                    path,
1414                });
1415                Ok(())
1416            }
1417            "sysroot" => {
1418                let path = self.take_value(&rendered_flag, inline)?;
1419                self.parsed.push(Argument::Path {
1420                    flag: rendered_flag,
1421                    path: path.into(),
1422                });
1423                Ok(())
1424            }
1425            "extern" => {
1426                let value = self.take_value(&rendered_flag, inline)?;
1427                let (name, path) = value
1428                    .split_once('=')
1429                    .map_or((value.as_str(), None), |(name, path)| {
1430                        (name, Some(PathBuf::from(path)))
1431                    });
1432                if let Some(path) = &path {
1433                    self.required_inputs.push(path.clone());
1434                }
1435                self.parsed.push(Argument::Extern {
1436                    name: name.into(),
1437                    path,
1438                });
1439                Ok(())
1440            }
1441            "remap-path-prefix" => {
1442                let value = self.take_value(&rendered_flag, inline)?;
1443                let Some((from, to)) = value.split_once('=') else {
1444                    return Err(BypassReason::MissingValue(rendered_flag));
1445                };
1446                self.parsed.push(Argument::RemapPath {
1447                    from: from.into(),
1448                    to: to.into(),
1449                });
1450                Ok(())
1451            }
1452            "codegen" => {
1453                let value = self.take_value(&rendered_flag, inline)?;
1454                self.parse_codegen(&value)
1455            }
1456            "jobs-frontend" => {
1457                let value = self.take_value(&rendered_flag, inline)?;
1458                self.parsed
1459                    .push(Argument::Plain(format!("{rendered_flag}={value}")));
1460                Ok(())
1461            }
1462            _ => Err(BypassReason::UnknownFlag(rendered_flag)),
1463        }
1464    }
1465
1466    fn parse_short(&mut self, value: &str) -> Result<(), BypassReason> {
1467        if let Some(attached) = value.strip_prefix("-Z") {
1468            let option = self.take_value("-Z", (!attached.is_empty()).then_some(attached))?;
1469            match option.as_str() {
1470                "embed-metadata=no" | "embed-metadata=yes" | "shell-argfiles"
1471                | "unstable-options" => {
1472                    self.parsed.push(Argument::Plain(format!("-Z{option}")));
1473                    return Ok(());
1474                }
1475                "threads" | "threads=" => {
1476                    return Err(BypassReason::MissingValue("-Zthreads".into()));
1477                }
1478                _ => {}
1479            }
1480            if option.starts_with("threads=") {
1481                self.parsed.push(Argument::Plain(format!("-Z{option}")));
1482                return Ok(());
1483            }
1484            return Err(BypassReason::UnknownFlag(format!("-Z{option}")));
1485        }
1486        match value {
1487            // `-vV` is how cargo and build scripts ask for the verbose
1488            // version, so it is a query rather than a flag left unmodeled.
1489            "-h" | "-V" | "-vV" => return Err(BypassReason::CompilerQuery),
1490            "-g" | "-O" | "-v" => {
1491                self.parsed.push(Argument::Plain(value.into()));
1492                return Ok(());
1493            }
1494            _ => {}
1495        }
1496        for (short, long) in [
1497            ("-A", "--allow"),
1498            ("-W", "--warn"),
1499            ("-D", "--deny"),
1500            ("-F", "--forbid"),
1501        ] {
1502            if let Some(attached) = value.strip_prefix(short) {
1503                let lint = self.take_value(short, (!attached.is_empty()).then_some(attached))?;
1504                self.parsed.push(Argument::Plain(format!("{long}={lint}")));
1505                return Ok(());
1506            }
1507        }
1508        if let Some(attached) = value.strip_prefix("-C") {
1509            let option = self.take_value("-C", (!attached.is_empty()).then_some(attached))?;
1510            return self.parse_codegen(&option);
1511        }
1512        if let Some(attached) = value.strip_prefix("-L") {
1513            let search = self.take_value("-L", (!attached.is_empty()).then_some(attached))?;
1514            let (kind, path) = search
1515                .split_once('=')
1516                .map_or(("all", search.as_str()), |(kind, path)| (kind, path));
1517            if !matches!(kind, "dependency" | "native") {
1518                return Err(BypassReason::UnsupportedSearchPath(kind.into()));
1519            }
1520            self.parsed.push(Argument::SearchPath {
1521                kind: kind.into(),
1522                path: path.into(),
1523            });
1524            return Ok(());
1525        }
1526        if value == "-l" || value.starts_with("-l") {
1527            return Err(BypassReason::NativeLibrary);
1528        }
1529        if let Some(attached) = value.strip_prefix("-o") {
1530            let path = self.take_value("-o", (!attached.is_empty()).then_some(attached))?;
1531            self.explicit_output = Some(path.clone().into());
1532            self.parsed.push(Argument::Path {
1533                flag: "-o".into(),
1534                path: path.into(),
1535            });
1536            return Ok(());
1537        }
1538        Err(BypassReason::UnknownFlag(value.into()))
1539    }
1540
1541    fn parse_codegen(&mut self, value: &str) -> Result<(), BypassReason> {
1542        let name = value.split_once('=').map_or(value, |(name, _)| name);
1543        if name == "incremental" {
1544            return Err(BypassReason::Incremental);
1545        }
1546        if SUPPORTED_CODEGEN_OPTIONS.binary_search(&name).is_err() && name != "linker" {
1547            return Err(BypassReason::UnknownCodegenOption(name.into()));
1548        }
1549        // Cargo compiles every dependency of an LTO build with this flag, so
1550        // the rlibs carry bitcode for the final link to optimize across. The
1551        // switch is keyed as text like any other option. A value naming the
1552        // linker plugin is a host file the key does not describe, so that
1553        // spelling still bypasses.
1554        if name == "linker-plugin-lto"
1555            && let Some((_, selection)) = value.split_once('=')
1556            && !matches!(
1557                selection,
1558                "" | "y" | "yes" | "on" | "true" | "n" | "no" | "off" | "false"
1559            )
1560        {
1561            return Err(BypassReason::UnknownCodegenOption(format!(
1562                "linker-plugin-lto={selection}"
1563            )));
1564        }
1565        // `-fuse-ld=<name>` selects the linker the driver invokes. Parsed
1566        // rather than keyed as text so the linker identity probe can pin the
1567        // selected linker; a repeated selection dedups, and a conflicting
1568        // second selection falls through to Plain, which native-link handling
1569        // declines to model. The driver honors the last selection, so a
1570        // conflict is a link whose linker this adapter cannot name.
1571        if name == "link-arg"
1572            && let Some((_, option)) = value.split_once('=')
1573            && let Some(selection) = option.strip_prefix("-fuse-ld=")
1574            && !selection.is_empty()
1575            && !selection.contains(',')
1576            && !self.parsed.iter().any(
1577                |argument| matches!(argument, Argument::FuseLd(existing) if existing != selection),
1578            )
1579        {
1580            if !self.parsed.iter().any(
1581                |argument| matches!(argument, Argument::FuseLd(existing) if existing == selection),
1582            ) {
1583                self.parsed.push(Argument::FuseLd(selection.to_owned()));
1584            }
1585            return Ok(());
1586        }
1587        // Only a single filename under @rpath is modeled. Other spellings,
1588        // combined linker options, and explicit absolute names still bypass.
1589        if name == "link-arg"
1590            && let Some(option) = value.strip_prefix("link-arg=-Wl,-install_name,@rpath/")
1591            && safe_install_name(option)
1592        {
1593            self.parsed.push(Argument::InstallName(option.to_owned()));
1594            return Ok(());
1595        }
1596        // `-oso_prefix` names a checkout-specific path, so it is parsed
1597        // rather than keyed as text: the path normalizes like every other
1598        // path in the key, which is what lets two checkouts passing their own
1599        // prefixes agree on one action.
1600        if matches!(name, "link-arg" | "link-args")
1601            && let Some((_, option)) = value.split_once('=')
1602            && let Some(prefix) = option.strip_prefix("-Wl,-oso_prefix,")
1603            && !prefix.trim_end_matches('/').is_empty()
1604            && !prefix.contains(',')
1605        {
1606            let trailing_slash = prefix.ends_with('/');
1607            self.parsed.push(Argument::OsoPrefix {
1608                path: PathBuf::from(prefix.trim_end_matches('/')),
1609                trailing_slash,
1610            });
1611            return Ok(());
1612        }
1613        self.parsed
1614            .push(Argument::Plain(format!("--codegen={value}")));
1615        if name == "extra-filename" {
1616            self.extra_filename = value
1617                .split_once('=')
1618                .map_or(String::new(), |(_, value)| value.to_string());
1619        }
1620        Ok(())
1621    }
1622
1623    fn parse_input(&mut self, value: &str) -> Result<(), BypassReason> {
1624        if value == "-" {
1625            return Err(BypassReason::StandardInput);
1626        }
1627        if self.source.replace(value.into()).is_some() {
1628            return Err(BypassReason::MultipleInputs);
1629        }
1630        Ok(())
1631    }
1632
1633    fn classify(&self) -> Result<LinkOutput, BypassReason> {
1634        // A compilation that emits no `link` produces exactly what an rlib's
1635        // metadata emit does -- dep-info and `lib<name>.rmeta` -- whatever it
1636        // calls its crate type. `cargo check` and clippy compile every binary
1637        // and test target this way, and those were bypassing as an
1638        // unsupported crate type for a linked artifact none of them asked
1639        // for. No linker runs, so nothing downstream needs a linker identity
1640        // to describe, and rustc names the metadata the same way for every
1641        // crate type.
1642        let never_links = !self.emits.iter().any(|emit| emit.kind == "link");
1643        let builds_a_library = !self.test
1644            && !self.crate_types.is_empty()
1645            && self
1646                .crate_types
1647                .iter()
1648                .all(|crate_type| matches!(crate_type.as_str(), "lib" | "rlib"));
1649        let link_output = if never_links || builds_a_library {
1650            LinkOutput::Library
1651        } else if self
1652            .target
1653            .as_deref()
1654            .is_some_and(compiler_bundled_wasm_target)
1655            && ((self.test && self.crate_types.is_empty())
1656                || matches!(self.crate_types.as_slice(), [kind] if kind == "bin" || kind == "cdylib"))
1657        {
1658            if self.parsed.iter().any(|argument| match argument {
1659                Argument::Plain(value) if value == "--codegen=link-self-contained" => false,
1660                Argument::Plain(value) if value.starts_with("--codegen=link-self-contained=") => {
1661                    !matches!(
1662                        value.rsplit_once('=').map(|(_, value)| value),
1663                        Some("y" | "yes" | "on" | "true")
1664                    )
1665                }
1666                _ => false,
1667            }) {
1668                return Err(BypassReason::UnknownCodegenOption(
1669                    "link-self-contained".into(),
1670                ));
1671            }
1672            if self.target.as_deref().is_some_and(|target| target.contains("wasi"))
1673                && self.parsed.iter().any(|argument| {
1674                    matches!(argument, Argument::Plain(value) if value.strip_prefix("--codegen=target-feature=").is_some_and(|features| features.split(',').any(|feature| feature == "-crt-static")))
1675                })
1676            {
1677                return Err(BypassReason::UnknownCodegenOption(
1678                    "target-feature=-crt-static".into(),
1679                ));
1680            }
1681            // These targets use a linker and, where applicable, CRT objects
1682            // and libc shipped in the Rust toolchain. Unlike native linking,
1683            // there are no implicit host inputs outside compiler identity.
1684            LinkOutput::WasmExecutable
1685        } else if self.options.cache_native_links && self.links_a_native_artifact() {
1686            self.check_native_link_is_portable()?;
1687            if matches!(self.crate_types.as_slice(), [kind] if kind == "proc-macro") {
1688                LinkOutput::NativeProcMacro
1689            } else {
1690                LinkOutput::NativeExecutable
1691            }
1692        } else if self.test {
1693            return Err(BypassReason::UnsupportedCrateType("test".into()));
1694        } else {
1695            return Err(BypassReason::UnsupportedCrateType(
1696                self.crate_types
1697                    .iter()
1698                    .find(|crate_type| !matches!(crate_type.as_str(), "lib" | "rlib"))
1699                    .cloned()
1700                    .unwrap_or_else(|| "bin".into()),
1701            ));
1702        };
1703        // A link argument is handed to the linker verbatim, and its text is all
1704        // the adapter has to go on. `-Clink-arg=-Tlink.x` names a linker script
1705        // resolved off the search path, `-Clink-arg=-fuse-ld=lld` replaces the
1706        // linker the identity in the key describes, and neither is told apart
1707        // from `/STACK:8000000` by any rule short of knowing the linker's own
1708        // grammar. Where nothing links there is nothing to tell apart: an rlib
1709        // or an rmeta is produced without a linker invocation, so the option is
1710        // inert and the key carries its text like any other codegen option.
1711        if link_output != LinkOutput::Library
1712            && let Some(option) = self.first_link_argument()
1713        {
1714            return Err(BypassReason::UnmodeledLinkArgument(option.to_owned()));
1715        }
1716        if self
1717            .parsed
1718            .iter()
1719            .any(|arg| matches!(arg, Argument::InstallName(_)))
1720            && !(cfg!(target_os = "macos") && link_output == LinkOutput::NativeProcMacro)
1721        {
1722            return Err(BypassReason::UnmodeledLinkArgument(
1723                "link-arg=-Wl,-install_name".into(),
1724            ));
1725        }
1726        // `-fuse-ld` is modeled only for native links, where the linker
1727        // identity probe resolves and pins the linker it names. A wasm or
1728        // other non-native link consuming the selection has no identity that
1729        // describes it, so it declines the action like any other link
1730        // argument. Where nothing links, the selection is inert and the key
1731        // carries its text.
1732        if let Some(selection) = self.parsed.iter().find_map(|argument| {
1733            let Argument::FuseLd(selection) = argument else {
1734                return None;
1735            };
1736            Some(selection.as_str())
1737        }) && !matches!(
1738            link_output,
1739            LinkOutput::Library | LinkOutput::NativeExecutable | LinkOutput::NativeProcMacro
1740        ) {
1741            return Err(BypassReason::UnmodeledLinkArgument(format!(
1742                "link-arg=-fuse-ld={selection}"
1743            )));
1744        }
1745        if self.parsed.iter().any(|argument| {
1746            matches!(argument, Argument::Plain(value) if value.starts_with("--codegen=linker="))
1747        }) && !matches!(
1748            link_output,
1749            LinkOutput::NativeExecutable | LinkOutput::NativeProcMacro
1750        ) {
1751            return Err(BypassReason::UnknownCodegenOption("linker".into()));
1752        }
1753        // `-oso_prefix` is modeled, but only where ld64 is the linker reading
1754        // it: a native macOS link. Any other linker sees an option this
1755        // adapter cannot vouch for, exactly like the arguments above.
1756        if !matches!(
1757            link_output,
1758            LinkOutput::Library | LinkOutput::NativeExecutable | LinkOutput::NativeProcMacro
1759        ) && self
1760            .parsed
1761            .iter()
1762            .any(|argument| matches!(argument, Argument::OsoPrefix { .. }))
1763        {
1764            return Err(BypassReason::UnmodeledLinkArgument(
1765                "link-arg=-Wl,-oso_prefix".into(),
1766            ));
1767        }
1768        if let Some(name) = self.parsed.iter().find_map(|argument| match argument {
1769            Argument::Extern { name, path: None } if name != "proc_macro" => Some(name),
1770            _ => None,
1771        }) {
1772            return Err(BypassReason::UnresolvedExtern(name.clone()));
1773        }
1774        if let Some(emit) = self
1775            .emits
1776            .iter()
1777            .find(|emit| !matches!(emit.kind.as_str(), "dep-info" | "link" | "metadata"))
1778        {
1779            return Err(BypassReason::UnsupportedEmit(emit.kind.clone()));
1780        }
1781        if !self
1782            .emits
1783            .iter()
1784            .any(|emit| matches!(emit.kind.as_str(), "link" | "metadata"))
1785        {
1786            return Err(BypassReason::NoCacheableOutput);
1787        }
1788        Ok(link_output)
1789    }
1790}
1791
1792impl Parser<'_> {
1793    /// The first `-C link-arg` or `-C link-args` option in the invocation,
1794    /// rendered as it appears in the action key.
1795    fn first_link_argument(&self) -> Option<&str> {
1796        self.parsed.iter().find_map(|argument| {
1797            let Argument::Plain(value) = argument else {
1798                return None;
1799            };
1800            let option = value.strip_prefix("--codegen=")?;
1801            let name = option.split_once('=').map_or(option, |(name, _)| name);
1802            matches!(name, "link-arg" | "link-args").then_some(option)
1803        })
1804    }
1805
1806    /// Whether an `-oso_prefix` strips the directory this link's objects live
1807    /// in from the debug map ld64 records.
1808    ///
1809    /// The objects a native link reads -- this unit's own compiled objects
1810    /// and the rlibs it links -- sit in the output directory, so a prefix
1811    /// covering that directory leaves the debug map naming spellings every
1812    /// checkout shares. Toolchain objects stay absolute, as they do in the
1813    /// paths rustc itself embeds on every platform.
1814    fn oso_prefix_covers_outputs(&self) -> bool {
1815        let Some(directory) = self
1816            .out_dir
1817            .as_deref()
1818            .or_else(|| self.explicit_output.as_deref().and_then(Path::parent))
1819        else {
1820            return false;
1821        };
1822        if !directory.is_absolute() {
1823            return false;
1824        }
1825        let directory = normalize_components(directory);
1826        self.parsed.iter().any(|argument| {
1827            let Argument::OsoPrefix { path, .. } = argument else {
1828                return false;
1829            };
1830            path.is_absolute()
1831                && (directory.starts_with(normalize_components(path))
1832                    || std::fs::canonicalize(&directory)
1833                        .ok()
1834                        .zip(std::fs::canonicalize(path).ok())
1835                        .is_some_and(|(directory, prefix)| directory.starts_with(prefix)))
1836        })
1837    }
1838
1839    /// Whether this invocation links an artifact for the host.
1840    ///
1841    /// An explicit `--target` bypasses even when it spells the host triple:
1842    /// rustc without one links for the host by construction, which is the
1843    /// cheapest description of "the linker this adapter can identify", and
1844    /// cargo omits it for host builds anyway.
1845    fn links_a_native_artifact(&self) -> bool {
1846        self.target.is_none()
1847            // A compilation that emits no linked artifact did not link, so it
1848            // needs no linker to describe it. `cargo check --tests` asks for
1849            // metadata alone, and reading that as a link would send it looking
1850            // for a linker identity and refusing flags no linker ever saw.
1851            && self.emits.iter().any(|emit| emit.kind == "link")
1852            && ((self.test && self.crate_types.is_empty())
1853                || matches!(self.crate_types.as_slice(), [kind] if matches!(kind.as_str(), "bin" | "proc-macro")))
1854    }
1855
1856    /// Reject a native link whose result depends on something the key cannot
1857    /// describe, or that leaves artifacts beside the ones mbx would store.
1858    ///
1859    /// Each check names a value rather than a flag: an option absent from the
1860    /// invocation keeps rustc's default, which the compiler identity already
1861    /// pins, while any other spelling is refused rather than guessed at.
1862    fn check_native_link_is_portable(&self) -> Result<(), BypassReason> {
1863        for argument in &self.parsed {
1864            let Argument::Plain(value) = argument else {
1865                continue;
1866            };
1867            // `-g` is rustc's shorthand for debug info, and arrives as itself
1868            // rather than as a codegen option.
1869            let (name, value) = if value == "-g" {
1870                ("debuginfo", Some("2"))
1871            } else if let Some(option) = value.strip_prefix("--codegen=") {
1872                match option.split_once('=') {
1873                    Some((name, value)) => (name, Some(value)),
1874                    // A codegen flag with no value asks for its enabled form,
1875                    // which is what cargo passes for `-Crpath`. Reading it as
1876                    // "nothing to check" is how these slipped through.
1877                    None => (option, None),
1878                }
1879            } else {
1880                continue;
1881            };
1882            let unportable = match name {
1883                // Packed debug info leaves a .dSYM bundle or .dwp file beside
1884                // the binary, and mbx stores neither. Unpacked is macOS's
1885                // debug-map arrangement -- debug info stays in the objects and
1886                // nothing lands beside the binary -- so the same covering
1887                // `-oso_prefix` that makes the debug map portable covers it.
1888                "split-debuginfo" => match value {
1889                    Some("off") => false,
1890                    Some("unpacked") if cfg!(target_os = "macos") => {
1891                        !self.oso_prefix_covers_outputs()
1892                    }
1893                    _ => true,
1894                },
1895                // ld64 records absolute object paths and their timestamps in
1896                // the binary's debug map, so the same source links to
1897                // different bytes in another checkout -- or the same one
1898                // twice. An `-oso_prefix` covering the output directory
1899                // strips those paths down to spellings every checkout
1900                // shares, which is the same leniency a Linux link already
1901                // gets for the paths rustc itself embeds; what remains
1902                // (object timestamps) only shows up under verification.
1903                "debuginfo" if cfg!(target_os = "macos") => {
1904                    !matches!(value, Some("0" | "none")) && !self.oso_prefix_covers_outputs()
1905                }
1906                // An rpath and a dynamically linked native program embed this
1907                // checkout's absolute target directory. Cargo also passes
1908                // `prefer-dynamic` to proc macros, but their dynamic runtime
1909                // is rustc's own sysroot (already pinned by compiler identity),
1910                // while Cargo's dependencies are linked into the macro. There
1911                // is no checkout rpath to carry; keep rejecting an explicit
1912                // `rpath` independently.
1913                "rpath" => is_enabled(value),
1914                "prefer-dynamic" => {
1915                    is_enabled(value)
1916                        && !matches!(self.crate_types.as_slice(), [kind] if kind == "proc-macro")
1917                }
1918                // The CRT objects a self-contained link uses come from
1919                // somewhere other than where the driver reports.
1920                "link-self-contained" => true,
1921                _ => false,
1922            };
1923            if unportable {
1924                return Err(BypassReason::UnportableNativeLink(match value {
1925                    Some(value) => format!("{name}={value}"),
1926                    None => name.to_owned(),
1927                }));
1928            }
1929        }
1930        Ok(())
1931    }
1932}
1933
1934fn safe_install_name(name: &str) -> bool {
1935    !name.is_empty()
1936        && name
1937            .bytes()
1938            .all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte))
1939}
1940
1941/// Whether a boolean codegen option is asking for its enabled form. Absent a
1942/// value, rustc reads the flag itself as the request.
1943fn is_enabled(value: Option<&str>) -> bool {
1944    matches!(value, None | Some("y" | "yes" | "on" | "true"))
1945}
1946
1947fn compiler_bundled_wasm_target(target: &str) -> bool {
1948    COMPILER_BUNDLED_WASM_TARGETS.binary_search(&target).is_ok()
1949}
1950
1951fn parse_emits(value: &str) -> Vec<Emit> {
1952    value
1953        .split(',')
1954        .map(|emit| {
1955            let (kind, path) = emit
1956                .split_once('=')
1957                .map_or((emit, None), |(kind, path)| (kind, Some(path.into())));
1958            Emit {
1959                kind: kind.into(),
1960                path,
1961            }
1962        })
1963        .collect()
1964}
1965
1966struct ActionBuilder<'a> {
1967    invocation: &'a RustcInvocation,
1968    context: ActionContext,
1969    mappings: Vec<SharedPathMapping>,
1970    /// The directories resolved while building this one action. Its inputs
1971    /// share a few parents, and resolving each once is what keeps a crate
1972    /// with hundreds of them from paying a `realpath` walk per input.
1973    aliases: PathAliases,
1974    linker: Option<LinkerIdentity>,
1975}
1976
1977impl<'a> ActionBuilder<'a> {
1978    fn new(invocation: &'a RustcInvocation, mut context: ActionContext) -> Self {
1979        context.path_mappings = PathMapping::ordered(&context.path_mappings);
1980        let mappings = resolve_path_mappings(&shared_path_mappings(&context.path_mappings));
1981        Self {
1982            linker: None,
1983            invocation,
1984            mappings,
1985            aliases: PathAliases::default(),
1986            context,
1987        }
1988    }
1989
1990    fn linked_by(mut self, linker: Option<LinkerIdentity>) -> Self {
1991        self.linker = linker;
1992        self
1993    }
1994
1995    fn build(self) -> Result<RustcAction, BypassReason> {
1996        self.validate_mappings()?;
1997        let invocation = self.invocation_descriptor()?;
1998        let environment = self.environment_descriptor()?;
1999
2000        let mut inputs = BTreeMap::<String, CacheDigest>::new();
2001        for input in &self.context.inputs {
2002            input
2003                .digest
2004                .validate()
2005                .map_err(|_| BypassReason::InvalidInputDigest(input.path.display().to_string()))?;
2006            let path = self.normalize_path(&input.path)?;
2007            if inputs
2008                .insert(path.clone(), input.digest.clone())
2009                .is_some_and(|existing| existing != input.digest)
2010            {
2011                return Err(BypassReason::ConflictingInput(path));
2012            }
2013        }
2014        let required = self
2015            .invocation
2016            .required_inputs
2017            .iter()
2018            .map(|path| self.normalize_path(path))
2019            .collect::<Result<BTreeSet<_>, _>>()?;
2020        if let Some(missing) = required.iter().find(|path| !inputs.contains_key(*path)) {
2021            return Err(BypassReason::MissingRequiredInput(missing.clone()));
2022        }
2023        let inputs = inputs
2024            .into_iter()
2025            .map(|(path, digest)| InputDescriptor { path, digest })
2026            .collect();
2027        // A native link without a linker identity would be keyed as though the
2028        // host did not matter. Refuse rather than publish that claim.
2029        if self.invocation.links_natively() && self.linker.is_none() {
2030            return Err(BypassReason::UnportableNativeLink(
2031                "linker identity is unknown".into(),
2032            ));
2033        }
2034        let descriptor = ActionDescriptor {
2035            version: ACTION_SCHEMA_VERSION,
2036            kind: "rustc",
2037            adapter_version: ADAPTER_VERSION,
2038            compiler: invocation.compiler,
2039            arguments: invocation.arguments,
2040            environment,
2041            inputs,
2042            linker: self.linker.clone(),
2043        };
2044        let bytes = canonical_json(&descriptor)
2045            .map_err(|error| BypassReason::Serialization(error.to_string()))?;
2046        let digest = CacheDigest::blake3(&bytes);
2047        Ok(RustcAction { digest, bytes })
2048    }
2049
2050    fn invocation_descriptor(&self) -> Result<InvocationDescriptor, BypassReason> {
2051        self.validate_mappings()?;
2052        let arguments = self
2053            .invocation
2054            .arguments
2055            .iter()
2056            .map(|argument| self.normalize_argument(argument))
2057            .collect::<Result<Vec<_>, _>>()?;
2058        let required_inputs = self
2059            .invocation
2060            .required_inputs
2061            .iter()
2062            .map(|path| self.normalize_path(path))
2063            .collect::<Result<BTreeSet<_>, _>>()?
2064            .into_iter()
2065            .collect();
2066        Ok(InvocationDescriptor {
2067            version: ACTION_SCHEMA_VERSION,
2068            kind: "rustc",
2069            adapter_version: ADAPTER_VERSION,
2070            compiler: CompilerDescriptor {
2071                toolchain: self.context.compiler.toolchain.clone(),
2072                rustc_version: self.context.compiler.rustc_version.clone(),
2073                host: self.context.compiler.host.clone(),
2074                driver: self.context.compiler.driver.clone(),
2075            },
2076            arguments,
2077            required_inputs,
2078        })
2079    }
2080
2081    fn validate_mappings(&self) -> Result<(), BypassReason> {
2082        if !self.context.working_dir.is_absolute() {
2083            return Err(BypassReason::RelativeWorkingDirectory(
2084                self.context.working_dir.clone(),
2085            ));
2086        }
2087        let mut roots = BTreeSet::new();
2088        let mut placeholders = BTreeSet::new();
2089        for mapping in &self.mappings {
2090            if !mapping.root.is_absolute() {
2091                return Err(BypassReason::RelativePathMapping(mapping.root.clone()));
2092            }
2093            if mapping.placeholder.is_empty()
2094                || !mapping
2095                    .placeholder
2096                    .bytes()
2097                    .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
2098                || !roots.insert(normalize_components(&mapping.root))
2099                || !placeholders.insert(&mapping.placeholder)
2100            {
2101                return Err(BypassReason::InvalidPathPlaceholder(
2102                    mapping.placeholder.clone(),
2103                ));
2104            }
2105        }
2106        Ok(())
2107    }
2108
2109    fn normalize_argument(&self, argument: &Argument) -> Result<String, BypassReason> {
2110        match argument {
2111            Argument::Plain(value) => Ok(value.clone()),
2112            Argument::Path { flag, path } => Ok(format!("{flag}={}", self.normalize_path(path)?)),
2113            Argument::SearchPath { kind, path } => {
2114                let text = match self.normalize_path(path) {
2115                    Ok(text) => text,
2116                    // A native directory outside every mapped root is a host
2117                    // toolchain installation, not a checkout location. Its
2118                    // literal path is the key material: the path is
2119                    // version-stamped on the platforms that pass one (the MSVC
2120                    // toolset, the Windows SDK), so hosts that differ miss,
2121                    // which is the same identity-over-content stance
2122                    // [`LinkerIdentity`] takes.
2123                    Err(BypassReason::UnmappedAbsolutePath(absolute))
2124                        if kind == "native" && self.invocation.native_search_is_inert() =>
2125                    {
2126                        absolute
2127                            .to_str()
2128                            .ok_or(BypassReason::NonUtf8Path(absolute.clone()))?
2129                            .to_string()
2130                    }
2131                    Err(error) => return Err(error),
2132                };
2133                Ok(format!("-L{kind}={text}"))
2134            }
2135            Argument::Extern { name, path } => match path {
2136                Some(path) => Ok(format!("--extern={name}={}", self.normalize_path(path)?)),
2137                None => Ok(format!("--extern={name}")),
2138            },
2139            Argument::Emit(emits) => Ok(format!(
2140                "--emit={}",
2141                emits
2142                    .iter()
2143                    .map(|emit| match &emit.path {
2144                        Some(path) => self
2145                            .normalize_path(path)
2146                            .map(|path| format!("{}={path}", emit.kind)),
2147                        None => Ok(emit.kind.clone()),
2148                    })
2149                    .collect::<Result<Vec<_>, _>>()?
2150                    .join(",")
2151            )),
2152            Argument::RemapPath { from, to } => Ok(format!(
2153                "--remap-path-prefix={}={}",
2154                self.normalize_path(from)?,
2155                to
2156            )),
2157            Argument::InstallName(name) => Ok(format!(
2158                "--codegen=link-arg=-Wl,-install_name,@rpath/{name}"
2159            )),
2160            Argument::OsoPrefix {
2161                path,
2162                trailing_slash,
2163            } => Ok(format!(
2164                "--codegen=link-arg=-Wl,-oso_prefix,{}{}",
2165                self.normalize_path(path)?,
2166                if *trailing_slash { "/" } else { "" }
2167            )),
2168            Argument::FuseLd(selection) => Ok(format!("--codegen=link-arg=-fuse-ld={selection}")),
2169        }
2170    }
2171
2172    /// Environment values enter the key verbatim, because rustc may embed one
2173    /// through `env!`: unlike a path used to locate an input, changing the value
2174    /// changes the artifact.
2175    ///
2176    /// A name in `portable_environment` is the exception the caller has earned.
2177    /// Its value normalizes like any other path, so two checkouts agree on it.
2178    fn environment_descriptor(&self) -> Result<BTreeMap<String, Option<String>>, BypassReason> {
2179        self.context
2180            .environment
2181            .iter()
2182            .map(|(name, value)| {
2183                let value = match value {
2184                    Some(value) if self.context.portable_environment.contains(name) => {
2185                        Some(self.normalize_path(Path::new(value))?)
2186                    }
2187                    value => value.clone(),
2188                };
2189                Ok((name.clone(), value))
2190            })
2191            .collect()
2192    }
2193
2194    fn normalize_path(&self, path: &Path) -> Result<String, BypassReason> {
2195        normalize_resolved_shared_path_with(
2196            &self.aliases,
2197            path,
2198            &self.context.working_dir,
2199            &self.mappings,
2200        )
2201        .map_err(Into::into)
2202    }
2203}
2204
2205/// Whether a normalized path names something strictly beneath one of
2206/// `directories`, which are normalized the same way and so share its `/`
2207/// separator regardless of platform.
2208fn under_any_directory(path: &str, directories: &BTreeSet<String>) -> bool {
2209    directories.iter().any(|directory| {
2210        path.len() > directory.len()
2211            && path.as_bytes()[directory.len()] == b'/'
2212            && path.starts_with(directory)
2213    })
2214}
2215
2216fn denormalize_path(value: &str, mappings: &[PathMapping]) -> Result<PathBuf, BypassReason> {
2217    for mapping in mappings {
2218        let prefix = format!("${{{}}}", mapping.placeholder);
2219        let suffix = if value == prefix {
2220            ""
2221        } else if let Some(suffix) = value.strip_prefix(&format!("{prefix}/")) {
2222            suffix
2223        } else {
2224            continue;
2225        };
2226        if !mapping.root.is_absolute()
2227            || (!suffix.is_empty()
2228                && suffix.split('/').any(|component| {
2229                    component.is_empty()
2230                        || matches!(component, "." | "..")
2231                        || component.contains('\\')
2232                }))
2233        {
2234            return Err(BypassReason::InvalidPredictedInput(value.into()));
2235        }
2236        let mut path = normalize_components(&mapping.root);
2237        path.extend(suffix.split('/').filter(|component| !component.is_empty()));
2238        return Ok(path);
2239    }
2240    Err(BypassReason::InvalidPredictedInput(value.into()))
2241}
2242
2243fn normalize_components(path: &Path) -> PathBuf {
2244    let mut normalized = PathBuf::new();
2245    for component in path.components() {
2246        match component {
2247            Component::CurDir => {}
2248            Component::ParentDir => {
2249                normalized.pop();
2250            }
2251            component => normalized.push(component.as_os_str()),
2252        }
2253    }
2254    normalized
2255}
2256
2257fn absolute_path(path: &Path, working_dir: &Path) -> PathBuf {
2258    if path.is_absolute() {
2259        normalize_components(path)
2260    } else {
2261        normalize_components(&working_dir.join(path))
2262    }
2263}
2264
2265#[cfg(test)]
2266#[path = "rustc_cache_tests.rs"]
2267mod tests;