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