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