Skip to main content

mbx_cache_rustc/
dep_info.rs

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