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}
148
149impl DiscoveredInputs {
150    pub(crate) fn from_paths(
151        working_dir: &Path,
152        paths: BTreeSet<PathBuf>,
153        environment: BTreeMap<String, Option<String>>,
154        digests: &dyn FileDigestCache,
155    ) -> Result<Self, BypassReason> {
156        if !working_dir.is_absolute() {
157            return Err(BypassReason::RelativeWorkingDirectory(
158                working_dir.to_path_buf(),
159            ));
160        }
161        let working_dir = normalize_components(working_dir);
162        // Stat everything first: the identities drive one batched ledger
163        // lookup, so an upstream rlib the session already hashed -- once, when
164        // it was materialized or published -- is not read again by every crate
165        // that links it. A file the filesystem reports no modification time
166        // for gets no identity and is simply hashed.
167        let mut identified = Vec::with_capacity(paths.len());
168        for path in paths {
169            let metadata = std::fs::metadata(&path).map_err(|error| BypassReason::InputRead {
170                path: path.clone(),
171                message: error.to_string(),
172            })?;
173            if !metadata.is_file() {
174                return Err(BypassReason::InputRead {
175                    path,
176                    message: "input is not a regular file".into(),
177                });
178            }
179            let identity = FileIdentity::for_digest_cache(&path, &metadata).map_err(|error| {
180                BypassReason::InputRead {
181                    path: path.clone(),
182                    message: error.to_string(),
183                }
184            })?;
185            identified.push((path, identity));
186        }
187        let queries = identified
188            .iter()
189            .filter_map(|(_, identity)| identity.clone())
190            .collect::<Vec<_>>();
191        let mut recorded = digests
192            .resolve(FileDigestScope::Content, &queries)
193            .into_iter();
194        let mut inputs = Vec::with_capacity(identified.len());
195        let mut identities = Vec::with_capacity(identified.len());
196        let mut fresh = Vec::new();
197        for (path, identity) in identified {
198            let resolution = identity
199                .as_ref()
200                .and_then(|_| recorded.next())
201                .unwrap_or(FileDigestResolution::Unresolved);
202            let cached = match resolution {
203                FileDigestResolution::Digest(digest)
204                    if identity.as_ref().is_some_and(|identity| {
205                        identity.len == digest.size && identity.still_describes().unwrap_or(false)
206                    }) =>
207                {
208                    Some(digest)
209                }
210                FileDigestResolution::Digest(_)
211                | FileDigestResolution::EmbeddedTimestampMacro
212                | FileDigestResolution::Unresolved => None,
213            };
214            let digest = if let Some(digest) = cached {
215                identities.push(identity);
216                digest
217            } else {
218                let observed =
219                    digest_file_validated(FileDigestScope::Content, &path).map_err(|error| {
220                        BypassReason::InputRead {
221                            path: path.clone(),
222                            message: error.to_string(),
223                        }
224                    })?;
225                let (digest, observed_identity) = if let Some(observed) = observed {
226                    let digest = observed.resolution.into_digest().ok_or_else(|| {
227                        BypassReason::InputRead {
228                            path: path.clone(),
229                            message: "content digest resolution returned no digest".into(),
230                        }
231                    })?;
232                    if let Some(file) = observed.cache_identity.clone()
233                        && file.len == digest.size
234                    {
235                        fresh.push(RecordedFileDigest {
236                            file,
237                            digest: digest.clone(),
238                        });
239                    }
240                    (digest, observed.cache_identity)
241                } else {
242                    let digest = digest_file(FileDigestScope::Content, &path)
243                        .and_then(|resolution| {
244                            resolution.into_digest().ok_or_else(|| {
245                                std::io::Error::other(
246                                    "content digest resolution returned no digest",
247                                )
248                            })
249                        })
250                        .map_err(|error| BypassReason::InputRead {
251                            path: path.clone(),
252                            message: error.to_string(),
253                        })?;
254                    (digest, None)
255                };
256                identities.push(observed_identity);
257                digest
258            };
259            inputs.push(ActionInput { path, digest });
260        }
261        if !fresh.is_empty() {
262            digests.record(FileDigestScope::Content, fresh);
263        }
264        Ok(Self {
265            working_dir,
266            inputs,
267            environment,
268            identities,
269        })
270    }
271
272    /// Reject inputs whose modification time overlaps the compiler invocation.
273    ///
274    /// Input contents are first hashed after rustc reports their paths. This
275    /// timestamp barrier prevents a post-compile write from being mistaken for
276    /// the contents that produced the artifact. `verify` closes the remaining
277    /// race after hashing.
278    pub fn verify_not_modified_since(&self, started_at: SystemTime) -> Result<(), BypassReason> {
279        self.verify_not_modified_since_with_snapshots(started_at, &BTreeMap::new())
280    }
281
282    /// Reject inputs that changed from snapshots captured before rustc ran,
283    /// falling back to the wall-clock barrier for inputs only dep-info named.
284    ///
285    /// A snapshot may use metadata or content depending on what its filesystem
286    /// can compare reliably. Metadata snapshots need a change token: without
287    /// one a same-length rewrite can restore its mtime.
288    pub fn verify_not_modified_since_with_snapshots(
289        &self,
290        started_at: SystemTime,
291        before: &BTreeMap<PathBuf, FileSnapshot>,
292    ) -> Result<(), BypassReason> {
293        for input in &self.inputs {
294            if let Some(previous) = before.get(&input.path)
295                && previous.proves_content_change()
296            {
297                let metadata =
298                    std::fs::metadata(&input.path).map_err(|error| BypassReason::InputRead {
299                        path: input.path.clone(),
300                        message: error.to_string(),
301                    })?;
302                let identity = FileIdentity::for_digest_cache(&input.path, &metadata)
303                    .map_err(|error| BypassReason::InputRead {
304                        path: input.path.clone(),
305                        message: error.to_string(),
306                    })?
307                    .or_else(|| FileIdentity::describe(&input.path, &metadata));
308                if previous.matches(identity.as_ref(), &input.digest) {
309                    continue;
310                }
311                return Err(BypassReason::InputModifiedDuringCompilation(
312                    input.path.clone(),
313                ));
314            }
315            let metadata =
316                std::fs::metadata(&input.path).map_err(|error| BypassReason::InputRead {
317                    path: input.path.clone(),
318                    message: error.to_string(),
319                })?;
320            let modified = metadata
321                .modified()
322                .map_err(|error| BypassReason::InputRead {
323                    path: input.path.clone(),
324                    message: error.to_string(),
325                })?;
326            if modified >= started_at {
327                return Err(BypassReason::InputModifiedDuringCompilation(
328                    input.path.clone(),
329                ));
330            }
331        }
332        Ok(())
333    }
334
335    /// Compatibility form for callers that captured metadata identities.
336    pub fn verify_not_modified_since_with_identities(
337        &self,
338        started_at: SystemTime,
339        before: &BTreeMap<PathBuf, FileIdentity>,
340    ) -> Result<(), BypassReason> {
341        let snapshots = before
342            .iter()
343            .map(|(path, identity)| (path.clone(), identity.clone().into()))
344            .collect();
345        self.verify_not_modified_since_with_snapshots(started_at, &snapshots)
346    }
347
348    /// Rehash every discovered file after compilation and before publication.
349    /// This closes the discovery/compile race by degrading changed inputs to a
350    /// cache miss rather than storing outputs beneath a stale action key.
351    pub fn verify(&self) -> Result<(), BypassReason> {
352        for (index, input) in self.inputs.iter().enumerate() {
353            let read_error = |error: std::io::Error| BypassReason::InputRead {
354                path: input.path.clone(),
355                message: error.to_string(),
356            };
357            // An input still wearing the identity discovery recorded -- length,
358            // modification time, and change time -- is confirmed by that stat
359            // alone. The change time is what makes this as good as reading: it
360            // cannot be set from user space, so a rewrite that puts the old
361            // modification time back still shows, where a platform that reports
362            // none would let a same-length rewrite inside one timestamp tick
363            // through. Such an identity is not trusted here, and neither is an
364            // input that had none. Reading everything again cost as much as
365            // keying the compilation did: a large binary's dependency rlibs, a
366            // gigabyte of them, read twice for every edit.
367            if let Some(Some(identity)) = self.identities.get(index)
368                && identity.can_skip_content_verification()
369                && identity.still_describes().map_err(read_error)?
370            {
371                continue;
372            }
373            let matches = input.digest.matches_file(&input.path).map_err(|error| {
374                BypassReason::InputRead {
375                    path: input.path.clone(),
376                    message: error.to_string(),
377                }
378            })?;
379            if !matches {
380                return Err(BypassReason::InputChanged(input.path.clone()));
381            }
382        }
383        Ok(())
384    }
385
386    /// Merge the manifest into an action context after verifying that both use
387    /// the same compiler working directory.
388    pub fn apply_to(self, context: &mut ActionContext) -> Result<(), BypassReason> {
389        if normalize_components(&context.working_dir) != self.working_dir {
390            return Err(BypassReason::DiscoveryWorkingDirectory);
391        }
392        for (name, value) in &self.environment {
393            if context
394                .environment
395                .get(name)
396                .is_some_and(|previous| previous != value)
397            {
398                return Err(BypassReason::ConflictingEnvironment(name.clone()));
399            }
400        }
401        context.environment.extend(self.environment);
402        context.inputs.extend(self.inputs);
403        Ok(())
404    }
405}
406
407impl RustcInvocation {
408    /// Replace the original output flags with a single explicit dep-info file.
409    pub fn dep_info_command(&self, output: &Path) -> Result<DepInfoCommand, BypassReason> {
410        if !output.is_absolute() {
411            return Err(BypassReason::RelativeDepInfoPath(output.to_path_buf()));
412        }
413        let output_text = output
414            .to_str()
415            .ok_or_else(|| BypassReason::NonUtf8Path(output.to_path_buf()))?;
416        if output_text.contains(',') {
417            return Err(BypassReason::UnsafeDepInfoPath(output.to_path_buf()));
418        }
419
420        let mut arguments = Vec::new();
421        for argument in &self.arguments {
422            match argument {
423                Argument::Emit(_) => {}
424                Argument::Path { flag, .. } if flag == "--out-dir" || flag == "-o" => {}
425                argument => arguments.push(render_argument(argument)?),
426            }
427        }
428        arguments.push(format!("--emit=dep-info={output_text}").into());
429        arguments.push(self.source.clone().into_os_string());
430        Ok(DepInfoCommand {
431            arguments,
432            output: output.to_path_buf(),
433        })
434    }
435
436    /// Hash dep-info sources plus every direct compiler input already modeled
437    /// by the invocation (`--extern` artifacts and custom target specs).
438    pub fn discover_inputs(
439        &self,
440        dep_info: &RustcDepInfo,
441        working_dir: &Path,
442    ) -> Result<DiscoveredInputs, BypassReason> {
443        self.discover_inputs_with_mappings(
444            dep_info,
445            working_dir,
446            &[],
447            &mbx_cache_core::NoFileDigestCache,
448        )
449    }
450
451    /// Hash dep-info sources plus modeled compiler inputs, allowing native
452    /// search directories beneath the working directory or a mapped root.
453    ///
454    /// `digests` may answer for files the session already read in full;
455    /// pass [`mbx_cache_core::NoFileDigestCache`] to hash everything.
456    pub fn discover_inputs_with_mappings(
457        &self,
458        dep_info: &RustcDepInfo,
459        working_dir: &Path,
460        path_mappings: &[PathMapping],
461        digests: &dyn FileDigestCache,
462    ) -> Result<DiscoveredInputs, BypassReason> {
463        if !working_dir.is_absolute() {
464            return Err(BypassReason::RelativeWorkingDirectory(
465                working_dir.to_path_buf(),
466            ));
467        }
468        let working_dir = normalize_components(working_dir);
469        let mut paths = dep_info
470            .files
471            .iter()
472            .chain(&self.required_inputs)
473            .map(|path| {
474                let absolute = if path.is_absolute() {
475                    path.to_path_buf()
476                } else {
477                    working_dir.join(path)
478                };
479                normalize_components(&absolute)
480            })
481            .collect::<BTreeSet<_>>();
482        let admitted_roots = native_input_roots(&working_dir, path_mappings);
483        let mut native_bytes = 0_u64;
484        for argument in &self.arguments {
485            if let Argument::SearchPath { kind, path } = argument
486                && kind == "native"
487            {
488                let directory = if path.is_absolute() {
489                    path.clone()
490                } else {
491                    working_dir.join(path)
492                };
493                collect_native_directory(
494                    &directory,
495                    &admitted_roots,
496                    &mut paths,
497                    &mut native_bytes,
498                )?;
499            }
500        }
501        DiscoveredInputs::from_paths(&working_dir, paths, dep_info.environment.clone(), digests)
502    }
503}
504
505/// Return normalized roots whose native search directories can be tracked.
506pub(super) fn native_input_roots(
507    working_dir: &Path,
508    path_mappings: &[PathMapping],
509) -> Vec<PathBuf> {
510    std::iter::once(working_dir)
511        .chain(path_mappings.iter().map(|mapping| mapping.root.as_path()))
512        .map(normalize_components)
513        .collect()
514}
515
516/// Add regular files beneath an admitted native search directory, enforcing
517/// the prediction input count and the caller's cumulative native byte budget.
518pub(super) fn collect_native_directory(
519    directory: &Path,
520    admitted_roots: &[PathBuf],
521    paths: &mut BTreeSet<PathBuf>,
522    native_bytes: &mut u64,
523) -> Result<(), BypassReason> {
524    let directory = normalize_components(directory);
525    if !admitted_roots
526        .iter()
527        .any(|root| directory.starts_with(root))
528    {
529        return Err(BypassReason::UnsupportedSearchPath("native".into()));
530    }
531
532    let canonical_root = match directory.canonicalize() {
533        Ok(root) => root,
534        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
535        Err(error) => {
536            return Err(BypassReason::InputRead {
537                path: directory,
538                message: error.to_string(),
539            });
540        }
541    };
542    let mut pending = vec![directory];
543    while let Some(directory) = pending.pop() {
544        let entries = match std::fs::read_dir(&directory) {
545            Ok(entries) => entries,
546            Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
547            Err(error) => {
548                return Err(BypassReason::InputRead {
549                    path: directory,
550                    message: error.to_string(),
551                });
552            }
553        };
554        for entry in entries {
555            let entry = entry.map_err(|error| BypassReason::InputRead {
556                path: directory.clone(),
557                message: error.to_string(),
558            })?;
559            let path = entry.path();
560            let file_type = entry.file_type().map_err(|error| BypassReason::InputRead {
561                path: path.clone(),
562                message: error.to_string(),
563            })?;
564            if file_type.is_dir() {
565                pending.push(path);
566            } else if file_type.is_file() || file_type.is_symlink() {
567                // Homebrew's versionless dylibs are symlinks. Hash their
568                // referents under the searched name, but do not follow directory
569                // links, dangling links, or links outside this search tree.
570                if file_type.is_symlink() {
571                    let resolved =
572                        path.canonicalize()
573                            .map_err(|error| BypassReason::InputRead {
574                                path: path.clone(),
575                                message: error.to_string(),
576                            })?;
577                    if !resolved.starts_with(&canonical_root) || !resolved.is_file() {
578                        return Err(BypassReason::UnsupportedSearchPath("native".into()));
579                    }
580                }
581                *native_bytes = native_bytes
582                    .checked_add(
583                        std::fs::metadata(&path)
584                            .map_err(|error| BypassReason::InputRead {
585                                path: path.clone(),
586                                message: error.to_string(),
587                            })?
588                            .len(),
589                    )
590                    .ok_or_else(|| BypassReason::UnsupportedSearchPath("native".into()))?;
591                paths.insert(path);
592            } else {
593                return Err(BypassReason::UnsupportedSearchPath("native".into()));
594            }
595            if paths.len() > MAX_PREDICTED_INPUTS || *native_bytes > MAX_NATIVE_INPUT_BYTES {
596                return Err(BypassReason::UnsupportedSearchPath("native".into()));
597            }
598        }
599    }
600    Ok(())
601}
602
603fn render_argument(argument: &Argument) -> Result<OsString, BypassReason> {
604    let rendered = match argument {
605        Argument::Plain(value) => value.clone(),
606        Argument::Path { flag, path } => format!(
607            "{flag}={}",
608            path.to_str()
609                .ok_or_else(|| BypassReason::NonUtf8Path(path.clone()))?
610        ),
611        Argument::SearchPath { kind, path } => format!(
612            "-L{kind}={}",
613            path.to_str()
614                .ok_or_else(|| BypassReason::NonUtf8Path(path.clone()))?
615        ),
616        Argument::Extern { name, path } => match path {
617            Some(path) => format!(
618                "--extern={name}={}",
619                path.to_str()
620                    .ok_or_else(|| BypassReason::NonUtf8Path(path.clone()))?
621            ),
622            None => format!("--extern={name}"),
623        },
624        Argument::Emit(_) => unreachable!("emit arguments are removed before rendering"),
625        Argument::RemapPath { from, to } => format!(
626            "--remap-path-prefix={}={to}",
627            from.to_str()
628                .ok_or_else(|| BypassReason::NonUtf8Path(from.clone()))?
629        ),
630        // Nothing links while emitting dep-info, so the prefix is inert here;
631        // it is replayed verbatim to keep the command faithful.
632        Argument::OsoPrefix {
633            path,
634            trailing_slash,
635        } => format!(
636            "--codegen=link-arg=-Wl,-oso_prefix,{}{}",
637            path.to_str()
638                .ok_or_else(|| BypassReason::NonUtf8Path(path.clone()))?,
639            if *trailing_slash { "/" } else { "" }
640        ),
641        // Nothing links while emitting dep-info either; replayed verbatim for
642        // the same reason as the prefix above.
643        Argument::InstallName(name) => {
644            format!("--codegen=link-arg=-Wl,-install_name,@rpath/{name}")
645        }
646        Argument::FuseLd(selection) => format!("--codegen=link-arg=-fuse-ld={selection}"),
647    };
648    Ok(rendered.into())
649}
650
651fn unescape_environment(value: &str) -> Result<String, BypassReason> {
652    let mut output = String::with_capacity(value.len());
653    let mut characters = value.chars();
654    while let Some(character) = characters.next() {
655        if character != '\\' {
656            output.push(character);
657            continue;
658        }
659        match characters.next() {
660            Some('\\') => output.push('\\'),
661            Some('n') => output.push('\n'),
662            Some('r') => output.push('\r'),
663            Some(character) => {
664                return Err(BypassReason::MalformedDepInfo(format!(
665                    "unknown environment escape \\{character}"
666                )));
667            }
668            None => {
669                return Err(BypassReason::MalformedDepInfo(
670                    "environment input ends with an unterminated escape".into(),
671                ));
672            }
673        }
674    }
675    Ok(output)
676}
677
678#[cfg(test)]
679mod tests {
680    use super::*;
681    use std::process::Command;
682
683    fn args(values: &[&str]) -> Vec<OsString> {
684        values.iter().map(OsString::from).collect()
685    }
686
687    #[test]
688    fn parses_files_spaces_and_environment_records() {
689        let parsed = RustcDepInfo::parse(
690            "target/lib.rlib: src/lib.rs src/a\\ file.rs generated.rs\n\
691             src/lib.rs:\n\
692             # env-dep:SET=value\\nnext\n\
693             # env-dep:UNSET\n\
694             # env-dep:SLASH=a\\\\b\n",
695        )
696        .unwrap();
697        assert_eq!(
698            parsed.files,
699            vec![
700                PathBuf::from("generated.rs"),
701                PathBuf::from("src/a file.rs"),
702                PathBuf::from("src/lib.rs"),
703            ]
704        );
705        assert_eq!(parsed.environment["SET"], Some("value\nnext".into()));
706        assert_eq!(parsed.environment["UNSET"], None);
707        assert_eq!(parsed.environment["SLASH"], Some(r"a\b".into()));
708    }
709
710    #[test]
711    fn malformed_dep_info_bypasses_caching() {
712        for contents in [
713            "",
714            "target: ",
715            "target: src/trailing\\\n",
716            "target: src/lib.rs\n# env-dep:NAME=bad\\q\n",
717        ] {
718            assert!(RustcDepInfo::parse(contents).is_err(), "{contents:?}");
719        }
720    }
721
722    #[test]
723    fn native_directory_byte_limit_is_cumulative() {
724        let directory = tempfile::tempdir().unwrap();
725        let native = directory.path().join("native");
726        std::fs::create_dir_all(&native).unwrap();
727        std::fs::write(native.join("input.lib"), b"xx").unwrap();
728        let roots = native_input_roots(directory.path(), &[]);
729        let mut paths = BTreeSet::new();
730        let mut native_bytes = MAX_NATIVE_INPUT_BYTES - 1;
731
732        assert_eq!(
733            collect_native_directory(&native, &roots, &mut paths, &mut native_bytes),
734            Err(BypassReason::UnsupportedSearchPath("native".into()))
735        );
736    }
737
738    #[test]
739    fn discovery_command_removes_real_outputs() {
740        let invocation = RustcInvocation::parse(&args(&[
741            "--crate-name=widget",
742            "--crate-type=lib",
743            "--emit=dep-info,metadata,link",
744            "--out-dir=target/debug/deps",
745            "-o",
746            "target/debug/libwidget.rlib",
747            "src/lib.rs",
748        ]))
749        .unwrap();
750        let output = if cfg!(windows) {
751            PathBuf::from(r"C:\tmp\mbx cache\inputs.d")
752        } else {
753            PathBuf::from("/tmp/mbx cache/inputs.d")
754        };
755        let command = invocation.dep_info_command(&output).unwrap();
756        let arguments = command
757            .arguments()
758            .iter()
759            .map(|value| value.to_string_lossy())
760            .collect::<Vec<_>>();
761        assert_eq!(
762            arguments,
763            vec![
764                "--crate-name=widget",
765                "--crate-type=lib",
766                &format!("--emit=dep-info={}", output.display()),
767                "src/lib.rs",
768            ]
769        );
770    }
771
772    #[test]
773    fn discovery_hashes_externs_and_custom_targets() {
774        let directory = tempfile::tempdir().unwrap();
775        let root = directory.path();
776        let source = root.join("src/lib.rs");
777        let external = root.join("target/libdependency.rlib");
778        let target = root.join("targets/custom.json");
779        std::fs::create_dir_all(source.parent().unwrap()).unwrap();
780        std::fs::create_dir_all(external.parent().unwrap()).unwrap();
781        std::fs::create_dir_all(target.parent().unwrap()).unwrap();
782        std::fs::write(&source, "pub fn library() {}\n").unwrap();
783        std::fs::write(&external, "dependency artifact\n").unwrap();
784        std::fs::write(&target, "{}\n").unwrap();
785
786        let invocation = RustcInvocation::parse(&[
787            "--crate-name=widget".into(),
788            "--crate-type=lib".into(),
789            "--emit=metadata".into(),
790            format!("--extern=dependency={}", external.display()).into(),
791            format!("--target={}", target.display()).into(),
792            source.clone().into_os_string(),
793        ])
794        .unwrap();
795        let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
796        let discovered = invocation.discover_inputs(&dep_info, root).unwrap();
797        assert_eq!(discovered.inputs.len(), 3);
798
799        std::fs::remove_file(&external).unwrap();
800        assert!(matches!(
801            invocation.discover_inputs(&dep_info, root),
802            Err(BypassReason::InputRead { path, .. }) if path == external
803        ));
804    }
805
806    #[test]
807    fn discovery_rejects_inputs_modified_during_compilation() {
808        let directory = tempfile::tempdir().unwrap();
809        let source = directory.path().join("lib.rs");
810        std::fs::write(&source, "pub fn library() {}\n").unwrap();
811        let invocation = RustcInvocation::parse(&[
812            "--crate-name=widget".into(),
813            "--crate-type=lib".into(),
814            "--emit=dep-info,metadata".into(),
815            source.clone().into_os_string(),
816        ])
817        .unwrap();
818        let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
819        let discovered = invocation
820            .discover_inputs(&dep_info, directory.path())
821            .unwrap();
822        let modified = std::fs::metadata(&source).unwrap().modified().unwrap();
823
824        assert_eq!(
825            discovered.verify_not_modified_since(modified),
826            Err(BypassReason::InputModifiedDuringCompilation(source))
827        );
828    }
829
830    #[cfg(unix)]
831    #[test]
832    fn precompile_snapshot_does_not_depend_on_the_host_clock() {
833        let directory = tempfile::tempdir().unwrap();
834        let source = directory.path().join("lib.rs");
835        std::fs::write(&source, "pub fn library() {0}\n").unwrap();
836        let invocation = RustcInvocation::parse(&[
837            "--crate-name=widget".into(),
838            "--crate-type=lib".into(),
839            "--emit=dep-info,metadata".into(),
840            source.clone().into_os_string(),
841        ])
842        .unwrap();
843        let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
844        let discovered = invocation
845            .discover_inputs(&dep_info, directory.path())
846            .unwrap();
847        let snapshot = FileSnapshot::capture(&source).unwrap().unwrap();
848        let before = BTreeMap::from([(source.clone(), snapshot)]);
849
850        // Every ordinary mtime is after the epoch. The unchanged identity is
851        // nevertheless enough proof when the filesystem clock is far ahead.
852        discovered
853            .verify_not_modified_since_with_snapshots(SystemTime::UNIX_EPOCH, &before)
854            .unwrap();
855
856        // Conversely, a filesystem clock far behind must not conceal a write.
857        std::fs::write(&source, "pub fn library() {1}\n").unwrap();
858        assert_eq!(
859            discovered.verify_not_modified_since_with_snapshots(
860                SystemTime::now() + std::time::Duration::from_secs(60),
861                &before,
862            ),
863            Err(BypassReason::InputModifiedDuringCompilation(source))
864        );
865    }
866
867    /// An external MSVC toolset directory, outside every mapped root.
868    fn toolchain_native_directory(version: &str) -> PathBuf {
869        if cfg!(windows) {
870            PathBuf::from(format!(
871                r"C:\Program Files\Microsoft Visual Studio\2022\BuildTools\VC\Tools\MSVC\{version}\lib\x64"
872            ))
873        } else {
874            PathBuf::from(format!("/opt/msvc/{version}/lib/x64"))
875        }
876    }
877
878    fn library_with_native_search(source: &Path, directory: &Path) -> RustcInvocation {
879        RustcInvocation::parse(&[
880            "--crate-name=widget".into(),
881            "--crate-type=lib".into(),
882            "--emit=metadata,link".into(),
883            format!("-Lnative={}", directory.display()).into(),
884            source.to_path_buf().into_os_string(),
885        ])
886        .unwrap()
887    }
888
889    fn library_context(root: &Path, mappings: Vec<PathMapping>) -> ActionContext {
890        ActionContext {
891            compiler: crate::CompilerIdentity {
892                toolchain: "core:rust@test".into(),
893                rustc_version: "test".into(),
894                host: std::env::consts::ARCH.into(),
895                driver: None,
896            },
897            working_dir: root.to_path_buf(),
898            path_mappings: mappings,
899            environment: BTreeMap::new(),
900            portable_environment: BTreeSet::new(),
901            inputs: Vec::new(),
902        }
903    }
904
905    #[cfg(windows)]
906    #[test]
907    fn unmapped_toolchain_directory_cannot_hide_a_static_archive() {
908        let directory = tempfile::tempdir().unwrap();
909        let root = directory.path();
910        let source = root.join("lib.rs");
911        std::fs::write(&source, "pub fn library() {}\n").unwrap();
912        let toolchain = toolchain_native_directory("14.51.36231");
913        let invocation = library_with_native_search(&source, &toolchain);
914        let mappings = vec![PathMapping::new(root, "workspace")];
915        let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
916
917        let context = library_context(root, mappings.clone());
918        assert!(matches!(
919            invocation.invocation_digest(&context),
920            Err(BypassReason::UnmappedAbsolutePath(path)) if path == toolchain
921        ));
922        assert_eq!(
923            invocation.discover_inputs_with_mappings(
924                &dep_info,
925                root,
926                &mappings,
927                &mbx_cache_core::NoFileDigestCache,
928            ),
929            Err(BypassReason::UnsupportedSearchPath("native".into()))
930        );
931    }
932
933    #[test]
934    fn source_level_static_link_cannot_hide_an_unmapped_archive() {
935        let directory = tempfile::tempdir().unwrap();
936        let workspace = directory.path().join("workspace");
937        let external = directory.path().join("external");
938        std::fs::create_dir(&workspace).unwrap();
939        std::fs::create_dir(&external).unwrap();
940        let source = workspace.join("lib.rs");
941        std::fs::write(
942            &source,
943            "#[link(name = \"foo\", kind = \"static\")] unsafe extern \"C\" {}\n",
944        )
945        .unwrap();
946        std::fs::write(external.join("libfoo.a"), b"first archive").unwrap();
947        let invocation = library_with_native_search(&source, &external);
948        let mappings = vec![PathMapping::new(&workspace, "workspace")];
949        let context = library_context(&workspace, mappings.clone());
950        let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
951
952        // rustc's dep-info names the source but not an archive from #[link].
953        // A path-only key would restore a stale rlib after libfoo.a changed.
954        assert!(matches!(
955            invocation.invocation_digest(&context),
956            Err(BypassReason::UnmappedAbsolutePath(path)) if path == external
957        ));
958        assert_eq!(
959            invocation.discover_inputs_with_mappings(
960                &dep_info,
961                &workspace,
962                &mappings,
963                &mbx_cache_core::NoFileDigestCache,
964            ),
965            Err(BypassReason::UnsupportedSearchPath("native".into()))
966        );
967    }
968
969    #[cfg(windows)]
970    #[test]
971    fn project_directory_with_toolchain_suffix_is_not_trusted() {
972        let directory = tempfile::tempdir().unwrap();
973        let root = directory.path().join("workspace");
974        std::fs::create_dir(&root).unwrap();
975        let source = root.join("lib.rs");
976        std::fs::write(&source, "pub fn library() {}\n").unwrap();
977        let fake_toolchain = directory.path().join("MSVC/14.51.36231/lib/x64");
978        let invocation = library_with_native_search(&source, &fake_toolchain);
979        let context = library_context(&root, vec![PathMapping::new(&root, "workspace")]);
980
981        // The apparent toolset version cannot make a mutable project archive inert.
982        assert!(matches!(
983            invocation.invocation_digest(&context),
984            Err(BypassReason::UnmappedAbsolutePath(path)) if path == fake_toolchain
985        ));
986    }
987
988    #[test]
989    fn unmapped_native_directory_still_refuses_a_native_link() {
990        let directory = tempfile::tempdir().unwrap();
991        let root = directory.path();
992        let source = root.join("main.rs");
993        std::fs::write(&source, "fn main() {}\n").unwrap();
994        let toolchain = toolchain_native_directory("14.51.36231");
995        let invocation = RustcInvocation::parse_with(
996            &[
997                "--crate-name=app".into(),
998                "--crate-type=bin".into(),
999                "--emit=link".into(),
1000                format!("-Lnative={}", toolchain.display()).into(),
1001                source.clone().into_os_string(),
1002            ],
1003            crate::ParseOptions::caching_native_links(true),
1004        )
1005        .unwrap();
1006        let mappings = vec![PathMapping::new(root, "workspace")];
1007
1008        // A linker reads those directories, so their contents stay inputs the
1009        // key must account for, and an unmapped one stays a bypass.
1010        let context = library_context(root, mappings.clone());
1011        assert!(matches!(
1012            invocation.invocation_digest(&context),
1013            Err(BypassReason::UnmappedAbsolutePath(_))
1014        ));
1015        let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
1016        assert_eq!(
1017            invocation.discover_inputs_with_mappings(
1018                &dep_info,
1019                root,
1020                &mappings,
1021                &mbx_cache_core::NoFileDigestCache
1022            ),
1023            Err(BypassReason::UnsupportedSearchPath("native".into()))
1024        );
1025    }
1026
1027    #[test]
1028    fn discovery_resolves_parent_components_against_the_working_directory() {
1029        let directory = tempfile::tempdir().unwrap();
1030        let root = directory.path().join("project");
1031        let shared = directory.path().join("shared.rs");
1032        std::fs::create_dir(&root).unwrap();
1033        std::fs::write(&shared, "pub fn shared() {}\n").unwrap();
1034
1035        let invocation = RustcInvocation::parse(&args(&[
1036            "--crate-name=widget",
1037            "--crate-type=lib",
1038            "--emit=metadata",
1039            "../shared.rs",
1040        ]))
1041        .unwrap();
1042        let dep_info = RustcDepInfo::parse("output: ../shared.rs\n").unwrap();
1043        let discovered = invocation.discover_inputs(&dep_info, &root).unwrap();
1044
1045        assert_eq!(discovered.inputs.len(), 1);
1046        assert_eq!(discovered.inputs[0].path, shared);
1047    }
1048
1049    #[test]
1050    fn rustc_dep_info_round_trip_discovers_real_inputs() {
1051        let directory = tempfile::tempdir().unwrap();
1052        let root = directory.path();
1053        std::fs::write(
1054            root.join("lib.rs"),
1055            "mod child; const _: &str = include_str!(\"data file.txt\"); \
1056             const _: &str = env!(\"MBX_DISCOVERY_TEST\"); \
1057             const _: Option<&str> = option_env!(\"MBX_DISCOVERY_UNSET\");",
1058        )
1059        .unwrap();
1060        std::fs::write(root.join("child.rs"), "pub fn child() {}\n").unwrap();
1061        std::fs::write(root.join("data file.txt"), "included\n").unwrap();
1062
1063        let invocation = RustcInvocation::parse(&args(&[
1064            "--crate-name=mbx_cache_discovery_test",
1065            "--crate-type=lib",
1066            "--emit=metadata,link",
1067            "lib.rs",
1068        ]))
1069        .unwrap();
1070        let dep_info_path = root.join("discovery inputs.d");
1071        let discovery_command = invocation.dep_info_command(&dep_info_path).unwrap();
1072        let rustc = std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into());
1073        let output = match Command::new(rustc)
1074            .args(discovery_command.arguments())
1075            .current_dir(root)
1076            .env("MBX_DISCOVERY_TEST", "observed")
1077            .env_remove("MBX_DISCOVERY_UNSET")
1078            .output()
1079        {
1080            Ok(output) => output,
1081            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return,
1082            Err(error) => panic!("failed to execute rustc: {error}"),
1083        };
1084        assert!(
1085            output.status.success(),
1086            "{}",
1087            String::from_utf8_lossy(&output.stderr)
1088        );
1089        let parsed = RustcDepInfo::read(&dep_info_path).unwrap();
1090        let discovered = invocation.discover_inputs(&parsed, root).unwrap();
1091        assert_eq!(
1092            discovered.environment["MBX_DISCOVERY_TEST"],
1093            Some("observed".into())
1094        );
1095        assert_eq!(discovered.environment["MBX_DISCOVERY_UNSET"], None);
1096        assert_eq!(discovered.inputs.len(), 3);
1097        assert!(
1098            discovered
1099                .inputs
1100                .iter()
1101                .all(|input| input.digest.algorithm == "blake3")
1102        );
1103        let mut context = ActionContext {
1104            compiler: crate::CompilerIdentity {
1105                toolchain: "core:rust@test".into(),
1106                rustc_version: "test".into(),
1107                host: std::env::consts::ARCH.into(),
1108                driver: None,
1109            },
1110            working_dir: root.to_path_buf(),
1111            path_mappings: vec![crate::PathMapping::new(root, "workspace")],
1112            environment: BTreeMap::new(),
1113            portable_environment: BTreeSet::new(),
1114            inputs: Vec::new(),
1115        };
1116        discovered.clone().apply_to(&mut context).unwrap();
1117        let action = invocation.action(context).unwrap();
1118        assert!(
1119            String::from_utf8(action.bytes)
1120                .unwrap()
1121                .contains(r#""MBX_DISCOVERY_TEST":"observed""#)
1122        );
1123        discovered.verify().unwrap();
1124        std::fs::write(root.join("child.rs"), "pub fn changed() {}\n").unwrap();
1125        assert_eq!(
1126            discovered.verify(),
1127            Err(BypassReason::InputChanged(root.join("child.rs")))
1128        );
1129    }
1130
1131    /// A ledger that answers with a sentinel and remembers what was recorded.
1132    struct SentinelLedger {
1133        known: FileIdentity,
1134        digest: CacheDigest,
1135        recorded: std::sync::Mutex<Vec<RecordedFileDigest>>,
1136    }
1137
1138    impl FileDigestCache for SentinelLedger {
1139        fn find(&self, scope: FileDigestScope, files: &[FileIdentity]) -> Vec<Option<CacheDigest>> {
1140            assert_eq!(scope, FileDigestScope::Content);
1141            files
1142                .iter()
1143                .map(|file| (*file == self.known).then(|| self.digest.clone()))
1144                .collect()
1145        }
1146
1147        fn record(&self, scope: FileDigestScope, entries: Vec<RecordedFileDigest>) {
1148            assert_eq!(scope, FileDigestScope::Content);
1149            self.recorded.lock().unwrap().extend(entries);
1150        }
1151    }
1152
1153    #[cfg(unix)]
1154    struct ReplacingLedger {
1155        path: PathBuf,
1156        replacement: PathBuf,
1157        recorded: std::sync::Mutex<Vec<RecordedFileDigest>>,
1158    }
1159
1160    #[cfg(unix)]
1161    impl FileDigestCache for ReplacingLedger {
1162        fn find(&self, scope: FileDigestScope, files: &[FileIdentity]) -> Vec<Option<CacheDigest>> {
1163            assert_eq!(scope, FileDigestScope::Content);
1164            assert_eq!(files.len(), 1);
1165            std::fs::remove_file(&self.path).unwrap();
1166            std::fs::rename(&self.replacement, &self.path).unwrap();
1167            vec![None]
1168        }
1169
1170        fn record(&self, scope: FileDigestScope, entries: Vec<RecordedFileDigest>) {
1171            assert_eq!(scope, FileDigestScope::Content);
1172            self.recorded.lock().unwrap().extend(entries);
1173        }
1174    }
1175
1176    #[cfg(unix)]
1177    #[test]
1178    fn discovery_binds_a_replacement_digest_to_the_replacement_identity() {
1179        let directory = tempfile::tempdir().unwrap();
1180        let root = directory.path();
1181        let path = root.join("libdep.rlib");
1182        let replacement = root.join("replacement.rlib");
1183        std::fs::write(&path, b"original....").unwrap();
1184        std::fs::File::options()
1185            .write(true)
1186            .open(&path)
1187            .unwrap()
1188            .set_times(std::fs::FileTimes::new().set_modified(SystemTime::UNIX_EPOCH))
1189            .unwrap();
1190        std::fs::write(&replacement, b"replacement!").unwrap();
1191        std::fs::File::options()
1192            .write(true)
1193            .open(&replacement)
1194            .unwrap()
1195            .set_times(std::fs::FileTimes::new().set_modified(SystemTime::UNIX_EPOCH))
1196            .unwrap();
1197        let ledger = ReplacingLedger {
1198            path: path.clone(),
1199            replacement,
1200            recorded: std::sync::Mutex::new(Vec::new()),
1201        };
1202
1203        let discovered = DiscoveredInputs::from_paths(
1204            root,
1205            BTreeSet::from([path.clone()]),
1206            BTreeMap::new(),
1207            &ledger,
1208        )
1209        .unwrap();
1210        let current = FileIdentity::for_digest_cache(&path, &std::fs::metadata(&path).unwrap())
1211            .unwrap()
1212            .unwrap();
1213        let digest = CacheDigest::blake3_file(&path).unwrap();
1214
1215        assert_eq!(discovered.inputs[0].digest, digest);
1216        assert_eq!(discovered.identities[0].as_ref(), Some(&current));
1217        assert_eq!(
1218            ledger.recorded.lock().unwrap().as_slice(),
1219            &[RecordedFileDigest {
1220                file: current,
1221                digest,
1222            }]
1223        );
1224        discovered.verify().unwrap();
1225    }
1226
1227    #[test]
1228    fn discovery_reuses_recorded_digests_and_records_fresh_ones() {
1229        let directory = tempfile::tempdir().unwrap();
1230        let root = directory.path();
1231        let known_path = root.join("libdep.rlib");
1232        let fresh_path = root.join("lib.rs");
1233        std::fs::write(&known_path, b"rlib bytes").unwrap();
1234        std::fs::write(&fresh_path, b"fn lib() {}").unwrap();
1235        let metadata = std::fs::metadata(&known_path).unwrap();
1236        // A sentinel digest that hashing could never produce proves the read
1237        // was skipped: the discovered input carries it verbatim.
1238        let sentinel = CacheDigest {
1239            algorithm: "blake3".into(),
1240            hash: "c".repeat(64),
1241            size: metadata.len(),
1242        };
1243        let ledger = SentinelLedger {
1244            known: FileIdentity::describe(&known_path, &metadata).unwrap(),
1245            digest: sentinel.clone(),
1246            recorded: std::sync::Mutex::new(Vec::new()),
1247        };
1248
1249        let discovered = DiscoveredInputs::from_paths(
1250            root,
1251            BTreeSet::from([known_path.clone(), fresh_path.clone()]),
1252            BTreeMap::new(),
1253            &ledger,
1254        )
1255        .unwrap();
1256
1257        let by_path = |path: &Path| {
1258            discovered
1259                .inputs
1260                .iter()
1261                .find(|input| input.path == path)
1262                .unwrap()
1263                .digest
1264                .clone()
1265        };
1266        assert_eq!(
1267            by_path(&known_path),
1268            sentinel,
1269            "the recorded digest answers"
1270        );
1271        assert_eq!(
1272            by_path(&fresh_path),
1273            CacheDigest::blake3_file(&fresh_path).unwrap(),
1274            "an unrecorded file is hashed"
1275        );
1276        let recorded = ledger.recorded.lock().unwrap();
1277        assert_eq!(recorded.len(), 1, "only the fresh hash is recorded");
1278        assert_eq!(recorded[0].file.path, fresh_path);
1279        assert_eq!(recorded[0].digest, by_path(&fresh_path));
1280    }
1281
1282    /// Verification after the compiler runs confirms an input by its recorded
1283    /// identity rather than by reading it again; a file that has moved on is
1284    /// still read and still fails. Only where the identity carries a change
1285    /// time, which is what makes the stat as good as the read.
1286    #[cfg(unix)]
1287    #[test]
1288    fn verification_trusts_an_unchanged_identity_and_rereads_a_changed_one() {
1289        let directory = tempfile::tempdir().unwrap();
1290        let root = directory.path();
1291        let known_path = root.join("libdep.rlib");
1292        std::fs::write(&known_path, b"rlib bytes").unwrap();
1293        let metadata = std::fs::metadata(&known_path).unwrap();
1294        // The sentinel could never come from hashing the file, so a verify
1295        // that passes can only have trusted the identity.
1296        let sentinel = CacheDigest {
1297            algorithm: "blake3".into(),
1298            hash: "c".repeat(64),
1299            size: metadata.len(),
1300        };
1301        let ledger = SentinelLedger {
1302            known: FileIdentity::describe(&known_path, &metadata).unwrap(),
1303            digest: sentinel,
1304            recorded: std::sync::Mutex::new(Vec::new()),
1305        };
1306        let discovered = DiscoveredInputs::from_paths(
1307            root,
1308            BTreeSet::from([known_path.clone()]),
1309            BTreeMap::new(),
1310            &ledger,
1311        )
1312        .unwrap();
1313
1314        discovered.verify().unwrap();
1315
1316        // Rewritten with the same length: the identity moves with the write,
1317        // so the file is read again and the sentinel no longer matches it.
1318        std::thread::sleep(std::time::Duration::from_millis(20));
1319        std::fs::write(&known_path, b"RLIB BYTES").unwrap();
1320        assert_eq!(
1321            discovered.verify(),
1322            Err(BypassReason::InputChanged(known_path.clone()))
1323        );
1324
1325        std::fs::remove_file(&known_path).unwrap();
1326        assert!(matches!(
1327            discovered.verify(),
1328            Err(BypassReason::InputRead { path, .. }) if path == known_path
1329        ));
1330    }
1331
1332    /// A rewrite that restores length and modification time must still be
1333    /// hashed: the platform change token moves with every write, so the
1334    /// identity the ledger recorded no longer describes the file.
1335    #[cfg(unix)]
1336    #[test]
1337    fn a_disguised_rewrite_is_not_answered_from_the_ledger() {
1338        let directory = tempfile::tempdir().unwrap();
1339        let root = directory.path();
1340        let path = root.join("lib.rs");
1341        std::fs::write(&path, b"fn lib() -> u8 { 1 }").unwrap();
1342        let before = std::fs::metadata(&path).unwrap();
1343        let identity = FileIdentity::describe(&path, &before).unwrap();
1344
1345        // Same length, modification time put back: only the change token can
1346        // tell this file has new contents.
1347        std::fs::write(&path, b"fn lib() -> u8 { 2 }").unwrap();
1348        let file = std::fs::File::options().write(true).open(&path).unwrap();
1349        file.set_times(std::fs::FileTimes::new().set_modified(before.modified().unwrap()))
1350            .unwrap();
1351        drop(file);
1352        let after = std::fs::metadata(&path).unwrap();
1353        let disguised = FileIdentity::describe(&path, &after).unwrap();
1354        assert_eq!(disguised.len, identity.len);
1355        assert_eq!(disguised.modified, identity.modified);
1356        assert_ne!(
1357            disguised, identity,
1358            "the change token must expose the rewrite"
1359        );
1360
1361        let ledger = SentinelLedger {
1362            known: identity,
1363            digest: CacheDigest {
1364                algorithm: "blake3".into(),
1365                hash: "e".repeat(64),
1366                size: after.len(),
1367            },
1368            recorded: std::sync::Mutex::new(Vec::new()),
1369        };
1370        let discovered = DiscoveredInputs::from_paths(
1371            root,
1372            BTreeSet::from([path.clone()]),
1373            BTreeMap::new(),
1374            &ledger,
1375        )
1376        .unwrap();
1377        assert_eq!(
1378            discovered.inputs[0].digest,
1379            CacheDigest::blake3_file(&path).unwrap(),
1380            "the disguised rewrite is hashed, not answered from the ledger"
1381        );
1382    }
1383    #[cfg(unix)]
1384    #[test]
1385    fn native_file_symlinks_are_rehashed_on_prediction() {
1386        use std::os::unix::fs::symlink;
1387        let directory = tempfile::tempdir().unwrap();
1388        let workspace = directory.path().join("workspace");
1389        let native = directory.path().join("cellar/lib");
1390        std::fs::create_dir_all(&workspace).unwrap();
1391        std::fs::create_dir_all(&native).unwrap();
1392        let source = workspace.join("lib.rs");
1393        std::fs::write(&source, "pub fn value() {}\n").unwrap();
1394        std::fs::write(native.join("libssl.3.dylib"), "first").unwrap();
1395        symlink("libssl.3.dylib", native.join("libssl.dylib")).unwrap();
1396        let opt = directory.path().join("opt");
1397        symlink(&native, &opt).unwrap();
1398        let invocation = library_with_native_search(&source, &opt);
1399        let mappings = vec![
1400            PathMapping::new(&workspace, "workspace"),
1401            PathMapping::new(&opt, "native"),
1402        ];
1403        let dep_info = RustcDepInfo {
1404            files: vec![source],
1405            environment: BTreeMap::new(),
1406        };
1407        let discover = || {
1408            invocation
1409                .discover_inputs_with_mappings(
1410                    &dep_info,
1411                    &workspace,
1412                    &mappings,
1413                    &mbx_cache_core::NoFileDigestCache,
1414                )
1415                .unwrap()
1416        };
1417        let original = discover();
1418        let mut context = library_context(&workspace, mappings.clone());
1419        original.clone().apply_to(&mut context).unwrap();
1420        let initial = invocation.action(context.clone()).unwrap();
1421        let prediction = invocation.prediction(&context, &original).unwrap();
1422        context.inputs.clear();
1423        let mut previous = initial.digest;
1424        for change in ["contents", "add", "retarget", "remove"] {
1425            match change {
1426                "contents" => std::fs::write(native.join("libssl.3.dylib"), "updated").unwrap(),
1427                "add" => std::fs::write(native.join("libssl.4.dylib"), "replacement").unwrap(),
1428                "retarget" => {
1429                    std::fs::remove_file(native.join("libssl.dylib")).unwrap();
1430                    symlink("libssl.4.dylib", native.join("libssl.dylib")).unwrap();
1431                }
1432                _ => std::fs::remove_file(native.join("libssl.3.dylib")).unwrap(),
1433            }
1434            let predicted = prediction
1435                .discover(&workspace, &mappings, &mbx_cache_core::NoFileDigestCache)
1436                .unwrap();
1437            let mut predicted_context = context.clone();
1438            predicted.apply_to(&mut predicted_context).unwrap();
1439            let predicted_action = invocation.action(predicted_context).unwrap();
1440            let mut discovered_context = context.clone();
1441            discover().apply_to(&mut discovered_context).unwrap();
1442            assert_eq!(
1443                predicted_action,
1444                invocation.action(discovered_context).unwrap()
1445            );
1446            assert_ne!(predicted_action.digest, previous, "{change}");
1447            previous = predicted_action.digest;
1448        }
1449        std::fs::remove_file(native.join("libssl.dylib")).unwrap();
1450        let outside = directory.path().join("outside");
1451        std::fs::write(&outside, "untracked").unwrap();
1452        symlink(&outside, native.join("libssl.dylib")).unwrap();
1453        assert!(
1454            prediction
1455                .discover(&workspace, &mappings, &mbx_cache_core::NoFileDigestCache)
1456                .is_err()
1457        );
1458        std::fs::remove_file(native.join("libssl.dylib")).unwrap();
1459        symlink(".", native.join("loop")).unwrap();
1460        assert!(
1461            prediction
1462                .discover(&workspace, &mappings, &mbx_cache_core::NoFileDigestCache)
1463                .is_err()
1464        );
1465    }
1466}