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