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    /// Return the explicitly selected compilation target, if any.
504    pub fn target(&self) -> Option<&str> {
505        self.target.as_deref()
506    }
507
508    /// Return the crate name rustc will assign to this compilation.
509    pub fn crate_name(&self) -> &str {
510        &self.crate_name
511    }
512
513    /// Digest of the inputs this compilation owns: its sources and whatever
514    /// they read, but not the artifacts it merely links against.
515    ///
516    /// This is what separates a crate someone is editing from one sitting
517    /// above it in the graph. Rebuilding a dependency changes the action key of
518    /// every crate that links it, because their keys hash its artifact; it does
519    /// not change this. A caller watching for churn has to watch this instead,
520    /// or a single edited crate would drag its whole dependent cone along.
521    ///
522    /// Host paths enter the digest as they are. This describes one checkout to
523    /// itself rather than to the cache, so there is nothing here to make
524    /// portable.
525    pub fn source_fingerprint(&self, discovered: &DiscoveredInputs) -> CacheDigest {
526        let linked = self
527            .arguments
528            .iter()
529            .filter_map(|argument| match argument {
530                Argument::Extern {
531                    path: Some(path), ..
532                } => Some(path.as_path()),
533                _ => None,
534            })
535            .collect::<BTreeSet<_>>();
536        let owned = discovered
537            .inputs
538            .iter()
539            .filter(|input| !linked.contains(input.path.as_path()))
540            .map(|input| (input.path.as_path(), &input.digest))
541            .collect::<BTreeMap<_, _>>();
542        let mut bytes = Vec::new();
543        for (path, digest) in owned {
544            bytes.extend_from_slice(path.as_os_str().as_encoded_bytes());
545            bytes.push(0);
546            bytes.extend_from_slice(digest.key().as_bytes());
547            bytes.push(0);
548        }
549        CacheDigest::blake3(&bytes)
550    }
551
552    /// Resolve the files produced by this invocation.
553    ///
554    /// The initial cache tier requires one output directory so its artifact can
555    /// be represented by one protocol directory and restored atomically later.
556    pub fn outputs(&self, working_dir: &Path) -> Result<RustcOutputs, BypassReason> {
557        if !working_dir.is_absolute() {
558            return Err(BypassReason::RelativeWorkingDirectory(
559                working_dir.to_path_buf(),
560            ));
561        }
562        let explicit_output = self
563            .explicit_output
564            .as_deref()
565            .map(|path| absolute_path(path, working_dir));
566        let output_directory = explicit_output
567            .as_deref()
568            .and_then(Path::parent)
569            .map(Path::to_path_buf)
570            .or_else(|| {
571                self.out_dir
572                    .as_deref()
573                    .map(|path| absolute_path(path, working_dir))
574            })
575            .unwrap_or_else(|| normalize_components(working_dir));
576        // rustc applies `-o` to every emit that has no path of its own, so the
577        // file names cannot be derived from the crate name here. Cargo always
578        // uses --out-dir instead, so refusing to model this costs nothing.
579        if let Some(output) = &explicit_output
580            && self.emits.iter().any(|emit| {
581                emit.path.is_none()
582                    && matches!(emit.kind.as_str(), "dep-info" | "link" | "metadata")
583            })
584        {
585            return Err(BypassReason::ImplicitEmitWithOutputFile(output.clone()));
586        }
587        let mut files = BTreeSet::new();
588        let mut dep_info = None;
589        for emit in &self.emits {
590            if emit.kind == "dep-info" {
591                let path = emit.path.as_ref().map_or_else(
592                    || {
593                        explicit_output.clone().map_or_else(
594                            || {
595                                output_directory
596                                    .join(format!("{}{}.d", self.crate_name, self.extra_filename))
597                            },
598                            |path| path.with_extension("d"),
599                        )
600                    },
601                    |path| absolute_path(path, working_dir),
602                );
603                if path.file_name().is_none() {
604                    return Err(BypassReason::InvalidOutputPath(path));
605                }
606                dep_info = Some(path);
607                continue;
608            }
609            let (prefix, extension) = match emit.kind.as_str() {
610                "link" => match self.link_output {
611                    LinkOutput::Library => ("lib", "rlib"),
612                    LinkOutput::WasmExecutable => ("", "wasm"),
613                    LinkOutput::NativeExecutable => ("", std::env::consts::EXE_EXTENSION),
614                    LinkOutput::NativeProcMacro => (
615                        std::env::consts::DLL_PREFIX,
616                        std::env::consts::DLL_SUFFIX.trim_start_matches('.'),
617                    ),
618                },
619                "metadata" => ("lib", "rmeta"),
620                _ => continue,
621            };
622            let path = if let Some(path) = &emit.path {
623                absolute_path(path, working_dir)
624            } else {
625                let name = format!("{prefix}{}{}", self.crate_name, self.extra_filename);
626                output_directory.join(if extension.is_empty() {
627                    name
628                } else {
629                    format!("{name}.{extension}")
630                })
631            };
632            if path.file_name().is_none() {
633                return Err(BypassReason::InvalidOutputPath(path));
634            }
635            if path.parent() != Some(output_directory.as_path()) {
636                return Err(BypassReason::SplitOutputDirectories);
637            }
638            // Whether a restored output is a program is read back off its name,
639            // so a program that answers to a library's name would be restored
640            // without the permission that makes it runnable. Nothing cargo
641            // emits looks like this; a hand-built invocation could.
642            let has_library_extension = path
643                .extension()
644                .and_then(|extension| extension.to_str())
645                .is_some_and(|extension| matches!(extension, "rlib" | "rmeta"))
646                || (cfg!(windows)
647                    && path
648                        .file_stem()
649                        .and_then(|stem| Path::new(stem).extension())
650                        .and_then(|extension| extension.to_str())
651                        .is_some_and(|extension| matches!(extension, "rlib" | "rmeta")));
652            if emit.kind == "link"
653                && !matches!(self.link_output, LinkOutput::Library)
654                && has_library_extension
655            {
656                return Err(BypassReason::AmbiguousOutputName(path));
657            }
658            if emit.kind == "link" && self.emits_windows_pdb() {
659                files.insert(path.with_extension("pdb"));
660            }
661            files.insert(path);
662        }
663        let dep_info = dep_info.ok_or(BypassReason::NoDepInfo)?;
664        if dep_info.parent() != Some(output_directory.as_path()) {
665            return Err(BypassReason::SplitOutputDirectories);
666        }
667        Ok(RustcOutputs {
668            directory: output_directory,
669            files: files.into_iter().collect(),
670            dep_info,
671        })
672    }
673
674    /// Build canonical action bytes after precise input discovery has run.
675    ///
676    /// `context.inputs` must contain the source, every explicit extern, and
677    /// every additional source or environment-generated input discovered from
678    /// dep-info.
679    pub fn action(&self, context: ActionContext) -> Result<RustcAction, BypassReason> {
680        self.action_linked_by(context, None)
681    }
682
683    /// Build canonical action bytes for an invocation that links a native
684    /// program.
685    ///
686    /// A `linker` is required whenever [`RustcInvocation::links_natively`]
687    /// holds: the linker, its startup objects, and the platform SDK are inputs
688    /// rustc dep-info does not enumerate, so a key without them would claim
689    /// more than it can support. Passing one for anything else is ignored,
690    /// since nothing else depends on a linker.
691    pub fn action_linked_by(
692        &self,
693        context: ActionContext,
694        linker: Option<LinkerIdentity>,
695    ) -> Result<RustcAction, BypassReason> {
696        ActionBuilder::new(self, context).linked_by(linker).build()
697    }
698
699    /// Fingerprint the modeled invocation before dependency contents are known.
700    pub fn invocation_digest(&self, context: &ActionContext) -> Result<CacheDigest, BypassReason> {
701        let descriptor = ActionBuilder::new(self, context.clone()).invocation_descriptor()?;
702        let bytes = canonical_json(&descriptor)
703            .map_err(|error| BypassReason::Serialization(error.to_string()))?;
704        Ok(CacheDigest::blake3(&bytes))
705    }
706
707    /// Capture normalized dependency paths for a future invocation that has no
708    /// dep-info file yet.
709    pub fn prediction(
710        &self,
711        context: &ActionContext,
712        discovered: &DiscoveredInputs,
713    ) -> Result<RustcInputPrediction, BypassReason> {
714        let builder = ActionBuilder::new(self, context.clone());
715        builder.validate_mappings()?;
716        let mut native_directories = BTreeSet::new();
717        for argument in &self.arguments {
718            if let Argument::SearchPath { kind, path } = argument
719                && kind == "native"
720            {
721                match builder.normalize_path(path) {
722                    Ok(normalized) => {
723                        native_directories.insert(normalized);
724                    }
725                    // Inert and outside every mapped root: the directory
726                    // contributes no content inputs, so the prediction has
727                    // nothing to replay for it. Input discovery skips it the
728                    // same way, which is what keeps the predicted action key
729                    // equal to the one dep-info discovery builds.
730                    Err(BypassReason::UnmappedAbsolutePath(_)) if self.native_search_is_inert() => {
731                    }
732                    Err(error) => return Err(error),
733                }
734            }
735        }
736        // A file beneath a recorded directory is rediscovered by walking that
737        // directory, so naming it as well only makes the payload grow with the
738        // object tree a C dependency leaves in its `OUT_DIR`. `aws-lc-sys`
739        // leaves thousands of files there, which was enough to push the
740        // serialized prediction past the protocol's payload limit and lose the
741        // prediction entirely for the crate that most needed one.
742        let mut inputs = BTreeSet::new();
743        for input in &discovered.inputs {
744            let normalized = builder.normalize_path(&input.path)?;
745            if !under_any_directory(&normalized, &native_directories) {
746                inputs.insert(normalized);
747            }
748        }
749        inputs.extend(
750            native_directories
751                .into_iter()
752                .map(|directory| format!("{NATIVE_DIRECTORY_PREDICTION_PREFIX}{directory}")),
753        );
754        Ok(RustcInputPrediction {
755            version: 4,
756            inputs: inputs.into_iter().collect(),
757            environment: discovered.environment.keys().cloned().collect(),
758            compiler_duration_ns: 0,
759            crate_name: String::new(),
760        })
761    }
762}
763
764impl RustcOutputs {
765    /// The linked executable Cargo will run as a build script, when this is a
766    /// build-script compilation.
767    pub fn build_script_executable(&self, crate_name: &str) -> Option<&Path> {
768        (crate_name == "build_script_build")
769            .then(|| self.files.iter().find(|path| self.is_executable(path)))
770            .flatten()
771            .map(PathBuf::as_path)
772    }
773
774    /// Whether `path` is a linked program whose executable permission is part
775    /// of the declared output contract.
776    /// A program is whatever this invocation emitted that is not a library
777    /// artifact. Every tier the adapter admits distinguishes the two by
778    /// extension -- `rlib` and `rmeta` are the compiler's own, and a linked
779    /// program carries either the target's (`wasm`) or none at all -- so the
780    /// name is enough and no separate list has to be carried alongside.
781    pub fn is_executable(&self, path: &Path) -> bool {
782        self.files.iter().any(|output| output == path)
783            && !matches!(
784                path.extension().and_then(|extension| extension.to_str()),
785                Some("rlib" | "rmeta")
786            )
787    }
788}
789
790#[derive(Debug, Clone, PartialEq, Eq)]
791/// Mapping from a host-specific absolute root to a stable key placeholder.
792pub struct PathMapping {
793    /// Absolute host path to replace.
794    pub root: PathBuf,
795    /// Placeholder name without the surrounding `${...}` syntax.
796    pub placeholder: String,
797}
798
799impl PathMapping {
800    /// Map an absolute host path to a stable cache-key placeholder.
801    pub fn new(root: impl Into<PathBuf>, placeholder: impl Into<String>) -> Self {
802        Self {
803            root: root.into(),
804            placeholder: placeholder.into(),
805        }
806    }
807
808    /// Order mappings deepest root first, which is what normalization needs:
809    /// a target directory inside the workspace has to win over the workspace.
810    pub fn ordered(mappings: &[PathMapping]) -> Vec<PathMapping> {
811        let mut ordered = mappings.to_vec();
812        ordered.sort_by_key(|mapping| std::cmp::Reverse(mapping.root.components().count()));
813        ordered
814    }
815}
816
817fn shared_path_mappings(mappings: &[PathMapping]) -> Vec<SharedPathMapping> {
818    mappings
819        .iter()
820        .map(|mapping| SharedPathMapping::new(&mapping.root, &mapping.placeholder))
821        .collect()
822}
823
824/// Map an absolute path to its cache-key placeholder form.
825///
826/// `mappings` must already be ordered by [`PathMapping::ordered`]. Exposed for
827/// callers that need the placeholder text before an action exists -- notably to
828/// build the `--remap-path-prefix` flag that makes a compilation independent of
829/// a path in its environment.
830pub fn normalize_mapped_path(
831    path: &Path,
832    working_dir: &Path,
833    mappings: &[PathMapping],
834) -> Result<String, BypassReason> {
835    normalize_shared_path(path, working_dir, &shared_path_mappings(mappings)).map_err(Into::into)
836}
837
838#[derive(Debug, Clone, PartialEq, Eq)]
839/// Compiler properties that distinguish incompatible action outputs.
840pub struct CompilerIdentity {
841    /// Toolchain selector or installation identity.
842    pub toolchain: String,
843    /// Complete verbose rustc version string.
844    pub rustc_version: String,
845    /// Compiler host target triple.
846    pub host: String,
847    /// Identity of a compiler driver layered over rustc, when one is in use.
848    pub driver: Option<String>,
849}
850
851/// One file input paired with the digest used in the action key.
852#[derive(Debug, Clone, PartialEq, Eq)]
853pub struct ActionInput {
854    /// Absolute host path used to read and verify the input.
855    pub path: PathBuf,
856    /// Digest of the input contents.
857    pub digest: CacheDigest,
858}
859
860/// External information needed to construct a canonical rustc action.
861#[derive(Debug, Clone, PartialEq, Eq)]
862pub struct ActionContext {
863    /// Identity of the compiler that produces the outputs.
864    pub compiler: CompilerIdentity,
865    /// Absolute directory in which rustc runs.
866    pub working_dir: PathBuf,
867    /// Host roots replaced with stable placeholders in the key.
868    pub path_mappings: Vec<PathMapping>,
869    /// Environment inputs and their observed values.
870    pub environment: BTreeMap<String, Option<String>>,
871    /// Environment inputs whose absolute values the compilation has been made
872    /// independent of, and whose values the key therefore normalizes.
873    ///
874    /// Naming one here is a claim about the compilation, not a preference: the
875    /// caller must both neutralize the value inside it (with
876    /// `--remap-path-prefix`) and confirm no output carries the value anyway.
877    pub portable_environment: BTreeSet<String>,
878    /// Complete set of direct and discovered file inputs.
879    pub inputs: Vec<ActionInput>,
880}
881
882/// What produced a linked native program, beyond the compiler itself.
883///
884/// The fields are identity rather than content wherever a compiler's own
885/// identity is: a driver's version output names its toolchain more cheaply than
886/// hashing a hundred megabytes of it, and matches how rustc is identified. The
887/// CRT objects are hashed, because nothing else pins the libc a link resolves
888/// against. Nothing here is placeholder-mapped -- these paths are host
889/// locations rather than checkout locations, and two hosts that differ should
890/// miss rather than share.
891#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
892#[serde(deny_unknown_fields)]
893pub struct LinkerIdentity {
894    /// Resolved absolute path of the linker driver rustc will invoke.
895    pub driver: String,
896    /// Version output of that driver.
897    pub driver_version: String,
898    /// Version of the linker the driver selects.
899    pub linker_version: String,
900    /// Startup objects and libc the driver resolves, by probe name.
901    #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
902    pub crt_objects: BTreeMap<String, CacheDigest>,
903    /// Platform SDK identity, where the platform has one.
904    #[serde(skip_serializing_if = "Option::is_none", default)]
905    pub sdk: Option<String>,
906    /// Deployment target the link was made against, where one applies.
907    #[serde(skip_serializing_if = "Option::is_none", default)]
908    pub deployment_target: Option<String>,
909}
910
911/// Canonical action descriptor and its content digest.
912#[derive(Debug, Clone, PartialEq, Eq)]
913pub struct RustcAction {
914    /// Digest of `bytes`, used as the action-cache key.
915    pub digest: CacheDigest,
916    /// Canonical serialized action descriptor.
917    pub bytes: Vec<u8>,
918}
919
920/// Normalized input names from the last successful execution of one modeled
921/// rustc invocation.
922#[derive(Debug, Clone, PartialEq, Eq)]
923pub struct RustcInputPrediction {
924    /// Prediction schema version.
925    pub version: u8,
926    /// Normalized input paths observed during the successful invocation.
927    pub inputs: Vec<String>,
928    /// Names of environment variables read by the compilation.
929    pub environment: Vec<String>,
930    /// Compiler wall time from the successful invocation that produced this
931    /// prediction. Zero means no timing hint was recorded.
932    pub compiler_duration_ns: u64,
933    /// Crate name associated with the timing hint.
934    pub crate_name: String,
935}
936
937#[derive(Serialize, Deserialize)]
938#[serde(deny_unknown_fields)]
939struct RustcInputPredictionWire {
940    version: u8,
941    inputs: Vec<String>,
942    environment: Vec<String>,
943    #[serde(default, skip_serializing_if = "is_zero")]
944    compiler_duration_ns: u64,
945    #[serde(default, skip_serializing_if = "String::is_empty")]
946    crate_name: String,
947}
948
949impl Serialize for RustcInputPrediction {
950    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
951    where
952        S: Serializer,
953    {
954        RustcInputPredictionWire {
955            version: self.version,
956            inputs: if self.version == 4 {
957                compact_prediction_inputs(&self.inputs)
958            } else {
959                self.inputs.clone()
960            },
961            environment: self.environment.clone(),
962            compiler_duration_ns: self.compiler_duration_ns,
963            crate_name: self.crate_name.clone(),
964        }
965        .serialize(serializer)
966    }
967}
968
969impl<'de> Deserialize<'de> for RustcInputPrediction {
970    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
971    where
972        D: Deserializer<'de>,
973    {
974        let wire = RustcInputPredictionWire::deserialize(deserializer)?;
975        let inputs = if wire.version == 4 {
976            expand_prediction_inputs(&wire.inputs).map_err(serde::de::Error::custom)?
977        } else {
978            wire.inputs
979        };
980        Ok(Self {
981            version: wire.version,
982            inputs,
983            environment: wire.environment,
984            compiler_duration_ns: wire.compiler_duration_ns,
985            crate_name: wire.crate_name,
986        })
987    }
988}
989
990fn compact_prediction_inputs(inputs: &[String]) -> Vec<String> {
991    let mut previous = "";
992    inputs
993        .iter()
994        .map(|input| {
995            let mut shared = previous
996                .bytes()
997                .zip(input.bytes())
998                .take_while(|(left, right)| left == right)
999                .count();
1000            while !input.is_char_boundary(shared) {
1001                shared -= 1;
1002            }
1003            let compact = format!("{shared}:{}", &input[shared..]);
1004            previous = input;
1005            compact
1006        })
1007        .collect()
1008}
1009
1010fn expand_prediction_inputs(inputs: &[String]) -> Result<Vec<String>, &'static str> {
1011    let mut expanded: Vec<String> = Vec::with_capacity(inputs.len());
1012    let mut decoded_bytes = 0_usize;
1013    for input in inputs {
1014        let (shared_text, suffix) = input
1015            .split_once(':')
1016            .ok_or("compact rustc prediction input has no prefix length")?;
1017        let shared: usize = shared_text
1018            .parse()
1019            .map_err(|_| "compact rustc prediction prefix length is invalid")?;
1020        if shared.to_string() != shared_text
1021            || shared > expanded.last().map_or(0, String::len)
1022            || expanded
1023                .last()
1024                .is_some_and(|previous| !previous.is_char_boundary(shared))
1025        {
1026            return Err("compact rustc prediction prefix is not canonical");
1027        }
1028        let mut path = expanded
1029            .last()
1030            .map_or_else(String::new, |previous| previous[..shared].to_string());
1031        path.push_str(suffix);
1032        decoded_bytes = decoded_bytes
1033            .checked_add(path.len())
1034            .ok_or("compact rustc prediction is too large")?;
1035        if decoded_bytes > MAX_DECODED_PREDICTION_BYTES {
1036            return Err("compact rustc prediction is too large");
1037        }
1038        expanded.push(path);
1039    }
1040    Ok(expanded)
1041}
1042
1043fn is_zero(value: &u64) -> bool {
1044    *value == 0
1045}
1046
1047impl RustcInputPrediction {
1048    /// Rehash the predicted paths and read the current environment. The caller
1049    /// still recomputes the full action digest, so changed inputs are misses.
1050    pub fn discover(
1051        &self,
1052        working_dir: &Path,
1053        path_mappings: &[PathMapping],
1054        digests: &dyn FileDigestCache,
1055    ) -> Result<DiscoveredInputs, BypassReason> {
1056        if !matches!(self.version, 2..=4) {
1057            return Err(BypassReason::UnsupportedPrediction);
1058        }
1059        if self.inputs.len() > MAX_PREDICTED_INPUTS || self.environment.len() > 4 * 1024 {
1060            return Err(BypassReason::UnsupportedPrediction);
1061        }
1062        let mut paths = BTreeSet::new();
1063        let admitted_roots = dep_info::native_input_roots(working_dir, path_mappings);
1064        let mut native_bytes = 0_u64;
1065        for path in &self.inputs {
1066            if self.version >= 3
1067                && let Some(path) = path.strip_prefix(NATIVE_DIRECTORY_PREDICTION_PREFIX)
1068            {
1069                let directory = denormalize_path(path, path_mappings)?;
1070                dep_info::collect_native_directory(
1071                    &directory,
1072                    &admitted_roots,
1073                    &mut paths,
1074                    &mut native_bytes,
1075                )?;
1076            } else {
1077                paths.insert(denormalize_path(path, path_mappings)?);
1078            }
1079        }
1080        let environment = self
1081            .environment
1082            .iter()
1083            .map(|name| {
1084                if name.is_empty() || name.contains(['=', '\0']) {
1085                    return Err(BypassReason::UnsupportedPrediction);
1086                }
1087                let value = std::env::var_os(name)
1088                    .map(|value| {
1089                        value
1090                            .into_string()
1091                            .map_err(|_| BypassReason::UnsupportedPrediction)
1092                    })
1093                    .transpose()?;
1094                Ok((name.clone(), value))
1095            })
1096            .collect::<Result<BTreeMap<_, _>, _>>()?;
1097        DiscoveredInputs::from_paths(working_dir, paths, environment, digests)
1098    }
1099}
1100
1101#[derive(Serialize)]
1102struct ActionDescriptor {
1103    version: u8,
1104    kind: &'static str,
1105    adapter_version: u8,
1106    compiler: CompilerDescriptor,
1107    arguments: Vec<String>,
1108    environment: BTreeMap<String, Option<String>>,
1109    inputs: Vec<InputDescriptor>,
1110    /// Omitted entirely unless the invocation links natively, so every key
1111    /// written before this field existed still serializes to the same bytes.
1112    #[serde(skip_serializing_if = "Option::is_none")]
1113    linker: Option<LinkerIdentity>,
1114}
1115
1116#[derive(Serialize)]
1117struct InvocationDescriptor {
1118    version: u8,
1119    kind: &'static str,
1120    adapter_version: u8,
1121    compiler: CompilerDescriptor,
1122    arguments: Vec<String>,
1123    required_inputs: Vec<String>,
1124}
1125
1126#[derive(Serialize)]
1127struct CompilerDescriptor {
1128    toolchain: String,
1129    rustc_version: String,
1130    host: String,
1131    #[serde(skip_serializing_if = "Option::is_none")]
1132    driver: Option<String>,
1133}
1134
1135#[derive(Debug, Serialize, PartialEq, Eq, PartialOrd, Ord)]
1136struct InputDescriptor {
1137    path: String,
1138    digest: CacheDigest,
1139}
1140
1141struct Parser<'a> {
1142    arguments: &'a [OsString],
1143    index: usize,
1144    parsed: Vec<Argument>,
1145    source: Option<PathBuf>,
1146    crate_types: Vec<String>,
1147    emits: Vec<Emit>,
1148    required_inputs: Vec<PathBuf>,
1149    test: bool,
1150    crate_name: Option<String>,
1151    extra_filename: String,
1152    out_dir: Option<PathBuf>,
1153    explicit_output: Option<PathBuf>,
1154    target: Option<String>,
1155    options: ParseOptions,
1156}
1157
1158struct ExpandedArguments {
1159    arguments: Vec<OsString>,
1160}
1161
1162#[derive(Default)]
1163struct ResponseExpander {
1164    shell_argfiles: bool,
1165    next_is_unstable_option: bool,
1166    arguments: Vec<OsString>,
1167}
1168
1169impl ResponseExpander {
1170    fn push(&mut self, argument: String) {
1171        if self.next_is_unstable_option {
1172            self.shell_argfiles |= argument == "shell-argfiles";
1173            self.next_is_unstable_option = false;
1174        } else if let Some(option) = argument.strip_prefix("-Z") {
1175            if option.is_empty() {
1176                self.next_is_unstable_option = true;
1177            } else {
1178                self.shell_argfiles |= option == "shell-argfiles";
1179            }
1180        }
1181        self.arguments.push(argument.into());
1182    }
1183}
1184
1185/// Match rustc's argfile expansion: UTF-8, one option per line, no recursive
1186/// expansion, with the nightly shell form enabled only after its `-Z` flag.
1187fn expand_response_files(arguments: &[OsString]) -> Result<ExpandedArguments, BypassReason> {
1188    let mut expanded = ResponseExpander::default();
1189    for (index, argument) in arguments.iter().enumerate() {
1190        let argument = argument
1191            .to_str()
1192            .ok_or(BypassReason::NonUtf8Argument { index })?;
1193        let Some(argfile) = argument.strip_prefix('@') else {
1194            expanded.push(argument.to_string());
1195            continue;
1196        };
1197        let (path, shell) = match argfile.split_once(':') {
1198            Some(("shell", path)) if expanded.shell_argfiles => (path, true),
1199            _ => (argfile, false),
1200        };
1201        let contents = std::fs::read_to_string(path).map_err(|error| {
1202            BypassReason::ResponseFile(format!("{}: {error}", Path::new(path).display()))
1203        })?;
1204        if shell {
1205            let arguments = shlex::split(&contents).ok_or_else(|| {
1206                BypassReason::ResponseFile(format!(
1207                    "invalid shell-style arguments in {}",
1208                    Path::new(path).display()
1209                ))
1210            })?;
1211            for argument in arguments {
1212                expanded.push(argument);
1213            }
1214        } else {
1215            for argument in contents.lines() {
1216                expanded.push(argument.to_string());
1217            }
1218        }
1219    }
1220    Ok(ExpandedArguments {
1221        arguments: expanded.arguments,
1222    })
1223}
1224
1225impl<'a> Parser<'a> {
1226    fn new(arguments: &'a [OsString], options: ParseOptions) -> Self {
1227        Self {
1228            arguments,
1229            options,
1230            index: 0,
1231            parsed: Vec::new(),
1232            source: None,
1233            crate_types: Vec::new(),
1234            emits: Vec::new(),
1235            required_inputs: Vec::new(),
1236            test: false,
1237            crate_name: None,
1238            extra_filename: String::new(),
1239            out_dir: None,
1240            explicit_output: None,
1241            target: None,
1242        }
1243    }
1244
1245    fn parse(mut self) -> Result<RustcInvocation, BypassReason> {
1246        while self.index < self.arguments.len() {
1247            let value = self.current()?.to_string();
1248            self.index += 1;
1249            if let Some(long) = value.strip_prefix("--") {
1250                self.parse_long(long)?;
1251            } else if value.starts_with('-') && value != "-" {
1252                self.parse_short(&value)?;
1253            } else {
1254                self.parse_input(&value)?;
1255            }
1256        }
1257
1258        let source = self.source.clone().ok_or(BypassReason::MissingInput)?;
1259        let link_output = self.classify()?;
1260        let crate_name = self.crate_name.clone().map_or_else(
1261            || {
1262                source
1263                    .file_stem()
1264                    .and_then(|name| name.to_str())
1265                    .map(|name| name.replace('-', "_"))
1266                    .ok_or_else(|| BypassReason::NonUtf8Path(source.clone()))
1267            },
1268            Ok,
1269        )?;
1270        self.required_inputs.push(source.clone());
1271        Ok(RustcInvocation {
1272            arguments: self.parsed,
1273            source,
1274            required_inputs: self.required_inputs,
1275            crate_name,
1276            extra_filename: self.extra_filename,
1277            out_dir: self.out_dir,
1278            explicit_output: self.explicit_output,
1279            emits: self.emits,
1280            target: self.target,
1281            link_output,
1282        })
1283    }
1284
1285    fn current(&self) -> Result<&str, BypassReason> {
1286        self.arguments[self.index]
1287            .to_str()
1288            .ok_or(BypassReason::NonUtf8Argument { index: self.index })
1289    }
1290
1291    fn take_value(&mut self, flag: &str, inline: Option<&str>) -> Result<String, BypassReason> {
1292        if let Some(value) = inline {
1293            if value.is_empty() {
1294                return Err(BypassReason::MissingValue(flag.into()));
1295            }
1296            return Ok(value.into());
1297        }
1298        if self.index >= self.arguments.len() {
1299            return Err(BypassReason::MissingValue(flag.into()));
1300        }
1301        let value = self.current()?.to_string();
1302        self.index += 1;
1303        Ok(value)
1304    }
1305
1306    fn parse_long(&mut self, value: &str) -> Result<(), BypassReason> {
1307        let (flag, inline) = value
1308            .split_once('=')
1309            .map_or((value, None), |(flag, value)| (flag, Some(value)));
1310        let rendered_flag = format!("--{flag}");
1311        match flag {
1312            "help" | "version" | "explain" | "print" => Err(BypassReason::CompilerQuery),
1313            "test" => {
1314                self.test = true;
1315                self.parsed.push(Argument::Plain(rendered_flag));
1316                Ok(())
1317            }
1318            "verbose" => {
1319                self.parsed.push(Argument::Plain(rendered_flag));
1320                Ok(())
1321            }
1322            "crate-name" => {
1323                let value = self.take_value(&rendered_flag, inline)?;
1324                self.crate_name = Some(value.clone());
1325                self.parsed
1326                    .push(Argument::Plain(format!("{rendered_flag}={value}")));
1327                Ok(())
1328            }
1329            "cfg" | "check-cfg" | "edition" | "error-format" | "json" | "color"
1330            | "diagnostic-width" | "remap-path-scope" | "allow" | "warn" | "force-warn"
1331            | "deny" | "forbid" | "cap-lints" => {
1332                let value = self.take_value(&rendered_flag, inline)?;
1333                self.parsed
1334                    .push(Argument::Plain(format!("{rendered_flag}={value}")));
1335                Ok(())
1336            }
1337            "target" => {
1338                let value = self.take_value(&rendered_flag, inline)?;
1339                self.target = Some(value.clone());
1340                if value.ends_with(".json") || value.contains(['/', '\\']) {
1341                    let path = PathBuf::from(value);
1342                    self.required_inputs.push(path.clone());
1343                    self.parsed.push(Argument::Path {
1344                        flag: rendered_flag,
1345                        path,
1346                    });
1347                } else {
1348                    self.target = Some(value.clone());
1349                    self.parsed
1350                        .push(Argument::Plain(format!("{rendered_flag}={value}")));
1351                }
1352                Ok(())
1353            }
1354            "crate-type" => {
1355                let value = self.take_value(&rendered_flag, inline)?;
1356                self.crate_types
1357                    .extend(value.split(',').map(ToOwned::to_owned));
1358                self.parsed
1359                    .push(Argument::Plain(format!("{rendered_flag}={value}")));
1360                Ok(())
1361            }
1362            "emit" => {
1363                let value = self.take_value(&rendered_flag, inline)?;
1364                let emits = parse_emits(&value);
1365                self.emits.extend(emits.clone());
1366                self.parsed.push(Argument::Emit(emits));
1367                Ok(())
1368            }
1369            "out-dir" => {
1370                let path = PathBuf::from(self.take_value(&rendered_flag, inline)?);
1371                self.out_dir = Some(path.clone());
1372                self.parsed.push(Argument::Path {
1373                    flag: rendered_flag,
1374                    path,
1375                });
1376                Ok(())
1377            }
1378            "sysroot" => {
1379                let path = self.take_value(&rendered_flag, inline)?;
1380                self.parsed.push(Argument::Path {
1381                    flag: rendered_flag,
1382                    path: path.into(),
1383                });
1384                Ok(())
1385            }
1386            "extern" => {
1387                let value = self.take_value(&rendered_flag, inline)?;
1388                let (name, path) = value
1389                    .split_once('=')
1390                    .map_or((value.as_str(), None), |(name, path)| {
1391                        (name, Some(PathBuf::from(path)))
1392                    });
1393                if let Some(path) = &path {
1394                    self.required_inputs.push(path.clone());
1395                }
1396                self.parsed.push(Argument::Extern {
1397                    name: name.into(),
1398                    path,
1399                });
1400                Ok(())
1401            }
1402            "remap-path-prefix" => {
1403                let value = self.take_value(&rendered_flag, inline)?;
1404                let Some((from, to)) = value.split_once('=') else {
1405                    return Err(BypassReason::MissingValue(rendered_flag));
1406                };
1407                self.parsed.push(Argument::RemapPath {
1408                    from: from.into(),
1409                    to: to.into(),
1410                });
1411                Ok(())
1412            }
1413            "codegen" => {
1414                let value = self.take_value(&rendered_flag, inline)?;
1415                self.parse_codegen(&value)
1416            }
1417            "jobs-frontend" => {
1418                let value = self.take_value(&rendered_flag, inline)?;
1419                self.parsed
1420                    .push(Argument::Plain(format!("{rendered_flag}={value}")));
1421                Ok(())
1422            }
1423            _ => Err(BypassReason::UnknownFlag(rendered_flag)),
1424        }
1425    }
1426
1427    fn parse_short(&mut self, value: &str) -> Result<(), BypassReason> {
1428        if let Some(attached) = value.strip_prefix("-Z") {
1429            let option = self.take_value("-Z", (!attached.is_empty()).then_some(attached))?;
1430            match option.as_str() {
1431                "shell-argfiles" | "unstable-options" => {
1432                    self.parsed.push(Argument::Plain(format!("-Z{option}")));
1433                    return Ok(());
1434                }
1435                "threads" | "threads=" => {
1436                    return Err(BypassReason::MissingValue("-Zthreads".into()));
1437                }
1438                _ => {}
1439            }
1440            if option.starts_with("threads=") {
1441                self.parsed.push(Argument::Plain(format!("-Z{option}")));
1442                return Ok(());
1443            }
1444            return Err(BypassReason::UnknownFlag(format!("-Z{option}")));
1445        }
1446        match value {
1447            // `-vV` is how cargo and build scripts ask for the verbose
1448            // version, so it is a query rather than a flag left unmodeled.
1449            "-h" | "-V" | "-vV" => return Err(BypassReason::CompilerQuery),
1450            "-g" | "-O" | "-v" => {
1451                self.parsed.push(Argument::Plain(value.into()));
1452                return Ok(());
1453            }
1454            _ => {}
1455        }
1456        for (short, long) in [
1457            ("-A", "--allow"),
1458            ("-W", "--warn"),
1459            ("-D", "--deny"),
1460            ("-F", "--forbid"),
1461        ] {
1462            if let Some(attached) = value.strip_prefix(short) {
1463                let lint = self.take_value(short, (!attached.is_empty()).then_some(attached))?;
1464                self.parsed.push(Argument::Plain(format!("{long}={lint}")));
1465                return Ok(());
1466            }
1467        }
1468        if let Some(attached) = value.strip_prefix("-C") {
1469            let option = self.take_value("-C", (!attached.is_empty()).then_some(attached))?;
1470            return self.parse_codegen(&option);
1471        }
1472        if let Some(attached) = value.strip_prefix("-L") {
1473            let search = self.take_value("-L", (!attached.is_empty()).then_some(attached))?;
1474            let (kind, path) = search
1475                .split_once('=')
1476                .map_or(("all", search.as_str()), |(kind, path)| (kind, path));
1477            if !matches!(kind, "dependency" | "native") {
1478                return Err(BypassReason::UnsupportedSearchPath(kind.into()));
1479            }
1480            self.parsed.push(Argument::SearchPath {
1481                kind: kind.into(),
1482                path: path.into(),
1483            });
1484            return Ok(());
1485        }
1486        if value == "-l" || value.starts_with("-l") {
1487            return Err(BypassReason::NativeLibrary);
1488        }
1489        if let Some(attached) = value.strip_prefix("-o") {
1490            let path = self.take_value("-o", (!attached.is_empty()).then_some(attached))?;
1491            self.explicit_output = Some(path.clone().into());
1492            self.parsed.push(Argument::Path {
1493                flag: "-o".into(),
1494                path: path.into(),
1495            });
1496            return Ok(());
1497        }
1498        Err(BypassReason::UnknownFlag(value.into()))
1499    }
1500
1501    fn parse_codegen(&mut self, value: &str) -> Result<(), BypassReason> {
1502        let name = value.split_once('=').map_or(value, |(name, _)| name);
1503        if name == "incremental" {
1504            return Err(BypassReason::Incremental);
1505        }
1506        if SUPPORTED_CODEGEN_OPTIONS.binary_search(&name).is_err()
1507            && !(cfg!(windows) && name == "linker")
1508        {
1509            return Err(BypassReason::UnknownCodegenOption(name.into()));
1510        }
1511        // Cargo compiles every dependency of an LTO build with this flag, so
1512        // the rlibs carry bitcode for the final link to optimize across. The
1513        // switch is keyed as text like any other option. A value naming the
1514        // linker plugin is a host file the key does not describe, so that
1515        // spelling still bypasses.
1516        if name == "linker-plugin-lto"
1517            && let Some((_, selection)) = value.split_once('=')
1518            && !matches!(
1519                selection,
1520                "" | "y" | "yes" | "on" | "true" | "n" | "no" | "off" | "false"
1521            )
1522        {
1523            return Err(BypassReason::UnknownCodegenOption(format!(
1524                "linker-plugin-lto={selection}"
1525            )));
1526        }
1527        // `-fuse-ld=<name>` selects the linker the driver invokes. Parsed
1528        // rather than keyed as text so the linker identity probe can pin the
1529        // selected linker; a repeated selection dedups, and a conflicting
1530        // second selection falls through to Plain, which native-link handling
1531        // declines to model. The driver honors the last selection, so a
1532        // conflict is a link whose linker this adapter cannot name.
1533        if name == "link-arg"
1534            && let Some((_, option)) = value.split_once('=')
1535            && let Some(selection) = option.strip_prefix("-fuse-ld=")
1536            && !selection.is_empty()
1537            && !selection.contains(',')
1538            && !self.parsed.iter().any(
1539                |argument| matches!(argument, Argument::FuseLd(existing) if existing != selection),
1540            )
1541        {
1542            if !self.parsed.iter().any(
1543                |argument| matches!(argument, Argument::FuseLd(existing) if existing == selection),
1544            ) {
1545                self.parsed.push(Argument::FuseLd(selection.to_owned()));
1546            }
1547            return Ok(());
1548        }
1549        // `-oso_prefix` names a checkout-specific path, so it is parsed
1550        // rather than keyed as text: the path normalizes like every other
1551        // path in the key, which is what lets two checkouts passing their own
1552        // prefixes agree on one action.
1553        if matches!(name, "link-arg" | "link-args")
1554            && let Some((_, option)) = value.split_once('=')
1555            && let Some(prefix) = option.strip_prefix("-Wl,-oso_prefix,")
1556            && !prefix.trim_end_matches('/').is_empty()
1557            && !prefix.contains(',')
1558        {
1559            let trailing_slash = prefix.ends_with('/');
1560            self.parsed.push(Argument::OsoPrefix {
1561                path: PathBuf::from(prefix.trim_end_matches('/')),
1562                trailing_slash,
1563            });
1564            return Ok(());
1565        }
1566        self.parsed
1567            .push(Argument::Plain(format!("--codegen={value}")));
1568        if name == "extra-filename" {
1569            self.extra_filename = value
1570                .split_once('=')
1571                .map_or(String::new(), |(_, value)| value.to_string());
1572        }
1573        Ok(())
1574    }
1575
1576    fn parse_input(&mut self, value: &str) -> Result<(), BypassReason> {
1577        if value == "-" {
1578            return Err(BypassReason::StandardInput);
1579        }
1580        if self.source.replace(value.into()).is_some() {
1581            return Err(BypassReason::MultipleInputs);
1582        }
1583        Ok(())
1584    }
1585
1586    fn classify(&self) -> Result<LinkOutput, BypassReason> {
1587        // A compilation that emits no `link` produces exactly what an rlib's
1588        // metadata emit does -- dep-info and `lib<name>.rmeta` -- whatever it
1589        // calls its crate type. `cargo check` and clippy compile every binary
1590        // and test target this way, and those were bypassing as an
1591        // unsupported crate type for a linked artifact none of them asked
1592        // for. No linker runs, so nothing downstream needs a linker identity
1593        // to describe, and rustc names the metadata the same way for every
1594        // crate type.
1595        let never_links = !self.emits.iter().any(|emit| emit.kind == "link");
1596        let builds_a_library = !self.test
1597            && !self.crate_types.is_empty()
1598            && self
1599                .crate_types
1600                .iter()
1601                .all(|crate_type| matches!(crate_type.as_str(), "lib" | "rlib"));
1602        let link_output = if never_links || builds_a_library {
1603            LinkOutput::Library
1604        } else if self
1605            .target
1606            .as_deref()
1607            .is_some_and(compiler_bundled_wasm_target)
1608            && ((self.test && self.crate_types.is_empty())
1609                || matches!(self.crate_types.as_slice(), [kind] if kind == "bin" || kind == "cdylib"))
1610        {
1611            if self.parsed.iter().any(|argument| match argument {
1612                Argument::Plain(value) if value == "--codegen=link-self-contained" => false,
1613                Argument::Plain(value) if value.starts_with("--codegen=link-self-contained=") => {
1614                    !matches!(
1615                        value.rsplit_once('=').map(|(_, value)| value),
1616                        Some("y" | "yes" | "on" | "true")
1617                    )
1618                }
1619                _ => false,
1620            }) {
1621                return Err(BypassReason::UnknownCodegenOption(
1622                    "link-self-contained".into(),
1623                ));
1624            }
1625            if self.target.as_deref().is_some_and(|target| target.contains("wasi"))
1626                && self.parsed.iter().any(|argument| {
1627                    matches!(argument, Argument::Plain(value) if value.strip_prefix("--codegen=target-feature=").is_some_and(|features| features.split(',').any(|feature| feature == "-crt-static")))
1628                })
1629            {
1630                return Err(BypassReason::UnknownCodegenOption(
1631                    "target-feature=-crt-static".into(),
1632                ));
1633            }
1634            // These targets use a linker and, where applicable, CRT objects
1635            // and libc shipped in the Rust toolchain. Unlike native linking,
1636            // there are no implicit host inputs outside compiler identity.
1637            LinkOutput::WasmExecutable
1638        } else if self.options.cache_native_links && self.links_a_native_artifact() {
1639            self.check_native_link_is_portable()?;
1640            if matches!(self.crate_types.as_slice(), [kind] if kind == "proc-macro") {
1641                LinkOutput::NativeProcMacro
1642            } else {
1643                LinkOutput::NativeExecutable
1644            }
1645        } else if self.test {
1646            return Err(BypassReason::UnsupportedCrateType("test".into()));
1647        } else {
1648            return Err(BypassReason::UnsupportedCrateType(
1649                self.crate_types
1650                    .iter()
1651                    .find(|crate_type| !matches!(crate_type.as_str(), "lib" | "rlib"))
1652                    .cloned()
1653                    .unwrap_or_else(|| "bin".into()),
1654            ));
1655        };
1656        // A link argument is handed to the linker verbatim, and its text is all
1657        // the adapter has to go on. `-Clink-arg=-Tlink.x` names a linker script
1658        // resolved off the search path, `-Clink-arg=-fuse-ld=lld` replaces the
1659        // linker the identity in the key describes, and neither is told apart
1660        // from `/STACK:8000000` by any rule short of knowing the linker's own
1661        // grammar. Where nothing links there is nothing to tell apart: an rlib
1662        // or an rmeta is produced without a linker invocation, so the option is
1663        // inert and the key carries its text like any other codegen option.
1664        if link_output != LinkOutput::Library
1665            && let Some(option) = self.first_link_argument()
1666        {
1667            return Err(BypassReason::UnmodeledLinkArgument(option.to_owned()));
1668        }
1669        // `-fuse-ld` is modeled only for native links, where the linker
1670        // identity probe resolves and pins the linker it names. A wasm or
1671        // other non-native link consuming the selection has no identity that
1672        // describes it, so it declines the action like any other link
1673        // argument. Where nothing links, the selection is inert and the key
1674        // carries its text.
1675        if let Some(selection) = self.parsed.iter().find_map(|argument| {
1676            let Argument::FuseLd(selection) = argument else {
1677                return None;
1678            };
1679            Some(selection.as_str())
1680        }) && !matches!(
1681            link_output,
1682            LinkOutput::Library | LinkOutput::NativeExecutable | LinkOutput::NativeProcMacro
1683        ) {
1684            return Err(BypassReason::UnmodeledLinkArgument(format!(
1685                "link-arg=-fuse-ld={selection}"
1686            )));
1687        }
1688        if self.parsed.iter().any(|argument| {
1689            matches!(argument, Argument::Plain(value) if value.starts_with("--codegen=linker="))
1690        }) && !matches!(
1691            link_output,
1692            LinkOutput::NativeExecutable | LinkOutput::NativeProcMacro
1693        ) {
1694            return Err(BypassReason::UnknownCodegenOption("linker".into()));
1695        }
1696        // `-oso_prefix` is modeled, but only where ld64 is the linker reading
1697        // it: a native macOS link. Any other linker sees an option this
1698        // adapter cannot vouch for, exactly like the arguments above.
1699        if !matches!(
1700            link_output,
1701            LinkOutput::Library | LinkOutput::NativeExecutable | LinkOutput::NativeProcMacro
1702        ) && self
1703            .parsed
1704            .iter()
1705            .any(|argument| matches!(argument, Argument::OsoPrefix { .. }))
1706        {
1707            return Err(BypassReason::UnmodeledLinkArgument(
1708                "link-arg=-Wl,-oso_prefix".into(),
1709            ));
1710        }
1711        if let Some(name) = self.parsed.iter().find_map(|argument| match argument {
1712            Argument::Extern { name, path: None } if name != "proc_macro" => Some(name),
1713            _ => None,
1714        }) {
1715            return Err(BypassReason::UnresolvedExtern(name.clone()));
1716        }
1717        if let Some(emit) = self
1718            .emits
1719            .iter()
1720            .find(|emit| !matches!(emit.kind.as_str(), "dep-info" | "link" | "metadata"))
1721        {
1722            return Err(BypassReason::UnsupportedEmit(emit.kind.clone()));
1723        }
1724        if !self
1725            .emits
1726            .iter()
1727            .any(|emit| matches!(emit.kind.as_str(), "link" | "metadata"))
1728        {
1729            return Err(BypassReason::NoCacheableOutput);
1730        }
1731        Ok(link_output)
1732    }
1733}
1734
1735impl Parser<'_> {
1736    /// The first `-C link-arg` or `-C link-args` option in the invocation,
1737    /// rendered as it appears in the action key.
1738    fn first_link_argument(&self) -> Option<&str> {
1739        self.parsed.iter().find_map(|argument| {
1740            let Argument::Plain(value) = argument else {
1741                return None;
1742            };
1743            let option = value.strip_prefix("--codegen=")?;
1744            let name = option.split_once('=').map_or(option, |(name, _)| name);
1745            matches!(name, "link-arg" | "link-args").then_some(option)
1746        })
1747    }
1748
1749    /// Whether an `-oso_prefix` strips the directory this link's objects live
1750    /// in from the debug map ld64 records.
1751    ///
1752    /// The objects a native link reads -- this unit's own compiled objects
1753    /// and the rlibs it links -- sit in the output directory, so a prefix
1754    /// covering that directory leaves the debug map naming spellings every
1755    /// checkout shares. Toolchain objects stay absolute, as they do in the
1756    /// paths rustc itself embeds on every platform.
1757    fn oso_prefix_covers_outputs(&self) -> bool {
1758        let Some(directory) = self
1759            .out_dir
1760            .as_deref()
1761            .or_else(|| self.explicit_output.as_deref().and_then(Path::parent))
1762        else {
1763            return false;
1764        };
1765        if !directory.is_absolute() {
1766            return false;
1767        }
1768        let directory = normalize_components(directory);
1769        self.parsed.iter().any(|argument| {
1770            let Argument::OsoPrefix { path, .. } = argument else {
1771                return false;
1772            };
1773            path.is_absolute() && directory.starts_with(normalize_components(path))
1774        })
1775    }
1776
1777    /// Whether this invocation links an artifact for the host.
1778    ///
1779    /// An explicit `--target` bypasses even when it spells the host triple:
1780    /// rustc without one links for the host by construction, which is the
1781    /// cheapest description of "the linker this adapter can identify", and
1782    /// cargo omits it for host builds anyway.
1783    fn links_a_native_artifact(&self) -> bool {
1784        self.target.is_none()
1785            // A compilation that emits no linked artifact did not link, so it
1786            // needs no linker to describe it. `cargo check --tests` asks for
1787            // metadata alone, and reading that as a link would send it looking
1788            // for a linker identity and refusing flags no linker ever saw.
1789            && self.emits.iter().any(|emit| emit.kind == "link")
1790            && ((self.test && self.crate_types.is_empty())
1791                || matches!(self.crate_types.as_slice(), [kind] if matches!(kind.as_str(), "bin" | "proc-macro")))
1792    }
1793
1794    /// Reject a native link whose result depends on something the key cannot
1795    /// describe, or that leaves artifacts beside the ones mbx would store.
1796    ///
1797    /// Each check names a value rather than a flag: an option absent from the
1798    /// invocation keeps rustc's default, which the compiler identity already
1799    /// pins, while any other spelling is refused rather than guessed at.
1800    fn check_native_link_is_portable(&self) -> Result<(), BypassReason> {
1801        for argument in &self.parsed {
1802            let Argument::Plain(value) = argument else {
1803                continue;
1804            };
1805            // `-g` is rustc's shorthand for debug info, and arrives as itself
1806            // rather than as a codegen option.
1807            let (name, value) = if value == "-g" {
1808                ("debuginfo", Some("2"))
1809            } else if let Some(option) = value.strip_prefix("--codegen=") {
1810                match option.split_once('=') {
1811                    Some((name, value)) => (name, Some(value)),
1812                    // A codegen flag with no value asks for its enabled form,
1813                    // which is what cargo passes for `-Crpath`. Reading it as
1814                    // "nothing to check" is how these slipped through.
1815                    None => (option, None),
1816                }
1817            } else {
1818                continue;
1819            };
1820            let unportable = match name {
1821                // Packed debug info leaves a .dSYM bundle or .dwp file beside
1822                // the binary, and mbx stores neither. Unpacked is macOS's
1823                // debug-map arrangement -- debug info stays in the objects and
1824                // nothing lands beside the binary -- so the same covering
1825                // `-oso_prefix` that makes the debug map portable covers it.
1826                "split-debuginfo" => match value {
1827                    Some("off") => false,
1828                    Some("unpacked") if cfg!(target_os = "macos") => {
1829                        !self.oso_prefix_covers_outputs()
1830                    }
1831                    _ => true,
1832                },
1833                // ld64 records absolute object paths and their timestamps in
1834                // the binary's debug map, so the same source links to
1835                // different bytes in another checkout -- or the same one
1836                // twice. An `-oso_prefix` covering the output directory
1837                // strips those paths down to spellings every checkout
1838                // shares, which is the same leniency a Linux link already
1839                // gets for the paths rustc itself embeds; what remains
1840                // (object timestamps) only shows up under verification.
1841                "debuginfo" if cfg!(target_os = "macos") => {
1842                    !matches!(value, Some("0" | "none")) && !self.oso_prefix_covers_outputs()
1843                }
1844                // An rpath and a dynamically linked native program embed this
1845                // checkout's absolute target directory. Cargo also passes
1846                // `prefer-dynamic` to proc macros, but their dynamic runtime
1847                // is rustc's own sysroot (already pinned by compiler identity),
1848                // while Cargo's dependencies are linked into the macro. There
1849                // is no checkout rpath to carry; keep rejecting an explicit
1850                // `rpath` independently.
1851                "rpath" => is_enabled(value),
1852                "prefer-dynamic" => {
1853                    is_enabled(value)
1854                        && !matches!(self.crate_types.as_slice(), [kind] if kind == "proc-macro")
1855                }
1856                // The CRT objects a self-contained link uses come from
1857                // somewhere other than where the driver reports.
1858                "link-self-contained" => true,
1859                _ => false,
1860            };
1861            if unportable {
1862                return Err(BypassReason::UnportableNativeLink(match value {
1863                    Some(value) => format!("{name}={value}"),
1864                    None => name.to_owned(),
1865                }));
1866            }
1867        }
1868        Ok(())
1869    }
1870}
1871
1872/// Whether a boolean codegen option is asking for its enabled form. Absent a
1873/// value, rustc reads the flag itself as the request.
1874fn is_enabled(value: Option<&str>) -> bool {
1875    matches!(value, None | Some("y" | "yes" | "on" | "true"))
1876}
1877
1878fn compiler_bundled_wasm_target(target: &str) -> bool {
1879    COMPILER_BUNDLED_WASM_TARGETS.binary_search(&target).is_ok()
1880}
1881
1882fn parse_emits(value: &str) -> Vec<Emit> {
1883    value
1884        .split(',')
1885        .map(|emit| {
1886            let (kind, path) = emit
1887                .split_once('=')
1888                .map_or((emit, None), |(kind, path)| (kind, Some(path.into())));
1889            Emit {
1890                kind: kind.into(),
1891                path,
1892            }
1893        })
1894        .collect()
1895}
1896
1897struct ActionBuilder<'a> {
1898    invocation: &'a RustcInvocation,
1899    context: ActionContext,
1900    mappings: Vec<SharedPathMapping>,
1901    linker: Option<LinkerIdentity>,
1902}
1903
1904impl<'a> ActionBuilder<'a> {
1905    fn new(invocation: &'a RustcInvocation, mut context: ActionContext) -> Self {
1906        context.path_mappings = PathMapping::ordered(&context.path_mappings);
1907        let mappings = resolve_path_mappings(&shared_path_mappings(&context.path_mappings));
1908        Self {
1909            linker: None,
1910            invocation,
1911            mappings,
1912            context,
1913        }
1914    }
1915
1916    fn linked_by(mut self, linker: Option<LinkerIdentity>) -> Self {
1917        self.linker = linker;
1918        self
1919    }
1920
1921    fn build(self) -> Result<RustcAction, BypassReason> {
1922        self.validate_mappings()?;
1923        let invocation = self.invocation_descriptor()?;
1924        let environment = self.environment_descriptor()?;
1925
1926        let mut inputs = BTreeMap::<String, CacheDigest>::new();
1927        for input in &self.context.inputs {
1928            input
1929                .digest
1930                .validate()
1931                .map_err(|_| BypassReason::InvalidInputDigest(input.path.display().to_string()))?;
1932            let path = self.normalize_path(&input.path)?;
1933            if inputs
1934                .insert(path.clone(), input.digest.clone())
1935                .is_some_and(|existing| existing != input.digest)
1936            {
1937                return Err(BypassReason::ConflictingInput(path));
1938            }
1939        }
1940        let required = self
1941            .invocation
1942            .required_inputs
1943            .iter()
1944            .map(|path| self.normalize_path(path))
1945            .collect::<Result<BTreeSet<_>, _>>()?;
1946        if let Some(missing) = required.iter().find(|path| !inputs.contains_key(*path)) {
1947            return Err(BypassReason::MissingRequiredInput(missing.clone()));
1948        }
1949        let inputs = inputs
1950            .into_iter()
1951            .map(|(path, digest)| InputDescriptor { path, digest })
1952            .collect();
1953        // A native link without a linker identity would be keyed as though the
1954        // host did not matter. Refuse rather than publish that claim.
1955        if self.invocation.links_natively() && self.linker.is_none() {
1956            return Err(BypassReason::UnportableNativeLink(
1957                "linker identity is unknown".into(),
1958            ));
1959        }
1960        let descriptor = ActionDescriptor {
1961            version: ACTION_SCHEMA_VERSION,
1962            kind: "rustc",
1963            adapter_version: ADAPTER_VERSION,
1964            compiler: invocation.compiler,
1965            arguments: invocation.arguments,
1966            environment,
1967            inputs,
1968            linker: self.linker.clone(),
1969        };
1970        let bytes = canonical_json(&descriptor)
1971            .map_err(|error| BypassReason::Serialization(error.to_string()))?;
1972        let digest = CacheDigest::blake3(&bytes);
1973        Ok(RustcAction { digest, bytes })
1974    }
1975
1976    fn invocation_descriptor(&self) -> Result<InvocationDescriptor, BypassReason> {
1977        self.validate_mappings()?;
1978        let arguments = self
1979            .invocation
1980            .arguments
1981            .iter()
1982            .map(|argument| self.normalize_argument(argument))
1983            .collect::<Result<Vec<_>, _>>()?;
1984        let required_inputs = self
1985            .invocation
1986            .required_inputs
1987            .iter()
1988            .map(|path| self.normalize_path(path))
1989            .collect::<Result<BTreeSet<_>, _>>()?
1990            .into_iter()
1991            .collect();
1992        Ok(InvocationDescriptor {
1993            version: ACTION_SCHEMA_VERSION,
1994            kind: "rustc",
1995            adapter_version: ADAPTER_VERSION,
1996            compiler: CompilerDescriptor {
1997                toolchain: self.context.compiler.toolchain.clone(),
1998                rustc_version: self.context.compiler.rustc_version.clone(),
1999                host: self.context.compiler.host.clone(),
2000                driver: self.context.compiler.driver.clone(),
2001            },
2002            arguments,
2003            required_inputs,
2004        })
2005    }
2006
2007    fn validate_mappings(&self) -> Result<(), BypassReason> {
2008        if !self.context.working_dir.is_absolute() {
2009            return Err(BypassReason::RelativeWorkingDirectory(
2010                self.context.working_dir.clone(),
2011            ));
2012        }
2013        let mut roots = BTreeSet::new();
2014        let mut placeholders = BTreeSet::new();
2015        for mapping in &self.mappings {
2016            if !mapping.root.is_absolute() {
2017                return Err(BypassReason::RelativePathMapping(mapping.root.clone()));
2018            }
2019            if mapping.placeholder.is_empty()
2020                || !mapping
2021                    .placeholder
2022                    .bytes()
2023                    .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
2024                || !roots.insert(normalize_components(&mapping.root))
2025                || !placeholders.insert(&mapping.placeholder)
2026            {
2027                return Err(BypassReason::InvalidPathPlaceholder(
2028                    mapping.placeholder.clone(),
2029                ));
2030            }
2031        }
2032        Ok(())
2033    }
2034
2035    fn normalize_argument(&self, argument: &Argument) -> Result<String, BypassReason> {
2036        match argument {
2037            Argument::Plain(value) => Ok(value.clone()),
2038            Argument::Path { flag, path } => Ok(format!("{flag}={}", self.normalize_path(path)?)),
2039            Argument::SearchPath { kind, path } => {
2040                let text = match self.normalize_path(path) {
2041                    Ok(text) => text,
2042                    // A native directory outside every mapped root is a host
2043                    // toolchain installation, not a checkout location. Its
2044                    // literal path is the key material: the path is
2045                    // version-stamped on the platforms that pass one (the MSVC
2046                    // toolset, the Windows SDK), so hosts that differ miss,
2047                    // which is the same identity-over-content stance
2048                    // [`LinkerIdentity`] takes.
2049                    Err(BypassReason::UnmappedAbsolutePath(absolute))
2050                        if kind == "native" && self.invocation.native_search_is_inert() =>
2051                    {
2052                        absolute
2053                            .to_str()
2054                            .ok_or(BypassReason::NonUtf8Path(absolute.clone()))?
2055                            .to_string()
2056                    }
2057                    Err(error) => return Err(error),
2058                };
2059                Ok(format!("-L{kind}={text}"))
2060            }
2061            Argument::Extern { name, path } => match path {
2062                Some(path) => Ok(format!("--extern={name}={}", self.normalize_path(path)?)),
2063                None => Ok(format!("--extern={name}")),
2064            },
2065            Argument::Emit(emits) => Ok(format!(
2066                "--emit={}",
2067                emits
2068                    .iter()
2069                    .map(|emit| match &emit.path {
2070                        Some(path) => self
2071                            .normalize_path(path)
2072                            .map(|path| format!("{}={path}", emit.kind)),
2073                        None => Ok(emit.kind.clone()),
2074                    })
2075                    .collect::<Result<Vec<_>, _>>()?
2076                    .join(",")
2077            )),
2078            Argument::RemapPath { from, to } => Ok(format!(
2079                "--remap-path-prefix={}={}",
2080                self.normalize_path(from)?,
2081                to
2082            )),
2083            Argument::OsoPrefix {
2084                path,
2085                trailing_slash,
2086            } => Ok(format!(
2087                "--codegen=link-arg=-Wl,-oso_prefix,{}{}",
2088                self.normalize_path(path)?,
2089                if *trailing_slash { "/" } else { "" }
2090            )),
2091            Argument::FuseLd(selection) => Ok(format!("--codegen=link-arg=-fuse-ld={selection}")),
2092        }
2093    }
2094
2095    /// Environment values enter the key verbatim, because rustc may embed one
2096    /// through `env!`: unlike a path used to locate an input, changing the value
2097    /// changes the artifact.
2098    ///
2099    /// A name in `portable_environment` is the exception the caller has earned.
2100    /// Its value normalizes like any other path, so two checkouts agree on it.
2101    fn environment_descriptor(&self) -> Result<BTreeMap<String, Option<String>>, BypassReason> {
2102        self.context
2103            .environment
2104            .iter()
2105            .map(|(name, value)| {
2106                let value = match value {
2107                    Some(value) if self.context.portable_environment.contains(name) => {
2108                        Some(self.normalize_path(Path::new(value))?)
2109                    }
2110                    value => value.clone(),
2111                };
2112                Ok((name.clone(), value))
2113            })
2114            .collect()
2115    }
2116
2117    fn normalize_path(&self, path: &Path) -> Result<String, BypassReason> {
2118        normalize_resolved_shared_path(path, &self.context.working_dir, &self.mappings)
2119            .map_err(Into::into)
2120    }
2121}
2122
2123/// Whether a normalized path names something strictly beneath one of
2124/// `directories`, which are normalized the same way and so share its `/`
2125/// separator regardless of platform.
2126fn under_any_directory(path: &str, directories: &BTreeSet<String>) -> bool {
2127    directories.iter().any(|directory| {
2128        path.len() > directory.len()
2129            && path.as_bytes()[directory.len()] == b'/'
2130            && path.starts_with(directory)
2131    })
2132}
2133
2134fn denormalize_path(value: &str, mappings: &[PathMapping]) -> Result<PathBuf, BypassReason> {
2135    for mapping in mappings {
2136        let prefix = format!("${{{}}}", mapping.placeholder);
2137        let suffix = if value == prefix {
2138            ""
2139        } else if let Some(suffix) = value.strip_prefix(&format!("{prefix}/")) {
2140            suffix
2141        } else {
2142            continue;
2143        };
2144        if !mapping.root.is_absolute()
2145            || (!suffix.is_empty()
2146                && suffix.split('/').any(|component| {
2147                    component.is_empty()
2148                        || matches!(component, "." | "..")
2149                        || component.contains('\\')
2150                }))
2151        {
2152            return Err(BypassReason::InvalidPredictedInput(value.into()));
2153        }
2154        let mut path = normalize_components(&mapping.root);
2155        path.extend(suffix.split('/').filter(|component| !component.is_empty()));
2156        return Ok(path);
2157    }
2158    Err(BypassReason::InvalidPredictedInput(value.into()))
2159}
2160
2161fn normalize_components(path: &Path) -> PathBuf {
2162    let mut normalized = PathBuf::new();
2163    for component in path.components() {
2164        match component {
2165            Component::CurDir => {}
2166            Component::ParentDir => {
2167                normalized.pop();
2168            }
2169            component => normalized.push(component.as_os_str()),
2170        }
2171    }
2172    normalized
2173}
2174
2175fn absolute_path(path: &Path, working_dir: &Path) -> PathBuf {
2176    if path.is_absolute() {
2177        normalize_components(path)
2178    } else {
2179        normalize_components(&working_dir.join(path))
2180    }
2181}
2182
2183#[cfg(test)]
2184#[path = "rustc_cache_tests.rs"]
2185mod tests;