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