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