Skip to main content

mbx_cache_rustc/
lib.rs

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