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        for argument in &self.arguments {
532            if path_mappings.is_some()
533                && let Argument::SearchPath { kind, path } = argument
534                && kind == "native"
535            {
536                let directory = if path.is_absolute() {
537                    path.clone()
538                } else {
539                    working_dir.join(path)
540                };
541                collect_native_directory(
542                    &directory,
543                    &admitted_roots,
544                    &mut paths,
545                    &mut native_bytes,
546                )?;
547            }
548        }
549        let native_directories = self
550            .arguments
551            .iter()
552            .filter_map(|argument| match argument {
553                Argument::SearchPath { kind, path } if kind == "native" => Some(absolute(path)),
554                _ => None,
555            })
556            .collect::<Vec<_>>();
557        let native_only = paths
558            .difference(&sources)
559            .filter(|path| native_directories.iter().any(|root| path.starts_with(root)))
560            .cloned()
561            .collect();
562        Ok(DiscoveredInputs::from_paths(
563            &working_dir,
564            paths,
565            dep_info.environment.clone(),
566            digests,
567        )?
568        .with_native_only(native_only))
569    }
570}
571
572/// Return normalized roots whose native search directories can be tracked.
573pub(super) fn native_input_roots(
574    working_dir: &Path,
575    path_mappings: &[PathMapping],
576) -> Vec<PathBuf> {
577    std::iter::once(working_dir)
578        .chain(path_mappings.iter().map(|mapping| mapping.root.as_path()))
579        .map(normalize_components)
580        .collect()
581}
582
583/// Add regular files beneath an admitted native search directory, enforcing
584/// the prediction input count and the caller's cumulative native byte budget.
585pub(super) fn collect_native_directory(
586    directory: &Path,
587    admitted_roots: &[PathBuf],
588    paths: &mut BTreeSet<PathBuf>,
589    native_bytes: &mut u64,
590) -> Result<(), BypassReason> {
591    let directory = normalize_components(directory);
592    if !admitted_roots
593        .iter()
594        .any(|root| directory.starts_with(root))
595    {
596        return Err(BypassReason::UnsupportedSearchPath("native".into()));
597    }
598
599    let canonical_root = match directory.canonicalize() {
600        Ok(root) => root,
601        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
602        Err(error) => {
603            return Err(BypassReason::InputRead {
604                path: directory,
605                message: error.to_string(),
606            });
607        }
608    };
609    let mut pending = vec![directory];
610    while let Some(directory) = pending.pop() {
611        let entries = match std::fs::read_dir(&directory) {
612            Ok(entries) => entries,
613            Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
614            Err(error) => {
615                return Err(BypassReason::InputRead {
616                    path: directory,
617                    message: error.to_string(),
618                });
619            }
620        };
621        for entry in entries {
622            let entry = entry.map_err(|error| BypassReason::InputRead {
623                path: directory.clone(),
624                message: error.to_string(),
625            })?;
626            let path = entry.path();
627            let file_type = entry.file_type().map_err(|error| BypassReason::InputRead {
628                path: path.clone(),
629                message: error.to_string(),
630            })?;
631            if file_type.is_dir() {
632                pending.push(path);
633            } else if file_type.is_file() || file_type.is_symlink() {
634                // Homebrew's versionless dylibs are symlinks. Hash their
635                // referents under the searched name, but do not follow directory
636                // links, dangling links, or links outside this search tree.
637                if file_type.is_symlink() {
638                    let resolved =
639                        path.canonicalize()
640                            .map_err(|error| BypassReason::InputRead {
641                                path: path.clone(),
642                                message: error.to_string(),
643                            })?;
644                    if !resolved.starts_with(&canonical_root) || !resolved.is_file() {
645                        return Err(BypassReason::UnsupportedSearchPath("native".into()));
646                    }
647                }
648                *native_bytes = native_bytes
649                    .checked_add(
650                        std::fs::metadata(&path)
651                            .map_err(|error| BypassReason::InputRead {
652                                path: path.clone(),
653                                message: error.to_string(),
654                            })?
655                            .len(),
656                    )
657                    .ok_or_else(|| BypassReason::UnsupportedSearchPath("native".into()))?;
658                paths.insert(path);
659            } else {
660                return Err(BypassReason::UnsupportedSearchPath("native".into()));
661            }
662            if paths.len() > MAX_PREDICTED_INPUTS || *native_bytes > MAX_NATIVE_INPUT_BYTES {
663                return Err(BypassReason::UnsupportedSearchPath("native".into()));
664            }
665        }
666    }
667    Ok(())
668}
669
670fn render_argument(argument: &Argument) -> Result<OsString, BypassReason> {
671    let rendered = match argument {
672        Argument::Plain(value) => value.clone(),
673        Argument::Path { flag, path } => format!(
674            "{flag}={}",
675            path.to_str()
676                .ok_or_else(|| BypassReason::NonUtf8Path(path.clone()))?
677        ),
678        Argument::SearchPath { kind, path } => format!(
679            "-L{kind}={}",
680            path.to_str()
681                .ok_or_else(|| BypassReason::NonUtf8Path(path.clone()))?
682        ),
683        Argument::Extern { name, path } => match path {
684            Some(path) => format!(
685                "--extern={name}={}",
686                path.to_str()
687                    .ok_or_else(|| BypassReason::NonUtf8Path(path.clone()))?
688            ),
689            None => format!("--extern={name}"),
690        },
691        Argument::Emit(_) => unreachable!("emit arguments are removed before rendering"),
692        Argument::RemapPath { from, to } => format!(
693            "--remap-path-prefix={}={to}",
694            from.to_str()
695                .ok_or_else(|| BypassReason::NonUtf8Path(from.clone()))?
696        ),
697        // Nothing links while emitting dep-info, so the prefix is inert here;
698        // it is replayed verbatim to keep the command faithful.
699        Argument::OsoPrefix {
700            path,
701            trailing_slash,
702        } => format!(
703            "--codegen=link-arg=-Wl,-oso_prefix,{}{}",
704            path.to_str()
705                .ok_or_else(|| BypassReason::NonUtf8Path(path.clone()))?,
706            if *trailing_slash { "/" } else { "" }
707        ),
708        // Nothing links while emitting dep-info either; replayed verbatim for
709        // the same reason as the prefix above.
710        Argument::InstallName(name) => {
711            format!("--codegen=link-arg=-Wl,-install_name,@rpath/{name}")
712        }
713        Argument::FuseLd(selection) => format!("--codegen=link-arg=-fuse-ld={selection}"),
714    };
715    Ok(rendered.into())
716}
717
718fn unescape_environment(value: &str) -> Result<String, BypassReason> {
719    let mut output = String::with_capacity(value.len());
720    let mut characters = value.chars();
721    while let Some(character) = characters.next() {
722        if character != '\\' {
723            output.push(character);
724            continue;
725        }
726        match characters.next() {
727            Some('\\') => output.push('\\'),
728            Some('n') => output.push('\n'),
729            Some('r') => output.push('\r'),
730            Some(character) => {
731                return Err(BypassReason::MalformedDepInfo(format!(
732                    "unknown environment escape \\{character}"
733                )));
734            }
735            None => {
736                return Err(BypassReason::MalformedDepInfo(
737                    "environment input ends with an unterminated escape".into(),
738                ));
739            }
740        }
741    }
742    Ok(output)
743}
744
745#[cfg(test)]
746mod tests {
747    use super::*;
748    use std::process::Command;
749
750    fn args(values: &[&str]) -> Vec<OsString> {
751        values.iter().map(OsString::from).collect()
752    }
753
754    #[test]
755    fn parses_files_spaces_and_environment_records() {
756        let parsed = RustcDepInfo::parse(
757            "target/lib.rlib: src/lib.rs src/a\\ file.rs generated.rs\n\
758             src/lib.rs:\n\
759             # env-dep:SET=value\\nnext\n\
760             # env-dep:UNSET\n\
761             # env-dep:SLASH=a\\\\b\n",
762        )
763        .unwrap();
764        assert_eq!(
765            parsed.files,
766            vec![
767                PathBuf::from("generated.rs"),
768                PathBuf::from("src/a file.rs"),
769                PathBuf::from("src/lib.rs"),
770            ]
771        );
772        assert_eq!(parsed.environment["SET"], Some("value\nnext".into()));
773        assert_eq!(parsed.environment["UNSET"], None);
774        assert_eq!(parsed.environment["SLASH"], Some(r"a\b".into()));
775    }
776
777    #[test]
778    fn malformed_dep_info_bypasses_caching() {
779        for contents in [
780            "",
781            "target: ",
782            "target: src/trailing\\\n",
783            "target: src/lib.rs\n# env-dep:NAME=bad\\q\n",
784        ] {
785            assert!(RustcDepInfo::parse(contents).is_err(), "{contents:?}");
786        }
787    }
788
789    #[test]
790    fn native_directory_byte_limit_is_cumulative() {
791        let directory = tempfile::tempdir().unwrap();
792        let native = directory.path().join("native");
793        std::fs::create_dir_all(&native).unwrap();
794        std::fs::write(native.join("input.lib"), b"xx").unwrap();
795        let roots = native_input_roots(directory.path(), &[]);
796        let mut paths = BTreeSet::new();
797        let mut native_bytes = MAX_NATIVE_INPUT_BYTES - 1;
798
799        assert_eq!(
800            collect_native_directory(&native, &roots, &mut paths, &mut native_bytes),
801            Err(BypassReason::UnsupportedSearchPath("native".into()))
802        );
803    }
804
805    #[test]
806    fn discovery_command_removes_real_outputs() {
807        let invocation = RustcInvocation::parse(&args(&[
808            "--crate-name=widget",
809            "--crate-type=lib",
810            "--emit=dep-info,metadata,link",
811            "--out-dir=target/debug/deps",
812            "-o",
813            "target/debug/libwidget.rlib",
814            "src/lib.rs",
815        ]))
816        .unwrap();
817        let output = if cfg!(windows) {
818            PathBuf::from(r"C:\tmp\mbx cache\inputs.d")
819        } else {
820            PathBuf::from("/tmp/mbx cache/inputs.d")
821        };
822        let command = invocation.dep_info_command(&output).unwrap();
823        let arguments = command
824            .arguments()
825            .iter()
826            .map(|value| value.to_string_lossy())
827            .collect::<Vec<_>>();
828        assert_eq!(
829            arguments,
830            vec![
831                "--crate-name=widget",
832                "--crate-type=lib",
833                &format!("--emit=dep-info={}", output.display()),
834                "src/lib.rs",
835            ]
836        );
837    }
838
839    #[test]
840    fn discovery_hashes_externs_and_custom_targets() {
841        let directory = tempfile::tempdir().unwrap();
842        let root = directory.path();
843        let source = root.join("src/lib.rs");
844        let external = root.join("target/libdependency.rlib");
845        let target = root.join("targets/custom.json");
846        std::fs::create_dir_all(source.parent().unwrap()).unwrap();
847        std::fs::create_dir_all(external.parent().unwrap()).unwrap();
848        std::fs::create_dir_all(target.parent().unwrap()).unwrap();
849        std::fs::write(&source, "pub fn library() {}\n").unwrap();
850        std::fs::write(&external, "dependency artifact\n").unwrap();
851        std::fs::write(&target, "{}\n").unwrap();
852
853        let invocation = RustcInvocation::parse(&[
854            "--crate-name=widget".into(),
855            "--crate-type=lib".into(),
856            "--emit=metadata".into(),
857            format!("--extern=dependency={}", external.display()).into(),
858            format!("--target={}", target.display()).into(),
859            source.clone().into_os_string(),
860        ])
861        .unwrap();
862        let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
863        let discovered = invocation.discover_inputs(&dep_info, root).unwrap();
864        assert_eq!(discovered.inputs.len(), 3);
865
866        std::fs::remove_file(&external).unwrap();
867        assert!(matches!(
868            invocation.discover_inputs(&dep_info, root),
869            Err(BypassReason::InputRead { path, .. }) if path == external
870        ));
871    }
872
873    #[test]
874    fn discovery_rejects_inputs_modified_during_compilation() {
875        let directory = tempfile::tempdir().unwrap();
876        let source = directory.path().join("lib.rs");
877        std::fs::write(&source, "pub fn library() {}\n").unwrap();
878        let invocation = RustcInvocation::parse(&[
879            "--crate-name=widget".into(),
880            "--crate-type=lib".into(),
881            "--emit=dep-info,metadata".into(),
882            source.clone().into_os_string(),
883        ])
884        .unwrap();
885        let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
886        let discovered = invocation
887            .discover_inputs(&dep_info, directory.path())
888            .unwrap();
889        let modified = std::fs::metadata(&source).unwrap().modified().unwrap();
890
891        assert_eq!(
892            discovered.verify_not_modified_since(modified),
893            Err(BypassReason::InputModifiedDuringCompilation(source))
894        );
895    }
896
897    #[cfg(unix)]
898    #[test]
899    fn precompile_snapshot_does_not_depend_on_the_host_clock() {
900        let directory = tempfile::tempdir().unwrap();
901        let source = directory.path().join("lib.rs");
902        std::fs::write(&source, "pub fn library() {0}\n").unwrap();
903        let invocation = RustcInvocation::parse(&[
904            "--crate-name=widget".into(),
905            "--crate-type=lib".into(),
906            "--emit=dep-info,metadata".into(),
907            source.clone().into_os_string(),
908        ])
909        .unwrap();
910        let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
911        let discovered = invocation
912            .discover_inputs(&dep_info, directory.path())
913            .unwrap();
914        let snapshot = FileSnapshot::capture(&source).unwrap().unwrap();
915        let before = BTreeMap::from([(source.clone(), snapshot)]);
916
917        // Every ordinary mtime is after the epoch. The unchanged identity is
918        // nevertheless enough proof when the filesystem clock is far ahead.
919        discovered
920            .verify_not_modified_since_with_snapshots(SystemTime::UNIX_EPOCH, &before)
921            .unwrap();
922
923        // Conversely, a filesystem clock far behind must not conceal a write.
924        std::fs::write(&source, "pub fn library() {1}\n").unwrap();
925        assert_eq!(
926            discovered.verify_not_modified_since_with_snapshots(
927                SystemTime::now() + std::time::Duration::from_secs(60),
928                &before,
929            ),
930            Err(BypassReason::InputModifiedDuringCompilation(source))
931        );
932    }
933
934    /// An external MSVC toolset directory, outside every mapped root.
935    fn toolchain_native_directory(version: &str) -> PathBuf {
936        if cfg!(windows) {
937            PathBuf::from(format!(
938                r"C:\Program Files\Microsoft Visual Studio\2022\BuildTools\VC\Tools\MSVC\{version}\lib\x64"
939            ))
940        } else {
941            PathBuf::from(format!("/opt/msvc/{version}/lib/x64"))
942        }
943    }
944
945    fn library_with_native_search(source: &Path, directory: &Path) -> RustcInvocation {
946        RustcInvocation::parse(&[
947            "--crate-name=widget".into(),
948            "--crate-type=lib".into(),
949            "--emit=metadata,link".into(),
950            format!("-Lnative={}", directory.display()).into(),
951            source.to_path_buf().into_os_string(),
952        ])
953        .unwrap()
954    }
955
956    fn library_context(root: &Path, mappings: Vec<PathMapping>) -> ActionContext {
957        ActionContext {
958            compiler: crate::CompilerIdentity {
959                toolchain: "core:rust@test".into(),
960                rustc_version: "test".into(),
961                host: std::env::consts::ARCH.into(),
962                driver: None,
963            },
964            working_dir: root.to_path_buf(),
965            path_mappings: mappings,
966            environment: BTreeMap::new(),
967            portable_environment: BTreeSet::new(),
968            inputs: Vec::new(),
969        }
970    }
971
972    #[cfg(windows)]
973    #[test]
974    fn unmapped_toolchain_directory_cannot_hide_a_static_archive() {
975        let directory = tempfile::tempdir().unwrap();
976        let root = directory.path();
977        let source = root.join("lib.rs");
978        std::fs::write(&source, "pub fn library() {}\n").unwrap();
979        let toolchain = toolchain_native_directory("14.51.36231");
980        let invocation = library_with_native_search(&source, &toolchain);
981        let mappings = vec![PathMapping::new(root, "workspace")];
982        let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
983
984        let context = library_context(root, mappings.clone());
985        assert!(matches!(
986            invocation.invocation_digest(&context),
987            Err(BypassReason::UnmappedAbsolutePath(path)) if path == toolchain
988        ));
989        assert_eq!(
990            invocation.discover_inputs_with_mappings(
991                &dep_info,
992                root,
993                &mappings,
994                &mbx_cache_core::NoFileDigestCache,
995            ),
996            Err(BypassReason::UnsupportedSearchPath("native".into()))
997        );
998    }
999
1000    #[test]
1001    fn source_level_static_link_cannot_hide_an_unmapped_archive() {
1002        let directory = tempfile::tempdir().unwrap();
1003        let workspace = directory.path().join("workspace");
1004        let external = directory.path().join("external");
1005        std::fs::create_dir(&workspace).unwrap();
1006        std::fs::create_dir(&external).unwrap();
1007        let source = workspace.join("lib.rs");
1008        std::fs::write(
1009            &source,
1010            "#[link(name = \"foo\", kind = \"static\")] unsafe extern \"C\" {}\n",
1011        )
1012        .unwrap();
1013        std::fs::write(external.join("libfoo.a"), b"first archive").unwrap();
1014        let invocation = library_with_native_search(&source, &external);
1015        let mappings = vec![PathMapping::new(&workspace, "workspace")];
1016        let context = library_context(&workspace, mappings.clone());
1017        let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
1018
1019        // rustc's dep-info names the source but not an archive from #[link].
1020        // A path-only key would restore a stale rlib after libfoo.a changed.
1021        assert!(matches!(
1022            invocation.invocation_digest(&context),
1023            Err(BypassReason::UnmappedAbsolutePath(path)) if path == external
1024        ));
1025        assert_eq!(
1026            invocation.discover_inputs_with_mappings(
1027                &dep_info,
1028                &workspace,
1029                &mappings,
1030                &mbx_cache_core::NoFileDigestCache,
1031            ),
1032            Err(BypassReason::UnsupportedSearchPath("native".into()))
1033        );
1034    }
1035
1036    #[cfg(windows)]
1037    #[test]
1038    fn project_directory_with_toolchain_suffix_is_not_trusted() {
1039        let directory = tempfile::tempdir().unwrap();
1040        let root = directory.path().join("workspace");
1041        std::fs::create_dir(&root).unwrap();
1042        let source = root.join("lib.rs");
1043        std::fs::write(&source, "pub fn library() {}\n").unwrap();
1044        let fake_toolchain = directory.path().join("MSVC/14.51.36231/lib/x64");
1045        let invocation = library_with_native_search(&source, &fake_toolchain);
1046        let context = library_context(&root, vec![PathMapping::new(&root, "workspace")]);
1047
1048        // The apparent toolset version cannot make a mutable project archive inert.
1049        assert!(matches!(
1050            invocation.invocation_digest(&context),
1051            Err(BypassReason::UnmappedAbsolutePath(path)) if path == fake_toolchain
1052        ));
1053    }
1054
1055    #[test]
1056    fn unmapped_native_directory_still_refuses_a_native_link() {
1057        let directory = tempfile::tempdir().unwrap();
1058        let root = directory.path();
1059        let source = root.join("main.rs");
1060        std::fs::write(&source, "fn main() {}\n").unwrap();
1061        let toolchain = toolchain_native_directory("14.51.36231");
1062        let invocation = RustcInvocation::parse_with(
1063            &[
1064                "--crate-name=app".into(),
1065                "--crate-type=bin".into(),
1066                "--emit=link".into(),
1067                format!("-Lnative={}", toolchain.display()).into(),
1068                source.clone().into_os_string(),
1069            ],
1070            crate::ParseOptions::caching_native_links(true),
1071        )
1072        .unwrap();
1073        let mappings = vec![PathMapping::new(root, "workspace")];
1074
1075        // A linker reads those directories, so their contents stay inputs the
1076        // key must account for, and an unmapped one stays a bypass.
1077        let context = library_context(root, mappings.clone());
1078        assert!(matches!(
1079            invocation.invocation_digest(&context),
1080            Err(BypassReason::UnmappedAbsolutePath(_))
1081        ));
1082        let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
1083        assert_eq!(
1084            invocation.discover_inputs_with_mappings(
1085                &dep_info,
1086                root,
1087                &mappings,
1088                &mbx_cache_core::NoFileDigestCache
1089            ),
1090            Err(BypassReason::UnsupportedSearchPath("native".into()))
1091        );
1092    }
1093
1094    #[test]
1095    fn discovery_resolves_parent_components_against_the_working_directory() {
1096        let directory = tempfile::tempdir().unwrap();
1097        let root = directory.path().join("project");
1098        let shared = directory.path().join("shared.rs");
1099        std::fs::create_dir(&root).unwrap();
1100        std::fs::write(&shared, "pub fn shared() {}\n").unwrap();
1101
1102        let invocation = RustcInvocation::parse(&args(&[
1103            "--crate-name=widget",
1104            "--crate-type=lib",
1105            "--emit=metadata",
1106            "../shared.rs",
1107        ]))
1108        .unwrap();
1109        let dep_info = RustcDepInfo::parse("output: ../shared.rs\n").unwrap();
1110        let discovered = invocation.discover_inputs(&dep_info, &root).unwrap();
1111
1112        assert_eq!(discovered.inputs.len(), 1);
1113        assert_eq!(discovered.inputs[0].path, shared);
1114    }
1115
1116    #[test]
1117    fn rustc_dep_info_round_trip_discovers_real_inputs() {
1118        let directory = tempfile::tempdir().unwrap();
1119        let root = directory.path();
1120        std::fs::write(
1121            root.join("lib.rs"),
1122            "mod child; const _: &str = include_str!(\"data file.txt\"); \
1123             const _: &str = env!(\"MBX_DISCOVERY_TEST\"); \
1124             const _: Option<&str> = option_env!(\"MBX_DISCOVERY_UNSET\");",
1125        )
1126        .unwrap();
1127        std::fs::write(root.join("child.rs"), "pub fn child() {}\n").unwrap();
1128        std::fs::write(root.join("data file.txt"), "included\n").unwrap();
1129
1130        let invocation = RustcInvocation::parse(&args(&[
1131            "--crate-name=mbx_cache_discovery_test",
1132            "--crate-type=lib",
1133            "--emit=metadata,link",
1134            "lib.rs",
1135        ]))
1136        .unwrap();
1137        let dep_info_path = root.join("discovery inputs.d");
1138        let discovery_command = invocation.dep_info_command(&dep_info_path).unwrap();
1139        let rustc = std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into());
1140        let output = match Command::new(rustc)
1141            .args(discovery_command.arguments())
1142            .current_dir(root)
1143            .env("MBX_DISCOVERY_TEST", "observed")
1144            .env_remove("MBX_DISCOVERY_UNSET")
1145            .output()
1146        {
1147            Ok(output) => output,
1148            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return,
1149            Err(error) => panic!("failed to execute rustc: {error}"),
1150        };
1151        assert!(
1152            output.status.success(),
1153            "{}",
1154            String::from_utf8_lossy(&output.stderr)
1155        );
1156        let parsed = RustcDepInfo::read(&dep_info_path).unwrap();
1157        let discovered = invocation.discover_inputs(&parsed, root).unwrap();
1158        assert_eq!(
1159            discovered.environment["MBX_DISCOVERY_TEST"],
1160            Some("observed".into())
1161        );
1162        assert_eq!(discovered.environment["MBX_DISCOVERY_UNSET"], None);
1163        assert_eq!(discovered.inputs.len(), 3);
1164        assert!(
1165            discovered
1166                .inputs
1167                .iter()
1168                .all(|input| input.digest.algorithm == "blake3")
1169        );
1170        let mut context = ActionContext {
1171            compiler: crate::CompilerIdentity {
1172                toolchain: "core:rust@test".into(),
1173                rustc_version: "test".into(),
1174                host: std::env::consts::ARCH.into(),
1175                driver: None,
1176            },
1177            working_dir: root.to_path_buf(),
1178            path_mappings: vec![crate::PathMapping::new(root, "workspace")],
1179            environment: BTreeMap::new(),
1180            portable_environment: BTreeSet::new(),
1181            inputs: Vec::new(),
1182        };
1183        discovered.clone().apply_to(&mut context).unwrap();
1184        let action = invocation.action(context).unwrap();
1185        assert!(
1186            String::from_utf8(action.bytes)
1187                .unwrap()
1188                .contains(r#""MBX_DISCOVERY_TEST":"observed""#)
1189        );
1190        discovered.verify().unwrap();
1191        std::fs::write(root.join("child.rs"), "pub fn changed() {}\n").unwrap();
1192        assert_eq!(
1193            discovered.verify(),
1194            Err(BypassReason::InputChanged(root.join("child.rs")))
1195        );
1196    }
1197
1198    /// A ledger that answers with a sentinel and remembers what was recorded.
1199    struct SentinelLedger {
1200        known: FileIdentity,
1201        digest: CacheDigest,
1202        recorded: std::sync::Mutex<Vec<RecordedFileDigest>>,
1203    }
1204
1205    impl FileDigestCache for SentinelLedger {
1206        fn find(&self, scope: FileDigestScope, files: &[FileIdentity]) -> Vec<Option<CacheDigest>> {
1207            assert_eq!(scope, FileDigestScope::Content);
1208            files
1209                .iter()
1210                .map(|file| (*file == self.known).then(|| self.digest.clone()))
1211                .collect()
1212        }
1213
1214        fn record(&self, scope: FileDigestScope, entries: Vec<RecordedFileDigest>) {
1215            assert_eq!(scope, FileDigestScope::Content);
1216            self.recorded.lock().unwrap().extend(entries);
1217        }
1218    }
1219
1220    #[cfg(unix)]
1221    struct ReplacingLedger {
1222        path: PathBuf,
1223        replacement: PathBuf,
1224        recorded: std::sync::Mutex<Vec<RecordedFileDigest>>,
1225    }
1226
1227    #[cfg(unix)]
1228    impl FileDigestCache for ReplacingLedger {
1229        fn find(&self, scope: FileDigestScope, files: &[FileIdentity]) -> Vec<Option<CacheDigest>> {
1230            assert_eq!(scope, FileDigestScope::Content);
1231            assert_eq!(files.len(), 1);
1232            std::fs::remove_file(&self.path).unwrap();
1233            std::fs::rename(&self.replacement, &self.path).unwrap();
1234            // Digests of files changed this moment are withheld from reuse.
1235            std::thread::sleep(std::time::Duration::from_millis(50));
1236            vec![None]
1237        }
1238
1239        fn record(&self, scope: FileDigestScope, entries: Vec<RecordedFileDigest>) {
1240            assert_eq!(scope, FileDigestScope::Content);
1241            self.recorded.lock().unwrap().extend(entries);
1242        }
1243    }
1244
1245    #[cfg(unix)]
1246    #[test]
1247    fn discovery_binds_a_replacement_digest_to_the_replacement_identity() {
1248        let directory = tempfile::tempdir().unwrap();
1249        let root = directory.path();
1250        let path = root.join("libdep.rlib");
1251        let replacement = root.join("replacement.rlib");
1252        std::fs::write(&path, b"original....").unwrap();
1253        std::fs::File::options()
1254            .write(true)
1255            .open(&path)
1256            .unwrap()
1257            .set_times(std::fs::FileTimes::new().set_modified(SystemTime::UNIX_EPOCH))
1258            .unwrap();
1259        std::fs::write(&replacement, b"replacement!").unwrap();
1260        std::fs::File::options()
1261            .write(true)
1262            .open(&replacement)
1263            .unwrap()
1264            .set_times(std::fs::FileTimes::new().set_modified(SystemTime::UNIX_EPOCH))
1265            .unwrap();
1266        let ledger = ReplacingLedger {
1267            path: path.clone(),
1268            replacement,
1269            recorded: std::sync::Mutex::new(Vec::new()),
1270        };
1271
1272        let discovered = DiscoveredInputs::from_paths(
1273            root,
1274            BTreeSet::from([path.clone()]),
1275            BTreeMap::new(),
1276            &ledger,
1277        )
1278        .unwrap();
1279        let current = FileIdentity::for_digest_cache(&path, &std::fs::metadata(&path).unwrap())
1280            .unwrap()
1281            .unwrap();
1282        let digest = CacheDigest::blake3_file(&path).unwrap();
1283
1284        assert_eq!(discovered.inputs[0].digest, digest);
1285        assert_eq!(discovered.identities[0].as_ref(), Some(&current));
1286        assert_eq!(
1287            ledger.recorded.lock().unwrap().as_slice(),
1288            &[RecordedFileDigest {
1289                file: current,
1290                digest,
1291            }]
1292        );
1293        discovered.verify().unwrap();
1294    }
1295
1296    #[test]
1297    fn discovery_reuses_recorded_digests_and_records_fresh_ones() {
1298        let directory = tempfile::tempdir().unwrap();
1299        let root = directory.path();
1300        let known_path = root.join("libdep.rlib");
1301        let fresh_path = root.join("lib.rs");
1302        std::fs::write(&known_path, b"rlib bytes").unwrap();
1303        std::fs::write(&fresh_path, b"fn lib() {}").unwrap();
1304        // Digests of files written this moment are withheld from reuse.
1305        std::thread::sleep(std::time::Duration::from_millis(50));
1306        let metadata = std::fs::metadata(&known_path).unwrap();
1307        // A sentinel digest that hashing could never produce proves the read
1308        // was skipped: the discovered input carries it verbatim.
1309        let sentinel = CacheDigest {
1310            algorithm: "blake3".into(),
1311            hash: "c".repeat(64),
1312            size: metadata.len(),
1313        };
1314        let ledger = SentinelLedger {
1315            known: FileIdentity::describe(&known_path, &metadata).unwrap(),
1316            digest: sentinel.clone(),
1317            recorded: std::sync::Mutex::new(Vec::new()),
1318        };
1319
1320        let discovered = DiscoveredInputs::from_paths(
1321            root,
1322            BTreeSet::from([known_path.clone(), fresh_path.clone()]),
1323            BTreeMap::new(),
1324            &ledger,
1325        )
1326        .unwrap();
1327
1328        let by_path = |path: &Path| {
1329            discovered
1330                .inputs
1331                .iter()
1332                .find(|input| input.path == path)
1333                .unwrap()
1334                .digest
1335                .clone()
1336        };
1337        assert_eq!(
1338            by_path(&known_path),
1339            sentinel,
1340            "the recorded digest answers"
1341        );
1342        assert_eq!(
1343            by_path(&fresh_path),
1344            CacheDigest::blake3_file(&fresh_path).unwrap(),
1345            "an unrecorded file is hashed"
1346        );
1347        let recorded = ledger.recorded.lock().unwrap();
1348        assert_eq!(recorded.len(), 1, "only the fresh hash is recorded");
1349        assert_eq!(recorded[0].file.path, fresh_path);
1350        assert_eq!(recorded[0].digest, by_path(&fresh_path));
1351    }
1352
1353    /// Verification after the compiler runs confirms an input by its recorded
1354    /// identity rather than by reading it again; a file that has moved on is
1355    /// still read and still fails. Only where the identity carries a change
1356    /// time, which is what makes the stat as good as the read.
1357    #[cfg(unix)]
1358    #[test]
1359    fn verification_trusts_an_unchanged_identity_and_rereads_a_changed_one() {
1360        let directory = tempfile::tempdir().unwrap();
1361        let root = directory.path();
1362        let known_path = root.join("libdep.rlib");
1363        std::fs::write(&known_path, b"rlib bytes").unwrap();
1364        let metadata = std::fs::metadata(&known_path).unwrap();
1365        // The sentinel could never come from hashing the file, so a verify
1366        // that passes can only have trusted the identity.
1367        let sentinel = CacheDigest {
1368            algorithm: "blake3".into(),
1369            hash: "c".repeat(64),
1370            size: metadata.len(),
1371        };
1372        let ledger = SentinelLedger {
1373            known: FileIdentity::describe(&known_path, &metadata).unwrap(),
1374            digest: sentinel,
1375            recorded: std::sync::Mutex::new(Vec::new()),
1376        };
1377        let discovered = DiscoveredInputs::from_paths(
1378            root,
1379            BTreeSet::from([known_path.clone()]),
1380            BTreeMap::new(),
1381            &ledger,
1382        )
1383        .unwrap();
1384
1385        discovered.verify().unwrap();
1386
1387        // Rewritten with the same length: the identity moves with the write,
1388        // so the file is read again and the sentinel no longer matches it.
1389        std::thread::sleep(std::time::Duration::from_millis(20));
1390        std::fs::write(&known_path, b"RLIB BYTES").unwrap();
1391        assert_eq!(
1392            discovered.verify(),
1393            Err(BypassReason::InputChanged(known_path.clone()))
1394        );
1395
1396        std::fs::remove_file(&known_path).unwrap();
1397        assert!(matches!(
1398            discovered.verify(),
1399            Err(BypassReason::InputRead { path, .. }) if path == known_path
1400        ));
1401    }
1402
1403    /// A rewrite that restores length and modification time must still be
1404    /// hashed: the platform change token moves with every write, so the
1405    /// identity the ledger recorded no longer describes the file.
1406    #[cfg(unix)]
1407    #[test]
1408    fn a_disguised_rewrite_is_not_answered_from_the_ledger() {
1409        let directory = tempfile::tempdir().unwrap();
1410        let root = directory.path();
1411        let path = root.join("lib.rs");
1412        std::fs::write(&path, b"fn lib() -> u8 { 1 }").unwrap();
1413        let before = std::fs::metadata(&path).unwrap();
1414        let identity = FileIdentity::describe(&path, &before).unwrap();
1415
1416        // Same length, modification time put back: only the change token can
1417        // tell this file has new contents.
1418        std::fs::write(&path, b"fn lib() -> u8 { 2 }").unwrap();
1419        let file = std::fs::File::options().write(true).open(&path).unwrap();
1420        file.set_times(std::fs::FileTimes::new().set_modified(before.modified().unwrap()))
1421            .unwrap();
1422        drop(file);
1423        let after = std::fs::metadata(&path).unwrap();
1424        let disguised = FileIdentity::describe(&path, &after).unwrap();
1425        assert_eq!(disguised.len, identity.len);
1426        assert_eq!(disguised.modified, identity.modified);
1427        assert_ne!(
1428            disguised, identity,
1429            "the change token must expose the rewrite"
1430        );
1431
1432        let ledger = SentinelLedger {
1433            known: identity,
1434            digest: CacheDigest {
1435                algorithm: "blake3".into(),
1436                hash: "e".repeat(64),
1437                size: after.len(),
1438            },
1439            recorded: std::sync::Mutex::new(Vec::new()),
1440        };
1441        let discovered = DiscoveredInputs::from_paths(
1442            root,
1443            BTreeSet::from([path.clone()]),
1444            BTreeMap::new(),
1445            &ledger,
1446        )
1447        .unwrap();
1448        assert_eq!(
1449            discovered.inputs[0].digest,
1450            CacheDigest::blake3_file(&path).unwrap(),
1451            "the disguised rewrite is hashed, not answered from the ledger"
1452        );
1453    }
1454    #[cfg(unix)]
1455    #[test]
1456    fn native_file_symlinks_are_rehashed_on_prediction() {
1457        use std::os::unix::fs::symlink;
1458        let directory = tempfile::tempdir().unwrap();
1459        let workspace = directory.path().join("workspace");
1460        let native = directory.path().join("cellar/lib");
1461        std::fs::create_dir_all(&workspace).unwrap();
1462        std::fs::create_dir_all(&native).unwrap();
1463        let source = workspace.join("lib.rs");
1464        std::fs::write(&source, "pub fn value() {}\n").unwrap();
1465        std::fs::write(native.join("libssl.3.dylib"), "first").unwrap();
1466        symlink("libssl.3.dylib", native.join("libssl.dylib")).unwrap();
1467        let opt = directory.path().join("opt");
1468        symlink(&native, &opt).unwrap();
1469        let invocation = library_with_native_search(&source, &opt);
1470        let mappings = vec![
1471            PathMapping::new(&workspace, "workspace"),
1472            PathMapping::new(&opt, "native"),
1473        ];
1474        let dep_info = RustcDepInfo {
1475            files: vec![source],
1476            environment: BTreeMap::new(),
1477        };
1478        let discover = || {
1479            invocation
1480                .discover_inputs_with_mappings(
1481                    &dep_info,
1482                    &workspace,
1483                    &mappings,
1484                    &mbx_cache_core::NoFileDigestCache,
1485                )
1486                .unwrap()
1487        };
1488        let original = discover();
1489        let mut context = library_context(&workspace, mappings.clone());
1490        original.clone().apply_to(&mut context).unwrap();
1491        let initial = invocation.action(context.clone()).unwrap();
1492        let prediction = invocation.prediction(&context, &original).unwrap();
1493        context.inputs.clear();
1494        let mut previous = initial.digest;
1495        for change in ["contents", "add", "retarget", "remove"] {
1496            match change {
1497                "contents" => std::fs::write(native.join("libssl.3.dylib"), "updated").unwrap(),
1498                "add" => std::fs::write(native.join("libssl.4.dylib"), "replacement").unwrap(),
1499                "retarget" => {
1500                    std::fs::remove_file(native.join("libssl.dylib")).unwrap();
1501                    symlink("libssl.4.dylib", native.join("libssl.dylib")).unwrap();
1502                }
1503                _ => std::fs::remove_file(native.join("libssl.3.dylib")).unwrap(),
1504            }
1505            let predicted = prediction
1506                .discover(&workspace, &mappings, &mbx_cache_core::NoFileDigestCache)
1507                .unwrap();
1508            let mut predicted_context = context.clone();
1509            predicted.apply_to(&mut predicted_context).unwrap();
1510            let predicted_action = invocation.action(predicted_context).unwrap();
1511            let mut discovered_context = context.clone();
1512            discover().apply_to(&mut discovered_context).unwrap();
1513            assert_eq!(
1514                predicted_action,
1515                invocation.action(discovered_context).unwrap()
1516            );
1517            assert_ne!(predicted_action.digest, previous, "{change}");
1518            previous = predicted_action.digest;
1519        }
1520        std::fs::remove_file(native.join("libssl.dylib")).unwrap();
1521        let outside = directory.path().join("outside");
1522        std::fs::write(&outside, "untracked").unwrap();
1523        symlink(&outside, native.join("libssl.dylib")).unwrap();
1524        assert!(
1525            prediction
1526                .discover(&workspace, &mappings, &mbx_cache_core::NoFileDigestCache)
1527                .is_err()
1528        );
1529        std::fs::remove_file(native.join("libssl.dylib")).unwrap();
1530        symlink(".", native.join("loop")).unwrap();
1531        assert!(
1532            prediction
1533                .discover(&workspace, &mappings, &mbx_cache_core::NoFileDigestCache)
1534                .is_err()
1535        );
1536    }
1537}