Skip to main content

mbx_cache_rustc/
dep_info.rs

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