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