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