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::InstallName(name) => {
611            format!("--codegen=link-arg=-Wl,-install_name,@rpath/{name}")
612        }
613        Argument::FuseLd(selection) => format!("--codegen=link-arg=-fuse-ld={selection}"),
614    };
615    Ok(rendered.into())
616}
617
618fn unescape_environment(value: &str) -> Result<String, BypassReason> {
619    let mut output = String::with_capacity(value.len());
620    let mut characters = value.chars();
621    while let Some(character) = characters.next() {
622        if character != '\\' {
623            output.push(character);
624            continue;
625        }
626        match characters.next() {
627            Some('\\') => output.push('\\'),
628            Some('n') => output.push('\n'),
629            Some('r') => output.push('\r'),
630            Some(character) => {
631                return Err(BypassReason::MalformedDepInfo(format!(
632                    "unknown environment escape \\{character}"
633                )));
634            }
635            None => {
636                return Err(BypassReason::MalformedDepInfo(
637                    "environment input ends with an unterminated escape".into(),
638                ));
639            }
640        }
641    }
642    Ok(output)
643}
644
645#[cfg(test)]
646mod tests {
647    use super::*;
648    use std::process::Command;
649
650    fn args(values: &[&str]) -> Vec<OsString> {
651        values.iter().map(OsString::from).collect()
652    }
653
654    #[test]
655    fn parses_files_spaces_and_environment_records() {
656        let parsed = RustcDepInfo::parse(
657            "target/lib.rlib: src/lib.rs src/a\\ file.rs generated.rs\n\
658             src/lib.rs:\n\
659             # env-dep:SET=value\\nnext\n\
660             # env-dep:UNSET\n\
661             # env-dep:SLASH=a\\\\b\n",
662        )
663        .unwrap();
664        assert_eq!(
665            parsed.files,
666            vec![
667                PathBuf::from("generated.rs"),
668                PathBuf::from("src/a file.rs"),
669                PathBuf::from("src/lib.rs"),
670            ]
671        );
672        assert_eq!(parsed.environment["SET"], Some("value\nnext".into()));
673        assert_eq!(parsed.environment["UNSET"], None);
674        assert_eq!(parsed.environment["SLASH"], Some(r"a\b".into()));
675    }
676
677    #[test]
678    fn malformed_dep_info_bypasses_caching() {
679        for contents in [
680            "",
681            "target: ",
682            "target: src/trailing\\\n",
683            "target: src/lib.rs\n# env-dep:NAME=bad\\q\n",
684        ] {
685            assert!(RustcDepInfo::parse(contents).is_err(), "{contents:?}");
686        }
687    }
688
689    #[test]
690    fn native_directory_byte_limit_is_cumulative() {
691        let directory = tempfile::tempdir().unwrap();
692        let native = directory.path().join("native");
693        std::fs::create_dir_all(&native).unwrap();
694        std::fs::write(native.join("input.lib"), b"xx").unwrap();
695        let roots = native_input_roots(directory.path(), &[]);
696        let mut paths = BTreeSet::new();
697        let mut native_bytes = MAX_NATIVE_INPUT_BYTES - 1;
698
699        assert_eq!(
700            collect_native_directory(&native, &roots, &mut paths, &mut native_bytes),
701            Err(BypassReason::UnsupportedSearchPath("native".into()))
702        );
703    }
704
705    #[test]
706    fn discovery_command_removes_real_outputs() {
707        let invocation = RustcInvocation::parse(&args(&[
708            "--crate-name=widget",
709            "--crate-type=lib",
710            "--emit=dep-info,metadata,link",
711            "--out-dir=target/debug/deps",
712            "-o",
713            "target/debug/libwidget.rlib",
714            "src/lib.rs",
715        ]))
716        .unwrap();
717        let output = if cfg!(windows) {
718            PathBuf::from(r"C:\tmp\mbx cache\inputs.d")
719        } else {
720            PathBuf::from("/tmp/mbx cache/inputs.d")
721        };
722        let command = invocation.dep_info_command(&output).unwrap();
723        let arguments = command
724            .arguments()
725            .iter()
726            .map(|value| value.to_string_lossy())
727            .collect::<Vec<_>>();
728        assert_eq!(
729            arguments,
730            vec![
731                "--crate-name=widget",
732                "--crate-type=lib",
733                &format!("--emit=dep-info={}", output.display()),
734                "src/lib.rs",
735            ]
736        );
737    }
738
739    #[test]
740    fn discovery_hashes_externs_and_custom_targets() {
741        let directory = tempfile::tempdir().unwrap();
742        let root = directory.path();
743        let source = root.join("src/lib.rs");
744        let external = root.join("target/libdependency.rlib");
745        let target = root.join("targets/custom.json");
746        std::fs::create_dir_all(source.parent().unwrap()).unwrap();
747        std::fs::create_dir_all(external.parent().unwrap()).unwrap();
748        std::fs::create_dir_all(target.parent().unwrap()).unwrap();
749        std::fs::write(&source, "pub fn library() {}\n").unwrap();
750        std::fs::write(&external, "dependency artifact\n").unwrap();
751        std::fs::write(&target, "{}\n").unwrap();
752
753        let invocation = RustcInvocation::parse(&[
754            "--crate-name=widget".into(),
755            "--crate-type=lib".into(),
756            "--emit=metadata".into(),
757            format!("--extern=dependency={}", external.display()).into(),
758            format!("--target={}", target.display()).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.discover_inputs(&dep_info, root).unwrap();
764        assert_eq!(discovered.inputs.len(), 3);
765
766        std::fs::remove_file(&external).unwrap();
767        assert!(matches!(
768            invocation.discover_inputs(&dep_info, root),
769            Err(BypassReason::InputRead { path, .. }) if path == external
770        ));
771    }
772
773    #[test]
774    fn discovery_rejects_inputs_modified_during_compilation() {
775        let directory = tempfile::tempdir().unwrap();
776        let source = directory.path().join("lib.rs");
777        std::fs::write(&source, "pub fn library() {}\n").unwrap();
778        let invocation = RustcInvocation::parse(&[
779            "--crate-name=widget".into(),
780            "--crate-type=lib".into(),
781            "--emit=dep-info,metadata".into(),
782            source.clone().into_os_string(),
783        ])
784        .unwrap();
785        let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
786        let discovered = invocation
787            .discover_inputs(&dep_info, directory.path())
788            .unwrap();
789        let modified = std::fs::metadata(&source).unwrap().modified().unwrap();
790
791        assert_eq!(
792            discovered.verify_not_modified_since(modified),
793            Err(BypassReason::InputModifiedDuringCompilation(source))
794        );
795    }
796
797    #[cfg(unix)]
798    #[test]
799    fn precompile_snapshot_does_not_depend_on_the_host_clock() {
800        let directory = tempfile::tempdir().unwrap();
801        let source = directory.path().join("lib.rs");
802        std::fs::write(&source, "pub fn library() {0}\n").unwrap();
803        let invocation = RustcInvocation::parse(&[
804            "--crate-name=widget".into(),
805            "--crate-type=lib".into(),
806            "--emit=dep-info,metadata".into(),
807            source.clone().into_os_string(),
808        ])
809        .unwrap();
810        let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
811        let discovered = invocation
812            .discover_inputs(&dep_info, directory.path())
813            .unwrap();
814        let snapshot = FileSnapshot::capture(&source).unwrap().unwrap();
815        let before = BTreeMap::from([(source.clone(), snapshot)]);
816
817        // Every ordinary mtime is after the epoch. The unchanged identity is
818        // nevertheless enough proof when the filesystem clock is far ahead.
819        discovered
820            .verify_not_modified_since_with_snapshots(SystemTime::UNIX_EPOCH, &before)
821            .unwrap();
822
823        // Conversely, a filesystem clock far behind must not conceal a write.
824        std::fs::write(&source, "pub fn library() {1}\n").unwrap();
825        assert_eq!(
826            discovered.verify_not_modified_since_with_snapshots(
827                SystemTime::now() + std::time::Duration::from_secs(60),
828                &before,
829            ),
830            Err(BypassReason::InputModifiedDuringCompilation(source))
831        );
832    }
833
834    /// The MSVC toolset directories `cc`-built dependencies hand to every
835    /// downstream compile on Windows: absolute, version-stamped, and outside
836    /// every mapped root.
837    fn toolchain_native_directory(version: &str) -> PathBuf {
838        if cfg!(windows) {
839            PathBuf::from(format!(r"C:\Program Files\MSVC\{version}\lib\x64"))
840        } else {
841            PathBuf::from(format!("/opt/msvc/{version}/lib/x64"))
842        }
843    }
844
845    fn library_with_native_search(source: &Path, directory: &Path) -> RustcInvocation {
846        RustcInvocation::parse(&[
847            "--crate-name=widget".into(),
848            "--crate-type=lib".into(),
849            "--emit=metadata,link".into(),
850            format!("-Lnative={}", directory.display()).into(),
851            source.to_path_buf().into_os_string(),
852        ])
853        .unwrap()
854    }
855
856    fn library_context(root: &Path, mappings: Vec<PathMapping>) -> ActionContext {
857        ActionContext {
858            compiler: crate::CompilerIdentity {
859                toolchain: "core:rust@test".into(),
860                rustc_version: "test".into(),
861                host: std::env::consts::ARCH.into(),
862                driver: None,
863            },
864            working_dir: root.to_path_buf(),
865            path_mappings: mappings,
866            environment: BTreeMap::new(),
867            portable_environment: BTreeSet::new(),
868            inputs: Vec::new(),
869        }
870    }
871
872    #[test]
873    fn unmapped_native_directory_is_keyed_by_path_for_library_emits() {
874        let directory = tempfile::tempdir().unwrap();
875        let root = directory.path();
876        let source = root.join("lib.rs");
877        std::fs::write(&source, "pub fn library() {}\n").unwrap();
878        let toolchain = toolchain_native_directory("14.51.36231");
879        let invocation = library_with_native_search(&source, &toolchain);
880        let mappings = vec![PathMapping::new(root, "workspace")];
881        let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
882
883        // The directory does not even exist: its contents are not inputs.
884        let discovered = invocation
885            .discover_inputs_with_mappings(
886                &dep_info,
887                root,
888                &mappings,
889                &mbx_cache_core::NoFileDigestCache,
890            )
891            .unwrap();
892        assert_eq!(discovered.inputs.len(), 1);
893        assert_eq!(discovered.inputs[0].path, source);
894
895        // The literal path is key material, so a toolset update misses.
896        let context = library_context(root, mappings.clone());
897        let digest = invocation.invocation_digest(&context).unwrap();
898        let updated = library_with_native_search(&source, &toolchain_native_directory("14.52.0"));
899        assert_ne!(digest, updated.invocation_digest(&context).unwrap());
900
901        // The prediction skips the directory the same way discovery does, so a
902        // build that replays it derives the action key dep-info would have.
903        let mut recorded = context.clone();
904        discovered.clone().apply_to(&mut recorded).unwrap();
905        let action = invocation.action(recorded).unwrap();
906        let prediction = invocation.prediction(&context, &discovered).unwrap();
907        let replayed = prediction
908            .discover(
909                root,
910                &context.path_mappings,
911                &mbx_cache_core::NoFileDigestCache,
912            )
913            .unwrap();
914        let mut replay_context = context.clone();
915        replayed.apply_to(&mut replay_context).unwrap();
916        assert_eq!(
917            invocation.action(replay_context).unwrap().digest,
918            action.digest
919        );
920    }
921
922    #[test]
923    fn unmapped_native_directory_still_refuses_a_native_link() {
924        let directory = tempfile::tempdir().unwrap();
925        let root = directory.path();
926        let source = root.join("main.rs");
927        std::fs::write(&source, "fn main() {}\n").unwrap();
928        let toolchain = toolchain_native_directory("14.51.36231");
929        let invocation = RustcInvocation::parse_with(
930            &[
931                "--crate-name=app".into(),
932                "--crate-type=bin".into(),
933                "--emit=link".into(),
934                format!("-Lnative={}", toolchain.display()).into(),
935                source.clone().into_os_string(),
936            ],
937            crate::ParseOptions::caching_native_links(true),
938        )
939        .unwrap();
940        let mappings = vec![PathMapping::new(root, "workspace")];
941
942        // A linker reads those directories, so their contents stay inputs the
943        // key must account for, and an unmapped one stays a bypass.
944        let context = library_context(root, mappings.clone());
945        assert!(matches!(
946            invocation.invocation_digest(&context),
947            Err(BypassReason::UnmappedAbsolutePath(_))
948        ));
949        let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
950        assert_eq!(
951            invocation.discover_inputs_with_mappings(
952                &dep_info,
953                root,
954                &mappings,
955                &mbx_cache_core::NoFileDigestCache
956            ),
957            Err(BypassReason::UnsupportedSearchPath("native".into()))
958        );
959    }
960
961    #[test]
962    fn discovery_resolves_parent_components_against_the_working_directory() {
963        let directory = tempfile::tempdir().unwrap();
964        let root = directory.path().join("project");
965        let shared = directory.path().join("shared.rs");
966        std::fs::create_dir(&root).unwrap();
967        std::fs::write(&shared, "pub fn shared() {}\n").unwrap();
968
969        let invocation = RustcInvocation::parse(&args(&[
970            "--crate-name=widget",
971            "--crate-type=lib",
972            "--emit=metadata",
973            "../shared.rs",
974        ]))
975        .unwrap();
976        let dep_info = RustcDepInfo::parse("output: ../shared.rs\n").unwrap();
977        let discovered = invocation.discover_inputs(&dep_info, &root).unwrap();
978
979        assert_eq!(discovered.inputs.len(), 1);
980        assert_eq!(discovered.inputs[0].path, shared);
981    }
982
983    #[test]
984    fn rustc_dep_info_round_trip_discovers_real_inputs() {
985        let directory = tempfile::tempdir().unwrap();
986        let root = directory.path();
987        std::fs::write(
988            root.join("lib.rs"),
989            "mod child; const _: &str = include_str!(\"data file.txt\"); \
990             const _: &str = env!(\"MBX_DISCOVERY_TEST\"); \
991             const _: Option<&str> = option_env!(\"MBX_DISCOVERY_UNSET\");",
992        )
993        .unwrap();
994        std::fs::write(root.join("child.rs"), "pub fn child() {}\n").unwrap();
995        std::fs::write(root.join("data file.txt"), "included\n").unwrap();
996
997        let invocation = RustcInvocation::parse(&args(&[
998            "--crate-name=mbx_cache_discovery_test",
999            "--crate-type=lib",
1000            "--emit=metadata,link",
1001            "lib.rs",
1002        ]))
1003        .unwrap();
1004        let dep_info_path = root.join("discovery inputs.d");
1005        let discovery_command = invocation.dep_info_command(&dep_info_path).unwrap();
1006        let rustc = std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into());
1007        let output = match Command::new(rustc)
1008            .args(discovery_command.arguments())
1009            .current_dir(root)
1010            .env("MBX_DISCOVERY_TEST", "observed")
1011            .env_remove("MBX_DISCOVERY_UNSET")
1012            .output()
1013        {
1014            Ok(output) => output,
1015            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return,
1016            Err(error) => panic!("failed to execute rustc: {error}"),
1017        };
1018        assert!(
1019            output.status.success(),
1020            "{}",
1021            String::from_utf8_lossy(&output.stderr)
1022        );
1023        let parsed = RustcDepInfo::read(&dep_info_path).unwrap();
1024        let discovered = invocation.discover_inputs(&parsed, root).unwrap();
1025        assert_eq!(
1026            discovered.environment["MBX_DISCOVERY_TEST"],
1027            Some("observed".into())
1028        );
1029        assert_eq!(discovered.environment["MBX_DISCOVERY_UNSET"], None);
1030        assert_eq!(discovered.inputs.len(), 3);
1031        assert!(
1032            discovered
1033                .inputs
1034                .iter()
1035                .all(|input| input.digest.algorithm == "blake3")
1036        );
1037        let mut context = ActionContext {
1038            compiler: crate::CompilerIdentity {
1039                toolchain: "core:rust@test".into(),
1040                rustc_version: "test".into(),
1041                host: std::env::consts::ARCH.into(),
1042                driver: None,
1043            },
1044            working_dir: root.to_path_buf(),
1045            path_mappings: vec![crate::PathMapping::new(root, "workspace")],
1046            environment: BTreeMap::new(),
1047            portable_environment: BTreeSet::new(),
1048            inputs: Vec::new(),
1049        };
1050        discovered.clone().apply_to(&mut context).unwrap();
1051        let action = invocation.action(context).unwrap();
1052        assert!(
1053            String::from_utf8(action.bytes)
1054                .unwrap()
1055                .contains(r#""MBX_DISCOVERY_TEST":"observed""#)
1056        );
1057        discovered.verify().unwrap();
1058        std::fs::write(root.join("child.rs"), "pub fn changed() {}\n").unwrap();
1059        assert_eq!(
1060            discovered.verify(),
1061            Err(BypassReason::InputChanged(root.join("child.rs")))
1062        );
1063    }
1064
1065    /// A ledger that answers with a sentinel and remembers what was recorded.
1066    struct SentinelLedger {
1067        known: FileIdentity,
1068        digest: CacheDigest,
1069        recorded: std::sync::Mutex<Vec<RecordedFileDigest>>,
1070    }
1071
1072    impl FileDigestCache for SentinelLedger {
1073        fn find(&self, scope: FileDigestScope, files: &[FileIdentity]) -> Vec<Option<CacheDigest>> {
1074            assert_eq!(scope, FileDigestScope::Content);
1075            files
1076                .iter()
1077                .map(|file| (*file == self.known).then(|| self.digest.clone()))
1078                .collect()
1079        }
1080
1081        fn record(&self, scope: FileDigestScope, entries: Vec<RecordedFileDigest>) {
1082            assert_eq!(scope, FileDigestScope::Content);
1083            self.recorded.lock().unwrap().extend(entries);
1084        }
1085    }
1086
1087    #[test]
1088    fn discovery_reuses_recorded_digests_and_records_fresh_ones() {
1089        let directory = tempfile::tempdir().unwrap();
1090        let root = directory.path();
1091        let known_path = root.join("libdep.rlib");
1092        let fresh_path = root.join("lib.rs");
1093        std::fs::write(&known_path, b"rlib bytes").unwrap();
1094        std::fs::write(&fresh_path, b"fn lib() {}").unwrap();
1095        let metadata = std::fs::metadata(&known_path).unwrap();
1096        // A sentinel digest that hashing could never produce proves the read
1097        // was skipped: the discovered input carries it verbatim.
1098        let sentinel = CacheDigest {
1099            algorithm: "blake3".into(),
1100            hash: "c".repeat(64),
1101            size: metadata.len(),
1102        };
1103        let ledger = SentinelLedger {
1104            known: FileIdentity::describe(&known_path, &metadata).unwrap(),
1105            digest: sentinel.clone(),
1106            recorded: std::sync::Mutex::new(Vec::new()),
1107        };
1108
1109        let discovered = DiscoveredInputs::from_paths(
1110            root,
1111            BTreeSet::from([known_path.clone(), fresh_path.clone()]),
1112            BTreeMap::new(),
1113            &ledger,
1114        )
1115        .unwrap();
1116
1117        let by_path = |path: &Path| {
1118            discovered
1119                .inputs
1120                .iter()
1121                .find(|input| input.path == path)
1122                .unwrap()
1123                .digest
1124                .clone()
1125        };
1126        assert_eq!(
1127            by_path(&known_path),
1128            sentinel,
1129            "the recorded digest answers"
1130        );
1131        assert_eq!(
1132            by_path(&fresh_path),
1133            CacheDigest::blake3_file(&fresh_path).unwrap(),
1134            "an unrecorded file is hashed"
1135        );
1136        let recorded = ledger.recorded.lock().unwrap();
1137        assert_eq!(recorded.len(), 1, "only the fresh hash is recorded");
1138        assert_eq!(recorded[0].file.path, fresh_path);
1139        assert_eq!(recorded[0].digest, by_path(&fresh_path));
1140    }
1141
1142    /// Verification after the compiler runs confirms an input by its recorded
1143    /// identity rather than by reading it again; a file that has moved on is
1144    /// still read and still fails. Only where the identity carries a change
1145    /// time, which is what makes the stat as good as the read.
1146    #[cfg(unix)]
1147    #[test]
1148    fn verification_trusts_an_unchanged_identity_and_rereads_a_changed_one() {
1149        let directory = tempfile::tempdir().unwrap();
1150        let root = directory.path();
1151        let known_path = root.join("libdep.rlib");
1152        std::fs::write(&known_path, b"rlib bytes").unwrap();
1153        let metadata = std::fs::metadata(&known_path).unwrap();
1154        // The sentinel could never come from hashing the file, so a verify
1155        // that passes can only have trusted the identity.
1156        let sentinel = CacheDigest {
1157            algorithm: "blake3".into(),
1158            hash: "c".repeat(64),
1159            size: metadata.len(),
1160        };
1161        let ledger = SentinelLedger {
1162            known: FileIdentity::describe(&known_path, &metadata).unwrap(),
1163            digest: sentinel,
1164            recorded: std::sync::Mutex::new(Vec::new()),
1165        };
1166        let discovered = DiscoveredInputs::from_paths(
1167            root,
1168            BTreeSet::from([known_path.clone()]),
1169            BTreeMap::new(),
1170            &ledger,
1171        )
1172        .unwrap();
1173
1174        discovered.verify().unwrap();
1175
1176        // Rewritten with the same length: the identity moves with the write,
1177        // so the file is read again and the sentinel no longer matches it.
1178        std::thread::sleep(std::time::Duration::from_millis(20));
1179        std::fs::write(&known_path, b"RLIB BYTES").unwrap();
1180        assert_eq!(
1181            discovered.verify(),
1182            Err(BypassReason::InputChanged(known_path.clone()))
1183        );
1184
1185        std::fs::remove_file(&known_path).unwrap();
1186        assert!(matches!(
1187            discovered.verify(),
1188            Err(BypassReason::InputRead { path, .. }) if path == known_path
1189        ));
1190    }
1191
1192    /// A rewrite that restores length and modification time must still be
1193    /// hashed: the platform change token moves with every write, so the
1194    /// identity the ledger recorded no longer describes the file.
1195    #[cfg(unix)]
1196    #[test]
1197    fn a_disguised_rewrite_is_not_answered_from_the_ledger() {
1198        let directory = tempfile::tempdir().unwrap();
1199        let root = directory.path();
1200        let path = root.join("lib.rs");
1201        std::fs::write(&path, b"fn lib() -> u8 { 1 }").unwrap();
1202        let before = std::fs::metadata(&path).unwrap();
1203        let identity = FileIdentity::describe(&path, &before).unwrap();
1204
1205        // Same length, modification time put back: only the change token can
1206        // tell this file has new contents.
1207        std::fs::write(&path, b"fn lib() -> u8 { 2 }").unwrap();
1208        let file = std::fs::File::options().write(true).open(&path).unwrap();
1209        file.set_times(std::fs::FileTimes::new().set_modified(before.modified().unwrap()))
1210            .unwrap();
1211        drop(file);
1212        let after = std::fs::metadata(&path).unwrap();
1213        let disguised = FileIdentity::describe(&path, &after).unwrap();
1214        assert_eq!(disguised.len, identity.len);
1215        assert_eq!(disguised.modified, identity.modified);
1216        assert_ne!(
1217            disguised, identity,
1218            "the change token must expose the rewrite"
1219        );
1220
1221        let ledger = SentinelLedger {
1222            known: identity,
1223            digest: CacheDigest {
1224                algorithm: "blake3".into(),
1225                hash: "e".repeat(64),
1226                size: after.len(),
1227            },
1228            recorded: std::sync::Mutex::new(Vec::new()),
1229        };
1230        let discovered = DiscoveredInputs::from_paths(
1231            root,
1232            BTreeSet::from([path.clone()]),
1233            BTreeMap::new(),
1234            &ledger,
1235        )
1236        .unwrap();
1237        assert_eq!(
1238            discovered.inputs[0].digest,
1239            CacheDigest::blake3_file(&path).unwrap(),
1240            "the disguised rewrite is hashed, not answered from the ledger"
1241        );
1242    }
1243}