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};
5use mbx_cache_core::{
6    CacheDigest, FileDigestCache, FileDigestScope, FileIdentity, RecordedFileDigest,
7};
8use std::collections::{BTreeMap, BTreeSet};
9use std::ffi::OsString;
10use std::path::{Path, PathBuf};
11use std::time::SystemTime;
12
13/// A side-effect-minimized rustc invocation that emits only dependency data.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct DepInfoCommand {
16    arguments: Vec<OsString>,
17    output: PathBuf,
18}
19
20impl DepInfoCommand {
21    /// Arguments for the real compiler, excluding the compiler executable.
22    pub fn arguments(&self) -> &[OsString] {
23        &self.arguments
24    }
25
26    /// Exact file the compiler must populate with dep-info.
27    pub fn output(&self) -> &Path {
28        &self.output
29    }
30}
31
32/// The source and environment inputs reported by rustc's dep-info output.
33#[derive(Debug, Clone, Default, PartialEq, Eq)]
34pub struct RustcDepInfo {
35    /// Source paths listed in the first dep-info dependency rule.
36    pub files: Vec<PathBuf>,
37    /// Environment inputs recorded by rustc `# env-dep:` lines.
38    pub environment: BTreeMap<String, Option<String>>,
39}
40
41impl RustcDepInfo {
42    /// Read and parse a dep-info file, treating missing or non-UTF-8 output as
43    /// an explicit cache bypass.
44    pub fn read(path: &Path) -> Result<Self, BypassReason> {
45        let contents =
46            std::fs::read_to_string(path).map_err(|error| BypassReason::DepInfoRead {
47                path: path.to_path_buf(),
48                message: error.to_string(),
49            })?;
50        Self::parse(&contents)
51    }
52
53    /// Parse rustc's Makefile-style dep-info format.
54    ///
55    /// This intentionally follows Cargo's parser contract: the first target
56    /// rule contains all source dependencies, spaces are escaped with a
57    /// trailing backslash on each token fragment, and `# env-dep:` records
58    /// contain the environment observed by `env!` and `option_env!`.
59    pub fn parse(contents: &str) -> Result<Self, BypassReason> {
60        let mut files = BTreeSet::new();
61        let mut environment = BTreeMap::new();
62        let mut found_dependencies = false;
63
64        for line in contents.lines() {
65            if let Some(record) = line.strip_prefix("# env-dep:") {
66                let (name, value) = record
67                    .split_once('=')
68                    .map_or((record, None), |(name, value)| (name, Some(value)));
69                let name = unescape_environment(name)?;
70                if name.is_empty() {
71                    return Err(BypassReason::MalformedDepInfo(
72                        "environment input has an empty name".into(),
73                    ));
74                }
75                let value = value.map(unescape_environment).transpose()?;
76                if environment
77                    .insert(name.clone(), value.clone())
78                    .is_some_and(|previous| previous != value)
79                {
80                    return Err(BypassReason::ConflictingEnvironment(name));
81                }
82                continue;
83            }
84
85            let Some(separator) = line.find(": ") else {
86                continue;
87            };
88            if found_dependencies {
89                continue;
90            }
91            found_dependencies = true;
92            let mut fragments = line[separator + 2..].split_whitespace();
93            while let Some(fragment) = fragments.next() {
94                let mut file = fragment.to_string();
95                while file.ends_with('\\') {
96                    file.pop();
97                    let continuation = fragments.next().ok_or_else(|| {
98                        BypassReason::MalformedDepInfo(
99                            "dependency path ends with an unterminated escape".into(),
100                        )
101                    })?;
102                    file.push(' ');
103                    file.push_str(continuation);
104                }
105                if file.is_empty() {
106                    return Err(BypassReason::MalformedDepInfo(
107                        "dependency path is empty".into(),
108                    ));
109                }
110                files.insert(PathBuf::from(file));
111            }
112        }
113
114        if !found_dependencies {
115            return Err(BypassReason::MalformedDepInfo(
116                "dependency rule is missing".into(),
117            ));
118        }
119        if files.is_empty() {
120            return Err(BypassReason::MalformedDepInfo(
121                "dependency rule contains no inputs".into(),
122            ));
123        }
124        Ok(Self {
125            files: files.into_iter().collect(),
126            environment,
127        })
128    }
129}
130
131/// A complete, content-addressed compiler input manifest.
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct DiscoveredInputs {
134    working_dir: PathBuf,
135    /// Content-addressed compiler input files.
136    pub inputs: Vec<ActionInput>,
137    /// Environment inputs captured from dep-info.
138    pub environment: BTreeMap<String, Option<String>>,
139    /// What each input looked like on disk when its digest was established,
140    /// index-aligned with `inputs`; `None` where the filesystem gave nothing to
141    /// compare against later. Lets `verify` confirm an input by stat instead of
142    /// by reading it again.
143    identities: Vec<Option<FileIdentity>>,
144}
145
146impl DiscoveredInputs {
147    pub(crate) fn from_paths(
148        working_dir: &Path,
149        paths: BTreeSet<PathBuf>,
150        environment: BTreeMap<String, Option<String>>,
151        digests: &dyn FileDigestCache,
152    ) -> Result<Self, BypassReason> {
153        if !working_dir.is_absolute() {
154            return Err(BypassReason::RelativeWorkingDirectory(
155                working_dir.to_path_buf(),
156            ));
157        }
158        let working_dir = normalize_components(working_dir);
159        // Stat everything first: the identities drive one batched ledger
160        // lookup, so an upstream rlib the session already hashed -- once, when
161        // it was materialized or published -- is not read again by every crate
162        // that links it. A file the filesystem reports no modification time
163        // for gets no identity and is simply hashed.
164        let mut identified = Vec::with_capacity(paths.len());
165        for path in paths {
166            let metadata = std::fs::metadata(&path).map_err(|error| BypassReason::InputRead {
167                path: path.clone(),
168                message: error.to_string(),
169            })?;
170            if !metadata.is_file() {
171                return Err(BypassReason::InputRead {
172                    path,
173                    message: "input is not a regular file".into(),
174                });
175            }
176            let identity = FileIdentity::describe(&path, &metadata);
177            identified.push((path, identity));
178        }
179        let queries = identified
180            .iter()
181            .filter_map(|(_, identity)| identity.clone())
182            .collect::<Vec<_>>();
183        let mut recorded = digests.find(FileDigestScope::Content, &queries).into_iter();
184        let mut inputs = Vec::with_capacity(identified.len());
185        let mut identities = Vec::with_capacity(identified.len());
186        let mut fresh = Vec::new();
187        for (path, identity) in identified {
188            identities.push(identity.clone());
189            let remembered = identity
190                .as_ref()
191                .and_then(|_| recorded.next().flatten())
192                .filter(|digest| {
193                    identity
194                        .as_ref()
195                        .is_some_and(|identity| identity.len == digest.size)
196                });
197            let digest = match remembered {
198                Some(digest) => digest,
199                None => {
200                    let digest = CacheDigest::blake3_file(&path).map_err(|error| {
201                        BypassReason::InputRead {
202                            path: path.clone(),
203                            message: error.to_string(),
204                        }
205                    })?;
206                    if let Some(identity) = identity
207                        && identity.len == digest.size
208                    {
209                        fresh.push(RecordedFileDigest {
210                            file: identity,
211                            digest: digest.clone(),
212                        });
213                    }
214                    digest
215                }
216            };
217            inputs.push(ActionInput { path, digest });
218        }
219        if !fresh.is_empty() {
220            digests.record(FileDigestScope::Content, fresh);
221        }
222        Ok(Self {
223            working_dir,
224            inputs,
225            environment,
226            identities,
227        })
228    }
229
230    /// Reject inputs whose modification time overlaps the compiler invocation.
231    ///
232    /// Input contents are first hashed after rustc reports their paths. This
233    /// timestamp barrier prevents a post-compile write from being mistaken for
234    /// the contents that produced the artifact. `verify` closes the remaining
235    /// race after hashing.
236    pub fn verify_not_modified_since(&self, started_at: SystemTime) -> Result<(), BypassReason> {
237        self.verify_not_modified_since_with_identities(started_at, &BTreeMap::new())
238    }
239
240    /// Reject inputs that changed from identities captured before rustc ran,
241    /// falling back to the wall-clock barrier for inputs only dep-info named.
242    ///
243    /// Exact identity comparison does not order a client timestamp against a
244    /// filesystem timestamp, so it remains valid when an NFS server and the
245    /// compiler host use different clocks. Only identities carrying a change
246    /// token qualify: without one a same-length rewrite can restore its mtime.
247    pub fn verify_not_modified_since_with_identities(
248        &self,
249        started_at: SystemTime,
250        before: &BTreeMap<PathBuf, FileIdentity>,
251    ) -> Result<(), BypassReason> {
252        for input in &self.inputs {
253            let metadata =
254                std::fs::metadata(&input.path).map_err(|error| BypassReason::InputRead {
255                    path: input.path.clone(),
256                    message: error.to_string(),
257                })?;
258            let identity = FileIdentity::describe(&input.path, &metadata);
259            if let Some(previous) = before.get(&input.path)
260                && previous.changed.is_some()
261            {
262                if identity.as_ref() == Some(previous) {
263                    continue;
264                }
265                return Err(BypassReason::InputModifiedDuringCompilation(
266                    input.path.clone(),
267                ));
268            }
269            let modified = metadata
270                .modified()
271                .map_err(|error| BypassReason::InputRead {
272                    path: input.path.clone(),
273                    message: error.to_string(),
274                })?;
275            if modified >= started_at {
276                return Err(BypassReason::InputModifiedDuringCompilation(
277                    input.path.clone(),
278                ));
279            }
280        }
281        Ok(())
282    }
283
284    /// Rehash every discovered file after compilation and before publication.
285    /// This closes the discovery/compile race by degrading changed inputs to a
286    /// cache miss rather than storing outputs beneath a stale action key.
287    pub fn verify(&self) -> Result<(), BypassReason> {
288        for (index, input) in self.inputs.iter().enumerate() {
289            let read_error = |error: std::io::Error| BypassReason::InputRead {
290                path: input.path.clone(),
291                message: error.to_string(),
292            };
293            // An input still wearing the identity discovery recorded -- length,
294            // modification time, and change time -- is confirmed by that stat
295            // alone. The change time is what makes this as good as reading: it
296            // cannot be set from user space, so a rewrite that puts the old
297            // modification time back still shows, where a platform that reports
298            // none would let a same-length rewrite inside one timestamp tick
299            // through. Such an identity is not trusted here, and neither is an
300            // input that had none. Reading everything again cost as much as
301            // keying the compilation did: a large binary's dependency rlibs, a
302            // gigabyte of them, read twice for every edit.
303            if let Some(Some(identity)) = self.identities.get(index)
304                && identity.changed.is_some()
305                && identity.still_describes().map_err(read_error)?
306            {
307                continue;
308            }
309            let matches = input.digest.matches_file(&input.path).map_err(|error| {
310                BypassReason::InputRead {
311                    path: input.path.clone(),
312                    message: error.to_string(),
313                }
314            })?;
315            if !matches {
316                return Err(BypassReason::InputChanged(input.path.clone()));
317            }
318        }
319        Ok(())
320    }
321
322    /// Merge the manifest into an action context after verifying that both use
323    /// the same compiler working directory.
324    pub fn apply_to(self, context: &mut ActionContext) -> Result<(), BypassReason> {
325        if normalize_components(&context.working_dir) != self.working_dir {
326            return Err(BypassReason::DiscoveryWorkingDirectory);
327        }
328        for (name, value) in &self.environment {
329            if context
330                .environment
331                .get(name)
332                .is_some_and(|previous| previous != value)
333            {
334                return Err(BypassReason::ConflictingEnvironment(name.clone()));
335            }
336        }
337        context.environment.extend(self.environment);
338        context.inputs.extend(self.inputs);
339        Ok(())
340    }
341}
342
343impl RustcInvocation {
344    /// Replace the original output flags with a single explicit dep-info file.
345    pub fn dep_info_command(&self, output: &Path) -> Result<DepInfoCommand, BypassReason> {
346        if !output.is_absolute() {
347            return Err(BypassReason::RelativeDepInfoPath(output.to_path_buf()));
348        }
349        let output_text = output
350            .to_str()
351            .ok_or_else(|| BypassReason::NonUtf8Path(output.to_path_buf()))?;
352        if output_text.contains(',') {
353            return Err(BypassReason::UnsafeDepInfoPath(output.to_path_buf()));
354        }
355
356        let mut arguments = Vec::new();
357        for argument in &self.arguments {
358            match argument {
359                Argument::Emit(_) => {}
360                Argument::Path { flag, .. } if flag == "--out-dir" || flag == "-o" => {}
361                argument => arguments.push(render_argument(argument)?),
362            }
363        }
364        arguments.push(format!("--emit=dep-info={output_text}").into());
365        arguments.push(self.source.clone().into_os_string());
366        Ok(DepInfoCommand {
367            arguments,
368            output: output.to_path_buf(),
369        })
370    }
371
372    /// Hash dep-info sources plus every direct compiler input already modeled
373    /// by the invocation (`--extern` artifacts and custom target specs).
374    pub fn discover_inputs(
375        &self,
376        dep_info: &RustcDepInfo,
377        working_dir: &Path,
378    ) -> Result<DiscoveredInputs, BypassReason> {
379        self.discover_inputs_with_mappings(
380            dep_info,
381            working_dir,
382            &[],
383            &mbx_cache_core::NoFileDigestCache,
384        )
385    }
386
387    /// Hash dep-info sources plus modeled compiler inputs, allowing native
388    /// search directories beneath the working directory or a mapped root.
389    ///
390    /// `digests` may answer for files the session already read in full;
391    /// pass [`mbx_cache_core::NoFileDigestCache`] to hash everything.
392    pub fn discover_inputs_with_mappings(
393        &self,
394        dep_info: &RustcDepInfo,
395        working_dir: &Path,
396        path_mappings: &[PathMapping],
397        digests: &dyn FileDigestCache,
398    ) -> Result<DiscoveredInputs, BypassReason> {
399        if !working_dir.is_absolute() {
400            return Err(BypassReason::RelativeWorkingDirectory(
401                working_dir.to_path_buf(),
402            ));
403        }
404        let working_dir = normalize_components(working_dir);
405        let mut paths = dep_info
406            .files
407            .iter()
408            .chain(&self.required_inputs)
409            .map(|path| {
410                let absolute = if path.is_absolute() {
411                    path.to_path_buf()
412                } else {
413                    working_dir.join(path)
414                };
415                normalize_components(&absolute)
416            })
417            .collect::<BTreeSet<_>>();
418        let admitted_roots = native_input_roots(&working_dir, path_mappings);
419        let mut native_bytes = 0_u64;
420        for argument in &self.arguments {
421            if let Argument::SearchPath { kind, path } = argument
422                && kind == "native"
423            {
424                let directory = if path.is_absolute() {
425                    path.clone()
426                } else {
427                    working_dir.join(path)
428                };
429                // An inert directory outside every mapped root enters the key
430                // by its literal path in the arguments, not by its contents --
431                // predictions skip it under the same rule, so both discovery
432                // paths agree on the action key.
433                if self.native_search_is_inert()
434                    && matches!(
435                        super::normalize_mapped_path(&directory, &working_dir, path_mappings),
436                        Err(BypassReason::UnmappedAbsolutePath(_))
437                    )
438                {
439                    continue;
440                }
441                collect_native_directory(
442                    &directory,
443                    &admitted_roots,
444                    &mut paths,
445                    &mut native_bytes,
446                )?;
447            }
448        }
449        DiscoveredInputs::from_paths(&working_dir, paths, dep_info.environment.clone(), digests)
450    }
451}
452
453/// Return normalized roots whose native search directories can be tracked.
454pub(super) fn native_input_roots(
455    working_dir: &Path,
456    path_mappings: &[PathMapping],
457) -> Vec<PathBuf> {
458    std::iter::once(working_dir)
459        .chain(path_mappings.iter().map(|mapping| mapping.root.as_path()))
460        .map(normalize_components)
461        .collect()
462}
463
464/// Add regular files beneath an admitted native search directory, enforcing
465/// the prediction input count and the caller's cumulative native byte budget.
466pub(super) fn collect_native_directory(
467    directory: &Path,
468    admitted_roots: &[PathBuf],
469    paths: &mut BTreeSet<PathBuf>,
470    native_bytes: &mut u64,
471) -> Result<(), BypassReason> {
472    let directory = normalize_components(directory);
473    if !admitted_roots
474        .iter()
475        .any(|root| directory.starts_with(root))
476    {
477        return Err(BypassReason::UnsupportedSearchPath("native".into()));
478    }
479
480    let mut pending = vec![directory];
481    while let Some(directory) = pending.pop() {
482        let entries = match std::fs::read_dir(&directory) {
483            Ok(entries) => entries,
484            Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
485            Err(error) => {
486                return Err(BypassReason::InputRead {
487                    path: directory,
488                    message: error.to_string(),
489                });
490            }
491        };
492        for entry in entries {
493            let entry = entry.map_err(|error| BypassReason::InputRead {
494                path: directory.clone(),
495                message: error.to_string(),
496            })?;
497            let path = entry.path();
498            let file_type = entry.file_type().map_err(|error| BypassReason::InputRead {
499                path: path.clone(),
500                message: error.to_string(),
501            })?;
502            if file_type.is_dir() {
503                pending.push(path);
504            } else if file_type.is_file() {
505                *native_bytes = native_bytes
506                    .checked_add(
507                        entry
508                            .metadata()
509                            .map_err(|error| BypassReason::InputRead {
510                                path: path.clone(),
511                                message: error.to_string(),
512                            })?
513                            .len(),
514                    )
515                    .ok_or_else(|| BypassReason::UnsupportedSearchPath("native".into()))?;
516                paths.insert(path);
517            } else {
518                return Err(BypassReason::UnsupportedSearchPath("native".into()));
519            }
520            if paths.len() > MAX_PREDICTED_INPUTS || *native_bytes > MAX_NATIVE_INPUT_BYTES {
521                return Err(BypassReason::UnsupportedSearchPath("native".into()));
522            }
523        }
524    }
525    Ok(())
526}
527
528fn render_argument(argument: &Argument) -> Result<OsString, BypassReason> {
529    let rendered = match argument {
530        Argument::Plain(value) => value.clone(),
531        Argument::Path { flag, path } => format!(
532            "{flag}={}",
533            path.to_str()
534                .ok_or_else(|| BypassReason::NonUtf8Path(path.clone()))?
535        ),
536        Argument::SearchPath { kind, path } => format!(
537            "-L{kind}={}",
538            path.to_str()
539                .ok_or_else(|| BypassReason::NonUtf8Path(path.clone()))?
540        ),
541        Argument::Extern { name, path } => match path {
542            Some(path) => format!(
543                "--extern={name}={}",
544                path.to_str()
545                    .ok_or_else(|| BypassReason::NonUtf8Path(path.clone()))?
546            ),
547            None => format!("--extern={name}"),
548        },
549        Argument::Emit(_) => unreachable!("emit arguments are removed before rendering"),
550        Argument::RemapPath { from, to } => format!(
551            "--remap-path-prefix={}={to}",
552            from.to_str()
553                .ok_or_else(|| BypassReason::NonUtf8Path(from.clone()))?
554        ),
555        // Nothing links while emitting dep-info, so the prefix is inert here;
556        // it is replayed verbatim to keep the command faithful.
557        Argument::OsoPrefix {
558            path,
559            trailing_slash,
560        } => format!(
561            "--codegen=link-arg=-Wl,-oso_prefix,{}{}",
562            path.to_str()
563                .ok_or_else(|| BypassReason::NonUtf8Path(path.clone()))?,
564            if *trailing_slash { "/" } else { "" }
565        ),
566        // Nothing links while emitting dep-info either; replayed verbatim for
567        // the same reason as the prefix above.
568        Argument::FuseLd(selection) => format!("--codegen=link-arg=-fuse-ld={selection}"),
569    };
570    Ok(rendered.into())
571}
572
573fn unescape_environment(value: &str) -> Result<String, BypassReason> {
574    let mut output = String::with_capacity(value.len());
575    let mut characters = value.chars();
576    while let Some(character) = characters.next() {
577        if character != '\\' {
578            output.push(character);
579            continue;
580        }
581        match characters.next() {
582            Some('\\') => output.push('\\'),
583            Some('n') => output.push('\n'),
584            Some('r') => output.push('\r'),
585            Some(character) => {
586                return Err(BypassReason::MalformedDepInfo(format!(
587                    "unknown environment escape \\{character}"
588                )));
589            }
590            None => {
591                return Err(BypassReason::MalformedDepInfo(
592                    "environment input ends with an unterminated escape".into(),
593                ));
594            }
595        }
596    }
597    Ok(output)
598}
599
600#[cfg(test)]
601mod tests {
602    use super::*;
603    use std::process::Command;
604
605    fn args(values: &[&str]) -> Vec<OsString> {
606        values.iter().map(OsString::from).collect()
607    }
608
609    #[test]
610    fn parses_files_spaces_and_environment_records() {
611        let parsed = RustcDepInfo::parse(
612            "target/lib.rlib: src/lib.rs src/a\\ file.rs generated.rs\n\
613             src/lib.rs:\n\
614             # env-dep:SET=value\\nnext\n\
615             # env-dep:UNSET\n\
616             # env-dep:SLASH=a\\\\b\n",
617        )
618        .unwrap();
619        assert_eq!(
620            parsed.files,
621            vec![
622                PathBuf::from("generated.rs"),
623                PathBuf::from("src/a file.rs"),
624                PathBuf::from("src/lib.rs"),
625            ]
626        );
627        assert_eq!(parsed.environment["SET"], Some("value\nnext".into()));
628        assert_eq!(parsed.environment["UNSET"], None);
629        assert_eq!(parsed.environment["SLASH"], Some(r"a\b".into()));
630    }
631
632    #[test]
633    fn malformed_dep_info_bypasses_caching() {
634        for contents in [
635            "",
636            "target: ",
637            "target: src/trailing\\\n",
638            "target: src/lib.rs\n# env-dep:NAME=bad\\q\n",
639        ] {
640            assert!(RustcDepInfo::parse(contents).is_err(), "{contents:?}");
641        }
642    }
643
644    #[test]
645    fn native_directory_byte_limit_is_cumulative() {
646        let directory = tempfile::tempdir().unwrap();
647        let native = directory.path().join("native");
648        std::fs::create_dir_all(&native).unwrap();
649        std::fs::write(native.join("input.lib"), b"xx").unwrap();
650        let roots = native_input_roots(directory.path(), &[]);
651        let mut paths = BTreeSet::new();
652        let mut native_bytes = MAX_NATIVE_INPUT_BYTES - 1;
653
654        assert_eq!(
655            collect_native_directory(&native, &roots, &mut paths, &mut native_bytes),
656            Err(BypassReason::UnsupportedSearchPath("native".into()))
657        );
658    }
659
660    #[test]
661    fn discovery_command_removes_real_outputs() {
662        let invocation = RustcInvocation::parse(&args(&[
663            "--crate-name=widget",
664            "--crate-type=lib",
665            "--emit=dep-info,metadata,link",
666            "--out-dir=target/debug/deps",
667            "-o",
668            "target/debug/libwidget.rlib",
669            "src/lib.rs",
670        ]))
671        .unwrap();
672        let output = if cfg!(windows) {
673            PathBuf::from(r"C:\tmp\mbx cache\inputs.d")
674        } else {
675            PathBuf::from("/tmp/mbx cache/inputs.d")
676        };
677        let command = invocation.dep_info_command(&output).unwrap();
678        let arguments = command
679            .arguments()
680            .iter()
681            .map(|value| value.to_string_lossy())
682            .collect::<Vec<_>>();
683        assert_eq!(
684            arguments,
685            vec![
686                "--crate-name=widget",
687                "--crate-type=lib",
688                &format!("--emit=dep-info={}", output.display()),
689                "src/lib.rs",
690            ]
691        );
692    }
693
694    #[test]
695    fn discovery_hashes_externs_and_custom_targets() {
696        let directory = tempfile::tempdir().unwrap();
697        let root = directory.path();
698        let source = root.join("src/lib.rs");
699        let external = root.join("target/libdependency.rlib");
700        let target = root.join("targets/custom.json");
701        std::fs::create_dir_all(source.parent().unwrap()).unwrap();
702        std::fs::create_dir_all(external.parent().unwrap()).unwrap();
703        std::fs::create_dir_all(target.parent().unwrap()).unwrap();
704        std::fs::write(&source, "pub fn library() {}\n").unwrap();
705        std::fs::write(&external, "dependency artifact\n").unwrap();
706        std::fs::write(&target, "{}\n").unwrap();
707
708        let invocation = RustcInvocation::parse(&[
709            "--crate-name=widget".into(),
710            "--crate-type=lib".into(),
711            "--emit=metadata".into(),
712            format!("--extern=dependency={}", external.display()).into(),
713            format!("--target={}", target.display()).into(),
714            source.clone().into_os_string(),
715        ])
716        .unwrap();
717        let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
718        let discovered = invocation.discover_inputs(&dep_info, root).unwrap();
719        assert_eq!(discovered.inputs.len(), 3);
720
721        std::fs::remove_file(&external).unwrap();
722        assert!(matches!(
723            invocation.discover_inputs(&dep_info, root),
724            Err(BypassReason::InputRead { path, .. }) if path == external
725        ));
726    }
727
728    #[test]
729    fn discovery_rejects_inputs_modified_during_compilation() {
730        let directory = tempfile::tempdir().unwrap();
731        let source = directory.path().join("lib.rs");
732        std::fs::write(&source, "pub fn library() {}\n").unwrap();
733        let invocation = RustcInvocation::parse(&[
734            "--crate-name=widget".into(),
735            "--crate-type=lib".into(),
736            "--emit=dep-info,metadata".into(),
737            source.clone().into_os_string(),
738        ])
739        .unwrap();
740        let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
741        let discovered = invocation
742            .discover_inputs(&dep_info, directory.path())
743            .unwrap();
744        let modified = std::fs::metadata(&source).unwrap().modified().unwrap();
745
746        assert_eq!(
747            discovered.verify_not_modified_since(modified),
748            Err(BypassReason::InputModifiedDuringCompilation(source))
749        );
750    }
751
752    #[cfg(unix)]
753    #[test]
754    fn precompile_identity_does_not_depend_on_the_host_clock() {
755        let directory = tempfile::tempdir().unwrap();
756        let source = directory.path().join("lib.rs");
757        std::fs::write(&source, "pub fn library() {0}\n").unwrap();
758        let invocation = RustcInvocation::parse(&[
759            "--crate-name=widget".into(),
760            "--crate-type=lib".into(),
761            "--emit=dep-info,metadata".into(),
762            source.clone().into_os_string(),
763        ])
764        .unwrap();
765        let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
766        let discovered = invocation
767            .discover_inputs(&dep_info, directory.path())
768            .unwrap();
769        let identity =
770            FileIdentity::describe(&source, &std::fs::metadata(&source).unwrap()).unwrap();
771        let before = BTreeMap::from([(source.clone(), identity)]);
772
773        // Every ordinary mtime is after the epoch. The unchanged identity is
774        // nevertheless enough proof when the filesystem clock is far ahead.
775        discovered
776            .verify_not_modified_since_with_identities(SystemTime::UNIX_EPOCH, &before)
777            .unwrap();
778
779        // Conversely, a filesystem clock far behind must not conceal a write.
780        std::fs::write(&source, "pub fn library() {1}\n").unwrap();
781        assert_eq!(
782            discovered.verify_not_modified_since_with_identities(
783                SystemTime::now() + std::time::Duration::from_secs(60),
784                &before,
785            ),
786            Err(BypassReason::InputModifiedDuringCompilation(source))
787        );
788    }
789
790    /// The MSVC toolset directories `cc`-built dependencies hand to every
791    /// downstream compile on Windows: absolute, version-stamped, and outside
792    /// every mapped root.
793    fn toolchain_native_directory(version: &str) -> PathBuf {
794        if cfg!(windows) {
795            PathBuf::from(format!(r"C:\Program Files\MSVC\{version}\lib\x64"))
796        } else {
797            PathBuf::from(format!("/opt/msvc/{version}/lib/x64"))
798        }
799    }
800
801    fn library_with_native_search(source: &Path, directory: &Path) -> RustcInvocation {
802        RustcInvocation::parse(&[
803            "--crate-name=widget".into(),
804            "--crate-type=lib".into(),
805            "--emit=metadata,link".into(),
806            format!("-Lnative={}", directory.display()).into(),
807            source.to_path_buf().into_os_string(),
808        ])
809        .unwrap()
810    }
811
812    fn library_context(root: &Path, mappings: Vec<PathMapping>) -> ActionContext {
813        ActionContext {
814            compiler: crate::CompilerIdentity {
815                toolchain: "core:rust@test".into(),
816                rustc_version: "test".into(),
817                host: std::env::consts::ARCH.into(),
818                driver: None,
819            },
820            working_dir: root.to_path_buf(),
821            path_mappings: mappings,
822            environment: BTreeMap::new(),
823            portable_environment: BTreeSet::new(),
824            inputs: Vec::new(),
825        }
826    }
827
828    #[test]
829    fn unmapped_native_directory_is_keyed_by_path_for_library_emits() {
830        let directory = tempfile::tempdir().unwrap();
831        let root = directory.path();
832        let source = root.join("lib.rs");
833        std::fs::write(&source, "pub fn library() {}\n").unwrap();
834        let toolchain = toolchain_native_directory("14.51.36231");
835        let invocation = library_with_native_search(&source, &toolchain);
836        let mappings = vec![PathMapping::new(root, "workspace")];
837        let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
838
839        // The directory does not even exist: its contents are not inputs.
840        let discovered = invocation
841            .discover_inputs_with_mappings(
842                &dep_info,
843                root,
844                &mappings,
845                &mbx_cache_core::NoFileDigestCache,
846            )
847            .unwrap();
848        assert_eq!(discovered.inputs.len(), 1);
849        assert_eq!(discovered.inputs[0].path, source);
850
851        // The literal path is key material, so a toolset update misses.
852        let context = library_context(root, mappings.clone());
853        let digest = invocation.invocation_digest(&context).unwrap();
854        let updated = library_with_native_search(&source, &toolchain_native_directory("14.52.0"));
855        assert_ne!(digest, updated.invocation_digest(&context).unwrap());
856
857        // The prediction skips the directory the same way discovery does, so a
858        // build that replays it derives the action key dep-info would have.
859        let mut recorded = context.clone();
860        discovered.clone().apply_to(&mut recorded).unwrap();
861        let action = invocation.action(recorded).unwrap();
862        let prediction = invocation.prediction(&context, &discovered).unwrap();
863        let replayed = prediction
864            .discover(
865                root,
866                &context.path_mappings,
867                &mbx_cache_core::NoFileDigestCache,
868            )
869            .unwrap();
870        let mut replay_context = context.clone();
871        replayed.apply_to(&mut replay_context).unwrap();
872        assert_eq!(
873            invocation.action(replay_context).unwrap().digest,
874            action.digest
875        );
876    }
877
878    #[test]
879    fn unmapped_native_directory_still_refuses_a_native_link() {
880        let directory = tempfile::tempdir().unwrap();
881        let root = directory.path();
882        let source = root.join("main.rs");
883        std::fs::write(&source, "fn main() {}\n").unwrap();
884        let toolchain = toolchain_native_directory("14.51.36231");
885        let invocation = RustcInvocation::parse_with(
886            &[
887                "--crate-name=app".into(),
888                "--crate-type=bin".into(),
889                "--emit=link".into(),
890                format!("-Lnative={}", toolchain.display()).into(),
891                source.clone().into_os_string(),
892            ],
893            crate::ParseOptions::caching_native_links(true),
894        )
895        .unwrap();
896        let mappings = vec![PathMapping::new(root, "workspace")];
897
898        // A linker reads those directories, so their contents stay inputs the
899        // key must account for, and an unmapped one stays a bypass.
900        let context = library_context(root, mappings.clone());
901        assert!(matches!(
902            invocation.invocation_digest(&context),
903            Err(BypassReason::UnmappedAbsolutePath(_))
904        ));
905        let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
906        assert_eq!(
907            invocation.discover_inputs_with_mappings(
908                &dep_info,
909                root,
910                &mappings,
911                &mbx_cache_core::NoFileDigestCache
912            ),
913            Err(BypassReason::UnsupportedSearchPath("native".into()))
914        );
915    }
916
917    #[test]
918    fn discovery_resolves_parent_components_against_the_working_directory() {
919        let directory = tempfile::tempdir().unwrap();
920        let root = directory.path().join("project");
921        let shared = directory.path().join("shared.rs");
922        std::fs::create_dir(&root).unwrap();
923        std::fs::write(&shared, "pub fn shared() {}\n").unwrap();
924
925        let invocation = RustcInvocation::parse(&args(&[
926            "--crate-name=widget",
927            "--crate-type=lib",
928            "--emit=metadata",
929            "../shared.rs",
930        ]))
931        .unwrap();
932        let dep_info = RustcDepInfo::parse("output: ../shared.rs\n").unwrap();
933        let discovered = invocation.discover_inputs(&dep_info, &root).unwrap();
934
935        assert_eq!(discovered.inputs.len(), 1);
936        assert_eq!(discovered.inputs[0].path, shared);
937    }
938
939    #[test]
940    fn rustc_dep_info_round_trip_discovers_real_inputs() {
941        let directory = tempfile::tempdir().unwrap();
942        let root = directory.path();
943        std::fs::write(
944            root.join("lib.rs"),
945            "mod child; const _: &str = include_str!(\"data file.txt\"); \
946             const _: &str = env!(\"MBX_DISCOVERY_TEST\"); \
947             const _: Option<&str> = option_env!(\"MBX_DISCOVERY_UNSET\");",
948        )
949        .unwrap();
950        std::fs::write(root.join("child.rs"), "pub fn child() {}\n").unwrap();
951        std::fs::write(root.join("data file.txt"), "included\n").unwrap();
952
953        let invocation = RustcInvocation::parse(&args(&[
954            "--crate-name=mbx_cache_discovery_test",
955            "--crate-type=lib",
956            "--emit=metadata,link",
957            "lib.rs",
958        ]))
959        .unwrap();
960        let dep_info_path = root.join("discovery inputs.d");
961        let discovery_command = invocation.dep_info_command(&dep_info_path).unwrap();
962        let rustc = std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into());
963        let output = match Command::new(rustc)
964            .args(discovery_command.arguments())
965            .current_dir(root)
966            .env("MBX_DISCOVERY_TEST", "observed")
967            .env_remove("MBX_DISCOVERY_UNSET")
968            .output()
969        {
970            Ok(output) => output,
971            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return,
972            Err(error) => panic!("failed to execute rustc: {error}"),
973        };
974        assert!(
975            output.status.success(),
976            "{}",
977            String::from_utf8_lossy(&output.stderr)
978        );
979        let parsed = RustcDepInfo::read(&dep_info_path).unwrap();
980        let discovered = invocation.discover_inputs(&parsed, root).unwrap();
981        assert_eq!(
982            discovered.environment["MBX_DISCOVERY_TEST"],
983            Some("observed".into())
984        );
985        assert_eq!(discovered.environment["MBX_DISCOVERY_UNSET"], None);
986        assert_eq!(discovered.inputs.len(), 3);
987        assert!(
988            discovered
989                .inputs
990                .iter()
991                .all(|input| input.digest.algorithm == "blake3")
992        );
993        let mut context = ActionContext {
994            compiler: crate::CompilerIdentity {
995                toolchain: "core:rust@test".into(),
996                rustc_version: "test".into(),
997                host: std::env::consts::ARCH.into(),
998                driver: None,
999            },
1000            working_dir: root.to_path_buf(),
1001            path_mappings: vec![crate::PathMapping::new(root, "workspace")],
1002            environment: BTreeMap::new(),
1003            portable_environment: BTreeSet::new(),
1004            inputs: Vec::new(),
1005        };
1006        discovered.clone().apply_to(&mut context).unwrap();
1007        let action = invocation.action(context).unwrap();
1008        assert!(
1009            String::from_utf8(action.bytes)
1010                .unwrap()
1011                .contains(r#""MBX_DISCOVERY_TEST":"observed""#)
1012        );
1013        discovered.verify().unwrap();
1014        std::fs::write(root.join("child.rs"), "pub fn changed() {}\n").unwrap();
1015        assert_eq!(
1016            discovered.verify(),
1017            Err(BypassReason::InputChanged(root.join("child.rs")))
1018        );
1019    }
1020
1021    /// A ledger that answers with a sentinel and remembers what was recorded.
1022    struct SentinelLedger {
1023        known: FileIdentity,
1024        digest: CacheDigest,
1025        recorded: std::sync::Mutex<Vec<RecordedFileDigest>>,
1026    }
1027
1028    impl FileDigestCache for SentinelLedger {
1029        fn find(&self, scope: FileDigestScope, files: &[FileIdentity]) -> Vec<Option<CacheDigest>> {
1030            assert_eq!(scope, FileDigestScope::Content);
1031            files
1032                .iter()
1033                .map(|file| (*file == self.known).then(|| self.digest.clone()))
1034                .collect()
1035        }
1036
1037        fn record(&self, scope: FileDigestScope, entries: Vec<RecordedFileDigest>) {
1038            assert_eq!(scope, FileDigestScope::Content);
1039            self.recorded.lock().unwrap().extend(entries);
1040        }
1041    }
1042
1043    #[test]
1044    fn discovery_reuses_recorded_digests_and_records_fresh_ones() {
1045        let directory = tempfile::tempdir().unwrap();
1046        let root = directory.path();
1047        let known_path = root.join("libdep.rlib");
1048        let fresh_path = root.join("lib.rs");
1049        std::fs::write(&known_path, b"rlib bytes").unwrap();
1050        std::fs::write(&fresh_path, b"fn lib() {}").unwrap();
1051        let metadata = std::fs::metadata(&known_path).unwrap();
1052        // A sentinel digest that hashing could never produce proves the read
1053        // was skipped: the discovered input carries it verbatim.
1054        let sentinel = CacheDigest {
1055            algorithm: "blake3".into(),
1056            hash: "c".repeat(64),
1057            size: metadata.len(),
1058        };
1059        let ledger = SentinelLedger {
1060            known: FileIdentity::describe(&known_path, &metadata).unwrap(),
1061            digest: sentinel.clone(),
1062            recorded: std::sync::Mutex::new(Vec::new()),
1063        };
1064
1065        let discovered = DiscoveredInputs::from_paths(
1066            root,
1067            BTreeSet::from([known_path.clone(), fresh_path.clone()]),
1068            BTreeMap::new(),
1069            &ledger,
1070        )
1071        .unwrap();
1072
1073        let by_path = |path: &Path| {
1074            discovered
1075                .inputs
1076                .iter()
1077                .find(|input| input.path == path)
1078                .unwrap()
1079                .digest
1080                .clone()
1081        };
1082        assert_eq!(
1083            by_path(&known_path),
1084            sentinel,
1085            "the recorded digest answers"
1086        );
1087        assert_eq!(
1088            by_path(&fresh_path),
1089            CacheDigest::blake3_file(&fresh_path).unwrap(),
1090            "an unrecorded file is hashed"
1091        );
1092        let recorded = ledger.recorded.lock().unwrap();
1093        assert_eq!(recorded.len(), 1, "only the fresh hash is recorded");
1094        assert_eq!(recorded[0].file.path, fresh_path);
1095        assert_eq!(recorded[0].digest, by_path(&fresh_path));
1096    }
1097
1098    /// Verification after the compiler runs confirms an input by its recorded
1099    /// identity rather than by reading it again; a file that has moved on is
1100    /// still read and still fails. Only where the identity carries a change
1101    /// time, which is what makes the stat as good as the read.
1102    #[cfg(unix)]
1103    #[test]
1104    fn verification_trusts_an_unchanged_identity_and_rereads_a_changed_one() {
1105        let directory = tempfile::tempdir().unwrap();
1106        let root = directory.path();
1107        let known_path = root.join("libdep.rlib");
1108        std::fs::write(&known_path, b"rlib bytes").unwrap();
1109        let metadata = std::fs::metadata(&known_path).unwrap();
1110        // The sentinel could never come from hashing the file, so a verify
1111        // that passes can only have trusted the identity.
1112        let sentinel = CacheDigest {
1113            algorithm: "blake3".into(),
1114            hash: "c".repeat(64),
1115            size: metadata.len(),
1116        };
1117        let ledger = SentinelLedger {
1118            known: FileIdentity::describe(&known_path, &metadata).unwrap(),
1119            digest: sentinel,
1120            recorded: std::sync::Mutex::new(Vec::new()),
1121        };
1122        let discovered = DiscoveredInputs::from_paths(
1123            root,
1124            BTreeSet::from([known_path.clone()]),
1125            BTreeMap::new(),
1126            &ledger,
1127        )
1128        .unwrap();
1129
1130        discovered.verify().unwrap();
1131
1132        // Rewritten with the same length: the identity moves with the write,
1133        // so the file is read again and the sentinel no longer matches it.
1134        std::thread::sleep(std::time::Duration::from_millis(20));
1135        std::fs::write(&known_path, b"RLIB BYTES").unwrap();
1136        assert_eq!(
1137            discovered.verify(),
1138            Err(BypassReason::InputChanged(known_path.clone()))
1139        );
1140
1141        std::fs::remove_file(&known_path).unwrap();
1142        assert!(matches!(
1143            discovered.verify(),
1144            Err(BypassReason::InputRead { path, .. }) if path == known_path
1145        ));
1146    }
1147
1148    /// A rewrite that restores length and modification time must still be
1149    /// hashed: the platform change token moves with every write, so the
1150    /// identity the ledger recorded no longer describes the file.
1151    #[cfg(unix)]
1152    #[test]
1153    fn a_disguised_rewrite_is_not_answered_from_the_ledger() {
1154        let directory = tempfile::tempdir().unwrap();
1155        let root = directory.path();
1156        let path = root.join("lib.rs");
1157        std::fs::write(&path, b"fn lib() -> u8 { 1 }").unwrap();
1158        let before = std::fs::metadata(&path).unwrap();
1159        let identity = FileIdentity::describe(&path, &before).unwrap();
1160
1161        // Same length, modification time put back: only the change token can
1162        // tell this file has new contents.
1163        std::fs::write(&path, b"fn lib() -> u8 { 2 }").unwrap();
1164        let file = std::fs::File::options().write(true).open(&path).unwrap();
1165        file.set_times(std::fs::FileTimes::new().set_modified(before.modified().unwrap()))
1166            .unwrap();
1167        drop(file);
1168        let after = std::fs::metadata(&path).unwrap();
1169        let disguised = FileIdentity::describe(&path, &after).unwrap();
1170        assert_eq!(disguised.len, identity.len);
1171        assert_eq!(disguised.modified, identity.modified);
1172        assert_ne!(
1173            disguised, identity,
1174            "the change token must expose the rewrite"
1175        );
1176
1177        let ledger = SentinelLedger {
1178            known: identity,
1179            digest: CacheDigest {
1180                algorithm: "blake3".into(),
1181                hash: "e".repeat(64),
1182                size: after.len(),
1183            },
1184            recorded: std::sync::Mutex::new(Vec::new()),
1185        };
1186        let discovered = DiscoveredInputs::from_paths(
1187            root,
1188            BTreeSet::from([path.clone()]),
1189            BTreeMap::new(),
1190            &ledger,
1191        )
1192        .unwrap();
1193        assert_eq!(
1194            discovered.inputs[0].digest,
1195            CacheDigest::blake3_file(&path).unwrap(),
1196            "the disguised rewrite is hashed, not answered from the ledger"
1197        );
1198    }
1199}