Skip to main content

mbx_cache_rustc/
dep_info.rs

1use super::{
2    ActionContext, ActionInput, Argument, BypassReason, MAX_NATIVE_INPUT_BYTES,
3    MAX_PREDICTED_INPUTS, PathMapping, RustcInvocation, normalize_components,
4};
5#[cfg(test)]
6use mbx_cache_core::CacheDigest;
7use mbx_cache_core::{
8    FileDigestCache, FileDigestResolution, FileDigestScope, FileIdentity, FileSnapshot,
9    RecordedFileDigest, digest_file, digest_file_validated,
10};
11use std::collections::{BTreeMap, BTreeSet};
12use std::ffi::OsString;
13use std::path::{Path, PathBuf};
14use std::time::SystemTime;
15
16/// A side-effect-minimized rustc invocation that emits only dependency data.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct DepInfoCommand {
19    arguments: Vec<OsString>,
20    output: PathBuf,
21}
22
23impl DepInfoCommand {
24    /// Arguments for the real compiler, excluding the compiler executable.
25    pub fn arguments(&self) -> &[OsString] {
26        &self.arguments
27    }
28
29    /// Exact file the compiler must populate with dep-info.
30    pub fn output(&self) -> &Path {
31        &self.output
32    }
33}
34
35/// The source and environment inputs reported by rustc's dep-info output.
36#[derive(Debug, Clone, Default, PartialEq, Eq)]
37pub struct RustcDepInfo {
38    /// Source paths listed in the first dep-info dependency rule.
39    pub files: Vec<PathBuf>,
40    /// Environment inputs recorded by rustc `# env-dep:` lines.
41    pub environment: BTreeMap<String, Option<String>>,
42}
43
44impl RustcDepInfo {
45    /// Read and parse a dep-info file, treating missing or non-UTF-8 output as
46    /// an explicit cache bypass.
47    pub fn read(path: &Path) -> Result<Self, BypassReason> {
48        let contents =
49            std::fs::read_to_string(path).map_err(|error| BypassReason::DepInfoRead {
50                path: path.to_path_buf(),
51                message: error.to_string(),
52            })?;
53        Self::parse(&contents)
54    }
55
56    /// Parse rustc's Makefile-style dep-info format.
57    ///
58    /// This intentionally follows Cargo's parser contract: the first target
59    /// rule contains all source dependencies, spaces are escaped with a
60    /// trailing backslash on each token fragment, and `# env-dep:` records
61    /// contain the environment observed by `env!` and `option_env!`.
62    pub fn parse(contents: &str) -> Result<Self, BypassReason> {
63        let mut files = BTreeSet::new();
64        let mut environment = BTreeMap::new();
65        let mut found_dependencies = false;
66
67        for line in contents.lines() {
68            if let Some(record) = line.strip_prefix("# env-dep:") {
69                let (name, value) = record
70                    .split_once('=')
71                    .map_or((record, None), |(name, value)| (name, Some(value)));
72                let name = unescape_environment(name)?;
73                if name.is_empty() {
74                    return Err(BypassReason::MalformedDepInfo(
75                        "environment input has an empty name".into(),
76                    ));
77                }
78                let value = value.map(unescape_environment).transpose()?;
79                if environment
80                    .insert(name.clone(), value.clone())
81                    .is_some_and(|previous| previous != value)
82                {
83                    return Err(BypassReason::ConflictingEnvironment(name));
84                }
85                continue;
86            }
87
88            let Some(separator) = line.find(": ") else {
89                continue;
90            };
91            if found_dependencies {
92                continue;
93            }
94            found_dependencies = true;
95            let mut fragments = line[separator + 2..].split_whitespace();
96            while let Some(fragment) = fragments.next() {
97                let mut file = fragment.to_string();
98                while file.ends_with('\\') {
99                    file.pop();
100                    let continuation = fragments.next().ok_or_else(|| {
101                        BypassReason::MalformedDepInfo(
102                            "dependency path ends with an unterminated escape".into(),
103                        )
104                    })?;
105                    file.push(' ');
106                    file.push_str(continuation);
107                }
108                if file.is_empty() {
109                    return Err(BypassReason::MalformedDepInfo(
110                        "dependency path is empty".into(),
111                    ));
112                }
113                files.insert(PathBuf::from(file));
114            }
115        }
116
117        if !found_dependencies {
118            return Err(BypassReason::MalformedDepInfo(
119                "dependency rule is missing".into(),
120            ));
121        }
122        if files.is_empty() {
123            return Err(BypassReason::MalformedDepInfo(
124                "dependency rule contains no inputs".into(),
125            ));
126        }
127        Ok(Self {
128            files: files.into_iter().collect(),
129            environment,
130        })
131    }
132}
133
134/// A complete, content-addressed compiler input manifest.
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub struct DiscoveredInputs {
137    working_dir: PathBuf,
138    /// Content-addressed compiler input files.
139    pub inputs: Vec<ActionInput>,
140    /// Environment inputs captured from dep-info.
141    pub environment: BTreeMap<String, Option<String>>,
142    /// What each input looked like on disk when its digest was established,
143    /// index-aligned with `inputs`; `None` where the filesystem gave nothing to
144    /// compare against later. Lets `verify` confirm an input by stat instead of
145    /// by reading it again.
146    identities: Vec<Option<FileIdentity>>,
147    /// Inputs that entered only because they sit in a native search directory,
148    /// not because dep-info or the invocation named them. They belong in a
149    /// shared action key, but they are not the crate's own sources.
150    native_only: BTreeSet<PathBuf>,
151}
152
153impl DiscoveredInputs {
154    pub(crate) fn from_paths(
155        working_dir: &Path,
156        paths: BTreeSet<PathBuf>,
157        environment: BTreeMap<String, Option<String>>,
158        digests: &dyn FileDigestCache,
159    ) -> Result<Self, BypassReason> {
160        if !working_dir.is_absolute() {
161            return Err(BypassReason::RelativeWorkingDirectory(
162                working_dir.to_path_buf(),
163            ));
164        }
165        let working_dir = normalize_components(working_dir);
166        // Stat everything first: the identities drive one batched ledger
167        // lookup, so an upstream rlib the session already hashed -- once, when
168        // it was materialized or published -- is not read again by every crate
169        // that links it. A file the filesystem reports no modification time
170        // for gets no identity and is simply hashed.
171        let mut identified = Vec::with_capacity(paths.len());
172        for path in paths {
173            let metadata = std::fs::metadata(&path).map_err(|error| BypassReason::InputRead {
174                path: path.clone(),
175                message: error.to_string(),
176            })?;
177            if !metadata.is_file() {
178                return Err(BypassReason::InputRead {
179                    path,
180                    message: "input is not a regular file".into(),
181                });
182            }
183            let identity = FileIdentity::for_digest_cache(&path, &metadata).map_err(|error| {
184                BypassReason::InputRead {
185                    path: path.clone(),
186                    message: error.to_string(),
187                }
188            })?;
189            identified.push((path, identity));
190        }
191        let queries = identified
192            .iter()
193            .filter_map(|(_, identity)| identity.clone())
194            .collect::<Vec<_>>();
195        let mut recorded = digests
196            .resolve(FileDigestScope::Content, &queries)
197            .into_iter();
198        let mut inputs = Vec::with_capacity(identified.len());
199        let mut identities = Vec::with_capacity(identified.len());
200        let mut fresh = Vec::new();
201        for (path, identity) in identified {
202            let resolution = identity
203                .as_ref()
204                .and_then(|_| recorded.next())
205                .unwrap_or(FileDigestResolution::Unresolved);
206            let cached = match resolution {
207                FileDigestResolution::Digest(digest)
208                    if identity.as_ref().is_some_and(|identity| {
209                        identity.len == digest.size && identity.still_describes().unwrap_or(false)
210                    }) =>
211                {
212                    Some(digest)
213                }
214                FileDigestResolution::Digest(_)
215                | FileDigestResolution::EmbeddedTimestampMacro
216                | FileDigestResolution::Unresolved => None,
217            };
218            let digest = if let Some(digest) = cached {
219                identities.push(identity);
220                digest
221            } else {
222                let observed =
223                    digest_file_validated(FileDigestScope::Content, &path).map_err(|error| {
224                        BypassReason::InputRead {
225                            path: path.clone(),
226                            message: error.to_string(),
227                        }
228                    })?;
229                let (digest, observed_identity) = if let Some(observed) = observed {
230                    let digest = observed.resolution.into_digest().ok_or_else(|| {
231                        BypassReason::InputRead {
232                            path: path.clone(),
233                            message: "content digest resolution returned no digest".into(),
234                        }
235                    })?;
236                    if let Some(file) = observed.cache_identity.clone()
237                        && file.len == digest.size
238                    {
239                        fresh.push(RecordedFileDigest {
240                            file,
241                            digest: digest.clone(),
242                        });
243                    }
244                    (digest, observed.cache_identity)
245                } else {
246                    let digest = digest_file(FileDigestScope::Content, &path)
247                        .and_then(|resolution| {
248                            resolution.into_digest().ok_or_else(|| {
249                                std::io::Error::other(
250                                    "content digest resolution returned no digest",
251                                )
252                            })
253                        })
254                        .map_err(|error| BypassReason::InputRead {
255                            path: path.clone(),
256                            message: error.to_string(),
257                        })?;
258                    (digest, None)
259                };
260                identities.push(observed_identity);
261                digest
262            };
263            inputs.push(ActionInput { path, digest });
264        }
265        if !fresh.is_empty() {
266            digests.record(FileDigestScope::Content, fresh);
267        }
268        Ok(Self {
269            working_dir,
270            inputs,
271            environment,
272            identities,
273            native_only: BTreeSet::new(),
274        })
275    }
276
277    /// Mark inputs that were found only by scanning native search directories.
278    pub(crate) fn with_native_only(mut self, native_only: BTreeSet<PathBuf>) -> Self {
279        self.native_only = native_only;
280        self
281    }
282
283    /// Whether `path` is an input only because a native search directory holds it.
284    pub fn is_native_only(&self, path: &Path) -> bool {
285        self.native_only.contains(path)
286    }
287
288    /// Reject inputs whose modification time overlaps the compiler invocation.
289    ///
290    /// Input contents are first hashed after rustc reports their paths. This
291    /// timestamp barrier prevents a post-compile write from being mistaken for
292    /// the contents that produced the artifact. `verify` closes the remaining
293    /// race after hashing.
294    pub fn verify_not_modified_since(&self, started_at: SystemTime) -> Result<(), BypassReason> {
295        self.verify_not_modified_since_with_snapshots(started_at, &BTreeMap::new())
296    }
297
298    /// Reject inputs that changed from snapshots captured before rustc ran,
299    /// falling back to the wall-clock barrier for inputs only dep-info named.
300    ///
301    /// A snapshot may use metadata or content depending on what its filesystem
302    /// can compare reliably. Metadata snapshots need a change token: without
303    /// one a same-length rewrite can restore its mtime.
304    pub fn verify_not_modified_since_with_snapshots(
305        &self,
306        started_at: SystemTime,
307        before: &BTreeMap<PathBuf, FileSnapshot>,
308    ) -> Result<(), BypassReason> {
309        for input in &self.inputs {
310            if let Some(previous) = before.get(&input.path)
311                && previous.proves_content_change()
312            {
313                let metadata =
314                    std::fs::metadata(&input.path).map_err(|error| BypassReason::InputRead {
315                        path: input.path.clone(),
316                        message: error.to_string(),
317                    })?;
318                let identity = FileIdentity::for_digest_cache(&input.path, &metadata)
319                    .map_err(|error| BypassReason::InputRead {
320                        path: input.path.clone(),
321                        message: error.to_string(),
322                    })?
323                    .or_else(|| FileIdentity::describe(&input.path, &metadata));
324                if previous.matches(identity.as_ref(), &input.digest) {
325                    continue;
326                }
327                return Err(BypassReason::InputModifiedDuringCompilation(
328                    input.path.clone(),
329                ));
330            }
331            let metadata =
332                std::fs::metadata(&input.path).map_err(|error| BypassReason::InputRead {
333                    path: input.path.clone(),
334                    message: error.to_string(),
335                })?;
336            let modified = metadata
337                .modified()
338                .map_err(|error| BypassReason::InputRead {
339                    path: input.path.clone(),
340                    message: error.to_string(),
341                })?;
342            if modified >= started_at {
343                return Err(BypassReason::InputModifiedDuringCompilation(
344                    input.path.clone(),
345                ));
346            }
347        }
348        Ok(())
349    }
350
351    /// Compatibility form for callers that captured metadata identities.
352    pub fn verify_not_modified_since_with_identities(
353        &self,
354        started_at: SystemTime,
355        before: &BTreeMap<PathBuf, FileIdentity>,
356    ) -> Result<(), BypassReason> {
357        let snapshots = before
358            .iter()
359            .map(|(path, identity)| (path.clone(), identity.clone().into()))
360            .collect();
361        self.verify_not_modified_since_with_snapshots(started_at, &snapshots)
362    }
363
364    /// Rehash every discovered file after compilation and before publication.
365    /// This closes the discovery/compile race by degrading changed inputs to a
366    /// cache miss rather than storing outputs beneath a stale action key.
367    pub fn verify(&self) -> Result<(), BypassReason> {
368        for (index, input) in self.inputs.iter().enumerate() {
369            let read_error = |error: std::io::Error| BypassReason::InputRead {
370                path: input.path.clone(),
371                message: error.to_string(),
372            };
373            // An input still wearing the identity discovery recorded -- length,
374            // modification time, and change time -- is confirmed by that stat
375            // alone. The change time is what makes this as good as reading: it
376            // cannot be set from user space, so a rewrite that puts the old
377            // modification time back still shows, where a platform that reports
378            // none would let a same-length rewrite inside one timestamp tick
379            // through. Such an identity is not trusted here, and neither is an
380            // input that had none. Reading everything again cost as much as
381            // keying the compilation did: a large binary's dependency rlibs, a
382            // gigabyte of them, read twice for every edit.
383            if let Some(Some(identity)) = self.identities.get(index)
384                && identity.can_skip_content_verification()
385                && identity.still_describes().map_err(read_error)?
386            {
387                continue;
388            }
389            let matches = input.digest.matches_file(&input.path).map_err(|error| {
390                BypassReason::InputRead {
391                    path: input.path.clone(),
392                    message: error.to_string(),
393                }
394            })?;
395            if !matches {
396                return Err(BypassReason::InputChanged(input.path.clone()));
397            }
398        }
399        Ok(())
400    }
401
402    /// Merge the manifest into an action context after verifying that both use
403    /// the same compiler working directory.
404    pub fn apply_to(self, context: &mut ActionContext) -> Result<(), BypassReason> {
405        if normalize_components(&context.working_dir) != self.working_dir {
406            return Err(BypassReason::DiscoveryWorkingDirectory);
407        }
408        for (name, value) in &self.environment {
409            if context
410                .environment
411                .get(name)
412                .is_some_and(|previous| previous != value)
413            {
414                return Err(BypassReason::ConflictingEnvironment(name.clone()));
415            }
416        }
417        context.environment.extend(self.environment);
418        context.inputs.extend(self.inputs);
419        Ok(())
420    }
421}
422
423impl RustcInvocation {
424    /// Replace the original output flags with a single explicit dep-info file.
425    pub fn dep_info_command(&self, output: &Path) -> Result<DepInfoCommand, BypassReason> {
426        if !output.is_absolute() {
427            return Err(BypassReason::RelativeDepInfoPath(output.to_path_buf()));
428        }
429        let output_text = output
430            .to_str()
431            .ok_or_else(|| BypassReason::NonUtf8Path(output.to_path_buf()))?;
432        if output_text.contains(',') {
433            return Err(BypassReason::UnsafeDepInfoPath(output.to_path_buf()));
434        }
435
436        let mut arguments = Vec::new();
437        for argument in &self.arguments {
438            match argument {
439                Argument::Emit(_) => {}
440                Argument::Path { flag, .. } if flag == "--out-dir" || flag == "-o" => {}
441                argument => arguments.push(render_argument(argument)?),
442            }
443        }
444        arguments.push(format!("--emit=dep-info={output_text}").into());
445        arguments.push(self.source.clone().into_os_string());
446        Ok(DepInfoCommand {
447            arguments,
448            output: output.to_path_buf(),
449        })
450    }
451
452    /// Hash dep-info sources plus every direct compiler input already modeled
453    /// by the invocation (`--extern` artifacts and custom target specs).
454    pub fn discover_inputs(
455        &self,
456        dep_info: &RustcDepInfo,
457        working_dir: &Path,
458    ) -> Result<DiscoveredInputs, BypassReason> {
459        self.discover_inputs_with_mappings(
460            dep_info,
461            working_dir,
462            &[],
463            &mbx_cache_core::NoFileDigestCache,
464        )
465    }
466
467    /// Hash dep-info sources plus modeled compiler inputs, allowing native
468    /// search directories beneath the working directory or a mapped root.
469    ///
470    /// `digests` may answer for files the session already read in full;
471    /// pass [`mbx_cache_core::NoFileDigestCache`] to hash everything.
472    pub fn discover_inputs_with_mappings(
473        &self,
474        dep_info: &RustcDepInfo,
475        working_dir: &Path,
476        path_mappings: &[PathMapping],
477        digests: &dyn FileDigestCache,
478    ) -> Result<DiscoveredInputs, BypassReason> {
479        self.discover(dep_info, working_dir, Some(path_mappings), digests)
480    }
481
482    /// Hash dep-info sources and modeled compiler inputs, leaving native search
483    /// directories out.
484    ///
485    /// The result cannot key a shared action: the linker may read libraries it
486    /// does not list. It is enough to tell whether a crate's own sources changed,
487    /// which is all that private incremental state needs, so it works for a
488    /// compilation whose search paths [`Self::discover_inputs_with_mappings`]
489    /// refuses.
490    pub fn discover_source_inputs(
491        &self,
492        dep_info: &RustcDepInfo,
493        working_dir: &Path,
494        digests: &dyn FileDigestCache,
495    ) -> Result<DiscoveredInputs, BypassReason> {
496        self.discover(dep_info, working_dir, None, digests)
497    }
498
499    /// Native search directories are collected only when `path_mappings` is given.
500    fn discover(
501        &self,
502        dep_info: &RustcDepInfo,
503        working_dir: &Path,
504        path_mappings: Option<&[PathMapping]>,
505        digests: &dyn FileDigestCache,
506    ) -> Result<DiscoveredInputs, BypassReason> {
507        if !working_dir.is_absolute() {
508            return Err(BypassReason::RelativeWorkingDirectory(
509                working_dir.to_path_buf(),
510            ));
511        }
512        let working_dir = normalize_components(working_dir);
513        let absolute = |path: &PathBuf| {
514            let absolute = if path.is_absolute() {
515                path.to_path_buf()
516            } else {
517                working_dir.join(path)
518            };
519            normalize_components(&absolute)
520        };
521        // What dep-info names is the crate's own. A required input also covers
522        // the crate root, `--extern` artifacts and target specifications, so it
523        // is a source unless a native search directory holds it: there it is a
524        // library resolved from that directory, which belongs in a shared key
525        // but is no more a source than the directory around it.
526        let sources = dep_info.files.iter().map(absolute).collect::<BTreeSet<_>>();
527        let mut paths = sources.clone();
528        paths.extend(self.required_inputs.iter().map(absolute));
529        let admitted_roots = native_input_roots(&working_dir, path_mappings.unwrap_or_default());
530        let mut native_bytes = 0_u64;
531        let mut counted = BTreeSet::new();
532        for argument in &self.arguments {
533            if path_mappings.is_some()
534                && let Argument::SearchPath { kind, path } = argument
535                && kind == "native"
536            {
537                let directory = if path.is_absolute() {
538                    path.clone()
539                } else {
540                    working_dir.join(path)
541                };
542                collect_native_directory(
543                    &directory,
544                    &admitted_roots,
545                    &mut paths,
546                    &mut native_bytes,
547                    &mut counted,
548                )?;
549            }
550        }
551        let native_directories = self
552            .arguments
553            .iter()
554            .filter_map(|argument| match argument {
555                Argument::SearchPath { kind, path } if kind == "native" => Some(absolute(path)),
556                _ => None,
557            })
558            .collect::<Vec<_>>();
559        let native_only = paths
560            .difference(&sources)
561            .filter(|path| native_directories.iter().any(|root| path.starts_with(root)))
562            .cloned()
563            .collect();
564        Ok(DiscoveredInputs::from_paths(
565            &working_dir,
566            paths,
567            dep_info.environment.clone(),
568            digests,
569        )?
570        .with_native_only(native_only))
571    }
572}
573
574/// Return normalized roots whose native search directories can be tracked.
575pub(super) fn native_input_roots(
576    working_dir: &Path,
577    path_mappings: &[PathMapping],
578) -> Vec<PathBuf> {
579    std::iter::once(working_dir)
580        .chain(path_mappings.iter().map(|mapping| mapping.root.as_path()))
581        .map(normalize_components)
582        .collect()
583}
584
585/// Add regular files beneath an admitted native search directory, enforcing
586/// the prediction input count and the caller's cumulative native byte budget.
587/// `counted` holds the files already charged to that budget, so overlapping
588/// walks charge each file once even when `paths` already names it.
589pub(super) fn collect_native_directory(
590    directory: &Path,
591    admitted_roots: &[PathBuf],
592    paths: &mut BTreeSet<PathBuf>,
593    native_bytes: &mut u64,
594    counted: &mut BTreeSet<PathBuf>,
595) -> Result<(), BypassReason> {
596    let directory = normalize_components(directory);
597    if !admitted_roots
598        .iter()
599        .any(|root| directory.starts_with(root))
600    {
601        return Err(BypassReason::UnsupportedSearchPath("native".into()));
602    }
603
604    let canonical_root = match directory.canonicalize() {
605        Ok(root) => root,
606        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
607        Err(error) => {
608            return Err(BypassReason::InputRead {
609                path: directory,
610                message: error.to_string(),
611            });
612        }
613    };
614    let mut pending = vec![directory];
615    while let Some(directory) = pending.pop() {
616        let entries = match std::fs::read_dir(&directory) {
617            Ok(entries) => entries,
618            Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
619            Err(error) => {
620                return Err(BypassReason::InputRead {
621                    path: directory,
622                    message: error.to_string(),
623                });
624            }
625        };
626        for entry in entries {
627            let entry = entry.map_err(|error| BypassReason::InputRead {
628                path: directory.clone(),
629                message: error.to_string(),
630            })?;
631            let path = entry.path();
632            let file_type = entry.file_type().map_err(|error| BypassReason::InputRead {
633                path: path.clone(),
634                message: error.to_string(),
635            })?;
636            if file_type.is_dir() {
637                if !is_linker_irrelevant_directory(&path) {
638                    pending.push(path);
639                }
640            } else if is_linker_irrelevant_file(&path) {
641                continue;
642            } else if file_type.is_file() || file_type.is_symlink() {
643                // Homebrew's versionless dylibs are symlinks. Hash their
644                // referents under the searched name, but do not follow directory
645                // links, dangling links, or links outside this search tree.
646                if file_type.is_symlink() {
647                    let resolved =
648                        path.canonicalize()
649                            .map_err(|error| BypassReason::InputRead {
650                                path: path.clone(),
651                                message: error.to_string(),
652                            })?;
653                    if !resolved.starts_with(&canonical_root) || !resolved.is_file() {
654                        return Err(BypassReason::UnsupportedSearchPath("native".into()));
655                    }
656                }
657                let len = std::fs::metadata(&path)
658                    .map_err(|error| BypassReason::InputRead {
659                        path: path.clone(),
660                        message: error.to_string(),
661                    })?
662                    .len();
663                // A directory named by several `-L native=` arguments, or nested
664                // under another one, is walked more than once; count each file
665                // against the budget only the first time it is seen.
666                paths.insert(path.clone());
667                if counted.insert(path) {
668                    *native_bytes = native_bytes
669                        .checked_add(len)
670                        .ok_or_else(|| BypassReason::UnsupportedSearchPath("native".into()))?;
671                }
672            } else {
673                return Err(BypassReason::UnsupportedSearchPath("native".into()));
674            }
675            if paths.len() > MAX_PREDICTED_INPUTS || *native_bytes > MAX_NATIVE_INPUT_BYTES {
676                return Err(BypassReason::UnsupportedSearchPath("native".into()));
677            }
678        }
679    }
680    Ok(())
681}
682
683/// Directories that build scripts leave beside libraries but that a linker
684/// never reads. They often record the absolute output path (`prefix=` in
685/// pkg-config files, `binary-path:` in dSYM relocation YAML), so hashing them
686/// would make the key depend on the target directory.
687fn is_linker_irrelevant_directory(path: &Path) -> bool {
688    let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
689        return false;
690    };
691    name == "pkgconfig" || name == "cmake" || name.ends_with(".dSYM")
692}
693
694/// pkg-config and CMake metadata. Headers stay hashed: linker scripts can
695/// `INCLUDE` files from a search path, and `+verbatim` names any file.
696fn is_linker_irrelevant_file(path: &Path) -> bool {
697    path.extension()
698        .and_then(|extension| extension.to_str())
699        .is_some_and(|extension| {
700            ["pc", "cmake"]
701                .iter()
702                .any(|ignored| extension.eq_ignore_ascii_case(ignored))
703        })
704}
705
706fn render_argument(argument: &Argument) -> Result<OsString, BypassReason> {
707    let rendered = match argument {
708        Argument::Plain(value) => value.clone(),
709        Argument::Path { flag, path } => format!(
710            "{flag}={}",
711            path.to_str()
712                .ok_or_else(|| BypassReason::NonUtf8Path(path.clone()))?
713        ),
714        Argument::SearchPath { kind, path } => format!(
715            "-L{kind}={}",
716            path.to_str()
717                .ok_or_else(|| BypassReason::NonUtf8Path(path.clone()))?
718        ),
719        Argument::Extern { name, path } => match path {
720            Some(path) => format!(
721                "--extern={name}={}",
722                path.to_str()
723                    .ok_or_else(|| BypassReason::NonUtf8Path(path.clone()))?
724            ),
725            None => format!("--extern={name}"),
726        },
727        Argument::Emit(_) => unreachable!("emit arguments are removed before rendering"),
728        Argument::RemapPath { from, to } => format!(
729            "--remap-path-prefix={}={to}",
730            from.to_str()
731                .ok_or_else(|| BypassReason::NonUtf8Path(from.clone()))?
732        ),
733        // Nothing links while emitting dep-info, so the prefix is inert here;
734        // it is replayed verbatim to keep the command faithful.
735        Argument::OsoPrefix {
736            path,
737            trailing_slash,
738        } => format!(
739            "--codegen=link-arg=-Wl,-oso_prefix,{}{}",
740            path.to_str()
741                .ok_or_else(|| BypassReason::NonUtf8Path(path.clone()))?,
742            if *trailing_slash { "/" } else { "" }
743        ),
744        // Nothing links while emitting dep-info either; replayed verbatim for
745        // the same reason as the prefix above.
746        Argument::InstallName(name) => {
747            format!("--codegen=link-arg=-Wl,-install_name,@rpath/{name}")
748        }
749        Argument::FuseLd(selection) => format!("--codegen=link-arg=-fuse-ld={selection}"),
750    };
751    Ok(rendered.into())
752}
753
754fn unescape_environment(value: &str) -> Result<String, BypassReason> {
755    let mut output = String::with_capacity(value.len());
756    let mut characters = value.chars();
757    while let Some(character) = characters.next() {
758        if character != '\\' {
759            output.push(character);
760            continue;
761        }
762        match characters.next() {
763            Some('\\') => output.push('\\'),
764            Some('n') => output.push('\n'),
765            Some('r') => output.push('\r'),
766            Some(character) => {
767                return Err(BypassReason::MalformedDepInfo(format!(
768                    "unknown environment escape \\{character}"
769                )));
770            }
771            None => {
772                return Err(BypassReason::MalformedDepInfo(
773                    "environment input ends with an unterminated escape".into(),
774                ));
775            }
776        }
777    }
778    Ok(output)
779}
780
781#[cfg(test)]
782mod tests {
783    use super::*;
784    use std::process::Command;
785
786    fn args(values: &[&str]) -> Vec<OsString> {
787        values.iter().map(OsString::from).collect()
788    }
789
790    #[test]
791    fn parses_files_spaces_and_environment_records() {
792        let parsed = RustcDepInfo::parse(
793            "target/lib.rlib: src/lib.rs src/a\\ file.rs generated.rs\n\
794             src/lib.rs:\n\
795             # env-dep:SET=value\\nnext\n\
796             # env-dep:UNSET\n\
797             # env-dep:SLASH=a\\\\b\n",
798        )
799        .unwrap();
800        assert_eq!(
801            parsed.files,
802            vec![
803                PathBuf::from("generated.rs"),
804                PathBuf::from("src/a file.rs"),
805                PathBuf::from("src/lib.rs"),
806            ]
807        );
808        assert_eq!(parsed.environment["SET"], Some("value\nnext".into()));
809        assert_eq!(parsed.environment["UNSET"], None);
810        assert_eq!(parsed.environment["SLASH"], Some(r"a\b".into()));
811    }
812
813    #[test]
814    fn malformed_dep_info_bypasses_caching() {
815        for contents in [
816            "",
817            "target: ",
818            "target: src/trailing\\\n",
819            "target: src/lib.rs\n# env-dep:NAME=bad\\q\n",
820        ] {
821            assert!(RustcDepInfo::parse(contents).is_err(), "{contents:?}");
822        }
823    }
824
825    #[test]
826    fn native_directory_byte_limit_is_cumulative() {
827        let directory = tempfile::tempdir().unwrap();
828        let native = directory.path().join("native");
829        std::fs::create_dir_all(&native).unwrap();
830        std::fs::write(native.join("input.lib"), b"xx").unwrap();
831        let roots = native_input_roots(directory.path(), &[]);
832        let mut paths = BTreeSet::new();
833        let mut native_bytes = MAX_NATIVE_INPUT_BYTES - 1;
834        let mut counted = BTreeSet::new();
835
836        assert_eq!(
837            collect_native_directory(&native, &roots, &mut paths, &mut native_bytes, &mut counted),
838            Err(BypassReason::UnsupportedSearchPath("native".into()))
839        );
840    }
841
842    #[test]
843    fn native_directory_walked_twice_counts_bytes_once() {
844        let directory = tempfile::tempdir().unwrap();
845        let native = directory.path().join("native");
846        std::fs::create_dir_all(native.join("lib")).unwrap();
847        std::fs::write(native.join("input.lib"), b"xx").unwrap();
848        std::fs::write(native.join("lib").join("nested.lib"), b"yyy").unwrap();
849        let roots = native_input_roots(directory.path(), &[]);
850        let mut paths = BTreeSet::new();
851        let mut native_bytes = 0;
852        let mut counted = BTreeSet::new();
853
854        for dir in [&native, &native, &native.join("lib")] {
855            collect_native_directory(dir, &roots, &mut paths, &mut native_bytes, &mut counted)
856                .unwrap();
857        }
858
859        assert_eq!(paths.len(), 2);
860        assert_eq!(native_bytes, 5);
861    }
862
863    #[test]
864    fn native_directory_charges_files_already_named_as_inputs() {
865        let directory = tempfile::tempdir().unwrap();
866        let native = directory.path().join("native");
867        std::fs::create_dir_all(&native).unwrap();
868        let archive = native.join("libfoo.a");
869        std::fs::write(&archive, b"xx").unwrap();
870        let roots = native_input_roots(directory.path(), &[]);
871        let mut paths = BTreeSet::from([archive]);
872        let mut native_bytes = 0;
873        let mut counted = BTreeSet::new();
874
875        collect_native_directory(&native, &roots, &mut paths, &mut native_bytes, &mut counted)
876            .unwrap();
877
878        assert_eq!(native_bytes, 2);
879    }
880
881    #[test]
882    fn native_directory_skips_files_the_linker_never_reads() {
883        let directory = tempfile::tempdir().unwrap();
884        let native = directory.path().join("native");
885        for ignored in [
886            "lib/pkgconfig/zlib.pc",
887            "check.dSYM/Contents/Resources/Relocations/aarch64/check.yml",
888            "cmake/Config.cmake",
889            "top.pc",
890        ] {
891            let path = native.join(ignored);
892            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
893            std::fs::write(path, b"prefix=/abs/target\n").unwrap();
894        }
895        std::fs::write(native.join("libz.a"), b"!<arch>\n").unwrap();
896        std::fs::create_dir_all(native.join("lib")).unwrap();
897        std::fs::write(native.join("lib/libz.so.1"), b"elf").unwrap();
898        let roots = native_input_roots(directory.path(), &[]);
899        let mut paths = BTreeSet::new();
900        let mut native_bytes = 0;
901        let mut counted = BTreeSet::new();
902
903        collect_native_directory(&native, &roots, &mut paths, &mut native_bytes, &mut counted)
904            .unwrap();
905
906        assert_eq!(
907            paths.into_iter().collect::<Vec<_>>(),
908            vec![native.join("lib/libz.so.1"), native.join("libz.a")]
909        );
910    }
911
912    #[test]
913    fn discovery_command_removes_real_outputs() {
914        let invocation = RustcInvocation::parse(&args(&[
915            "--crate-name=widget",
916            "--crate-type=lib",
917            "--emit=dep-info,metadata,link",
918            "--out-dir=target/debug/deps",
919            "-o",
920            "target/debug/libwidget.rlib",
921            "src/lib.rs",
922        ]))
923        .unwrap();
924        let output = if cfg!(windows) {
925            PathBuf::from(r"C:\tmp\mbx cache\inputs.d")
926        } else {
927            PathBuf::from("/tmp/mbx cache/inputs.d")
928        };
929        let command = invocation.dep_info_command(&output).unwrap();
930        let arguments = command
931            .arguments()
932            .iter()
933            .map(|value| value.to_string_lossy())
934            .collect::<Vec<_>>();
935        assert_eq!(
936            arguments,
937            vec![
938                "--crate-name=widget",
939                "--crate-type=lib",
940                &format!("--emit=dep-info={}", output.display()),
941                "src/lib.rs",
942            ]
943        );
944    }
945
946    #[test]
947    fn discovery_hashes_externs_and_custom_targets() {
948        let directory = tempfile::tempdir().unwrap();
949        let root = directory.path();
950        let source = root.join("src/lib.rs");
951        let external = root.join("target/libdependency.rlib");
952        let target = root.join("targets/custom.json");
953        std::fs::create_dir_all(source.parent().unwrap()).unwrap();
954        std::fs::create_dir_all(external.parent().unwrap()).unwrap();
955        std::fs::create_dir_all(target.parent().unwrap()).unwrap();
956        std::fs::write(&source, "pub fn library() {}\n").unwrap();
957        std::fs::write(&external, "dependency artifact\n").unwrap();
958        std::fs::write(&target, "{}\n").unwrap();
959
960        let invocation = RustcInvocation::parse(&[
961            "--crate-name=widget".into(),
962            "--crate-type=lib".into(),
963            "--emit=metadata".into(),
964            format!("--extern=dependency={}", external.display()).into(),
965            format!("--target={}", target.display()).into(),
966            source.clone().into_os_string(),
967        ])
968        .unwrap();
969        let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
970        let discovered = invocation.discover_inputs(&dep_info, root).unwrap();
971        assert_eq!(discovered.inputs.len(), 3);
972
973        std::fs::remove_file(&external).unwrap();
974        assert!(matches!(
975            invocation.discover_inputs(&dep_info, root),
976            Err(BypassReason::InputRead { path, .. }) if path == external
977        ));
978    }
979
980    #[test]
981    fn discovery_rejects_inputs_modified_during_compilation() {
982        let directory = tempfile::tempdir().unwrap();
983        let source = directory.path().join("lib.rs");
984        std::fs::write(&source, "pub fn library() {}\n").unwrap();
985        let invocation = RustcInvocation::parse(&[
986            "--crate-name=widget".into(),
987            "--crate-type=lib".into(),
988            "--emit=dep-info,metadata".into(),
989            source.clone().into_os_string(),
990        ])
991        .unwrap();
992        let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
993        let discovered = invocation
994            .discover_inputs(&dep_info, directory.path())
995            .unwrap();
996        let modified = std::fs::metadata(&source).unwrap().modified().unwrap();
997
998        assert_eq!(
999            discovered.verify_not_modified_since(modified),
1000            Err(BypassReason::InputModifiedDuringCompilation(source))
1001        );
1002    }
1003
1004    #[test]
1005    fn profile_use_is_discovered_verified_and_rehashed_by_prediction() {
1006        let directory = tempfile::tempdir().unwrap();
1007        let root = directory.path();
1008        let source = root.join("lib.rs");
1009        let profile = root.join("profile.profdata");
1010        std::fs::write(&source, "pub fn library() {}\n").unwrap();
1011        std::fs::write(&profile, "first profile").unwrap();
1012        let invocation = RustcInvocation::parse(&[
1013            "--crate-name=widget".into(),
1014            "--crate-type=lib".into(),
1015            "--emit=dep-info,metadata".into(),
1016            format!("-Cprofile-use={}", profile.display()).into(),
1017            "lib.rs".into(),
1018        ])
1019        .unwrap();
1020        let mappings = vec![PathMapping::new(root, "workspace")];
1021        let dep_info = RustcDepInfo::parse("widget.d: lib.rs\n").unwrap();
1022        let discover = || {
1023            invocation
1024                .discover_inputs_with_mappings(
1025                    &dep_info,
1026                    root,
1027                    &mappings,
1028                    &mbx_cache_core::NoFileDigestCache,
1029                )
1030                .unwrap()
1031        };
1032        let initial_inputs = discover();
1033        assert!(
1034            initial_inputs
1035                .inputs
1036                .iter()
1037                .any(|input| input.path == profile)
1038        );
1039        let mut context = library_context(root, mappings.clone());
1040        initial_inputs.clone().apply_to(&mut context).unwrap();
1041        let initial_action = invocation.action(context.clone()).unwrap();
1042        let prediction = invocation.prediction(&context, &initial_inputs).unwrap();
1043
1044        std::fs::write(&profile, "updated profile").unwrap();
1045        assert_eq!(
1046            initial_inputs.verify(),
1047            Err(BypassReason::InputChanged(profile.clone()))
1048        );
1049        context.inputs.clear();
1050        let predicted = prediction
1051            .discover(root, &mappings, &mbx_cache_core::NoFileDigestCache)
1052            .unwrap();
1053        predicted.clone().apply_to(&mut context).unwrap();
1054        let predicted_action = invocation.action(context.clone()).unwrap();
1055        assert_ne!(predicted_action.digest, initial_action.digest);
1056        context.inputs.clear();
1057        discover().apply_to(&mut context).unwrap();
1058        assert_eq!(predicted_action, invocation.action(context).unwrap());
1059
1060        let missing_profile = root.join("missing.profdata");
1061        let missing_invocation = RustcInvocation::parse(&[
1062            "--crate-name=widget".into(),
1063            "--crate-type=lib".into(),
1064            "--emit=dep-info,metadata".into(),
1065            format!("-Cprofile-use={}", missing_profile.display()).into(),
1066            "lib.rs".into(),
1067        ])
1068        .unwrap();
1069        assert!(matches!(
1070            missing_invocation.discover_inputs_with_mappings(
1071                &dep_info,
1072                root,
1073                &mappings,
1074                &mbx_cache_core::NoFileDigestCache,
1075            ),
1076            Err(BypassReason::InputRead { path, .. }) if path == missing_profile
1077        ));
1078    }
1079
1080    #[cfg(unix)]
1081    #[test]
1082    fn precompile_snapshot_does_not_depend_on_the_host_clock() {
1083        let directory = tempfile::tempdir().unwrap();
1084        let source = directory.path().join("lib.rs");
1085        std::fs::write(&source, "pub fn library() {0}\n").unwrap();
1086        let invocation = RustcInvocation::parse(&[
1087            "--crate-name=widget".into(),
1088            "--crate-type=lib".into(),
1089            "--emit=dep-info,metadata".into(),
1090            source.clone().into_os_string(),
1091        ])
1092        .unwrap();
1093        let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
1094        let discovered = invocation
1095            .discover_inputs(&dep_info, directory.path())
1096            .unwrap();
1097        let snapshot = FileSnapshot::capture(&source).unwrap().unwrap();
1098        let before = BTreeMap::from([(source.clone(), snapshot)]);
1099
1100        // Every ordinary mtime is after the epoch. The unchanged identity is
1101        // nevertheless enough proof when the filesystem clock is far ahead.
1102        discovered
1103            .verify_not_modified_since_with_snapshots(SystemTime::UNIX_EPOCH, &before)
1104            .unwrap();
1105
1106        // Conversely, a filesystem clock far behind must not conceal a write.
1107        std::fs::write(&source, "pub fn library() {1}\n").unwrap();
1108        assert_eq!(
1109            discovered.verify_not_modified_since_with_snapshots(
1110                SystemTime::now() + std::time::Duration::from_secs(60),
1111                &before,
1112            ),
1113            Err(BypassReason::InputModifiedDuringCompilation(source))
1114        );
1115    }
1116
1117    /// An external MSVC toolset directory, outside every mapped root.
1118    fn toolchain_native_directory(version: &str) -> PathBuf {
1119        if cfg!(windows) {
1120            PathBuf::from(format!(
1121                r"C:\Program Files\Microsoft Visual Studio\2022\BuildTools\VC\Tools\MSVC\{version}\lib\x64"
1122            ))
1123        } else {
1124            PathBuf::from(format!("/opt/msvc/{version}/lib/x64"))
1125        }
1126    }
1127
1128    fn library_with_native_search(source: &Path, directory: &Path) -> RustcInvocation {
1129        RustcInvocation::parse(&[
1130            "--crate-name=widget".into(),
1131            "--crate-type=lib".into(),
1132            "--emit=metadata,link".into(),
1133            format!("-Lnative={}", directory.display()).into(),
1134            source.to_path_buf().into_os_string(),
1135        ])
1136        .unwrap()
1137    }
1138
1139    fn library_context(root: &Path, mappings: Vec<PathMapping>) -> ActionContext {
1140        ActionContext {
1141            compiler: crate::CompilerIdentity {
1142                toolchain: "core:rust@test".into(),
1143                rustc_version: "test".into(),
1144                host: std::env::consts::ARCH.into(),
1145                driver: None,
1146            },
1147            working_dir: root.to_path_buf(),
1148            path_mappings: mappings,
1149            environment: BTreeMap::new(),
1150            portable_environment: BTreeSet::new(),
1151            inputs: Vec::new(),
1152        }
1153    }
1154
1155    #[cfg(windows)]
1156    #[test]
1157    fn unmapped_toolchain_directory_cannot_hide_a_static_archive() {
1158        let directory = tempfile::tempdir().unwrap();
1159        let root = directory.path();
1160        let source = root.join("lib.rs");
1161        std::fs::write(&source, "pub fn library() {}\n").unwrap();
1162        let toolchain = toolchain_native_directory("14.51.36231");
1163        let invocation = library_with_native_search(&source, &toolchain);
1164        let mappings = vec![PathMapping::new(root, "workspace")];
1165        let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
1166
1167        let context = library_context(root, mappings.clone());
1168        assert!(matches!(
1169            invocation.invocation_digest(&context),
1170            Err(BypassReason::UnmappedAbsolutePath(path)) if path == toolchain
1171        ));
1172        assert_eq!(
1173            invocation.discover_inputs_with_mappings(
1174                &dep_info,
1175                root,
1176                &mappings,
1177                &mbx_cache_core::NoFileDigestCache,
1178            ),
1179            Err(BypassReason::UnsupportedSearchPath("native".into()))
1180        );
1181    }
1182
1183    #[test]
1184    fn source_level_static_link_cannot_hide_an_unmapped_archive() {
1185        let directory = tempfile::tempdir().unwrap();
1186        let workspace = directory.path().join("workspace");
1187        let external = directory.path().join("external");
1188        std::fs::create_dir(&workspace).unwrap();
1189        std::fs::create_dir(&external).unwrap();
1190        let source = workspace.join("lib.rs");
1191        std::fs::write(
1192            &source,
1193            "#[link(name = \"foo\", kind = \"static\")] unsafe extern \"C\" {}\n",
1194        )
1195        .unwrap();
1196        std::fs::write(external.join("libfoo.a"), b"first archive").unwrap();
1197        let invocation = library_with_native_search(&source, &external);
1198        let mappings = vec![PathMapping::new(&workspace, "workspace")];
1199        let context = library_context(&workspace, mappings.clone());
1200        let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
1201
1202        // rustc's dep-info names the source but not an archive from #[link].
1203        // A path-only key would restore a stale rlib after libfoo.a changed.
1204        assert!(matches!(
1205            invocation.invocation_digest(&context),
1206            Err(BypassReason::UnmappedAbsolutePath(path)) if path == external
1207        ));
1208        assert_eq!(
1209            invocation.discover_inputs_with_mappings(
1210                &dep_info,
1211                &workspace,
1212                &mappings,
1213                &mbx_cache_core::NoFileDigestCache,
1214            ),
1215            Err(BypassReason::UnsupportedSearchPath("native".into()))
1216        );
1217    }
1218
1219    #[cfg(windows)]
1220    #[test]
1221    fn project_directory_with_toolchain_suffix_is_not_trusted() {
1222        let directory = tempfile::tempdir().unwrap();
1223        let root = directory.path().join("workspace");
1224        std::fs::create_dir(&root).unwrap();
1225        let source = root.join("lib.rs");
1226        std::fs::write(&source, "pub fn library() {}\n").unwrap();
1227        let fake_toolchain = directory.path().join("MSVC/14.51.36231/lib/x64");
1228        let invocation = library_with_native_search(&source, &fake_toolchain);
1229        let context = library_context(&root, vec![PathMapping::new(&root, "workspace")]);
1230
1231        // The apparent toolset version cannot make a mutable project archive inert.
1232        assert!(matches!(
1233            invocation.invocation_digest(&context),
1234            Err(BypassReason::UnmappedAbsolutePath(path)) if path == fake_toolchain
1235        ));
1236    }
1237
1238    #[test]
1239    fn unmapped_native_directory_still_refuses_a_native_link() {
1240        let directory = tempfile::tempdir().unwrap();
1241        let root = directory.path();
1242        let source = root.join("main.rs");
1243        std::fs::write(&source, "fn main() {}\n").unwrap();
1244        let toolchain = toolchain_native_directory("14.51.36231");
1245        let invocation = RustcInvocation::parse_with(
1246            &[
1247                "--crate-name=app".into(),
1248                "--crate-type=bin".into(),
1249                "--emit=link".into(),
1250                format!("-Lnative={}", toolchain.display()).into(),
1251                source.clone().into_os_string(),
1252            ],
1253            crate::ParseOptions::caching_native_links(true),
1254        )
1255        .unwrap();
1256        let mappings = vec![PathMapping::new(root, "workspace")];
1257
1258        // A linker reads those directories, so their contents stay inputs the
1259        // key must account for, and an unmapped one stays a bypass.
1260        let context = library_context(root, mappings.clone());
1261        assert!(matches!(
1262            invocation.invocation_digest(&context),
1263            Err(BypassReason::UnmappedAbsolutePath(_))
1264        ));
1265        let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
1266        assert_eq!(
1267            invocation.discover_inputs_with_mappings(
1268                &dep_info,
1269                root,
1270                &mappings,
1271                &mbx_cache_core::NoFileDigestCache
1272            ),
1273            Err(BypassReason::UnsupportedSearchPath("native".into()))
1274        );
1275    }
1276
1277    #[test]
1278    fn discovery_resolves_parent_components_against_the_working_directory() {
1279        let directory = tempfile::tempdir().unwrap();
1280        let root = directory.path().join("project");
1281        let shared = directory.path().join("shared.rs");
1282        std::fs::create_dir(&root).unwrap();
1283        std::fs::write(&shared, "pub fn shared() {}\n").unwrap();
1284
1285        let invocation = RustcInvocation::parse(&args(&[
1286            "--crate-name=widget",
1287            "--crate-type=lib",
1288            "--emit=metadata",
1289            "../shared.rs",
1290        ]))
1291        .unwrap();
1292        let dep_info = RustcDepInfo::parse("output: ../shared.rs\n").unwrap();
1293        let discovered = invocation.discover_inputs(&dep_info, &root).unwrap();
1294
1295        assert_eq!(discovered.inputs.len(), 1);
1296        assert_eq!(discovered.inputs[0].path, shared);
1297    }
1298
1299    #[test]
1300    fn rustc_dep_info_round_trip_discovers_real_inputs() {
1301        let directory = tempfile::tempdir().unwrap();
1302        let root = directory.path();
1303        std::fs::write(
1304            root.join("lib.rs"),
1305            "mod child; const _: &str = include_str!(\"data file.txt\"); \
1306             const _: &str = env!(\"MBX_DISCOVERY_TEST\"); \
1307             const _: Option<&str> = option_env!(\"MBX_DISCOVERY_UNSET\");",
1308        )
1309        .unwrap();
1310        std::fs::write(root.join("child.rs"), "pub fn child() {}\n").unwrap();
1311        std::fs::write(root.join("data file.txt"), "included\n").unwrap();
1312
1313        let invocation = RustcInvocation::parse(&args(&[
1314            "--crate-name=mbx_cache_discovery_test",
1315            "--crate-type=lib",
1316            "--emit=metadata,link",
1317            "lib.rs",
1318        ]))
1319        .unwrap();
1320        let dep_info_path = root.join("discovery inputs.d");
1321        let discovery_command = invocation.dep_info_command(&dep_info_path).unwrap();
1322        let rustc = std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into());
1323        let output = match Command::new(rustc)
1324            .args(discovery_command.arguments())
1325            .current_dir(root)
1326            .env("MBX_DISCOVERY_TEST", "observed")
1327            .env_remove("MBX_DISCOVERY_UNSET")
1328            .output()
1329        {
1330            Ok(output) => output,
1331            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return,
1332            Err(error) => panic!("failed to execute rustc: {error}"),
1333        };
1334        assert!(
1335            output.status.success(),
1336            "{}",
1337            String::from_utf8_lossy(&output.stderr)
1338        );
1339        let parsed = RustcDepInfo::read(&dep_info_path).unwrap();
1340        let discovered = invocation.discover_inputs(&parsed, root).unwrap();
1341        assert_eq!(
1342            discovered.environment["MBX_DISCOVERY_TEST"],
1343            Some("observed".into())
1344        );
1345        assert_eq!(discovered.environment["MBX_DISCOVERY_UNSET"], None);
1346        assert_eq!(discovered.inputs.len(), 3);
1347        assert!(
1348            discovered
1349                .inputs
1350                .iter()
1351                .all(|input| input.digest.algorithm == "blake3")
1352        );
1353        let mut context = ActionContext {
1354            compiler: crate::CompilerIdentity {
1355                toolchain: "core:rust@test".into(),
1356                rustc_version: "test".into(),
1357                host: std::env::consts::ARCH.into(),
1358                driver: None,
1359            },
1360            working_dir: root.to_path_buf(),
1361            path_mappings: vec![crate::PathMapping::new(root, "workspace")],
1362            environment: BTreeMap::new(),
1363            portable_environment: BTreeSet::new(),
1364            inputs: Vec::new(),
1365        };
1366        discovered.clone().apply_to(&mut context).unwrap();
1367        let action = invocation.action(context).unwrap();
1368        assert!(
1369            String::from_utf8(action.bytes)
1370                .unwrap()
1371                .contains(r#""MBX_DISCOVERY_TEST":"observed""#)
1372        );
1373        discovered.verify().unwrap();
1374        std::fs::write(root.join("child.rs"), "pub fn changed() {}\n").unwrap();
1375        assert_eq!(
1376            discovered.verify(),
1377            Err(BypassReason::InputChanged(root.join("child.rs")))
1378        );
1379    }
1380
1381    /// A ledger that answers with a sentinel and remembers what was recorded.
1382    struct SentinelLedger {
1383        known: FileIdentity,
1384        digest: CacheDigest,
1385        recorded: std::sync::Mutex<Vec<RecordedFileDigest>>,
1386    }
1387
1388    impl FileDigestCache for SentinelLedger {
1389        fn find(&self, scope: FileDigestScope, files: &[FileIdentity]) -> Vec<Option<CacheDigest>> {
1390            assert_eq!(scope, FileDigestScope::Content);
1391            files
1392                .iter()
1393                .map(|file| (*file == self.known).then(|| self.digest.clone()))
1394                .collect()
1395        }
1396
1397        fn record(&self, scope: FileDigestScope, entries: Vec<RecordedFileDigest>) {
1398            assert_eq!(scope, FileDigestScope::Content);
1399            self.recorded.lock().unwrap().extend(entries);
1400        }
1401    }
1402
1403    #[cfg(unix)]
1404    struct ReplacingLedger {
1405        path: PathBuf,
1406        replacement: PathBuf,
1407        recorded: std::sync::Mutex<Vec<RecordedFileDigest>>,
1408    }
1409
1410    #[cfg(unix)]
1411    impl FileDigestCache for ReplacingLedger {
1412        fn find(&self, scope: FileDigestScope, files: &[FileIdentity]) -> Vec<Option<CacheDigest>> {
1413            assert_eq!(scope, FileDigestScope::Content);
1414            assert_eq!(files.len(), 1);
1415            std::fs::remove_file(&self.path).unwrap();
1416            std::fs::rename(&self.replacement, &self.path).unwrap();
1417            // Digests of files changed this moment are withheld from reuse.
1418            std::thread::sleep(std::time::Duration::from_millis(50));
1419            vec![None]
1420        }
1421
1422        fn record(&self, scope: FileDigestScope, entries: Vec<RecordedFileDigest>) {
1423            assert_eq!(scope, FileDigestScope::Content);
1424            self.recorded.lock().unwrap().extend(entries);
1425        }
1426    }
1427
1428    #[cfg(unix)]
1429    #[test]
1430    fn discovery_binds_a_replacement_digest_to_the_replacement_identity() {
1431        let directory = tempfile::tempdir().unwrap();
1432        let root = directory.path();
1433        let path = root.join("libdep.rlib");
1434        let replacement = root.join("replacement.rlib");
1435        std::fs::write(&path, b"original....").unwrap();
1436        std::fs::File::options()
1437            .write(true)
1438            .open(&path)
1439            .unwrap()
1440            .set_times(std::fs::FileTimes::new().set_modified(SystemTime::UNIX_EPOCH))
1441            .unwrap();
1442        std::fs::write(&replacement, b"replacement!").unwrap();
1443        std::fs::File::options()
1444            .write(true)
1445            .open(&replacement)
1446            .unwrap()
1447            .set_times(std::fs::FileTimes::new().set_modified(SystemTime::UNIX_EPOCH))
1448            .unwrap();
1449        let ledger = ReplacingLedger {
1450            path: path.clone(),
1451            replacement,
1452            recorded: std::sync::Mutex::new(Vec::new()),
1453        };
1454
1455        let discovered = DiscoveredInputs::from_paths(
1456            root,
1457            BTreeSet::from([path.clone()]),
1458            BTreeMap::new(),
1459            &ledger,
1460        )
1461        .unwrap();
1462        let current = FileIdentity::for_digest_cache(&path, &std::fs::metadata(&path).unwrap())
1463            .unwrap()
1464            .unwrap();
1465        let digest = CacheDigest::blake3_file(&path).unwrap();
1466
1467        assert_eq!(discovered.inputs[0].digest, digest);
1468        assert_eq!(discovered.identities[0].as_ref(), Some(&current));
1469        assert_eq!(
1470            ledger.recorded.lock().unwrap().as_slice(),
1471            &[RecordedFileDigest {
1472                file: current,
1473                digest,
1474            }]
1475        );
1476        discovered.verify().unwrap();
1477    }
1478
1479    #[test]
1480    fn discovery_reuses_recorded_digests_and_records_fresh_ones() {
1481        let directory = tempfile::tempdir().unwrap();
1482        let root = directory.path();
1483        let known_path = root.join("libdep.rlib");
1484        let fresh_path = root.join("lib.rs");
1485        std::fs::write(&known_path, b"rlib bytes").unwrap();
1486        std::fs::write(&fresh_path, b"fn lib() {}").unwrap();
1487        // Digests of files written this moment are withheld from reuse.
1488        std::thread::sleep(std::time::Duration::from_millis(50));
1489        let metadata = std::fs::metadata(&known_path).unwrap();
1490        // A sentinel digest that hashing could never produce proves the read
1491        // was skipped: the discovered input carries it verbatim.
1492        let sentinel = CacheDigest {
1493            algorithm: "blake3".into(),
1494            hash: "c".repeat(64),
1495            size: metadata.len(),
1496        };
1497        let ledger = SentinelLedger {
1498            known: FileIdentity::describe(&known_path, &metadata).unwrap(),
1499            digest: sentinel.clone(),
1500            recorded: std::sync::Mutex::new(Vec::new()),
1501        };
1502
1503        let discovered = DiscoveredInputs::from_paths(
1504            root,
1505            BTreeSet::from([known_path.clone(), fresh_path.clone()]),
1506            BTreeMap::new(),
1507            &ledger,
1508        )
1509        .unwrap();
1510
1511        let by_path = |path: &Path| {
1512            discovered
1513                .inputs
1514                .iter()
1515                .find(|input| input.path == path)
1516                .unwrap()
1517                .digest
1518                .clone()
1519        };
1520        assert_eq!(
1521            by_path(&known_path),
1522            sentinel,
1523            "the recorded digest answers"
1524        );
1525        assert_eq!(
1526            by_path(&fresh_path),
1527            CacheDigest::blake3_file(&fresh_path).unwrap(),
1528            "an unrecorded file is hashed"
1529        );
1530        let recorded = ledger.recorded.lock().unwrap();
1531        assert_eq!(recorded.len(), 1, "only the fresh hash is recorded");
1532        assert_eq!(recorded[0].file.path, fresh_path);
1533        assert_eq!(recorded[0].digest, by_path(&fresh_path));
1534    }
1535
1536    /// Verification after the compiler runs confirms an input by its recorded
1537    /// identity rather than by reading it again; a file that has moved on is
1538    /// still read and still fails. Only where the identity carries a change
1539    /// time, which is what makes the stat as good as the read.
1540    #[cfg(unix)]
1541    #[test]
1542    fn verification_trusts_an_unchanged_identity_and_rereads_a_changed_one() {
1543        let directory = tempfile::tempdir().unwrap();
1544        let root = directory.path();
1545        let known_path = root.join("libdep.rlib");
1546        std::fs::write(&known_path, b"rlib bytes").unwrap();
1547        let metadata = std::fs::metadata(&known_path).unwrap();
1548        // The sentinel could never come from hashing the file, so a verify
1549        // that passes can only have trusted the identity.
1550        let sentinel = CacheDigest {
1551            algorithm: "blake3".into(),
1552            hash: "c".repeat(64),
1553            size: metadata.len(),
1554        };
1555        let ledger = SentinelLedger {
1556            known: FileIdentity::describe(&known_path, &metadata).unwrap(),
1557            digest: sentinel,
1558            recorded: std::sync::Mutex::new(Vec::new()),
1559        };
1560        let discovered = DiscoveredInputs::from_paths(
1561            root,
1562            BTreeSet::from([known_path.clone()]),
1563            BTreeMap::new(),
1564            &ledger,
1565        )
1566        .unwrap();
1567
1568        discovered.verify().unwrap();
1569
1570        // Rewritten with the same length: the identity moves with the write,
1571        // so the file is read again and the sentinel no longer matches it.
1572        std::thread::sleep(std::time::Duration::from_millis(20));
1573        std::fs::write(&known_path, b"RLIB BYTES").unwrap();
1574        assert_eq!(
1575            discovered.verify(),
1576            Err(BypassReason::InputChanged(known_path.clone()))
1577        );
1578
1579        std::fs::remove_file(&known_path).unwrap();
1580        assert!(matches!(
1581            discovered.verify(),
1582            Err(BypassReason::InputRead { path, .. }) if path == known_path
1583        ));
1584    }
1585
1586    /// A rewrite that restores length and modification time must still be
1587    /// hashed: the platform change token moves with every write, so the
1588    /// identity the ledger recorded no longer describes the file.
1589    #[cfg(unix)]
1590    #[test]
1591    fn a_disguised_rewrite_is_not_answered_from_the_ledger() {
1592        let directory = tempfile::tempdir().unwrap();
1593        let root = directory.path();
1594        let path = root.join("lib.rs");
1595        std::fs::write(&path, b"fn lib() -> u8 { 1 }").unwrap();
1596        let before = std::fs::metadata(&path).unwrap();
1597        let identity = FileIdentity::describe(&path, &before).unwrap();
1598
1599        // Same length, modification time put back: only the change token can
1600        // tell this file has new contents.
1601        std::fs::write(&path, b"fn lib() -> u8 { 2 }").unwrap();
1602        let file = std::fs::File::options().write(true).open(&path).unwrap();
1603        file.set_times(std::fs::FileTimes::new().set_modified(before.modified().unwrap()))
1604            .unwrap();
1605        drop(file);
1606        let after = std::fs::metadata(&path).unwrap();
1607        let disguised = FileIdentity::describe(&path, &after).unwrap();
1608        assert_eq!(disguised.len, identity.len);
1609        assert_eq!(disguised.modified, identity.modified);
1610        assert_ne!(
1611            disguised, identity,
1612            "the change token must expose the rewrite"
1613        );
1614
1615        let ledger = SentinelLedger {
1616            known: identity,
1617            digest: CacheDigest {
1618                algorithm: "blake3".into(),
1619                hash: "e".repeat(64),
1620                size: after.len(),
1621            },
1622            recorded: std::sync::Mutex::new(Vec::new()),
1623        };
1624        let discovered = DiscoveredInputs::from_paths(
1625            root,
1626            BTreeSet::from([path.clone()]),
1627            BTreeMap::new(),
1628            &ledger,
1629        )
1630        .unwrap();
1631        assert_eq!(
1632            discovered.inputs[0].digest,
1633            CacheDigest::blake3_file(&path).unwrap(),
1634            "the disguised rewrite is hashed, not answered from the ledger"
1635        );
1636    }
1637    #[cfg(unix)]
1638    #[test]
1639    fn native_file_symlinks_are_rehashed_on_prediction() {
1640        use std::os::unix::fs::symlink;
1641        let directory = tempfile::tempdir().unwrap();
1642        let workspace = directory.path().join("workspace");
1643        let native = directory.path().join("cellar/lib");
1644        std::fs::create_dir_all(&workspace).unwrap();
1645        std::fs::create_dir_all(&native).unwrap();
1646        let source = workspace.join("lib.rs");
1647        std::fs::write(&source, "pub fn value() {}\n").unwrap();
1648        std::fs::write(native.join("libssl.3.dylib"), "first").unwrap();
1649        symlink("libssl.3.dylib", native.join("libssl.dylib")).unwrap();
1650        let opt = directory.path().join("opt");
1651        symlink(&native, &opt).unwrap();
1652        let invocation = library_with_native_search(&source, &opt);
1653        let mappings = vec![
1654            PathMapping::new(&workspace, "workspace"),
1655            PathMapping::new(&opt, "native"),
1656        ];
1657        let dep_info = RustcDepInfo {
1658            files: vec![source],
1659            environment: BTreeMap::new(),
1660        };
1661        let discover = || {
1662            invocation
1663                .discover_inputs_with_mappings(
1664                    &dep_info,
1665                    &workspace,
1666                    &mappings,
1667                    &mbx_cache_core::NoFileDigestCache,
1668                )
1669                .unwrap()
1670        };
1671        let original = discover();
1672        let mut context = library_context(&workspace, mappings.clone());
1673        original.clone().apply_to(&mut context).unwrap();
1674        let initial = invocation.action(context.clone()).unwrap();
1675        let prediction = invocation.prediction(&context, &original).unwrap();
1676        context.inputs.clear();
1677        let mut previous = initial.digest;
1678        for change in ["contents", "add", "retarget", "remove"] {
1679            match change {
1680                "contents" => std::fs::write(native.join("libssl.3.dylib"), "updated").unwrap(),
1681                "add" => std::fs::write(native.join("libssl.4.dylib"), "replacement").unwrap(),
1682                "retarget" => {
1683                    std::fs::remove_file(native.join("libssl.dylib")).unwrap();
1684                    symlink("libssl.4.dylib", native.join("libssl.dylib")).unwrap();
1685                }
1686                _ => std::fs::remove_file(native.join("libssl.3.dylib")).unwrap(),
1687            }
1688            let predicted = prediction
1689                .discover(&workspace, &mappings, &mbx_cache_core::NoFileDigestCache)
1690                .unwrap();
1691            let mut predicted_context = context.clone();
1692            predicted.apply_to(&mut predicted_context).unwrap();
1693            let predicted_action = invocation.action(predicted_context).unwrap();
1694            let mut discovered_context = context.clone();
1695            discover().apply_to(&mut discovered_context).unwrap();
1696            assert_eq!(
1697                predicted_action,
1698                invocation.action(discovered_context).unwrap()
1699            );
1700            assert_ne!(predicted_action.digest, previous, "{change}");
1701            previous = predicted_action.digest;
1702        }
1703        std::fs::remove_file(native.join("libssl.dylib")).unwrap();
1704        let outside = directory.path().join("outside");
1705        std::fs::write(&outside, "untracked").unwrap();
1706        symlink(&outside, native.join("libssl.dylib")).unwrap();
1707        assert!(
1708            prediction
1709                .discover(&workspace, &mappings, &mbx_cache_core::NoFileDigestCache)
1710                .is_err()
1711        );
1712        std::fs::remove_file(native.join("libssl.dylib")).unwrap();
1713        symlink(".", native.join("loop")).unwrap();
1714        assert!(
1715            prediction
1716                .discover(&workspace, &mappings, &mbx_cache_core::NoFileDigestCache)
1717                .is_err()
1718        );
1719    }
1720}