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, digest_file_validated,
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    /// Inputs that entered only because they sit in a native search directory,
148    /// not because dep-info or the invocation named them. They belong in a
149    /// shared action key, but they are not the crate's own sources.
150    native_only: BTreeSet<PathBuf>,
151}
152
153impl DiscoveredInputs {
154    pub(crate) fn from_paths(
155        working_dir: &Path,
156        paths: BTreeSet<PathBuf>,
157        environment: BTreeMap<String, Option<String>>,
158        digests: &dyn FileDigestCache,
159    ) -> Result<Self, BypassReason> {
160        if !working_dir.is_absolute() {
161            return Err(BypassReason::RelativeWorkingDirectory(
162                working_dir.to_path_buf(),
163            ));
164        }
165        let working_dir = normalize_components(working_dir);
166        // Stat everything first: the identities drive one batched ledger
167        // lookup, so an upstream rlib the session already hashed -- once, when
168        // it was materialized or published -- is not read again by every crate
169        // that links it. A file the filesystem reports no modification time
170        // for gets no identity and is simply hashed.
171        let mut identified = Vec::with_capacity(paths.len());
172        for path in paths {
173            let metadata = std::fs::metadata(&path).map_err(|error| BypassReason::InputRead {
174                path: path.clone(),
175                message: error.to_string(),
176            })?;
177            if !metadata.is_file() {
178                return Err(BypassReason::InputRead {
179                    path,
180                    message: "input is not a regular file".into(),
181                });
182            }
183            let identity = FileIdentity::for_digest_cache(&path, &metadata).map_err(|error| {
184                BypassReason::InputRead {
185                    path: path.clone(),
186                    message: error.to_string(),
187                }
188            })?;
189            identified.push((path, identity));
190        }
191        let queries = identified
192            .iter()
193            .filter_map(|(_, identity)| identity.clone())
194            .collect::<Vec<_>>();
195        let mut recorded = digests
196            .resolve(FileDigestScope::Content, &queries)
197            .into_iter();
198        let mut inputs = Vec::with_capacity(identified.len());
199        let mut identities = Vec::with_capacity(identified.len());
200        let mut fresh = Vec::new();
201        for (path, identity) in identified {
202            let resolution = identity
203                .as_ref()
204                .and_then(|_| recorded.next())
205                .unwrap_or(FileDigestResolution::Unresolved);
206            let cached = match resolution {
207                FileDigestResolution::Digest(digest)
208                    if identity.as_ref().is_some_and(|identity| {
209                        identity.len == digest.size && identity.still_describes().unwrap_or(false)
210                    }) =>
211                {
212                    Some(digest)
213                }
214                FileDigestResolution::Digest(_)
215                | FileDigestResolution::EmbeddedTimestampMacro
216                | FileDigestResolution::Unresolved => None,
217            };
218            let digest = if let Some(digest) = cached {
219                identities.push(identity);
220                digest
221            } else {
222                let observed =
223                    digest_file_validated(FileDigestScope::Content, &path).map_err(|error| {
224                        BypassReason::InputRead {
225                            path: path.clone(),
226                            message: error.to_string(),
227                        }
228                    })?;
229                let (digest, observed_identity) = if let Some(observed) = observed {
230                    let digest = observed.resolution.into_digest().ok_or_else(|| {
231                        BypassReason::InputRead {
232                            path: path.clone(),
233                            message: "content digest resolution returned no digest".into(),
234                        }
235                    })?;
236                    if let Some(file) = observed.cache_identity.clone()
237                        && file.len == digest.size
238                    {
239                        fresh.push(RecordedFileDigest {
240                            file,
241                            digest: digest.clone(),
242                        });
243                    }
244                    (digest, observed.cache_identity)
245                } else {
246                    let digest = digest_file(FileDigestScope::Content, &path)
247                        .and_then(|resolution| {
248                            resolution.into_digest().ok_or_else(|| {
249                                std::io::Error::other(
250                                    "content digest resolution returned no digest",
251                                )
252                            })
253                        })
254                        .map_err(|error| BypassReason::InputRead {
255                            path: path.clone(),
256                            message: error.to_string(),
257                        })?;
258                    (digest, None)
259                };
260                identities.push(observed_identity);
261                digest
262            };
263            inputs.push(ActionInput { path, digest });
264        }
265        if !fresh.is_empty() {
266            digests.record(FileDigestScope::Content, fresh);
267        }
268        Ok(Self {
269            working_dir,
270            inputs,
271            environment,
272            identities,
273            native_only: BTreeSet::new(),
274        })
275    }
276
277    /// Mark inputs that were found only by scanning native search directories.
278    pub(crate) fn with_native_only(mut self, native_only: BTreeSet<PathBuf>) -> Self {
279        self.native_only = native_only;
280        self
281    }
282
283    /// Whether `path` is an input only because a native search directory holds it.
284    pub fn is_native_only(&self, path: &Path) -> bool {
285        self.native_only.contains(path)
286    }
287
288    /// Reject inputs whose modification time overlaps the compiler invocation.
289    ///
290    /// Input contents are first hashed after rustc reports their paths. This
291    /// timestamp barrier prevents a post-compile write from being mistaken for
292    /// the contents that produced the artifact. `verify` closes the remaining
293    /// race after hashing.
294    pub fn verify_not_modified_since(&self, started_at: SystemTime) -> Result<(), BypassReason> {
295        self.verify_not_modified_since_with_snapshots(started_at, &BTreeMap::new())
296    }
297
298    /// Reject inputs that changed from snapshots captured before rustc ran,
299    /// falling back to the wall-clock barrier for inputs only dep-info named.
300    ///
301    /// A snapshot may use metadata or content depending on what its filesystem
302    /// can compare reliably. Metadata snapshots need a change token: without
303    /// one a same-length rewrite can restore its mtime.
304    pub fn verify_not_modified_since_with_snapshots(
305        &self,
306        started_at: SystemTime,
307        before: &BTreeMap<PathBuf, FileSnapshot>,
308    ) -> Result<(), BypassReason> {
309        // A metadata-only change keeps the result, so it must not hide a
310        // content change to a later input.
311        let mut metadata_changed = None;
312        for input in &self.inputs {
313            if let Some(previous) = before.get(&input.path)
314                && previous.proves_content_change()
315            {
316                let metadata =
317                    std::fs::metadata(&input.path).map_err(|error| BypassReason::InputRead {
318                        path: input.path.clone(),
319                        message: error.to_string(),
320                    })?;
321                let identity = FileIdentity::for_digest_cache(&input.path, &metadata)
322                    .map_err(|error| BypassReason::InputRead {
323                        path: input.path.clone(),
324                        message: error.to_string(),
325                    })?
326                    .or_else(|| FileIdentity::describe(&input.path, &metadata));
327                if previous.matches(identity.as_ref(), &input.digest) {
328                    continue;
329                }
330                if previous.differs_only_in_change_token(identity.as_ref()) {
331                    metadata_changed.get_or_insert_with(|| input.path.clone());
332                    continue;
333                }
334                return Err(BypassReason::InputModifiedDuringCompilation(
335                    input.path.clone(),
336                ));
337            }
338            let metadata =
339                std::fs::metadata(&input.path).map_err(|error| BypassReason::InputRead {
340                    path: input.path.clone(),
341                    message: error.to_string(),
342                })?;
343            let modified = metadata
344                .modified()
345                .map_err(|error| BypassReason::InputRead {
346                    path: input.path.clone(),
347                    message: error.to_string(),
348                })?;
349            if modified >= started_at {
350                return Err(BypassReason::InputModifiedDuringCompilation(
351                    input.path.clone(),
352                ));
353            }
354        }
355        metadata_changed.map_or(Ok(()), |path| {
356            Err(BypassReason::InputMetadataChangedDuringCompilation(path))
357        })
358    }
359
360    /// Compatibility form for callers that captured metadata identities.
361    pub fn verify_not_modified_since_with_identities(
362        &self,
363        started_at: SystemTime,
364        before: &BTreeMap<PathBuf, FileIdentity>,
365    ) -> Result<(), BypassReason> {
366        let snapshots = before
367            .iter()
368            .map(|(path, identity)| (path.clone(), identity.clone().into()))
369            .collect();
370        self.verify_not_modified_since_with_snapshots(started_at, &snapshots)
371    }
372
373    /// Rehash every discovered file after compilation and before publication.
374    /// This closes the discovery/compile race by degrading changed inputs to a
375    /// cache miss rather than storing outputs beneath a stale action key.
376    pub fn verify(&self) -> Result<(), BypassReason> {
377        for (index, input) in self.inputs.iter().enumerate() {
378            let read_error = |error: std::io::Error| BypassReason::InputRead {
379                path: input.path.clone(),
380                message: error.to_string(),
381            };
382            // An input still wearing the identity discovery recorded -- length,
383            // modification time, and change time -- is confirmed by that stat
384            // alone. The change time is what makes this as good as reading: it
385            // cannot be set from user space, so a rewrite that puts the old
386            // modification time back still shows, where a platform that reports
387            // none would let a same-length rewrite inside one timestamp tick
388            // through. Such an identity is not trusted here, and neither is an
389            // input that had none. Reading everything again cost as much as
390            // keying the compilation did: a large binary's dependency rlibs, a
391            // gigabyte of them, read twice for every edit.
392            if let Some(Some(identity)) = self.identities.get(index)
393                && identity.can_skip_content_verification()
394                && identity.still_describes().map_err(read_error)?
395            {
396                continue;
397            }
398            let matches = input.digest.matches_file(&input.path).map_err(|error| {
399                BypassReason::InputRead {
400                    path: input.path.clone(),
401                    message: error.to_string(),
402                }
403            })?;
404            if !matches {
405                return Err(BypassReason::InputChanged(input.path.clone()));
406            }
407        }
408        Ok(())
409    }
410
411    /// Merge the manifest into an action context after verifying that both use
412    /// the same compiler working directory.
413    pub fn apply_to(self, context: &mut ActionContext) -> Result<(), BypassReason> {
414        if normalize_components(&context.working_dir) != self.working_dir {
415            return Err(BypassReason::DiscoveryWorkingDirectory);
416        }
417        for (name, value) in &self.environment {
418            if context
419                .environment
420                .get(name)
421                .is_some_and(|previous| previous != value)
422            {
423                return Err(BypassReason::ConflictingEnvironment(name.clone()));
424            }
425        }
426        context.environment.extend(self.environment);
427        context.inputs.extend(self.inputs);
428        Ok(())
429    }
430}
431
432impl RustcInvocation {
433    /// Replace the original output flags with a single explicit dep-info file.
434    pub fn dep_info_command(&self, output: &Path) -> Result<DepInfoCommand, BypassReason> {
435        if !output.is_absolute() {
436            return Err(BypassReason::RelativeDepInfoPath(output.to_path_buf()));
437        }
438        let output_text = output
439            .to_str()
440            .ok_or_else(|| BypassReason::NonUtf8Path(output.to_path_buf()))?;
441        if output_text.contains(',') {
442            return Err(BypassReason::UnsafeDepInfoPath(output.to_path_buf()));
443        }
444
445        let mut arguments = Vec::new();
446        for argument in &self.arguments {
447            match argument {
448                Argument::Emit(_) => {}
449                Argument::Path { flag, .. } if flag == "--out-dir" || flag == "-o" => {}
450                argument => arguments.push(render_argument(argument)?),
451            }
452        }
453        arguments.push(format!("--emit=dep-info={output_text}").into());
454        arguments.push(self.source.clone().into_os_string());
455        Ok(DepInfoCommand {
456            arguments,
457            output: output.to_path_buf(),
458        })
459    }
460
461    /// Hash dep-info sources plus every direct compiler input already modeled
462    /// by the invocation (`--extern` artifacts and custom target specs).
463    pub fn discover_inputs(
464        &self,
465        dep_info: &RustcDepInfo,
466        working_dir: &Path,
467    ) -> Result<DiscoveredInputs, BypassReason> {
468        self.discover_inputs_with_mappings(
469            dep_info,
470            working_dir,
471            &[],
472            &mbx_cache_core::NoFileDigestCache,
473        )
474    }
475
476    /// Hash dep-info sources plus modeled compiler inputs, allowing native
477    /// search directories beneath the working directory or a mapped root.
478    ///
479    /// `digests` may answer for files the session already read in full;
480    /// pass [`mbx_cache_core::NoFileDigestCache`] to hash everything.
481    pub fn discover_inputs_with_mappings(
482        &self,
483        dep_info: &RustcDepInfo,
484        working_dir: &Path,
485        path_mappings: &[PathMapping],
486        digests: &dyn FileDigestCache,
487    ) -> Result<DiscoveredInputs, BypassReason> {
488        self.discover(dep_info, working_dir, Some(path_mappings), digests)
489    }
490
491    /// Hash dep-info sources and modeled compiler inputs, leaving native search
492    /// directories out.
493    ///
494    /// The result cannot key a shared action: the linker may read libraries it
495    /// does not list. It is enough to tell whether a crate's own sources changed,
496    /// which is all that private incremental state needs, so it works for a
497    /// compilation whose search paths [`Self::discover_inputs_with_mappings`]
498    /// refuses.
499    pub fn discover_source_inputs(
500        &self,
501        dep_info: &RustcDepInfo,
502        working_dir: &Path,
503        digests: &dyn FileDigestCache,
504    ) -> Result<DiscoveredInputs, BypassReason> {
505        self.discover(dep_info, working_dir, None, digests)
506    }
507
508    /// Native search directories are collected only when `path_mappings` is given.
509    fn discover(
510        &self,
511        dep_info: &RustcDepInfo,
512        working_dir: &Path,
513        path_mappings: Option<&[PathMapping]>,
514        digests: &dyn FileDigestCache,
515    ) -> Result<DiscoveredInputs, BypassReason> {
516        if !working_dir.is_absolute() {
517            return Err(BypassReason::RelativeWorkingDirectory(
518                working_dir.to_path_buf(),
519            ));
520        }
521        let working_dir = normalize_components(working_dir);
522        let absolute = |path: &PathBuf| {
523            let absolute = if path.is_absolute() {
524                path.to_path_buf()
525            } else {
526                working_dir.join(path)
527            };
528            normalize_components(&absolute)
529        };
530        // What dep-info names is the crate's own. A required input also covers
531        // the crate root, `--extern` artifacts and target specifications, so it
532        // is a source unless a native search directory holds it: there it is a
533        // library resolved from that directory, which belongs in a shared key
534        // but is no more a source than the directory around it.
535        let sources = dep_info.files.iter().map(absolute).collect::<BTreeSet<_>>();
536        let mut paths = sources.clone();
537        paths.extend(self.required_inputs.iter().map(absolute));
538        let admitted_roots = native_input_roots(&working_dir, path_mappings.unwrap_or_default());
539        let mut native_bytes = 0_u64;
540        let mut counted = BTreeSet::new();
541        for argument in &self.arguments {
542            if path_mappings.is_some()
543                && let Argument::SearchPath { kind, path } = argument
544                && kind == "native"
545            {
546                let directory = if path.is_absolute() {
547                    path.clone()
548                } else {
549                    working_dir.join(path)
550                };
551                collect_native_directory(
552                    &directory,
553                    &admitted_roots,
554                    &mut paths,
555                    &mut native_bytes,
556                    &mut counted,
557                )?;
558            }
559        }
560        let native_directories = self
561            .arguments
562            .iter()
563            .filter_map(|argument| match argument {
564                Argument::SearchPath { kind, path } if kind == "native" => Some(absolute(path)),
565                _ => None,
566            })
567            .collect::<Vec<_>>();
568        let native_only = paths
569            .difference(&sources)
570            .filter(|path| native_directories.iter().any(|root| path.starts_with(root)))
571            .cloned()
572            .collect();
573        Ok(DiscoveredInputs::from_paths(
574            &working_dir,
575            paths,
576            dep_info.environment.clone(),
577            digests,
578        )?
579        .with_native_only(native_only))
580    }
581}
582
583/// Return normalized roots whose native search directories can be tracked.
584pub(super) fn native_input_roots(
585    working_dir: &Path,
586    path_mappings: &[PathMapping],
587) -> Vec<PathBuf> {
588    std::iter::once(working_dir)
589        .chain(path_mappings.iter().map(|mapping| mapping.root.as_path()))
590        .map(normalize_components)
591        .collect()
592}
593
594/// Add regular files beneath an admitted native search directory, enforcing
595/// the prediction input count and the caller's cumulative native byte budget.
596/// `counted` holds the files already charged to that budget, so overlapping
597/// walks charge each file once even when `paths` already names it.
598pub(super) fn collect_native_directory(
599    directory: &Path,
600    admitted_roots: &[PathBuf],
601    paths: &mut BTreeSet<PathBuf>,
602    native_bytes: &mut u64,
603    counted: &mut BTreeSet<PathBuf>,
604) -> Result<(), BypassReason> {
605    let directory = normalize_components(directory);
606    if !admitted_roots
607        .iter()
608        .any(|root| directory.starts_with(root))
609    {
610        return Err(BypassReason::UnsupportedSearchPath("native".into()));
611    }
612
613    let canonical_root = match directory.canonicalize() {
614        Ok(root) => root,
615        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
616        Err(error) => {
617            return Err(BypassReason::InputRead {
618                path: directory,
619                message: error.to_string(),
620            });
621        }
622    };
623    let mut pending = vec![directory];
624    while let Some(directory) = pending.pop() {
625        let entries = match std::fs::read_dir(&directory) {
626            Ok(entries) => entries,
627            Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
628            Err(error) => {
629                return Err(BypassReason::InputRead {
630                    path: directory,
631                    message: error.to_string(),
632                });
633            }
634        };
635        for entry in entries {
636            let entry = entry.map_err(|error| BypassReason::InputRead {
637                path: directory.clone(),
638                message: error.to_string(),
639            })?;
640            let path = entry.path();
641            let file_type = entry.file_type().map_err(|error| BypassReason::InputRead {
642                path: path.clone(),
643                message: error.to_string(),
644            })?;
645            if file_type.is_dir() {
646                if !is_linker_irrelevant_directory(&path) {
647                    pending.push(path);
648                }
649            } else if is_linker_irrelevant_file(&path) {
650                continue;
651            } else if file_type.is_file() || file_type.is_symlink() {
652                // Homebrew's versionless dylibs are symlinks. Hash their
653                // referents under the searched name, but do not follow directory
654                // links, dangling links, or links outside this search tree.
655                if file_type.is_symlink() {
656                    let resolved =
657                        path.canonicalize()
658                            .map_err(|error| BypassReason::InputRead {
659                                path: path.clone(),
660                                message: error.to_string(),
661                            })?;
662                    if !resolved.starts_with(&canonical_root) || !resolved.is_file() {
663                        return Err(BypassReason::UnsupportedSearchPath("native".into()));
664                    }
665                }
666                let len = std::fs::metadata(&path)
667                    .map_err(|error| BypassReason::InputRead {
668                        path: path.clone(),
669                        message: error.to_string(),
670                    })?
671                    .len();
672                // A directory named by several `-L native=` arguments, or nested
673                // under another one, is walked more than once; count each file
674                // against the budget only the first time it is seen.
675                paths.insert(path.clone());
676                if counted.insert(path) {
677                    *native_bytes = native_bytes
678                        .checked_add(len)
679                        .ok_or_else(|| BypassReason::UnsupportedSearchPath("native".into()))?;
680                }
681            } else {
682                return Err(BypassReason::UnsupportedSearchPath("native".into()));
683            }
684            if paths.len() > MAX_PREDICTED_INPUTS || *native_bytes > MAX_NATIVE_INPUT_BYTES {
685                return Err(BypassReason::UnsupportedSearchPath("native".into()));
686            }
687        }
688    }
689    Ok(())
690}
691
692/// Directories that build scripts leave beside libraries but that a linker
693/// never reads. They often record the absolute output path (`prefix=` in
694/// pkg-config files, `binary-path:` in dSYM relocation YAML), so hashing them
695/// would make the key depend on the target directory.
696fn is_linker_irrelevant_directory(path: &Path) -> bool {
697    let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
698        return false;
699    };
700    name == "pkgconfig" || name == "cmake" || name.ends_with(".dSYM")
701}
702
703/// pkg-config and CMake metadata. Headers stay hashed: linker scripts can
704/// `INCLUDE` files from a search path, and `+verbatim` names any file.
705fn is_linker_irrelevant_file(path: &Path) -> bool {
706    path.extension()
707        .and_then(|extension| extension.to_str())
708        .is_some_and(|extension| {
709            ["pc", "cmake"]
710                .iter()
711                .any(|ignored| extension.eq_ignore_ascii_case(ignored))
712        })
713}
714
715fn render_argument(argument: &Argument) -> Result<OsString, BypassReason> {
716    let rendered = match argument {
717        Argument::Plain(value) => value.clone(),
718        Argument::Path { flag, path } => format!(
719            "{flag}={}",
720            path.to_str()
721                .ok_or_else(|| BypassReason::NonUtf8Path(path.clone()))?
722        ),
723        Argument::SearchPath { kind, path } => format!(
724            "-L{kind}={}",
725            path.to_str()
726                .ok_or_else(|| BypassReason::NonUtf8Path(path.clone()))?
727        ),
728        Argument::Extern { name, path } => match path {
729            Some(path) => format!(
730                "--extern={name}={}",
731                path.to_str()
732                    .ok_or_else(|| BypassReason::NonUtf8Path(path.clone()))?
733            ),
734            None => format!("--extern={name}"),
735        },
736        Argument::Emit(_) => unreachable!("emit arguments are removed before rendering"),
737        Argument::RemapPath { from, to } => format!(
738            "--remap-path-prefix={}={to}",
739            from.to_str()
740                .ok_or_else(|| BypassReason::NonUtf8Path(from.clone()))?
741        ),
742        // Nothing links while emitting dep-info, so the prefix is inert here;
743        // it is replayed verbatim to keep the command faithful.
744        Argument::OsoPrefix {
745            path,
746            trailing_slash,
747        } => format!(
748            "--codegen=link-arg=-Wl,-oso_prefix,{}{}",
749            path.to_str()
750                .ok_or_else(|| BypassReason::NonUtf8Path(path.clone()))?,
751            if *trailing_slash { "/" } else { "" }
752        ),
753        // Nothing links while emitting dep-info either; replayed verbatim for
754        // the same reason as the prefix above.
755        Argument::InstallName(name) => {
756            format!("--codegen=link-arg=-Wl,-install_name,@rpath/{name}")
757        }
758        Argument::FuseLd(selection) => format!("--codegen=link-arg=-fuse-ld={selection}"),
759    };
760    Ok(rendered.into())
761}
762
763fn unescape_environment(value: &str) -> Result<String, BypassReason> {
764    let mut output = String::with_capacity(value.len());
765    let mut characters = value.chars();
766    while let Some(character) = characters.next() {
767        if character != '\\' {
768            output.push(character);
769            continue;
770        }
771        match characters.next() {
772            Some('\\') => output.push('\\'),
773            Some('n') => output.push('\n'),
774            Some('r') => output.push('\r'),
775            Some(character) => {
776                return Err(BypassReason::MalformedDepInfo(format!(
777                    "unknown environment escape \\{character}"
778                )));
779            }
780            None => {
781                return Err(BypassReason::MalformedDepInfo(
782                    "environment input ends with an unterminated escape".into(),
783                ));
784            }
785        }
786    }
787    Ok(output)
788}
789
790#[cfg(test)]
791mod tests {
792    use super::*;
793    use std::process::Command;
794
795    fn args(values: &[&str]) -> Vec<OsString> {
796        values.iter().map(OsString::from).collect()
797    }
798
799    #[test]
800    fn parses_files_spaces_and_environment_records() {
801        let parsed = RustcDepInfo::parse(
802            "target/lib.rlib: src/lib.rs src/a\\ file.rs generated.rs\n\
803             src/lib.rs:\n\
804             # env-dep:SET=value\\nnext\n\
805             # env-dep:UNSET\n\
806             # env-dep:SLASH=a\\\\b\n",
807        )
808        .unwrap();
809        assert_eq!(
810            parsed.files,
811            vec![
812                PathBuf::from("generated.rs"),
813                PathBuf::from("src/a file.rs"),
814                PathBuf::from("src/lib.rs"),
815            ]
816        );
817        assert_eq!(parsed.environment["SET"], Some("value\nnext".into()));
818        assert_eq!(parsed.environment["UNSET"], None);
819        assert_eq!(parsed.environment["SLASH"], Some(r"a\b".into()));
820    }
821
822    #[test]
823    fn malformed_dep_info_bypasses_caching() {
824        for contents in [
825            "",
826            "target: ",
827            "target: src/trailing\\\n",
828            "target: src/lib.rs\n# env-dep:NAME=bad\\q\n",
829        ] {
830            assert!(RustcDepInfo::parse(contents).is_err(), "{contents:?}");
831        }
832    }
833
834    #[test]
835    fn native_directory_byte_limit_is_cumulative() {
836        let directory = tempfile::tempdir().unwrap();
837        let native = directory.path().join("native");
838        std::fs::create_dir_all(&native).unwrap();
839        std::fs::write(native.join("input.lib"), b"xx").unwrap();
840        let roots = native_input_roots(directory.path(), &[]);
841        let mut paths = BTreeSet::new();
842        let mut native_bytes = MAX_NATIVE_INPUT_BYTES - 1;
843        let mut counted = BTreeSet::new();
844
845        assert_eq!(
846            collect_native_directory(&native, &roots, &mut paths, &mut native_bytes, &mut counted),
847            Err(BypassReason::UnsupportedSearchPath("native".into()))
848        );
849    }
850
851    #[test]
852    fn native_directory_walked_twice_counts_bytes_once() {
853        let directory = tempfile::tempdir().unwrap();
854        let native = directory.path().join("native");
855        std::fs::create_dir_all(native.join("lib")).unwrap();
856        std::fs::write(native.join("input.lib"), b"xx").unwrap();
857        std::fs::write(native.join("lib").join("nested.lib"), b"yyy").unwrap();
858        let roots = native_input_roots(directory.path(), &[]);
859        let mut paths = BTreeSet::new();
860        let mut native_bytes = 0;
861        let mut counted = BTreeSet::new();
862
863        for dir in [&native, &native, &native.join("lib")] {
864            collect_native_directory(dir, &roots, &mut paths, &mut native_bytes, &mut counted)
865                .unwrap();
866        }
867
868        assert_eq!(paths.len(), 2);
869        assert_eq!(native_bytes, 5);
870    }
871
872    #[test]
873    fn native_directory_charges_files_already_named_as_inputs() {
874        let directory = tempfile::tempdir().unwrap();
875        let native = directory.path().join("native");
876        std::fs::create_dir_all(&native).unwrap();
877        let archive = native.join("libfoo.a");
878        std::fs::write(&archive, b"xx").unwrap();
879        let roots = native_input_roots(directory.path(), &[]);
880        let mut paths = BTreeSet::from([archive]);
881        let mut native_bytes = 0;
882        let mut counted = BTreeSet::new();
883
884        collect_native_directory(&native, &roots, &mut paths, &mut native_bytes, &mut counted)
885            .unwrap();
886
887        assert_eq!(native_bytes, 2);
888    }
889
890    #[test]
891    fn native_directory_skips_files_the_linker_never_reads() {
892        let directory = tempfile::tempdir().unwrap();
893        let native = directory.path().join("native");
894        for ignored in [
895            "lib/pkgconfig/zlib.pc",
896            "check.dSYM/Contents/Resources/Relocations/aarch64/check.yml",
897            "cmake/Config.cmake",
898            "top.pc",
899        ] {
900            let path = native.join(ignored);
901            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
902            std::fs::write(path, b"prefix=/abs/target\n").unwrap();
903        }
904        std::fs::write(native.join("libz.a"), b"!<arch>\n").unwrap();
905        std::fs::create_dir_all(native.join("lib")).unwrap();
906        std::fs::write(native.join("lib/libz.so.1"), b"elf").unwrap();
907        let roots = native_input_roots(directory.path(), &[]);
908        let mut paths = BTreeSet::new();
909        let mut native_bytes = 0;
910        let mut counted = BTreeSet::new();
911
912        collect_native_directory(&native, &roots, &mut paths, &mut native_bytes, &mut counted)
913            .unwrap();
914
915        assert_eq!(
916            paths.into_iter().collect::<Vec<_>>(),
917            vec![native.join("lib/libz.so.1"), native.join("libz.a")]
918        );
919    }
920
921    #[test]
922    fn discovery_command_removes_real_outputs() {
923        let invocation = RustcInvocation::parse(&args(&[
924            "--crate-name=widget",
925            "--crate-type=lib",
926            "--emit=dep-info,metadata,link",
927            "--out-dir=target/debug/deps",
928            "-o",
929            "target/debug/libwidget.rlib",
930            "src/lib.rs",
931        ]))
932        .unwrap();
933        let output = if cfg!(windows) {
934            PathBuf::from(r"C:\tmp\mbx cache\inputs.d")
935        } else {
936            PathBuf::from("/tmp/mbx cache/inputs.d")
937        };
938        let command = invocation.dep_info_command(&output).unwrap();
939        let arguments = command
940            .arguments()
941            .iter()
942            .map(|value| value.to_string_lossy())
943            .collect::<Vec<_>>();
944        assert_eq!(
945            arguments,
946            vec![
947                "--crate-name=widget",
948                "--crate-type=lib",
949                &format!("--emit=dep-info={}", output.display()),
950                "src/lib.rs",
951            ]
952        );
953    }
954
955    #[test]
956    fn discovery_hashes_externs_and_custom_targets() {
957        let directory = tempfile::tempdir().unwrap();
958        let root = directory.path();
959        let source = root.join("src/lib.rs");
960        let external = root.join("target/libdependency.rlib");
961        let target = root.join("targets/custom.json");
962        std::fs::create_dir_all(source.parent().unwrap()).unwrap();
963        std::fs::create_dir_all(external.parent().unwrap()).unwrap();
964        std::fs::create_dir_all(target.parent().unwrap()).unwrap();
965        std::fs::write(&source, "pub fn library() {}\n").unwrap();
966        std::fs::write(&external, "dependency artifact\n").unwrap();
967        std::fs::write(&target, "{}\n").unwrap();
968
969        let invocation = RustcInvocation::parse(&[
970            "--crate-name=widget".into(),
971            "--crate-type=lib".into(),
972            "--emit=metadata".into(),
973            format!("--extern=dependency={}", external.display()).into(),
974            format!("--target={}", target.display()).into(),
975            source.clone().into_os_string(),
976        ])
977        .unwrap();
978        let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
979        let discovered = invocation.discover_inputs(&dep_info, root).unwrap();
980        assert_eq!(discovered.inputs.len(), 3);
981
982        std::fs::remove_file(&external).unwrap();
983        assert!(matches!(
984            invocation.discover_inputs(&dep_info, root),
985            Err(BypassReason::InputRead { path, .. }) if path == external
986        ));
987    }
988
989    /// macOS writes extended attributes to a file a second or two after an
990    /// app saves it, moving its change time alone. That must not discard a
991    /// compilation as if the source had changed: it leaves it uncached. It
992    /// must not hide a content change to another input either.
993    #[cfg(unix)]
994    #[test]
995    fn a_metadata_write_during_compilation_bypasses_without_discarding() {
996        use std::os::unix::fs::PermissionsExt as _;
997
998        let directory = tempfile::tempdir().unwrap();
999        let library = directory.path().join("lib.rs");
1000        let module = directory.path().join("module.rs");
1001        std::fs::write(&library, "mod module;\n").unwrap();
1002        std::fs::write(&module, "pub fn module() {}\n").unwrap();
1003        let invocation = RustcInvocation::parse(&[
1004            "--crate-name=widget".into(),
1005            "--crate-type=lib".into(),
1006            "--emit=dep-info,metadata".into(),
1007            library.clone().into_os_string(),
1008        ])
1009        .unwrap();
1010        let dep_info = RustcDepInfo::parse(&format!(
1011            "output: {} {}\n",
1012            library.display(),
1013            module.display()
1014        ))
1015        .unwrap();
1016        let identity = |path: &Path| {
1017            FileIdentity::for_digest_cache(path, &std::fs::metadata(path).unwrap()).unwrap()
1018        };
1019        // Settled metadata snapshots, whatever the host's timestamp tick.
1020        let Some(settled) = identity(&library).filter(|identity| identity.changed.is_some()) else {
1021            return; // No change token on this filesystem.
1022        };
1023        let before = BTreeMap::from([
1024            (library.clone(), FileSnapshot::from(settled.clone())),
1025            (
1026                module.clone(),
1027                FileSnapshot::from(identity(&module).unwrap()),
1028            ),
1029        ]);
1030
1031        let mode = std::fs::metadata(&library).unwrap().permissions().mode();
1032        std::fs::set_permissions(&library, std::fs::Permissions::from_mode(mode)).unwrap();
1033        if identity(&library) == Some(settled) {
1034            return; // The change time did not move within this tick.
1035        }
1036        let discovered = invocation
1037            .discover_inputs(&dep_info, directory.path())
1038            .unwrap();
1039        assert_eq!(
1040            discovered.verify_not_modified_since_with_snapshots(SystemTime::UNIX_EPOCH, &before),
1041            Err(BypassReason::InputMetadataChangedDuringCompilation(
1042                library.clone()
1043            ))
1044        );
1045
1046        // A rewrite of a different length is still a content change, even
1047        // behind the earlier metadata-only one.
1048        std::fs::write(&module, "pub fn module() { let _ = 1; }\n").unwrap();
1049        let discovered = invocation
1050            .discover_inputs(&dep_info, directory.path())
1051            .unwrap();
1052        assert_eq!(
1053            discovered.verify_not_modified_since_with_snapshots(SystemTime::UNIX_EPOCH, &before),
1054            Err(BypassReason::InputModifiedDuringCompilation(module))
1055        );
1056    }
1057
1058    #[test]
1059    fn discovery_rejects_inputs_modified_during_compilation() {
1060        let directory = tempfile::tempdir().unwrap();
1061        let source = directory.path().join("lib.rs");
1062        std::fs::write(&source, "pub fn library() {}\n").unwrap();
1063        let invocation = RustcInvocation::parse(&[
1064            "--crate-name=widget".into(),
1065            "--crate-type=lib".into(),
1066            "--emit=dep-info,metadata".into(),
1067            source.clone().into_os_string(),
1068        ])
1069        .unwrap();
1070        let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
1071        let discovered = invocation
1072            .discover_inputs(&dep_info, directory.path())
1073            .unwrap();
1074        let modified = std::fs::metadata(&source).unwrap().modified().unwrap();
1075
1076        assert_eq!(
1077            discovered.verify_not_modified_since(modified),
1078            Err(BypassReason::InputModifiedDuringCompilation(source))
1079        );
1080    }
1081
1082    #[test]
1083    fn profile_use_is_discovered_verified_and_rehashed_by_prediction() {
1084        let directory = tempfile::tempdir().unwrap();
1085        let root = directory.path();
1086        let source = root.join("lib.rs");
1087        let profile = root.join("profile.profdata");
1088        std::fs::write(&source, "pub fn library() {}\n").unwrap();
1089        std::fs::write(&profile, "first profile").unwrap();
1090        let invocation = RustcInvocation::parse(&[
1091            "--crate-name=widget".into(),
1092            "--crate-type=lib".into(),
1093            "--emit=dep-info,metadata".into(),
1094            format!("-Cprofile-use={}", profile.display()).into(),
1095            "lib.rs".into(),
1096        ])
1097        .unwrap();
1098        let mappings = vec![PathMapping::new(root, "workspace")];
1099        let dep_info = RustcDepInfo::parse("widget.d: lib.rs\n").unwrap();
1100        let discover = || {
1101            invocation
1102                .discover_inputs_with_mappings(
1103                    &dep_info,
1104                    root,
1105                    &mappings,
1106                    &mbx_cache_core::NoFileDigestCache,
1107                )
1108                .unwrap()
1109        };
1110        let initial_inputs = discover();
1111        assert!(
1112            initial_inputs
1113                .inputs
1114                .iter()
1115                .any(|input| input.path == profile)
1116        );
1117        let mut context = library_context(root, mappings.clone());
1118        initial_inputs.clone().apply_to(&mut context).unwrap();
1119        let initial_action = invocation.action(context.clone()).unwrap();
1120        let prediction = invocation.prediction(&context, &initial_inputs).unwrap();
1121
1122        std::fs::write(&profile, "updated profile").unwrap();
1123        assert_eq!(
1124            initial_inputs.verify(),
1125            Err(BypassReason::InputChanged(profile.clone()))
1126        );
1127        context.inputs.clear();
1128        let predicted = prediction
1129            .discover(root, &mappings, &mbx_cache_core::NoFileDigestCache)
1130            .unwrap();
1131        predicted.clone().apply_to(&mut context).unwrap();
1132        let predicted_action = invocation.action(context.clone()).unwrap();
1133        assert_ne!(predicted_action.digest, initial_action.digest);
1134        context.inputs.clear();
1135        discover().apply_to(&mut context).unwrap();
1136        assert_eq!(predicted_action, invocation.action(context).unwrap());
1137
1138        let missing_profile = root.join("missing.profdata");
1139        let missing_invocation = RustcInvocation::parse(&[
1140            "--crate-name=widget".into(),
1141            "--crate-type=lib".into(),
1142            "--emit=dep-info,metadata".into(),
1143            format!("-Cprofile-use={}", missing_profile.display()).into(),
1144            "lib.rs".into(),
1145        ])
1146        .unwrap();
1147        assert!(matches!(
1148            missing_invocation.discover_inputs_with_mappings(
1149                &dep_info,
1150                root,
1151                &mappings,
1152                &mbx_cache_core::NoFileDigestCache,
1153            ),
1154            Err(BypassReason::InputRead { path, .. }) if path == missing_profile
1155        ));
1156    }
1157
1158    #[cfg(unix)]
1159    #[test]
1160    fn precompile_snapshot_does_not_depend_on_the_host_clock() {
1161        let directory = tempfile::tempdir().unwrap();
1162        let source = directory.path().join("lib.rs");
1163        std::fs::write(&source, "pub fn library() {0}\n").unwrap();
1164        let invocation = RustcInvocation::parse(&[
1165            "--crate-name=widget".into(),
1166            "--crate-type=lib".into(),
1167            "--emit=dep-info,metadata".into(),
1168            source.clone().into_os_string(),
1169        ])
1170        .unwrap();
1171        let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
1172        let discovered = invocation
1173            .discover_inputs(&dep_info, directory.path())
1174            .unwrap();
1175        let snapshot = FileSnapshot::capture(&source).unwrap().unwrap();
1176        let before = BTreeMap::from([(source.clone(), snapshot)]);
1177
1178        // Every ordinary mtime is after the epoch. The unchanged identity is
1179        // nevertheless enough proof when the filesystem clock is far ahead.
1180        discovered
1181            .verify_not_modified_since_with_snapshots(SystemTime::UNIX_EPOCH, &before)
1182            .unwrap();
1183
1184        // Conversely, a filesystem clock far behind must not conceal a write.
1185        // Let the coarse ZFS timestamp tick advance before this same-length rewrite.
1186        std::thread::sleep(std::time::Duration::from_millis(20));
1187        std::fs::write(&source, "pub fn library() {1}\n").unwrap();
1188        let discovered = invocation
1189            .discover_inputs(&dep_info, directory.path())
1190            .unwrap();
1191        assert_eq!(
1192            discovered.verify_not_modified_since_with_snapshots(
1193                SystemTime::now() + std::time::Duration::from_secs(60),
1194                &before,
1195            ),
1196            Err(BypassReason::InputModifiedDuringCompilation(source))
1197        );
1198    }
1199
1200    /// An external MSVC toolset directory, outside every mapped root.
1201    fn toolchain_native_directory(version: &str) -> PathBuf {
1202        if cfg!(windows) {
1203            PathBuf::from(format!(
1204                r"C:\Program Files\Microsoft Visual Studio\2022\BuildTools\VC\Tools\MSVC\{version}\lib\x64"
1205            ))
1206        } else {
1207            PathBuf::from(format!("/opt/msvc/{version}/lib/x64"))
1208        }
1209    }
1210
1211    fn library_with_native_search(source: &Path, directory: &Path) -> RustcInvocation {
1212        RustcInvocation::parse(&[
1213            "--crate-name=widget".into(),
1214            "--crate-type=lib".into(),
1215            "--emit=metadata,link".into(),
1216            format!("-Lnative={}", directory.display()).into(),
1217            source.to_path_buf().into_os_string(),
1218        ])
1219        .unwrap()
1220    }
1221
1222    fn library_context(root: &Path, mappings: Vec<PathMapping>) -> ActionContext {
1223        ActionContext {
1224            compiler: crate::CompilerIdentity {
1225                toolchain: "core:rust@test".into(),
1226                rustc_version: "test".into(),
1227                host: std::env::consts::ARCH.into(),
1228                driver: None,
1229            },
1230            working_dir: root.to_path_buf(),
1231            path_mappings: mappings,
1232            environment: BTreeMap::new(),
1233            portable_environment: BTreeSet::new(),
1234            inputs: Vec::new(),
1235        }
1236    }
1237
1238    #[cfg(windows)]
1239    #[test]
1240    fn unmapped_toolchain_directory_cannot_hide_a_static_archive() {
1241        let directory = tempfile::tempdir().unwrap();
1242        let root = directory.path();
1243        let source = root.join("lib.rs");
1244        std::fs::write(&source, "pub fn library() {}\n").unwrap();
1245        let toolchain = toolchain_native_directory("14.51.36231");
1246        let invocation = library_with_native_search(&source, &toolchain);
1247        let mappings = vec![PathMapping::new(root, "workspace")];
1248        let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
1249
1250        let context = library_context(root, mappings.clone());
1251        assert!(matches!(
1252            invocation.invocation_digest(&context),
1253            Err(BypassReason::UnmappedAbsolutePath(path)) if path == toolchain
1254        ));
1255        assert_eq!(
1256            invocation.discover_inputs_with_mappings(
1257                &dep_info,
1258                root,
1259                &mappings,
1260                &mbx_cache_core::NoFileDigestCache,
1261            ),
1262            Err(BypassReason::UnsupportedSearchPath("native".into()))
1263        );
1264    }
1265
1266    #[test]
1267    fn source_level_static_link_cannot_hide_an_unmapped_archive() {
1268        let directory = tempfile::tempdir().unwrap();
1269        let workspace = directory.path().join("workspace");
1270        let external = directory.path().join("external");
1271        std::fs::create_dir(&workspace).unwrap();
1272        std::fs::create_dir(&external).unwrap();
1273        let source = workspace.join("lib.rs");
1274        std::fs::write(
1275            &source,
1276            "#[link(name = \"foo\", kind = \"static\")] unsafe extern \"C\" {}\n",
1277        )
1278        .unwrap();
1279        std::fs::write(external.join("libfoo.a"), b"first archive").unwrap();
1280        let invocation = library_with_native_search(&source, &external);
1281        let mappings = vec![PathMapping::new(&workspace, "workspace")];
1282        let context = library_context(&workspace, mappings.clone());
1283        let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
1284
1285        // rustc's dep-info names the source but not an archive from #[link].
1286        // A path-only key would restore a stale rlib after libfoo.a changed.
1287        assert!(matches!(
1288            invocation.invocation_digest(&context),
1289            Err(BypassReason::UnmappedAbsolutePath(path)) if path == external
1290        ));
1291        assert_eq!(
1292            invocation.discover_inputs_with_mappings(
1293                &dep_info,
1294                &workspace,
1295                &mappings,
1296                &mbx_cache_core::NoFileDigestCache,
1297            ),
1298            Err(BypassReason::UnsupportedSearchPath("native".into()))
1299        );
1300    }
1301
1302    #[cfg(windows)]
1303    #[test]
1304    fn project_directory_with_toolchain_suffix_is_not_trusted() {
1305        let directory = tempfile::tempdir().unwrap();
1306        let root = directory.path().join("workspace");
1307        std::fs::create_dir(&root).unwrap();
1308        let source = root.join("lib.rs");
1309        std::fs::write(&source, "pub fn library() {}\n").unwrap();
1310        let fake_toolchain = directory.path().join("MSVC/14.51.36231/lib/x64");
1311        let invocation = library_with_native_search(&source, &fake_toolchain);
1312        let context = library_context(&root, vec![PathMapping::new(&root, "workspace")]);
1313
1314        // The apparent toolset version cannot make a mutable project archive inert.
1315        assert!(matches!(
1316            invocation.invocation_digest(&context),
1317            Err(BypassReason::UnmappedAbsolutePath(path)) if path == fake_toolchain
1318        ));
1319    }
1320
1321    #[test]
1322    fn unmapped_native_directory_still_refuses_a_native_link() {
1323        let directory = tempfile::tempdir().unwrap();
1324        let root = directory.path();
1325        let source = root.join("main.rs");
1326        std::fs::write(&source, "fn main() {}\n").unwrap();
1327        let toolchain = toolchain_native_directory("14.51.36231");
1328        let invocation = RustcInvocation::parse_with(
1329            &[
1330                "--crate-name=app".into(),
1331                "--crate-type=bin".into(),
1332                "--emit=link".into(),
1333                format!("-Lnative={}", toolchain.display()).into(),
1334                source.clone().into_os_string(),
1335            ],
1336            crate::ParseOptions::caching_native_links(true),
1337        )
1338        .unwrap();
1339        let mappings = vec![PathMapping::new(root, "workspace")];
1340
1341        // A linker reads those directories, so their contents stay inputs the
1342        // key must account for, and an unmapped one stays a bypass.
1343        let context = library_context(root, mappings.clone());
1344        assert!(matches!(
1345            invocation.invocation_digest(&context),
1346            Err(BypassReason::UnmappedAbsolutePath(_))
1347        ));
1348        let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
1349        assert_eq!(
1350            invocation.discover_inputs_with_mappings(
1351                &dep_info,
1352                root,
1353                &mappings,
1354                &mbx_cache_core::NoFileDigestCache
1355            ),
1356            Err(BypassReason::UnsupportedSearchPath("native".into()))
1357        );
1358    }
1359
1360    #[test]
1361    fn discovery_resolves_parent_components_against_the_working_directory() {
1362        let directory = tempfile::tempdir().unwrap();
1363        let root = directory.path().join("project");
1364        let shared = directory.path().join("shared.rs");
1365        std::fs::create_dir(&root).unwrap();
1366        std::fs::write(&shared, "pub fn shared() {}\n").unwrap();
1367
1368        let invocation = RustcInvocation::parse(&args(&[
1369            "--crate-name=widget",
1370            "--crate-type=lib",
1371            "--emit=metadata",
1372            "../shared.rs",
1373        ]))
1374        .unwrap();
1375        let dep_info = RustcDepInfo::parse("output: ../shared.rs\n").unwrap();
1376        let discovered = invocation.discover_inputs(&dep_info, &root).unwrap();
1377
1378        assert_eq!(discovered.inputs.len(), 1);
1379        assert_eq!(discovered.inputs[0].path, shared);
1380    }
1381
1382    #[test]
1383    fn rustc_dep_info_round_trip_discovers_real_inputs() {
1384        let directory = tempfile::tempdir().unwrap();
1385        let root = directory.path();
1386        std::fs::write(
1387            root.join("lib.rs"),
1388            "mod child; const _: &str = include_str!(\"data file.txt\"); \
1389             const _: &str = env!(\"MBX_DISCOVERY_TEST\"); \
1390             const _: Option<&str> = option_env!(\"MBX_DISCOVERY_UNSET\");",
1391        )
1392        .unwrap();
1393        std::fs::write(root.join("child.rs"), "pub fn child() {}\n").unwrap();
1394        std::fs::write(root.join("data file.txt"), "included\n").unwrap();
1395
1396        let invocation = RustcInvocation::parse(&args(&[
1397            "--crate-name=mbx_cache_discovery_test",
1398            "--crate-type=lib",
1399            "--emit=metadata,link",
1400            "lib.rs",
1401        ]))
1402        .unwrap();
1403        let dep_info_path = root.join("discovery inputs.d");
1404        let discovery_command = invocation.dep_info_command(&dep_info_path).unwrap();
1405        let rustc = std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into());
1406        let output = match Command::new(rustc)
1407            .args(discovery_command.arguments())
1408            .current_dir(root)
1409            .env("MBX_DISCOVERY_TEST", "observed")
1410            .env_remove("MBX_DISCOVERY_UNSET")
1411            .output()
1412        {
1413            Ok(output) => output,
1414            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return,
1415            Err(error) => panic!("failed to execute rustc: {error}"),
1416        };
1417        assert!(
1418            output.status.success(),
1419            "{}",
1420            String::from_utf8_lossy(&output.stderr)
1421        );
1422        let parsed = RustcDepInfo::read(&dep_info_path).unwrap();
1423        let discovered = invocation.discover_inputs(&parsed, root).unwrap();
1424        assert_eq!(
1425            discovered.environment["MBX_DISCOVERY_TEST"],
1426            Some("observed".into())
1427        );
1428        assert_eq!(discovered.environment["MBX_DISCOVERY_UNSET"], None);
1429        assert_eq!(discovered.inputs.len(), 3);
1430        assert!(
1431            discovered
1432                .inputs
1433                .iter()
1434                .all(|input| input.digest.algorithm == "blake3")
1435        );
1436        let mut context = ActionContext {
1437            compiler: crate::CompilerIdentity {
1438                toolchain: "core:rust@test".into(),
1439                rustc_version: "test".into(),
1440                host: std::env::consts::ARCH.into(),
1441                driver: None,
1442            },
1443            working_dir: root.to_path_buf(),
1444            path_mappings: vec![crate::PathMapping::new(root, "workspace")],
1445            environment: BTreeMap::new(),
1446            portable_environment: BTreeSet::new(),
1447            inputs: Vec::new(),
1448        };
1449        discovered.clone().apply_to(&mut context).unwrap();
1450        let action = invocation.action(context).unwrap();
1451        assert!(
1452            String::from_utf8(action.bytes)
1453                .unwrap()
1454                .contains(r#""MBX_DISCOVERY_TEST":"observed""#)
1455        );
1456        discovered.verify().unwrap();
1457        std::fs::write(root.join("child.rs"), "pub fn changed() {}\n").unwrap();
1458        assert_eq!(
1459            discovered.verify(),
1460            Err(BypassReason::InputChanged(root.join("child.rs")))
1461        );
1462    }
1463
1464    /// A ledger that answers with a sentinel and remembers what was recorded.
1465    struct SentinelLedger {
1466        known: FileIdentity,
1467        digest: CacheDigest,
1468        recorded: std::sync::Mutex<Vec<RecordedFileDigest>>,
1469    }
1470
1471    impl FileDigestCache for SentinelLedger {
1472        fn find(&self, scope: FileDigestScope, files: &[FileIdentity]) -> Vec<Option<CacheDigest>> {
1473            assert_eq!(scope, FileDigestScope::Content);
1474            files
1475                .iter()
1476                .map(|file| (*file == self.known).then(|| self.digest.clone()))
1477                .collect()
1478        }
1479
1480        fn record(&self, scope: FileDigestScope, entries: Vec<RecordedFileDigest>) {
1481            assert_eq!(scope, FileDigestScope::Content);
1482            self.recorded.lock().unwrap().extend(entries);
1483        }
1484    }
1485
1486    #[cfg(unix)]
1487    struct ReplacingLedger {
1488        path: PathBuf,
1489        replacement: PathBuf,
1490        recorded: std::sync::Mutex<Vec<RecordedFileDigest>>,
1491    }
1492
1493    #[cfg(unix)]
1494    impl FileDigestCache for ReplacingLedger {
1495        fn find(&self, scope: FileDigestScope, files: &[FileIdentity]) -> Vec<Option<CacheDigest>> {
1496            assert_eq!(scope, FileDigestScope::Content);
1497            assert_eq!(files.len(), 1);
1498            std::fs::remove_file(&self.path).unwrap();
1499            std::fs::rename(&self.replacement, &self.path).unwrap();
1500            // Digests of files changed this moment are withheld from reuse.
1501            std::thread::sleep(std::time::Duration::from_millis(50));
1502            vec![None]
1503        }
1504
1505        fn record(&self, scope: FileDigestScope, entries: Vec<RecordedFileDigest>) {
1506            assert_eq!(scope, FileDigestScope::Content);
1507            self.recorded.lock().unwrap().extend(entries);
1508        }
1509    }
1510
1511    #[cfg(unix)]
1512    #[test]
1513    fn discovery_binds_a_replacement_digest_to_the_replacement_identity() {
1514        let directory = tempfile::tempdir().unwrap();
1515        let root = directory.path();
1516        let path = root.join("libdep.rlib");
1517        let replacement = root.join("replacement.rlib");
1518        std::fs::write(&path, b"original....").unwrap();
1519        std::fs::File::options()
1520            .write(true)
1521            .open(&path)
1522            .unwrap()
1523            .set_times(std::fs::FileTimes::new().set_modified(SystemTime::UNIX_EPOCH))
1524            .unwrap();
1525        std::fs::write(&replacement, b"replacement!").unwrap();
1526        std::fs::File::options()
1527            .write(true)
1528            .open(&replacement)
1529            .unwrap()
1530            .set_times(std::fs::FileTimes::new().set_modified(SystemTime::UNIX_EPOCH))
1531            .unwrap();
1532        let ledger = ReplacingLedger {
1533            path: path.clone(),
1534            replacement,
1535            recorded: std::sync::Mutex::new(Vec::new()),
1536        };
1537
1538        let discovered = DiscoveredInputs::from_paths(
1539            root,
1540            BTreeSet::from([path.clone()]),
1541            BTreeMap::new(),
1542            &ledger,
1543        )
1544        .unwrap();
1545        let current = FileIdentity::for_digest_cache(&path, &std::fs::metadata(&path).unwrap())
1546            .unwrap()
1547            .unwrap();
1548        let digest = CacheDigest::blake3_file(&path).unwrap();
1549
1550        assert_eq!(discovered.inputs[0].digest, digest);
1551        assert_eq!(discovered.identities[0].as_ref(), Some(&current));
1552        assert_eq!(
1553            ledger.recorded.lock().unwrap().as_slice(),
1554            &[RecordedFileDigest {
1555                file: current,
1556                digest,
1557            }]
1558        );
1559        discovered.verify().unwrap();
1560    }
1561
1562    #[test]
1563    fn discovery_reuses_recorded_digests_and_records_fresh_ones() {
1564        let directory = tempfile::tempdir().unwrap();
1565        let root = directory.path();
1566        let known_path = root.join("libdep.rlib");
1567        let fresh_path = root.join("lib.rs");
1568        std::fs::write(&known_path, b"rlib bytes").unwrap();
1569        std::fs::write(&fresh_path, b"fn lib() {}").unwrap();
1570        // Digests of files written this moment are withheld from reuse.
1571        std::thread::sleep(std::time::Duration::from_millis(50));
1572        let metadata = std::fs::metadata(&known_path).unwrap();
1573        // A sentinel digest that hashing could never produce proves the read
1574        // was skipped: the discovered input carries it verbatim.
1575        let sentinel = CacheDigest {
1576            algorithm: "blake3".into(),
1577            hash: "c".repeat(64),
1578            size: metadata.len(),
1579        };
1580        let ledger = SentinelLedger {
1581            known: FileIdentity::describe(&known_path, &metadata).unwrap(),
1582            digest: sentinel.clone(),
1583            recorded: std::sync::Mutex::new(Vec::new()),
1584        };
1585
1586        let discovered = DiscoveredInputs::from_paths(
1587            root,
1588            BTreeSet::from([known_path.clone(), fresh_path.clone()]),
1589            BTreeMap::new(),
1590            &ledger,
1591        )
1592        .unwrap();
1593
1594        let by_path = |path: &Path| {
1595            discovered
1596                .inputs
1597                .iter()
1598                .find(|input| input.path == path)
1599                .unwrap()
1600                .digest
1601                .clone()
1602        };
1603        assert_eq!(
1604            by_path(&known_path),
1605            sentinel,
1606            "the recorded digest answers"
1607        );
1608        assert_eq!(
1609            by_path(&fresh_path),
1610            CacheDigest::blake3_file(&fresh_path).unwrap(),
1611            "an unrecorded file is hashed"
1612        );
1613        let recorded = ledger.recorded.lock().unwrap();
1614        assert_eq!(recorded.len(), 1, "only the fresh hash is recorded");
1615        assert_eq!(recorded[0].file.path, fresh_path);
1616        assert_eq!(recorded[0].digest, by_path(&fresh_path));
1617    }
1618
1619    /// Verification after the compiler runs confirms an input by its recorded
1620    /// identity rather than by reading it again; a file that has moved on is
1621    /// still read and still fails. Only where the identity carries a change
1622    /// time, which is what makes the stat as good as the read.
1623    #[cfg(unix)]
1624    #[test]
1625    fn verification_trusts_an_unchanged_identity_and_rereads_a_changed_one() {
1626        let directory = tempfile::tempdir().unwrap();
1627        let root = directory.path();
1628        let known_path = root.join("libdep.rlib");
1629        std::fs::write(&known_path, b"rlib bytes").unwrap();
1630        let metadata = std::fs::metadata(&known_path).unwrap();
1631        // The sentinel could never come from hashing the file, so a verify
1632        // that passes can only have trusted the identity.
1633        let sentinel = CacheDigest {
1634            algorithm: "blake3".into(),
1635            hash: "c".repeat(64),
1636            size: metadata.len(),
1637        };
1638        let ledger = SentinelLedger {
1639            known: FileIdentity::describe(&known_path, &metadata).unwrap(),
1640            digest: sentinel,
1641            recorded: std::sync::Mutex::new(Vec::new()),
1642        };
1643        let discovered = DiscoveredInputs::from_paths(
1644            root,
1645            BTreeSet::from([known_path.clone()]),
1646            BTreeMap::new(),
1647            &ledger,
1648        )
1649        .unwrap();
1650
1651        discovered.verify().unwrap();
1652
1653        // Rewritten with the same length: the identity moves with the write,
1654        // so the file is read again and the sentinel no longer matches it.
1655        std::thread::sleep(std::time::Duration::from_millis(20));
1656        std::fs::write(&known_path, b"RLIB BYTES").unwrap();
1657        assert_eq!(
1658            discovered.verify(),
1659            Err(BypassReason::InputChanged(known_path.clone()))
1660        );
1661
1662        std::fs::remove_file(&known_path).unwrap();
1663        assert!(matches!(
1664            discovered.verify(),
1665            Err(BypassReason::InputRead { path, .. }) if path == known_path
1666        ));
1667    }
1668
1669    /// A rewrite that restores length and modification time must still be
1670    /// hashed: the platform change token moves with every write, so the
1671    /// identity the ledger recorded no longer describes the file.
1672    #[cfg(unix)]
1673    #[test]
1674    fn a_disguised_rewrite_is_not_answered_from_the_ledger() {
1675        let directory = tempfile::tempdir().unwrap();
1676        let root = directory.path();
1677        let path = root.join("lib.rs");
1678        std::fs::write(&path, b"fn lib() -> u8 { 1 }").unwrap();
1679        let before = std::fs::metadata(&path).unwrap();
1680        let identity = FileIdentity::describe(&path, &before).unwrap();
1681
1682        // Same length, modification time put back: only the change token can
1683        // tell this file has new contents. Filesystems such as ZFS stamp
1684        // files from a 1 ms tick, so wait for the change token to move.
1685        std::thread::sleep(std::time::Duration::from_millis(20));
1686        std::fs::write(&path, b"fn lib() -> u8 { 2 }").unwrap();
1687        let file = std::fs::File::options().write(true).open(&path).unwrap();
1688        file.set_times(std::fs::FileTimes::new().set_modified(before.modified().unwrap()))
1689            .unwrap();
1690        drop(file);
1691        let after = std::fs::metadata(&path).unwrap();
1692        let disguised = FileIdentity::describe(&path, &after).unwrap();
1693        assert_eq!(disguised.len, identity.len);
1694        assert_eq!(disguised.modified, identity.modified);
1695        assert_ne!(
1696            disguised, identity,
1697            "the change token must expose the rewrite"
1698        );
1699
1700        let ledger = SentinelLedger {
1701            known: identity,
1702            digest: CacheDigest {
1703                algorithm: "blake3".into(),
1704                hash: "e".repeat(64),
1705                size: after.len(),
1706            },
1707            recorded: std::sync::Mutex::new(Vec::new()),
1708        };
1709        let discovered = DiscoveredInputs::from_paths(
1710            root,
1711            BTreeSet::from([path.clone()]),
1712            BTreeMap::new(),
1713            &ledger,
1714        )
1715        .unwrap();
1716        assert_eq!(
1717            discovered.inputs[0].digest,
1718            CacheDigest::blake3_file(&path).unwrap(),
1719            "the disguised rewrite is hashed, not answered from the ledger"
1720        );
1721    }
1722    #[cfg(unix)]
1723    #[test]
1724    fn native_file_symlinks_are_rehashed_on_prediction() {
1725        use std::os::unix::fs::symlink;
1726        let directory = tempfile::tempdir().unwrap();
1727        let workspace = directory.path().join("workspace");
1728        let native = directory.path().join("cellar/lib");
1729        std::fs::create_dir_all(&workspace).unwrap();
1730        std::fs::create_dir_all(&native).unwrap();
1731        let source = workspace.join("lib.rs");
1732        std::fs::write(&source, "pub fn value() {}\n").unwrap();
1733        std::fs::write(native.join("libssl.3.dylib"), "first").unwrap();
1734        symlink("libssl.3.dylib", native.join("libssl.dylib")).unwrap();
1735        let opt = directory.path().join("opt");
1736        symlink(&native, &opt).unwrap();
1737        let invocation = library_with_native_search(&source, &opt);
1738        let mappings = vec![
1739            PathMapping::new(&workspace, "workspace"),
1740            PathMapping::new(&opt, "native"),
1741        ];
1742        let dep_info = RustcDepInfo {
1743            files: vec![source],
1744            environment: BTreeMap::new(),
1745        };
1746        let discover = || {
1747            invocation
1748                .discover_inputs_with_mappings(
1749                    &dep_info,
1750                    &workspace,
1751                    &mappings,
1752                    &mbx_cache_core::NoFileDigestCache,
1753                )
1754                .unwrap()
1755        };
1756        let original = discover();
1757        let mut context = library_context(&workspace, mappings.clone());
1758        original.clone().apply_to(&mut context).unwrap();
1759        let initial = invocation.action(context.clone()).unwrap();
1760        let prediction = invocation.prediction(&context, &original).unwrap();
1761        context.inputs.clear();
1762        let mut previous = initial.digest;
1763        for change in ["contents", "add", "retarget", "remove"] {
1764            match change {
1765                "contents" => std::fs::write(native.join("libssl.3.dylib"), "updated").unwrap(),
1766                "add" => std::fs::write(native.join("libssl.4.dylib"), "replacement").unwrap(),
1767                "retarget" => {
1768                    std::fs::remove_file(native.join("libssl.dylib")).unwrap();
1769                    symlink("libssl.4.dylib", native.join("libssl.dylib")).unwrap();
1770                }
1771                _ => std::fs::remove_file(native.join("libssl.3.dylib")).unwrap(),
1772            }
1773            let predicted = prediction
1774                .discover(&workspace, &mappings, &mbx_cache_core::NoFileDigestCache)
1775                .unwrap();
1776            let mut predicted_context = context.clone();
1777            predicted.apply_to(&mut predicted_context).unwrap();
1778            let predicted_action = invocation.action(predicted_context).unwrap();
1779            let mut discovered_context = context.clone();
1780            discover().apply_to(&mut discovered_context).unwrap();
1781            assert_eq!(
1782                predicted_action,
1783                invocation.action(discovered_context).unwrap()
1784            );
1785            assert_ne!(predicted_action.digest, previous, "{change}");
1786            previous = predicted_action.digest;
1787        }
1788        std::fs::remove_file(native.join("libssl.dylib")).unwrap();
1789        let outside = directory.path().join("outside");
1790        std::fs::write(&outside, "untracked").unwrap();
1791        symlink(&outside, native.join("libssl.dylib")).unwrap();
1792        assert!(
1793            prediction
1794                .discover(&workspace, &mappings, &mbx_cache_core::NoFileDigestCache)
1795                .is_err()
1796        );
1797        std::fs::remove_file(native.join("libssl.dylib")).unwrap();
1798        symlink(".", native.join("loop")).unwrap();
1799        assert!(
1800            prediction
1801                .discover(&workspace, &mappings, &mbx_cache_core::NoFileDigestCache)
1802                .is_err()
1803        );
1804    }
1805}