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    #[cfg(unix)]
1005    #[test]
1006    fn precompile_snapshot_does_not_depend_on_the_host_clock() {
1007        let directory = tempfile::tempdir().unwrap();
1008        let source = directory.path().join("lib.rs");
1009        std::fs::write(&source, "pub fn library() {0}\n").unwrap();
1010        let invocation = RustcInvocation::parse(&[
1011            "--crate-name=widget".into(),
1012            "--crate-type=lib".into(),
1013            "--emit=dep-info,metadata".into(),
1014            source.clone().into_os_string(),
1015        ])
1016        .unwrap();
1017        let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
1018        let discovered = invocation
1019            .discover_inputs(&dep_info, directory.path())
1020            .unwrap();
1021        let snapshot = FileSnapshot::capture(&source).unwrap().unwrap();
1022        let before = BTreeMap::from([(source.clone(), snapshot)]);
1023
1024        // Every ordinary mtime is after the epoch. The unchanged identity is
1025        // nevertheless enough proof when the filesystem clock is far ahead.
1026        discovered
1027            .verify_not_modified_since_with_snapshots(SystemTime::UNIX_EPOCH, &before)
1028            .unwrap();
1029
1030        // Conversely, a filesystem clock far behind must not conceal a write.
1031        std::fs::write(&source, "pub fn library() {1}\n").unwrap();
1032        assert_eq!(
1033            discovered.verify_not_modified_since_with_snapshots(
1034                SystemTime::now() + std::time::Duration::from_secs(60),
1035                &before,
1036            ),
1037            Err(BypassReason::InputModifiedDuringCompilation(source))
1038        );
1039    }
1040
1041    /// An external MSVC toolset directory, outside every mapped root.
1042    fn toolchain_native_directory(version: &str) -> PathBuf {
1043        if cfg!(windows) {
1044            PathBuf::from(format!(
1045                r"C:\Program Files\Microsoft Visual Studio\2022\BuildTools\VC\Tools\MSVC\{version}\lib\x64"
1046            ))
1047        } else {
1048            PathBuf::from(format!("/opt/msvc/{version}/lib/x64"))
1049        }
1050    }
1051
1052    fn library_with_native_search(source: &Path, directory: &Path) -> RustcInvocation {
1053        RustcInvocation::parse(&[
1054            "--crate-name=widget".into(),
1055            "--crate-type=lib".into(),
1056            "--emit=metadata,link".into(),
1057            format!("-Lnative={}", directory.display()).into(),
1058            source.to_path_buf().into_os_string(),
1059        ])
1060        .unwrap()
1061    }
1062
1063    fn library_context(root: &Path, mappings: Vec<PathMapping>) -> ActionContext {
1064        ActionContext {
1065            compiler: crate::CompilerIdentity {
1066                toolchain: "core:rust@test".into(),
1067                rustc_version: "test".into(),
1068                host: std::env::consts::ARCH.into(),
1069                driver: None,
1070            },
1071            working_dir: root.to_path_buf(),
1072            path_mappings: mappings,
1073            environment: BTreeMap::new(),
1074            portable_environment: BTreeSet::new(),
1075            inputs: Vec::new(),
1076        }
1077    }
1078
1079    #[cfg(windows)]
1080    #[test]
1081    fn unmapped_toolchain_directory_cannot_hide_a_static_archive() {
1082        let directory = tempfile::tempdir().unwrap();
1083        let root = directory.path();
1084        let source = root.join("lib.rs");
1085        std::fs::write(&source, "pub fn library() {}\n").unwrap();
1086        let toolchain = toolchain_native_directory("14.51.36231");
1087        let invocation = library_with_native_search(&source, &toolchain);
1088        let mappings = vec![PathMapping::new(root, "workspace")];
1089        let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
1090
1091        let context = library_context(root, mappings.clone());
1092        assert!(matches!(
1093            invocation.invocation_digest(&context),
1094            Err(BypassReason::UnmappedAbsolutePath(path)) if path == toolchain
1095        ));
1096        assert_eq!(
1097            invocation.discover_inputs_with_mappings(
1098                &dep_info,
1099                root,
1100                &mappings,
1101                &mbx_cache_core::NoFileDigestCache,
1102            ),
1103            Err(BypassReason::UnsupportedSearchPath("native".into()))
1104        );
1105    }
1106
1107    #[test]
1108    fn source_level_static_link_cannot_hide_an_unmapped_archive() {
1109        let directory = tempfile::tempdir().unwrap();
1110        let workspace = directory.path().join("workspace");
1111        let external = directory.path().join("external");
1112        std::fs::create_dir(&workspace).unwrap();
1113        std::fs::create_dir(&external).unwrap();
1114        let source = workspace.join("lib.rs");
1115        std::fs::write(
1116            &source,
1117            "#[link(name = \"foo\", kind = \"static\")] unsafe extern \"C\" {}\n",
1118        )
1119        .unwrap();
1120        std::fs::write(external.join("libfoo.a"), b"first archive").unwrap();
1121        let invocation = library_with_native_search(&source, &external);
1122        let mappings = vec![PathMapping::new(&workspace, "workspace")];
1123        let context = library_context(&workspace, mappings.clone());
1124        let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
1125
1126        // rustc's dep-info names the source but not an archive from #[link].
1127        // A path-only key would restore a stale rlib after libfoo.a changed.
1128        assert!(matches!(
1129            invocation.invocation_digest(&context),
1130            Err(BypassReason::UnmappedAbsolutePath(path)) if path == external
1131        ));
1132        assert_eq!(
1133            invocation.discover_inputs_with_mappings(
1134                &dep_info,
1135                &workspace,
1136                &mappings,
1137                &mbx_cache_core::NoFileDigestCache,
1138            ),
1139            Err(BypassReason::UnsupportedSearchPath("native".into()))
1140        );
1141    }
1142
1143    #[cfg(windows)]
1144    #[test]
1145    fn project_directory_with_toolchain_suffix_is_not_trusted() {
1146        let directory = tempfile::tempdir().unwrap();
1147        let root = directory.path().join("workspace");
1148        std::fs::create_dir(&root).unwrap();
1149        let source = root.join("lib.rs");
1150        std::fs::write(&source, "pub fn library() {}\n").unwrap();
1151        let fake_toolchain = directory.path().join("MSVC/14.51.36231/lib/x64");
1152        let invocation = library_with_native_search(&source, &fake_toolchain);
1153        let context = library_context(&root, vec![PathMapping::new(&root, "workspace")]);
1154
1155        // The apparent toolset version cannot make a mutable project archive inert.
1156        assert!(matches!(
1157            invocation.invocation_digest(&context),
1158            Err(BypassReason::UnmappedAbsolutePath(path)) if path == fake_toolchain
1159        ));
1160    }
1161
1162    #[test]
1163    fn unmapped_native_directory_still_refuses_a_native_link() {
1164        let directory = tempfile::tempdir().unwrap();
1165        let root = directory.path();
1166        let source = root.join("main.rs");
1167        std::fs::write(&source, "fn main() {}\n").unwrap();
1168        let toolchain = toolchain_native_directory("14.51.36231");
1169        let invocation = RustcInvocation::parse_with(
1170            &[
1171                "--crate-name=app".into(),
1172                "--crate-type=bin".into(),
1173                "--emit=link".into(),
1174                format!("-Lnative={}", toolchain.display()).into(),
1175                source.clone().into_os_string(),
1176            ],
1177            crate::ParseOptions::caching_native_links(true),
1178        )
1179        .unwrap();
1180        let mappings = vec![PathMapping::new(root, "workspace")];
1181
1182        // A linker reads those directories, so their contents stay inputs the
1183        // key must account for, and an unmapped one stays a bypass.
1184        let context = library_context(root, mappings.clone());
1185        assert!(matches!(
1186            invocation.invocation_digest(&context),
1187            Err(BypassReason::UnmappedAbsolutePath(_))
1188        ));
1189        let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
1190        assert_eq!(
1191            invocation.discover_inputs_with_mappings(
1192                &dep_info,
1193                root,
1194                &mappings,
1195                &mbx_cache_core::NoFileDigestCache
1196            ),
1197            Err(BypassReason::UnsupportedSearchPath("native".into()))
1198        );
1199    }
1200
1201    #[test]
1202    fn discovery_resolves_parent_components_against_the_working_directory() {
1203        let directory = tempfile::tempdir().unwrap();
1204        let root = directory.path().join("project");
1205        let shared = directory.path().join("shared.rs");
1206        std::fs::create_dir(&root).unwrap();
1207        std::fs::write(&shared, "pub fn shared() {}\n").unwrap();
1208
1209        let invocation = RustcInvocation::parse(&args(&[
1210            "--crate-name=widget",
1211            "--crate-type=lib",
1212            "--emit=metadata",
1213            "../shared.rs",
1214        ]))
1215        .unwrap();
1216        let dep_info = RustcDepInfo::parse("output: ../shared.rs\n").unwrap();
1217        let discovered = invocation.discover_inputs(&dep_info, &root).unwrap();
1218
1219        assert_eq!(discovered.inputs.len(), 1);
1220        assert_eq!(discovered.inputs[0].path, shared);
1221    }
1222
1223    #[test]
1224    fn rustc_dep_info_round_trip_discovers_real_inputs() {
1225        let directory = tempfile::tempdir().unwrap();
1226        let root = directory.path();
1227        std::fs::write(
1228            root.join("lib.rs"),
1229            "mod child; const _: &str = include_str!(\"data file.txt\"); \
1230             const _: &str = env!(\"MBX_DISCOVERY_TEST\"); \
1231             const _: Option<&str> = option_env!(\"MBX_DISCOVERY_UNSET\");",
1232        )
1233        .unwrap();
1234        std::fs::write(root.join("child.rs"), "pub fn child() {}\n").unwrap();
1235        std::fs::write(root.join("data file.txt"), "included\n").unwrap();
1236
1237        let invocation = RustcInvocation::parse(&args(&[
1238            "--crate-name=mbx_cache_discovery_test",
1239            "--crate-type=lib",
1240            "--emit=metadata,link",
1241            "lib.rs",
1242        ]))
1243        .unwrap();
1244        let dep_info_path = root.join("discovery inputs.d");
1245        let discovery_command = invocation.dep_info_command(&dep_info_path).unwrap();
1246        let rustc = std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into());
1247        let output = match Command::new(rustc)
1248            .args(discovery_command.arguments())
1249            .current_dir(root)
1250            .env("MBX_DISCOVERY_TEST", "observed")
1251            .env_remove("MBX_DISCOVERY_UNSET")
1252            .output()
1253        {
1254            Ok(output) => output,
1255            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return,
1256            Err(error) => panic!("failed to execute rustc: {error}"),
1257        };
1258        assert!(
1259            output.status.success(),
1260            "{}",
1261            String::from_utf8_lossy(&output.stderr)
1262        );
1263        let parsed = RustcDepInfo::read(&dep_info_path).unwrap();
1264        let discovered = invocation.discover_inputs(&parsed, root).unwrap();
1265        assert_eq!(
1266            discovered.environment["MBX_DISCOVERY_TEST"],
1267            Some("observed".into())
1268        );
1269        assert_eq!(discovered.environment["MBX_DISCOVERY_UNSET"], None);
1270        assert_eq!(discovered.inputs.len(), 3);
1271        assert!(
1272            discovered
1273                .inputs
1274                .iter()
1275                .all(|input| input.digest.algorithm == "blake3")
1276        );
1277        let mut context = ActionContext {
1278            compiler: crate::CompilerIdentity {
1279                toolchain: "core:rust@test".into(),
1280                rustc_version: "test".into(),
1281                host: std::env::consts::ARCH.into(),
1282                driver: None,
1283            },
1284            working_dir: root.to_path_buf(),
1285            path_mappings: vec![crate::PathMapping::new(root, "workspace")],
1286            environment: BTreeMap::new(),
1287            portable_environment: BTreeSet::new(),
1288            inputs: Vec::new(),
1289        };
1290        discovered.clone().apply_to(&mut context).unwrap();
1291        let action = invocation.action(context).unwrap();
1292        assert!(
1293            String::from_utf8(action.bytes)
1294                .unwrap()
1295                .contains(r#""MBX_DISCOVERY_TEST":"observed""#)
1296        );
1297        discovered.verify().unwrap();
1298        std::fs::write(root.join("child.rs"), "pub fn changed() {}\n").unwrap();
1299        assert_eq!(
1300            discovered.verify(),
1301            Err(BypassReason::InputChanged(root.join("child.rs")))
1302        );
1303    }
1304
1305    /// A ledger that answers with a sentinel and remembers what was recorded.
1306    struct SentinelLedger {
1307        known: FileIdentity,
1308        digest: CacheDigest,
1309        recorded: std::sync::Mutex<Vec<RecordedFileDigest>>,
1310    }
1311
1312    impl FileDigestCache for SentinelLedger {
1313        fn find(&self, scope: FileDigestScope, files: &[FileIdentity]) -> Vec<Option<CacheDigest>> {
1314            assert_eq!(scope, FileDigestScope::Content);
1315            files
1316                .iter()
1317                .map(|file| (*file == self.known).then(|| self.digest.clone()))
1318                .collect()
1319        }
1320
1321        fn record(&self, scope: FileDigestScope, entries: Vec<RecordedFileDigest>) {
1322            assert_eq!(scope, FileDigestScope::Content);
1323            self.recorded.lock().unwrap().extend(entries);
1324        }
1325    }
1326
1327    #[cfg(unix)]
1328    struct ReplacingLedger {
1329        path: PathBuf,
1330        replacement: PathBuf,
1331        recorded: std::sync::Mutex<Vec<RecordedFileDigest>>,
1332    }
1333
1334    #[cfg(unix)]
1335    impl FileDigestCache for ReplacingLedger {
1336        fn find(&self, scope: FileDigestScope, files: &[FileIdentity]) -> Vec<Option<CacheDigest>> {
1337            assert_eq!(scope, FileDigestScope::Content);
1338            assert_eq!(files.len(), 1);
1339            std::fs::remove_file(&self.path).unwrap();
1340            std::fs::rename(&self.replacement, &self.path).unwrap();
1341            // Digests of files changed this moment are withheld from reuse.
1342            std::thread::sleep(std::time::Duration::from_millis(50));
1343            vec![None]
1344        }
1345
1346        fn record(&self, scope: FileDigestScope, entries: Vec<RecordedFileDigest>) {
1347            assert_eq!(scope, FileDigestScope::Content);
1348            self.recorded.lock().unwrap().extend(entries);
1349        }
1350    }
1351
1352    #[cfg(unix)]
1353    #[test]
1354    fn discovery_binds_a_replacement_digest_to_the_replacement_identity() {
1355        let directory = tempfile::tempdir().unwrap();
1356        let root = directory.path();
1357        let path = root.join("libdep.rlib");
1358        let replacement = root.join("replacement.rlib");
1359        std::fs::write(&path, b"original....").unwrap();
1360        std::fs::File::options()
1361            .write(true)
1362            .open(&path)
1363            .unwrap()
1364            .set_times(std::fs::FileTimes::new().set_modified(SystemTime::UNIX_EPOCH))
1365            .unwrap();
1366        std::fs::write(&replacement, b"replacement!").unwrap();
1367        std::fs::File::options()
1368            .write(true)
1369            .open(&replacement)
1370            .unwrap()
1371            .set_times(std::fs::FileTimes::new().set_modified(SystemTime::UNIX_EPOCH))
1372            .unwrap();
1373        let ledger = ReplacingLedger {
1374            path: path.clone(),
1375            replacement,
1376            recorded: std::sync::Mutex::new(Vec::new()),
1377        };
1378
1379        let discovered = DiscoveredInputs::from_paths(
1380            root,
1381            BTreeSet::from([path.clone()]),
1382            BTreeMap::new(),
1383            &ledger,
1384        )
1385        .unwrap();
1386        let current = FileIdentity::for_digest_cache(&path, &std::fs::metadata(&path).unwrap())
1387            .unwrap()
1388            .unwrap();
1389        let digest = CacheDigest::blake3_file(&path).unwrap();
1390
1391        assert_eq!(discovered.inputs[0].digest, digest);
1392        assert_eq!(discovered.identities[0].as_ref(), Some(&current));
1393        assert_eq!(
1394            ledger.recorded.lock().unwrap().as_slice(),
1395            &[RecordedFileDigest {
1396                file: current,
1397                digest,
1398            }]
1399        );
1400        discovered.verify().unwrap();
1401    }
1402
1403    #[test]
1404    fn discovery_reuses_recorded_digests_and_records_fresh_ones() {
1405        let directory = tempfile::tempdir().unwrap();
1406        let root = directory.path();
1407        let known_path = root.join("libdep.rlib");
1408        let fresh_path = root.join("lib.rs");
1409        std::fs::write(&known_path, b"rlib bytes").unwrap();
1410        std::fs::write(&fresh_path, b"fn lib() {}").unwrap();
1411        // Digests of files written this moment are withheld from reuse.
1412        std::thread::sleep(std::time::Duration::from_millis(50));
1413        let metadata = std::fs::metadata(&known_path).unwrap();
1414        // A sentinel digest that hashing could never produce proves the read
1415        // was skipped: the discovered input carries it verbatim.
1416        let sentinel = CacheDigest {
1417            algorithm: "blake3".into(),
1418            hash: "c".repeat(64),
1419            size: metadata.len(),
1420        };
1421        let ledger = SentinelLedger {
1422            known: FileIdentity::describe(&known_path, &metadata).unwrap(),
1423            digest: sentinel.clone(),
1424            recorded: std::sync::Mutex::new(Vec::new()),
1425        };
1426
1427        let discovered = DiscoveredInputs::from_paths(
1428            root,
1429            BTreeSet::from([known_path.clone(), fresh_path.clone()]),
1430            BTreeMap::new(),
1431            &ledger,
1432        )
1433        .unwrap();
1434
1435        let by_path = |path: &Path| {
1436            discovered
1437                .inputs
1438                .iter()
1439                .find(|input| input.path == path)
1440                .unwrap()
1441                .digest
1442                .clone()
1443        };
1444        assert_eq!(
1445            by_path(&known_path),
1446            sentinel,
1447            "the recorded digest answers"
1448        );
1449        assert_eq!(
1450            by_path(&fresh_path),
1451            CacheDigest::blake3_file(&fresh_path).unwrap(),
1452            "an unrecorded file is hashed"
1453        );
1454        let recorded = ledger.recorded.lock().unwrap();
1455        assert_eq!(recorded.len(), 1, "only the fresh hash is recorded");
1456        assert_eq!(recorded[0].file.path, fresh_path);
1457        assert_eq!(recorded[0].digest, by_path(&fresh_path));
1458    }
1459
1460    /// Verification after the compiler runs confirms an input by its recorded
1461    /// identity rather than by reading it again; a file that has moved on is
1462    /// still read and still fails. Only where the identity carries a change
1463    /// time, which is what makes the stat as good as the read.
1464    #[cfg(unix)]
1465    #[test]
1466    fn verification_trusts_an_unchanged_identity_and_rereads_a_changed_one() {
1467        let directory = tempfile::tempdir().unwrap();
1468        let root = directory.path();
1469        let known_path = root.join("libdep.rlib");
1470        std::fs::write(&known_path, b"rlib bytes").unwrap();
1471        let metadata = std::fs::metadata(&known_path).unwrap();
1472        // The sentinel could never come from hashing the file, so a verify
1473        // that passes can only have trusted the identity.
1474        let sentinel = CacheDigest {
1475            algorithm: "blake3".into(),
1476            hash: "c".repeat(64),
1477            size: metadata.len(),
1478        };
1479        let ledger = SentinelLedger {
1480            known: FileIdentity::describe(&known_path, &metadata).unwrap(),
1481            digest: sentinel,
1482            recorded: std::sync::Mutex::new(Vec::new()),
1483        };
1484        let discovered = DiscoveredInputs::from_paths(
1485            root,
1486            BTreeSet::from([known_path.clone()]),
1487            BTreeMap::new(),
1488            &ledger,
1489        )
1490        .unwrap();
1491
1492        discovered.verify().unwrap();
1493
1494        // Rewritten with the same length: the identity moves with the write,
1495        // so the file is read again and the sentinel no longer matches it.
1496        std::thread::sleep(std::time::Duration::from_millis(20));
1497        std::fs::write(&known_path, b"RLIB BYTES").unwrap();
1498        assert_eq!(
1499            discovered.verify(),
1500            Err(BypassReason::InputChanged(known_path.clone()))
1501        );
1502
1503        std::fs::remove_file(&known_path).unwrap();
1504        assert!(matches!(
1505            discovered.verify(),
1506            Err(BypassReason::InputRead { path, .. }) if path == known_path
1507        ));
1508    }
1509
1510    /// A rewrite that restores length and modification time must still be
1511    /// hashed: the platform change token moves with every write, so the
1512    /// identity the ledger recorded no longer describes the file.
1513    #[cfg(unix)]
1514    #[test]
1515    fn a_disguised_rewrite_is_not_answered_from_the_ledger() {
1516        let directory = tempfile::tempdir().unwrap();
1517        let root = directory.path();
1518        let path = root.join("lib.rs");
1519        std::fs::write(&path, b"fn lib() -> u8 { 1 }").unwrap();
1520        let before = std::fs::metadata(&path).unwrap();
1521        let identity = FileIdentity::describe(&path, &before).unwrap();
1522
1523        // Same length, modification time put back: only the change token can
1524        // tell this file has new contents.
1525        std::fs::write(&path, b"fn lib() -> u8 { 2 }").unwrap();
1526        let file = std::fs::File::options().write(true).open(&path).unwrap();
1527        file.set_times(std::fs::FileTimes::new().set_modified(before.modified().unwrap()))
1528            .unwrap();
1529        drop(file);
1530        let after = std::fs::metadata(&path).unwrap();
1531        let disguised = FileIdentity::describe(&path, &after).unwrap();
1532        assert_eq!(disguised.len, identity.len);
1533        assert_eq!(disguised.modified, identity.modified);
1534        assert_ne!(
1535            disguised, identity,
1536            "the change token must expose the rewrite"
1537        );
1538
1539        let ledger = SentinelLedger {
1540            known: identity,
1541            digest: CacheDigest {
1542                algorithm: "blake3".into(),
1543                hash: "e".repeat(64),
1544                size: after.len(),
1545            },
1546            recorded: std::sync::Mutex::new(Vec::new()),
1547        };
1548        let discovered = DiscoveredInputs::from_paths(
1549            root,
1550            BTreeSet::from([path.clone()]),
1551            BTreeMap::new(),
1552            &ledger,
1553        )
1554        .unwrap();
1555        assert_eq!(
1556            discovered.inputs[0].digest,
1557            CacheDigest::blake3_file(&path).unwrap(),
1558            "the disguised rewrite is hashed, not answered from the ledger"
1559        );
1560    }
1561    #[cfg(unix)]
1562    #[test]
1563    fn native_file_symlinks_are_rehashed_on_prediction() {
1564        use std::os::unix::fs::symlink;
1565        let directory = tempfile::tempdir().unwrap();
1566        let workspace = directory.path().join("workspace");
1567        let native = directory.path().join("cellar/lib");
1568        std::fs::create_dir_all(&workspace).unwrap();
1569        std::fs::create_dir_all(&native).unwrap();
1570        let source = workspace.join("lib.rs");
1571        std::fs::write(&source, "pub fn value() {}\n").unwrap();
1572        std::fs::write(native.join("libssl.3.dylib"), "first").unwrap();
1573        symlink("libssl.3.dylib", native.join("libssl.dylib")).unwrap();
1574        let opt = directory.path().join("opt");
1575        symlink(&native, &opt).unwrap();
1576        let invocation = library_with_native_search(&source, &opt);
1577        let mappings = vec![
1578            PathMapping::new(&workspace, "workspace"),
1579            PathMapping::new(&opt, "native"),
1580        ];
1581        let dep_info = RustcDepInfo {
1582            files: vec![source],
1583            environment: BTreeMap::new(),
1584        };
1585        let discover = || {
1586            invocation
1587                .discover_inputs_with_mappings(
1588                    &dep_info,
1589                    &workspace,
1590                    &mappings,
1591                    &mbx_cache_core::NoFileDigestCache,
1592                )
1593                .unwrap()
1594        };
1595        let original = discover();
1596        let mut context = library_context(&workspace, mappings.clone());
1597        original.clone().apply_to(&mut context).unwrap();
1598        let initial = invocation.action(context.clone()).unwrap();
1599        let prediction = invocation.prediction(&context, &original).unwrap();
1600        context.inputs.clear();
1601        let mut previous = initial.digest;
1602        for change in ["contents", "add", "retarget", "remove"] {
1603            match change {
1604                "contents" => std::fs::write(native.join("libssl.3.dylib"), "updated").unwrap(),
1605                "add" => std::fs::write(native.join("libssl.4.dylib"), "replacement").unwrap(),
1606                "retarget" => {
1607                    std::fs::remove_file(native.join("libssl.dylib")).unwrap();
1608                    symlink("libssl.4.dylib", native.join("libssl.dylib")).unwrap();
1609                }
1610                _ => std::fs::remove_file(native.join("libssl.3.dylib")).unwrap(),
1611            }
1612            let predicted = prediction
1613                .discover(&workspace, &mappings, &mbx_cache_core::NoFileDigestCache)
1614                .unwrap();
1615            let mut predicted_context = context.clone();
1616            predicted.apply_to(&mut predicted_context).unwrap();
1617            let predicted_action = invocation.action(predicted_context).unwrap();
1618            let mut discovered_context = context.clone();
1619            discover().apply_to(&mut discovered_context).unwrap();
1620            assert_eq!(
1621                predicted_action,
1622                invocation.action(discovered_context).unwrap()
1623            );
1624            assert_ne!(predicted_action.digest, previous, "{change}");
1625            previous = predicted_action.digest;
1626        }
1627        std::fs::remove_file(native.join("libssl.dylib")).unwrap();
1628        let outside = directory.path().join("outside");
1629        std::fs::write(&outside, "untracked").unwrap();
1630        symlink(&outside, native.join("libssl.dylib")).unwrap();
1631        assert!(
1632            prediction
1633                .discover(&workspace, &mappings, &mbx_cache_core::NoFileDigestCache)
1634                .is_err()
1635        );
1636        std::fs::remove_file(native.join("libssl.dylib")).unwrap();
1637        symlink(".", native.join("loop")).unwrap();
1638        assert!(
1639            prediction
1640                .discover(&workspace, &mappings, &mbx_cache_core::NoFileDigestCache)
1641                .is_err()
1642        );
1643    }
1644}