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#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct DepInfoCommand {
19 arguments: Vec<OsString>,
20 output: PathBuf,
21}
22
23impl DepInfoCommand {
24 pub fn arguments(&self) -> &[OsString] {
26 &self.arguments
27 }
28
29 pub fn output(&self) -> &Path {
31 &self.output
32 }
33}
34
35#[derive(Debug, Clone, Default, PartialEq, Eq)]
37pub struct RustcDepInfo {
38 pub files: Vec<PathBuf>,
40 pub environment: BTreeMap<String, Option<String>>,
42}
43
44impl RustcDepInfo {
45 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 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#[derive(Debug, Clone, PartialEq, Eq)]
136pub struct DiscoveredInputs {
137 working_dir: PathBuf,
138 pub inputs: Vec<ActionInput>,
140 pub environment: BTreeMap<String, Option<String>>,
142 identities: Vec<Option<FileIdentity>>,
147 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 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 pub(crate) fn with_native_only(mut self, native_only: BTreeSet<PathBuf>) -> Self {
279 self.native_only = native_only;
280 self
281 }
282
283 pub fn is_native_only(&self, path: &Path) -> bool {
285 self.native_only.contains(path)
286 }
287
288 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 pub fn verify_not_modified_since_with_snapshots(
305 &self,
306 started_at: SystemTime,
307 before: &BTreeMap<PathBuf, FileSnapshot>,
308 ) -> Result<(), BypassReason> {
309 for input in &self.inputs {
310 if let Some(previous) = before.get(&input.path)
311 && previous.proves_content_change()
312 {
313 let metadata =
314 std::fs::metadata(&input.path).map_err(|error| BypassReason::InputRead {
315 path: input.path.clone(),
316 message: error.to_string(),
317 })?;
318 let identity = FileIdentity::for_digest_cache(&input.path, &metadata)
319 .map_err(|error| BypassReason::InputRead {
320 path: input.path.clone(),
321 message: error.to_string(),
322 })?
323 .or_else(|| FileIdentity::describe(&input.path, &metadata));
324 if previous.matches(identity.as_ref(), &input.digest) {
325 continue;
326 }
327 return Err(BypassReason::InputModifiedDuringCompilation(
328 input.path.clone(),
329 ));
330 }
331 let metadata =
332 std::fs::metadata(&input.path).map_err(|error| BypassReason::InputRead {
333 path: input.path.clone(),
334 message: error.to_string(),
335 })?;
336 let modified = metadata
337 .modified()
338 .map_err(|error| BypassReason::InputRead {
339 path: input.path.clone(),
340 message: error.to_string(),
341 })?;
342 if modified >= started_at {
343 return Err(BypassReason::InputModifiedDuringCompilation(
344 input.path.clone(),
345 ));
346 }
347 }
348 Ok(())
349 }
350
351 pub fn verify_not_modified_since_with_identities(
353 &self,
354 started_at: SystemTime,
355 before: &BTreeMap<PathBuf, FileIdentity>,
356 ) -> Result<(), BypassReason> {
357 let snapshots = before
358 .iter()
359 .map(|(path, identity)| (path.clone(), identity.clone().into()))
360 .collect();
361 self.verify_not_modified_since_with_snapshots(started_at, &snapshots)
362 }
363
364 pub fn verify(&self) -> Result<(), BypassReason> {
368 for (index, input) in self.inputs.iter().enumerate() {
369 let read_error = |error: std::io::Error| BypassReason::InputRead {
370 path: input.path.clone(),
371 message: error.to_string(),
372 };
373 if let Some(Some(identity)) = self.identities.get(index)
384 && identity.can_skip_content_verification()
385 && identity.still_describes().map_err(read_error)?
386 {
387 continue;
388 }
389 let matches = input.digest.matches_file(&input.path).map_err(|error| {
390 BypassReason::InputRead {
391 path: input.path.clone(),
392 message: error.to_string(),
393 }
394 })?;
395 if !matches {
396 return Err(BypassReason::InputChanged(input.path.clone()));
397 }
398 }
399 Ok(())
400 }
401
402 pub fn apply_to(self, context: &mut ActionContext) -> Result<(), BypassReason> {
405 if normalize_components(&context.working_dir) != self.working_dir {
406 return Err(BypassReason::DiscoveryWorkingDirectory);
407 }
408 for (name, value) in &self.environment {
409 if context
410 .environment
411 .get(name)
412 .is_some_and(|previous| previous != value)
413 {
414 return Err(BypassReason::ConflictingEnvironment(name.clone()));
415 }
416 }
417 context.environment.extend(self.environment);
418 context.inputs.extend(self.inputs);
419 Ok(())
420 }
421}
422
423impl RustcInvocation {
424 pub fn dep_info_command(&self, output: &Path) -> Result<DepInfoCommand, BypassReason> {
426 if !output.is_absolute() {
427 return Err(BypassReason::RelativeDepInfoPath(output.to_path_buf()));
428 }
429 let output_text = output
430 .to_str()
431 .ok_or_else(|| BypassReason::NonUtf8Path(output.to_path_buf()))?;
432 if output_text.contains(',') {
433 return Err(BypassReason::UnsafeDepInfoPath(output.to_path_buf()));
434 }
435
436 let mut arguments = Vec::new();
437 for argument in &self.arguments {
438 match argument {
439 Argument::Emit(_) => {}
440 Argument::Path { flag, .. } if flag == "--out-dir" || flag == "-o" => {}
441 argument => arguments.push(render_argument(argument)?),
442 }
443 }
444 arguments.push(format!("--emit=dep-info={output_text}").into());
445 arguments.push(self.source.clone().into_os_string());
446 Ok(DepInfoCommand {
447 arguments,
448 output: output.to_path_buf(),
449 })
450 }
451
452 pub fn discover_inputs(
455 &self,
456 dep_info: &RustcDepInfo,
457 working_dir: &Path,
458 ) -> Result<DiscoveredInputs, BypassReason> {
459 self.discover_inputs_with_mappings(
460 dep_info,
461 working_dir,
462 &[],
463 &mbx_cache_core::NoFileDigestCache,
464 )
465 }
466
467 pub fn discover_inputs_with_mappings(
473 &self,
474 dep_info: &RustcDepInfo,
475 working_dir: &Path,
476 path_mappings: &[PathMapping],
477 digests: &dyn FileDigestCache,
478 ) -> Result<DiscoveredInputs, BypassReason> {
479 self.discover(dep_info, working_dir, Some(path_mappings), digests)
480 }
481
482 pub fn discover_source_inputs(
491 &self,
492 dep_info: &RustcDepInfo,
493 working_dir: &Path,
494 digests: &dyn FileDigestCache,
495 ) -> Result<DiscoveredInputs, BypassReason> {
496 self.discover(dep_info, working_dir, None, digests)
497 }
498
499 fn discover(
501 &self,
502 dep_info: &RustcDepInfo,
503 working_dir: &Path,
504 path_mappings: Option<&[PathMapping]>,
505 digests: &dyn FileDigestCache,
506 ) -> Result<DiscoveredInputs, BypassReason> {
507 if !working_dir.is_absolute() {
508 return Err(BypassReason::RelativeWorkingDirectory(
509 working_dir.to_path_buf(),
510 ));
511 }
512 let working_dir = normalize_components(working_dir);
513 let absolute = |path: &PathBuf| {
514 let absolute = if path.is_absolute() {
515 path.to_path_buf()
516 } else {
517 working_dir.join(path)
518 };
519 normalize_components(&absolute)
520 };
521 let sources = dep_info.files.iter().map(absolute).collect::<BTreeSet<_>>();
527 let mut paths = sources.clone();
528 paths.extend(self.required_inputs.iter().map(absolute));
529 let admitted_roots = native_input_roots(&working_dir, path_mappings.unwrap_or_default());
530 let mut native_bytes = 0_u64;
531 let mut counted = BTreeSet::new();
532 for argument in &self.arguments {
533 if path_mappings.is_some()
534 && let Argument::SearchPath { kind, path } = argument
535 && kind == "native"
536 {
537 let directory = if path.is_absolute() {
538 path.clone()
539 } else {
540 working_dir.join(path)
541 };
542 collect_native_directory(
543 &directory,
544 &admitted_roots,
545 &mut paths,
546 &mut native_bytes,
547 &mut counted,
548 )?;
549 }
550 }
551 let native_directories = self
552 .arguments
553 .iter()
554 .filter_map(|argument| match argument {
555 Argument::SearchPath { kind, path } if kind == "native" => Some(absolute(path)),
556 _ => None,
557 })
558 .collect::<Vec<_>>();
559 let native_only = paths
560 .difference(&sources)
561 .filter(|path| native_directories.iter().any(|root| path.starts_with(root)))
562 .cloned()
563 .collect();
564 Ok(DiscoveredInputs::from_paths(
565 &working_dir,
566 paths,
567 dep_info.environment.clone(),
568 digests,
569 )?
570 .with_native_only(native_only))
571 }
572}
573
574pub(super) fn native_input_roots(
576 working_dir: &Path,
577 path_mappings: &[PathMapping],
578) -> Vec<PathBuf> {
579 std::iter::once(working_dir)
580 .chain(path_mappings.iter().map(|mapping| mapping.root.as_path()))
581 .map(normalize_components)
582 .collect()
583}
584
585pub(super) fn collect_native_directory(
590 directory: &Path,
591 admitted_roots: &[PathBuf],
592 paths: &mut BTreeSet<PathBuf>,
593 native_bytes: &mut u64,
594 counted: &mut BTreeSet<PathBuf>,
595) -> Result<(), BypassReason> {
596 let directory = normalize_components(directory);
597 if !admitted_roots
598 .iter()
599 .any(|root| directory.starts_with(root))
600 {
601 return Err(BypassReason::UnsupportedSearchPath("native".into()));
602 }
603
604 let canonical_root = match directory.canonicalize() {
605 Ok(root) => root,
606 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
607 Err(error) => {
608 return Err(BypassReason::InputRead {
609 path: directory,
610 message: error.to_string(),
611 });
612 }
613 };
614 let mut pending = vec![directory];
615 while let Some(directory) = pending.pop() {
616 let entries = match std::fs::read_dir(&directory) {
617 Ok(entries) => entries,
618 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
619 Err(error) => {
620 return Err(BypassReason::InputRead {
621 path: directory,
622 message: error.to_string(),
623 });
624 }
625 };
626 for entry in entries {
627 let entry = entry.map_err(|error| BypassReason::InputRead {
628 path: directory.clone(),
629 message: error.to_string(),
630 })?;
631 let path = entry.path();
632 let file_type = entry.file_type().map_err(|error| BypassReason::InputRead {
633 path: path.clone(),
634 message: error.to_string(),
635 })?;
636 if file_type.is_dir() {
637 if !is_linker_irrelevant_directory(&path) {
638 pending.push(path);
639 }
640 } else if is_linker_irrelevant_file(&path) {
641 continue;
642 } else if file_type.is_file() || file_type.is_symlink() {
643 if file_type.is_symlink() {
647 let resolved =
648 path.canonicalize()
649 .map_err(|error| BypassReason::InputRead {
650 path: path.clone(),
651 message: error.to_string(),
652 })?;
653 if !resolved.starts_with(&canonical_root) || !resolved.is_file() {
654 return Err(BypassReason::UnsupportedSearchPath("native".into()));
655 }
656 }
657 let len = std::fs::metadata(&path)
658 .map_err(|error| BypassReason::InputRead {
659 path: path.clone(),
660 message: error.to_string(),
661 })?
662 .len();
663 paths.insert(path.clone());
667 if counted.insert(path) {
668 *native_bytes = native_bytes
669 .checked_add(len)
670 .ok_or_else(|| BypassReason::UnsupportedSearchPath("native".into()))?;
671 }
672 } else {
673 return Err(BypassReason::UnsupportedSearchPath("native".into()));
674 }
675 if paths.len() > MAX_PREDICTED_INPUTS || *native_bytes > MAX_NATIVE_INPUT_BYTES {
676 return Err(BypassReason::UnsupportedSearchPath("native".into()));
677 }
678 }
679 }
680 Ok(())
681}
682
683fn is_linker_irrelevant_directory(path: &Path) -> bool {
688 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
689 return false;
690 };
691 name == "pkgconfig" || name == "cmake" || name.ends_with(".dSYM")
692}
693
694fn is_linker_irrelevant_file(path: &Path) -> bool {
697 path.extension()
698 .and_then(|extension| extension.to_str())
699 .is_some_and(|extension| {
700 ["pc", "cmake"]
701 .iter()
702 .any(|ignored| extension.eq_ignore_ascii_case(ignored))
703 })
704}
705
706fn render_argument(argument: &Argument) -> Result<OsString, BypassReason> {
707 let rendered = match argument {
708 Argument::Plain(value) => value.clone(),
709 Argument::Path { flag, path } => format!(
710 "{flag}={}",
711 path.to_str()
712 .ok_or_else(|| BypassReason::NonUtf8Path(path.clone()))?
713 ),
714 Argument::SearchPath { kind, path } => format!(
715 "-L{kind}={}",
716 path.to_str()
717 .ok_or_else(|| BypassReason::NonUtf8Path(path.clone()))?
718 ),
719 Argument::Extern { name, path } => match path {
720 Some(path) => format!(
721 "--extern={name}={}",
722 path.to_str()
723 .ok_or_else(|| BypassReason::NonUtf8Path(path.clone()))?
724 ),
725 None => format!("--extern={name}"),
726 },
727 Argument::Emit(_) => unreachable!("emit arguments are removed before rendering"),
728 Argument::RemapPath { from, to } => format!(
729 "--remap-path-prefix={}={to}",
730 from.to_str()
731 .ok_or_else(|| BypassReason::NonUtf8Path(from.clone()))?
732 ),
733 Argument::OsoPrefix {
736 path,
737 trailing_slash,
738 } => format!(
739 "--codegen=link-arg=-Wl,-oso_prefix,{}{}",
740 path.to_str()
741 .ok_or_else(|| BypassReason::NonUtf8Path(path.clone()))?,
742 if *trailing_slash { "/" } else { "" }
743 ),
744 Argument::InstallName(name) => {
747 format!("--codegen=link-arg=-Wl,-install_name,@rpath/{name}")
748 }
749 Argument::FuseLd(selection) => format!("--codegen=link-arg=-fuse-ld={selection}"),
750 };
751 Ok(rendered.into())
752}
753
754fn unescape_environment(value: &str) -> Result<String, BypassReason> {
755 let mut output = String::with_capacity(value.len());
756 let mut characters = value.chars();
757 while let Some(character) = characters.next() {
758 if character != '\\' {
759 output.push(character);
760 continue;
761 }
762 match characters.next() {
763 Some('\\') => output.push('\\'),
764 Some('n') => output.push('\n'),
765 Some('r') => output.push('\r'),
766 Some(character) => {
767 return Err(BypassReason::MalformedDepInfo(format!(
768 "unknown environment escape \\{character}"
769 )));
770 }
771 None => {
772 return Err(BypassReason::MalformedDepInfo(
773 "environment input ends with an unterminated escape".into(),
774 ));
775 }
776 }
777 }
778 Ok(output)
779}
780
781#[cfg(test)]
782mod tests {
783 use super::*;
784 use std::process::Command;
785
786 fn args(values: &[&str]) -> Vec<OsString> {
787 values.iter().map(OsString::from).collect()
788 }
789
790 #[test]
791 fn parses_files_spaces_and_environment_records() {
792 let parsed = RustcDepInfo::parse(
793 "target/lib.rlib: src/lib.rs src/a\\ file.rs generated.rs\n\
794 src/lib.rs:\n\
795 # env-dep:SET=value\\nnext\n\
796 # env-dep:UNSET\n\
797 # env-dep:SLASH=a\\\\b\n",
798 )
799 .unwrap();
800 assert_eq!(
801 parsed.files,
802 vec![
803 PathBuf::from("generated.rs"),
804 PathBuf::from("src/a file.rs"),
805 PathBuf::from("src/lib.rs"),
806 ]
807 );
808 assert_eq!(parsed.environment["SET"], Some("value\nnext".into()));
809 assert_eq!(parsed.environment["UNSET"], None);
810 assert_eq!(parsed.environment["SLASH"], Some(r"a\b".into()));
811 }
812
813 #[test]
814 fn malformed_dep_info_bypasses_caching() {
815 for contents in [
816 "",
817 "target: ",
818 "target: src/trailing\\\n",
819 "target: src/lib.rs\n# env-dep:NAME=bad\\q\n",
820 ] {
821 assert!(RustcDepInfo::parse(contents).is_err(), "{contents:?}");
822 }
823 }
824
825 #[test]
826 fn native_directory_byte_limit_is_cumulative() {
827 let directory = tempfile::tempdir().unwrap();
828 let native = directory.path().join("native");
829 std::fs::create_dir_all(&native).unwrap();
830 std::fs::write(native.join("input.lib"), b"xx").unwrap();
831 let roots = native_input_roots(directory.path(), &[]);
832 let mut paths = BTreeSet::new();
833 let mut native_bytes = MAX_NATIVE_INPUT_BYTES - 1;
834 let mut counted = BTreeSet::new();
835
836 assert_eq!(
837 collect_native_directory(&native, &roots, &mut paths, &mut native_bytes, &mut counted),
838 Err(BypassReason::UnsupportedSearchPath("native".into()))
839 );
840 }
841
842 #[test]
843 fn native_directory_walked_twice_counts_bytes_once() {
844 let directory = tempfile::tempdir().unwrap();
845 let native = directory.path().join("native");
846 std::fs::create_dir_all(native.join("lib")).unwrap();
847 std::fs::write(native.join("input.lib"), b"xx").unwrap();
848 std::fs::write(native.join("lib").join("nested.lib"), b"yyy").unwrap();
849 let roots = native_input_roots(directory.path(), &[]);
850 let mut paths = BTreeSet::new();
851 let mut native_bytes = 0;
852 let mut counted = BTreeSet::new();
853
854 for dir in [&native, &native, &native.join("lib")] {
855 collect_native_directory(dir, &roots, &mut paths, &mut native_bytes, &mut counted)
856 .unwrap();
857 }
858
859 assert_eq!(paths.len(), 2);
860 assert_eq!(native_bytes, 5);
861 }
862
863 #[test]
864 fn native_directory_charges_files_already_named_as_inputs() {
865 let directory = tempfile::tempdir().unwrap();
866 let native = directory.path().join("native");
867 std::fs::create_dir_all(&native).unwrap();
868 let archive = native.join("libfoo.a");
869 std::fs::write(&archive, b"xx").unwrap();
870 let roots = native_input_roots(directory.path(), &[]);
871 let mut paths = BTreeSet::from([archive]);
872 let mut native_bytes = 0;
873 let mut counted = BTreeSet::new();
874
875 collect_native_directory(&native, &roots, &mut paths, &mut native_bytes, &mut counted)
876 .unwrap();
877
878 assert_eq!(native_bytes, 2);
879 }
880
881 #[test]
882 fn native_directory_skips_files_the_linker_never_reads() {
883 let directory = tempfile::tempdir().unwrap();
884 let native = directory.path().join("native");
885 for ignored in [
886 "lib/pkgconfig/zlib.pc",
887 "check.dSYM/Contents/Resources/Relocations/aarch64/check.yml",
888 "cmake/Config.cmake",
889 "top.pc",
890 ] {
891 let path = native.join(ignored);
892 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
893 std::fs::write(path, b"prefix=/abs/target\n").unwrap();
894 }
895 std::fs::write(native.join("libz.a"), b"!<arch>\n").unwrap();
896 std::fs::create_dir_all(native.join("lib")).unwrap();
897 std::fs::write(native.join("lib/libz.so.1"), b"elf").unwrap();
898 let roots = native_input_roots(directory.path(), &[]);
899 let mut paths = BTreeSet::new();
900 let mut native_bytes = 0;
901 let mut counted = BTreeSet::new();
902
903 collect_native_directory(&native, &roots, &mut paths, &mut native_bytes, &mut counted)
904 .unwrap();
905
906 assert_eq!(
907 paths.into_iter().collect::<Vec<_>>(),
908 vec![native.join("lib/libz.so.1"), native.join("libz.a")]
909 );
910 }
911
912 #[test]
913 fn discovery_command_removes_real_outputs() {
914 let invocation = RustcInvocation::parse(&args(&[
915 "--crate-name=widget",
916 "--crate-type=lib",
917 "--emit=dep-info,metadata,link",
918 "--out-dir=target/debug/deps",
919 "-o",
920 "target/debug/libwidget.rlib",
921 "src/lib.rs",
922 ]))
923 .unwrap();
924 let output = if cfg!(windows) {
925 PathBuf::from(r"C:\tmp\mbx cache\inputs.d")
926 } else {
927 PathBuf::from("/tmp/mbx cache/inputs.d")
928 };
929 let command = invocation.dep_info_command(&output).unwrap();
930 let arguments = command
931 .arguments()
932 .iter()
933 .map(|value| value.to_string_lossy())
934 .collect::<Vec<_>>();
935 assert_eq!(
936 arguments,
937 vec![
938 "--crate-name=widget",
939 "--crate-type=lib",
940 &format!("--emit=dep-info={}", output.display()),
941 "src/lib.rs",
942 ]
943 );
944 }
945
946 #[test]
947 fn discovery_hashes_externs_and_custom_targets() {
948 let directory = tempfile::tempdir().unwrap();
949 let root = directory.path();
950 let source = root.join("src/lib.rs");
951 let external = root.join("target/libdependency.rlib");
952 let target = root.join("targets/custom.json");
953 std::fs::create_dir_all(source.parent().unwrap()).unwrap();
954 std::fs::create_dir_all(external.parent().unwrap()).unwrap();
955 std::fs::create_dir_all(target.parent().unwrap()).unwrap();
956 std::fs::write(&source, "pub fn library() {}\n").unwrap();
957 std::fs::write(&external, "dependency artifact\n").unwrap();
958 std::fs::write(&target, "{}\n").unwrap();
959
960 let invocation = RustcInvocation::parse(&[
961 "--crate-name=widget".into(),
962 "--crate-type=lib".into(),
963 "--emit=metadata".into(),
964 format!("--extern=dependency={}", external.display()).into(),
965 format!("--target={}", target.display()).into(),
966 source.clone().into_os_string(),
967 ])
968 .unwrap();
969 let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
970 let discovered = invocation.discover_inputs(&dep_info, root).unwrap();
971 assert_eq!(discovered.inputs.len(), 3);
972
973 std::fs::remove_file(&external).unwrap();
974 assert!(matches!(
975 invocation.discover_inputs(&dep_info, root),
976 Err(BypassReason::InputRead { path, .. }) if path == external
977 ));
978 }
979
980 #[test]
981 fn discovery_rejects_inputs_modified_during_compilation() {
982 let directory = tempfile::tempdir().unwrap();
983 let source = directory.path().join("lib.rs");
984 std::fs::write(&source, "pub fn library() {}\n").unwrap();
985 let invocation = RustcInvocation::parse(&[
986 "--crate-name=widget".into(),
987 "--crate-type=lib".into(),
988 "--emit=dep-info,metadata".into(),
989 source.clone().into_os_string(),
990 ])
991 .unwrap();
992 let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
993 let discovered = invocation
994 .discover_inputs(&dep_info, directory.path())
995 .unwrap();
996 let modified = std::fs::metadata(&source).unwrap().modified().unwrap();
997
998 assert_eq!(
999 discovered.verify_not_modified_since(modified),
1000 Err(BypassReason::InputModifiedDuringCompilation(source))
1001 );
1002 }
1003
1004 #[test]
1005 fn profile_use_is_discovered_verified_and_rehashed_by_prediction() {
1006 let directory = tempfile::tempdir().unwrap();
1007 let root = directory.path();
1008 let source = root.join("lib.rs");
1009 let profile = root.join("profile.profdata");
1010 std::fs::write(&source, "pub fn library() {}\n").unwrap();
1011 std::fs::write(&profile, "first profile").unwrap();
1012 let invocation = RustcInvocation::parse(&[
1013 "--crate-name=widget".into(),
1014 "--crate-type=lib".into(),
1015 "--emit=dep-info,metadata".into(),
1016 format!("-Cprofile-use={}", profile.display()).into(),
1017 "lib.rs".into(),
1018 ])
1019 .unwrap();
1020 let mappings = vec![PathMapping::new(root, "workspace")];
1021 let dep_info = RustcDepInfo::parse("widget.d: lib.rs\n").unwrap();
1022 let discover = || {
1023 invocation
1024 .discover_inputs_with_mappings(
1025 &dep_info,
1026 root,
1027 &mappings,
1028 &mbx_cache_core::NoFileDigestCache,
1029 )
1030 .unwrap()
1031 };
1032 let initial_inputs = discover();
1033 assert!(
1034 initial_inputs
1035 .inputs
1036 .iter()
1037 .any(|input| input.path == profile)
1038 );
1039 let mut context = library_context(root, mappings.clone());
1040 initial_inputs.clone().apply_to(&mut context).unwrap();
1041 let initial_action = invocation.action(context.clone()).unwrap();
1042 let prediction = invocation.prediction(&context, &initial_inputs).unwrap();
1043
1044 std::fs::write(&profile, "updated profile").unwrap();
1045 assert_eq!(
1046 initial_inputs.verify(),
1047 Err(BypassReason::InputChanged(profile.clone()))
1048 );
1049 context.inputs.clear();
1050 let predicted = prediction
1051 .discover(root, &mappings, &mbx_cache_core::NoFileDigestCache)
1052 .unwrap();
1053 predicted.clone().apply_to(&mut context).unwrap();
1054 let predicted_action = invocation.action(context.clone()).unwrap();
1055 assert_ne!(predicted_action.digest, initial_action.digest);
1056 context.inputs.clear();
1057 discover().apply_to(&mut context).unwrap();
1058 assert_eq!(predicted_action, invocation.action(context).unwrap());
1059
1060 let missing_profile = root.join("missing.profdata");
1061 let missing_invocation = RustcInvocation::parse(&[
1062 "--crate-name=widget".into(),
1063 "--crate-type=lib".into(),
1064 "--emit=dep-info,metadata".into(),
1065 format!("-Cprofile-use={}", missing_profile.display()).into(),
1066 "lib.rs".into(),
1067 ])
1068 .unwrap();
1069 assert!(matches!(
1070 missing_invocation.discover_inputs_with_mappings(
1071 &dep_info,
1072 root,
1073 &mappings,
1074 &mbx_cache_core::NoFileDigestCache,
1075 ),
1076 Err(BypassReason::InputRead { path, .. }) if path == missing_profile
1077 ));
1078 }
1079
1080 #[cfg(unix)]
1081 #[test]
1082 fn precompile_snapshot_does_not_depend_on_the_host_clock() {
1083 let directory = tempfile::tempdir().unwrap();
1084 let source = directory.path().join("lib.rs");
1085 std::fs::write(&source, "pub fn library() {0}\n").unwrap();
1086 let invocation = RustcInvocation::parse(&[
1087 "--crate-name=widget".into(),
1088 "--crate-type=lib".into(),
1089 "--emit=dep-info,metadata".into(),
1090 source.clone().into_os_string(),
1091 ])
1092 .unwrap();
1093 let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
1094 let discovered = invocation
1095 .discover_inputs(&dep_info, directory.path())
1096 .unwrap();
1097 let snapshot = FileSnapshot::capture(&source).unwrap().unwrap();
1098 let before = BTreeMap::from([(source.clone(), snapshot)]);
1099
1100 discovered
1103 .verify_not_modified_since_with_snapshots(SystemTime::UNIX_EPOCH, &before)
1104 .unwrap();
1105
1106 std::thread::sleep(std::time::Duration::from_millis(20));
1109 std::fs::write(&source, "pub fn library() {1}\n").unwrap();
1110 let discovered = invocation
1111 .discover_inputs(&dep_info, directory.path())
1112 .unwrap();
1113 assert_eq!(
1114 discovered.verify_not_modified_since_with_snapshots(
1115 SystemTime::now() + std::time::Duration::from_secs(60),
1116 &before,
1117 ),
1118 Err(BypassReason::InputModifiedDuringCompilation(source))
1119 );
1120 }
1121
1122 fn toolchain_native_directory(version: &str) -> PathBuf {
1124 if cfg!(windows) {
1125 PathBuf::from(format!(
1126 r"C:\Program Files\Microsoft Visual Studio\2022\BuildTools\VC\Tools\MSVC\{version}\lib\x64"
1127 ))
1128 } else {
1129 PathBuf::from(format!("/opt/msvc/{version}/lib/x64"))
1130 }
1131 }
1132
1133 fn library_with_native_search(source: &Path, directory: &Path) -> RustcInvocation {
1134 RustcInvocation::parse(&[
1135 "--crate-name=widget".into(),
1136 "--crate-type=lib".into(),
1137 "--emit=metadata,link".into(),
1138 format!("-Lnative={}", directory.display()).into(),
1139 source.to_path_buf().into_os_string(),
1140 ])
1141 .unwrap()
1142 }
1143
1144 fn library_context(root: &Path, mappings: Vec<PathMapping>) -> ActionContext {
1145 ActionContext {
1146 compiler: crate::CompilerIdentity {
1147 toolchain: "core:rust@test".into(),
1148 rustc_version: "test".into(),
1149 host: std::env::consts::ARCH.into(),
1150 driver: None,
1151 },
1152 working_dir: root.to_path_buf(),
1153 path_mappings: mappings,
1154 environment: BTreeMap::new(),
1155 portable_environment: BTreeSet::new(),
1156 inputs: Vec::new(),
1157 }
1158 }
1159
1160 #[cfg(windows)]
1161 #[test]
1162 fn unmapped_toolchain_directory_cannot_hide_a_static_archive() {
1163 let directory = tempfile::tempdir().unwrap();
1164 let root = directory.path();
1165 let source = root.join("lib.rs");
1166 std::fs::write(&source, "pub fn library() {}\n").unwrap();
1167 let toolchain = toolchain_native_directory("14.51.36231");
1168 let invocation = library_with_native_search(&source, &toolchain);
1169 let mappings = vec![PathMapping::new(root, "workspace")];
1170 let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
1171
1172 let context = library_context(root, mappings.clone());
1173 assert!(matches!(
1174 invocation.invocation_digest(&context),
1175 Err(BypassReason::UnmappedAbsolutePath(path)) if path == toolchain
1176 ));
1177 assert_eq!(
1178 invocation.discover_inputs_with_mappings(
1179 &dep_info,
1180 root,
1181 &mappings,
1182 &mbx_cache_core::NoFileDigestCache,
1183 ),
1184 Err(BypassReason::UnsupportedSearchPath("native".into()))
1185 );
1186 }
1187
1188 #[test]
1189 fn source_level_static_link_cannot_hide_an_unmapped_archive() {
1190 let directory = tempfile::tempdir().unwrap();
1191 let workspace = directory.path().join("workspace");
1192 let external = directory.path().join("external");
1193 std::fs::create_dir(&workspace).unwrap();
1194 std::fs::create_dir(&external).unwrap();
1195 let source = workspace.join("lib.rs");
1196 std::fs::write(
1197 &source,
1198 "#[link(name = \"foo\", kind = \"static\")] unsafe extern \"C\" {}\n",
1199 )
1200 .unwrap();
1201 std::fs::write(external.join("libfoo.a"), b"first archive").unwrap();
1202 let invocation = library_with_native_search(&source, &external);
1203 let mappings = vec![PathMapping::new(&workspace, "workspace")];
1204 let context = library_context(&workspace, mappings.clone());
1205 let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
1206
1207 assert!(matches!(
1210 invocation.invocation_digest(&context),
1211 Err(BypassReason::UnmappedAbsolutePath(path)) if path == external
1212 ));
1213 assert_eq!(
1214 invocation.discover_inputs_with_mappings(
1215 &dep_info,
1216 &workspace,
1217 &mappings,
1218 &mbx_cache_core::NoFileDigestCache,
1219 ),
1220 Err(BypassReason::UnsupportedSearchPath("native".into()))
1221 );
1222 }
1223
1224 #[cfg(windows)]
1225 #[test]
1226 fn project_directory_with_toolchain_suffix_is_not_trusted() {
1227 let directory = tempfile::tempdir().unwrap();
1228 let root = directory.path().join("workspace");
1229 std::fs::create_dir(&root).unwrap();
1230 let source = root.join("lib.rs");
1231 std::fs::write(&source, "pub fn library() {}\n").unwrap();
1232 let fake_toolchain = directory.path().join("MSVC/14.51.36231/lib/x64");
1233 let invocation = library_with_native_search(&source, &fake_toolchain);
1234 let context = library_context(&root, vec![PathMapping::new(&root, "workspace")]);
1235
1236 assert!(matches!(
1238 invocation.invocation_digest(&context),
1239 Err(BypassReason::UnmappedAbsolutePath(path)) if path == fake_toolchain
1240 ));
1241 }
1242
1243 #[test]
1244 fn unmapped_native_directory_still_refuses_a_native_link() {
1245 let directory = tempfile::tempdir().unwrap();
1246 let root = directory.path();
1247 let source = root.join("main.rs");
1248 std::fs::write(&source, "fn main() {}\n").unwrap();
1249 let toolchain = toolchain_native_directory("14.51.36231");
1250 let invocation = RustcInvocation::parse_with(
1251 &[
1252 "--crate-name=app".into(),
1253 "--crate-type=bin".into(),
1254 "--emit=link".into(),
1255 format!("-Lnative={}", toolchain.display()).into(),
1256 source.clone().into_os_string(),
1257 ],
1258 crate::ParseOptions::caching_native_links(true),
1259 )
1260 .unwrap();
1261 let mappings = vec![PathMapping::new(root, "workspace")];
1262
1263 let context = library_context(root, mappings.clone());
1266 assert!(matches!(
1267 invocation.invocation_digest(&context),
1268 Err(BypassReason::UnmappedAbsolutePath(_))
1269 ));
1270 let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
1271 assert_eq!(
1272 invocation.discover_inputs_with_mappings(
1273 &dep_info,
1274 root,
1275 &mappings,
1276 &mbx_cache_core::NoFileDigestCache
1277 ),
1278 Err(BypassReason::UnsupportedSearchPath("native".into()))
1279 );
1280 }
1281
1282 #[test]
1283 fn discovery_resolves_parent_components_against_the_working_directory() {
1284 let directory = tempfile::tempdir().unwrap();
1285 let root = directory.path().join("project");
1286 let shared = directory.path().join("shared.rs");
1287 std::fs::create_dir(&root).unwrap();
1288 std::fs::write(&shared, "pub fn shared() {}\n").unwrap();
1289
1290 let invocation = RustcInvocation::parse(&args(&[
1291 "--crate-name=widget",
1292 "--crate-type=lib",
1293 "--emit=metadata",
1294 "../shared.rs",
1295 ]))
1296 .unwrap();
1297 let dep_info = RustcDepInfo::parse("output: ../shared.rs\n").unwrap();
1298 let discovered = invocation.discover_inputs(&dep_info, &root).unwrap();
1299
1300 assert_eq!(discovered.inputs.len(), 1);
1301 assert_eq!(discovered.inputs[0].path, shared);
1302 }
1303
1304 #[test]
1305 fn rustc_dep_info_round_trip_discovers_real_inputs() {
1306 let directory = tempfile::tempdir().unwrap();
1307 let root = directory.path();
1308 std::fs::write(
1309 root.join("lib.rs"),
1310 "mod child; const _: &str = include_str!(\"data file.txt\"); \
1311 const _: &str = env!(\"MBX_DISCOVERY_TEST\"); \
1312 const _: Option<&str> = option_env!(\"MBX_DISCOVERY_UNSET\");",
1313 )
1314 .unwrap();
1315 std::fs::write(root.join("child.rs"), "pub fn child() {}\n").unwrap();
1316 std::fs::write(root.join("data file.txt"), "included\n").unwrap();
1317
1318 let invocation = RustcInvocation::parse(&args(&[
1319 "--crate-name=mbx_cache_discovery_test",
1320 "--crate-type=lib",
1321 "--emit=metadata,link",
1322 "lib.rs",
1323 ]))
1324 .unwrap();
1325 let dep_info_path = root.join("discovery inputs.d");
1326 let discovery_command = invocation.dep_info_command(&dep_info_path).unwrap();
1327 let rustc = std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into());
1328 let output = match Command::new(rustc)
1329 .args(discovery_command.arguments())
1330 .current_dir(root)
1331 .env("MBX_DISCOVERY_TEST", "observed")
1332 .env_remove("MBX_DISCOVERY_UNSET")
1333 .output()
1334 {
1335 Ok(output) => output,
1336 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return,
1337 Err(error) => panic!("failed to execute rustc: {error}"),
1338 };
1339 assert!(
1340 output.status.success(),
1341 "{}",
1342 String::from_utf8_lossy(&output.stderr)
1343 );
1344 let parsed = RustcDepInfo::read(&dep_info_path).unwrap();
1345 let discovered = invocation.discover_inputs(&parsed, root).unwrap();
1346 assert_eq!(
1347 discovered.environment["MBX_DISCOVERY_TEST"],
1348 Some("observed".into())
1349 );
1350 assert_eq!(discovered.environment["MBX_DISCOVERY_UNSET"], None);
1351 assert_eq!(discovered.inputs.len(), 3);
1352 assert!(
1353 discovered
1354 .inputs
1355 .iter()
1356 .all(|input| input.digest.algorithm == "blake3")
1357 );
1358 let mut context = ActionContext {
1359 compiler: crate::CompilerIdentity {
1360 toolchain: "core:rust@test".into(),
1361 rustc_version: "test".into(),
1362 host: std::env::consts::ARCH.into(),
1363 driver: None,
1364 },
1365 working_dir: root.to_path_buf(),
1366 path_mappings: vec![crate::PathMapping::new(root, "workspace")],
1367 environment: BTreeMap::new(),
1368 portable_environment: BTreeSet::new(),
1369 inputs: Vec::new(),
1370 };
1371 discovered.clone().apply_to(&mut context).unwrap();
1372 let action = invocation.action(context).unwrap();
1373 assert!(
1374 String::from_utf8(action.bytes)
1375 .unwrap()
1376 .contains(r#""MBX_DISCOVERY_TEST":"observed""#)
1377 );
1378 discovered.verify().unwrap();
1379 std::fs::write(root.join("child.rs"), "pub fn changed() {}\n").unwrap();
1380 assert_eq!(
1381 discovered.verify(),
1382 Err(BypassReason::InputChanged(root.join("child.rs")))
1383 );
1384 }
1385
1386 struct SentinelLedger {
1388 known: FileIdentity,
1389 digest: CacheDigest,
1390 recorded: std::sync::Mutex<Vec<RecordedFileDigest>>,
1391 }
1392
1393 impl FileDigestCache for SentinelLedger {
1394 fn find(&self, scope: FileDigestScope, files: &[FileIdentity]) -> Vec<Option<CacheDigest>> {
1395 assert_eq!(scope, FileDigestScope::Content);
1396 files
1397 .iter()
1398 .map(|file| (*file == self.known).then(|| self.digest.clone()))
1399 .collect()
1400 }
1401
1402 fn record(&self, scope: FileDigestScope, entries: Vec<RecordedFileDigest>) {
1403 assert_eq!(scope, FileDigestScope::Content);
1404 self.recorded.lock().unwrap().extend(entries);
1405 }
1406 }
1407
1408 #[cfg(unix)]
1409 struct ReplacingLedger {
1410 path: PathBuf,
1411 replacement: PathBuf,
1412 recorded: std::sync::Mutex<Vec<RecordedFileDigest>>,
1413 }
1414
1415 #[cfg(unix)]
1416 impl FileDigestCache for ReplacingLedger {
1417 fn find(&self, scope: FileDigestScope, files: &[FileIdentity]) -> Vec<Option<CacheDigest>> {
1418 assert_eq!(scope, FileDigestScope::Content);
1419 assert_eq!(files.len(), 1);
1420 std::fs::remove_file(&self.path).unwrap();
1421 std::fs::rename(&self.replacement, &self.path).unwrap();
1422 std::thread::sleep(std::time::Duration::from_millis(50));
1424 vec![None]
1425 }
1426
1427 fn record(&self, scope: FileDigestScope, entries: Vec<RecordedFileDigest>) {
1428 assert_eq!(scope, FileDigestScope::Content);
1429 self.recorded.lock().unwrap().extend(entries);
1430 }
1431 }
1432
1433 #[cfg(unix)]
1434 #[test]
1435 fn discovery_binds_a_replacement_digest_to_the_replacement_identity() {
1436 let directory = tempfile::tempdir().unwrap();
1437 let root = directory.path();
1438 let path = root.join("libdep.rlib");
1439 let replacement = root.join("replacement.rlib");
1440 std::fs::write(&path, b"original....").unwrap();
1441 std::fs::File::options()
1442 .write(true)
1443 .open(&path)
1444 .unwrap()
1445 .set_times(std::fs::FileTimes::new().set_modified(SystemTime::UNIX_EPOCH))
1446 .unwrap();
1447 std::fs::write(&replacement, b"replacement!").unwrap();
1448 std::fs::File::options()
1449 .write(true)
1450 .open(&replacement)
1451 .unwrap()
1452 .set_times(std::fs::FileTimes::new().set_modified(SystemTime::UNIX_EPOCH))
1453 .unwrap();
1454 let ledger = ReplacingLedger {
1455 path: path.clone(),
1456 replacement,
1457 recorded: std::sync::Mutex::new(Vec::new()),
1458 };
1459
1460 let discovered = DiscoveredInputs::from_paths(
1461 root,
1462 BTreeSet::from([path.clone()]),
1463 BTreeMap::new(),
1464 &ledger,
1465 )
1466 .unwrap();
1467 let current = FileIdentity::for_digest_cache(&path, &std::fs::metadata(&path).unwrap())
1468 .unwrap()
1469 .unwrap();
1470 let digest = CacheDigest::blake3_file(&path).unwrap();
1471
1472 assert_eq!(discovered.inputs[0].digest, digest);
1473 assert_eq!(discovered.identities[0].as_ref(), Some(¤t));
1474 assert_eq!(
1475 ledger.recorded.lock().unwrap().as_slice(),
1476 &[RecordedFileDigest {
1477 file: current,
1478 digest,
1479 }]
1480 );
1481 discovered.verify().unwrap();
1482 }
1483
1484 #[test]
1485 fn discovery_reuses_recorded_digests_and_records_fresh_ones() {
1486 let directory = tempfile::tempdir().unwrap();
1487 let root = directory.path();
1488 let known_path = root.join("libdep.rlib");
1489 let fresh_path = root.join("lib.rs");
1490 std::fs::write(&known_path, b"rlib bytes").unwrap();
1491 std::fs::write(&fresh_path, b"fn lib() {}").unwrap();
1492 std::thread::sleep(std::time::Duration::from_millis(50));
1494 let metadata = std::fs::metadata(&known_path).unwrap();
1495 let sentinel = CacheDigest {
1498 algorithm: "blake3".into(),
1499 hash: "c".repeat(64),
1500 size: metadata.len(),
1501 };
1502 let ledger = SentinelLedger {
1503 known: FileIdentity::describe(&known_path, &metadata).unwrap(),
1504 digest: sentinel.clone(),
1505 recorded: std::sync::Mutex::new(Vec::new()),
1506 };
1507
1508 let discovered = DiscoveredInputs::from_paths(
1509 root,
1510 BTreeSet::from([known_path.clone(), fresh_path.clone()]),
1511 BTreeMap::new(),
1512 &ledger,
1513 )
1514 .unwrap();
1515
1516 let by_path = |path: &Path| {
1517 discovered
1518 .inputs
1519 .iter()
1520 .find(|input| input.path == path)
1521 .unwrap()
1522 .digest
1523 .clone()
1524 };
1525 assert_eq!(
1526 by_path(&known_path),
1527 sentinel,
1528 "the recorded digest answers"
1529 );
1530 assert_eq!(
1531 by_path(&fresh_path),
1532 CacheDigest::blake3_file(&fresh_path).unwrap(),
1533 "an unrecorded file is hashed"
1534 );
1535 let recorded = ledger.recorded.lock().unwrap();
1536 assert_eq!(recorded.len(), 1, "only the fresh hash is recorded");
1537 assert_eq!(recorded[0].file.path, fresh_path);
1538 assert_eq!(recorded[0].digest, by_path(&fresh_path));
1539 }
1540
1541 #[cfg(unix)]
1546 #[test]
1547 fn verification_trusts_an_unchanged_identity_and_rereads_a_changed_one() {
1548 let directory = tempfile::tempdir().unwrap();
1549 let root = directory.path();
1550 let known_path = root.join("libdep.rlib");
1551 std::fs::write(&known_path, b"rlib bytes").unwrap();
1552 let metadata = std::fs::metadata(&known_path).unwrap();
1553 let sentinel = CacheDigest {
1556 algorithm: "blake3".into(),
1557 hash: "c".repeat(64),
1558 size: metadata.len(),
1559 };
1560 let ledger = SentinelLedger {
1561 known: FileIdentity::describe(&known_path, &metadata).unwrap(),
1562 digest: sentinel,
1563 recorded: std::sync::Mutex::new(Vec::new()),
1564 };
1565 let discovered = DiscoveredInputs::from_paths(
1566 root,
1567 BTreeSet::from([known_path.clone()]),
1568 BTreeMap::new(),
1569 &ledger,
1570 )
1571 .unwrap();
1572
1573 discovered.verify().unwrap();
1574
1575 std::thread::sleep(std::time::Duration::from_millis(20));
1578 std::fs::write(&known_path, b"RLIB BYTES").unwrap();
1579 assert_eq!(
1580 discovered.verify(),
1581 Err(BypassReason::InputChanged(known_path.clone()))
1582 );
1583
1584 std::fs::remove_file(&known_path).unwrap();
1585 assert!(matches!(
1586 discovered.verify(),
1587 Err(BypassReason::InputRead { path, .. }) if path == known_path
1588 ));
1589 }
1590
1591 #[cfg(unix)]
1595 #[test]
1596 fn a_disguised_rewrite_is_not_answered_from_the_ledger() {
1597 let directory = tempfile::tempdir().unwrap();
1598 let root = directory.path();
1599 let path = root.join("lib.rs");
1600 std::fs::write(&path, b"fn lib() -> u8 { 1 }").unwrap();
1601 let before = std::fs::metadata(&path).unwrap();
1602 let identity = FileIdentity::describe(&path, &before).unwrap();
1603
1604 std::thread::sleep(std::time::Duration::from_millis(20));
1608 std::fs::write(&path, b"fn lib() -> u8 { 2 }").unwrap();
1609 let file = std::fs::File::options().write(true).open(&path).unwrap();
1610 file.set_times(std::fs::FileTimes::new().set_modified(before.modified().unwrap()))
1611 .unwrap();
1612 drop(file);
1613 let after = std::fs::metadata(&path).unwrap();
1614 let disguised = FileIdentity::describe(&path, &after).unwrap();
1615 assert_eq!(disguised.len, identity.len);
1616 assert_eq!(disguised.modified, identity.modified);
1617 assert_ne!(
1618 disguised, identity,
1619 "the change token must expose the rewrite"
1620 );
1621
1622 let ledger = SentinelLedger {
1623 known: identity,
1624 digest: CacheDigest {
1625 algorithm: "blake3".into(),
1626 hash: "e".repeat(64),
1627 size: after.len(),
1628 },
1629 recorded: std::sync::Mutex::new(Vec::new()),
1630 };
1631 let discovered = DiscoveredInputs::from_paths(
1632 root,
1633 BTreeSet::from([path.clone()]),
1634 BTreeMap::new(),
1635 &ledger,
1636 )
1637 .unwrap();
1638 assert_eq!(
1639 discovered.inputs[0].digest,
1640 CacheDigest::blake3_file(&path).unwrap(),
1641 "the disguised rewrite is hashed, not answered from the ledger"
1642 );
1643 }
1644 #[cfg(unix)]
1645 #[test]
1646 fn native_file_symlinks_are_rehashed_on_prediction() {
1647 use std::os::unix::fs::symlink;
1648 let directory = tempfile::tempdir().unwrap();
1649 let workspace = directory.path().join("workspace");
1650 let native = directory.path().join("cellar/lib");
1651 std::fs::create_dir_all(&workspace).unwrap();
1652 std::fs::create_dir_all(&native).unwrap();
1653 let source = workspace.join("lib.rs");
1654 std::fs::write(&source, "pub fn value() {}\n").unwrap();
1655 std::fs::write(native.join("libssl.3.dylib"), "first").unwrap();
1656 symlink("libssl.3.dylib", native.join("libssl.dylib")).unwrap();
1657 let opt = directory.path().join("opt");
1658 symlink(&native, &opt).unwrap();
1659 let invocation = library_with_native_search(&source, &opt);
1660 let mappings = vec![
1661 PathMapping::new(&workspace, "workspace"),
1662 PathMapping::new(&opt, "native"),
1663 ];
1664 let dep_info = RustcDepInfo {
1665 files: vec![source],
1666 environment: BTreeMap::new(),
1667 };
1668 let discover = || {
1669 invocation
1670 .discover_inputs_with_mappings(
1671 &dep_info,
1672 &workspace,
1673 &mappings,
1674 &mbx_cache_core::NoFileDigestCache,
1675 )
1676 .unwrap()
1677 };
1678 let original = discover();
1679 let mut context = library_context(&workspace, mappings.clone());
1680 original.clone().apply_to(&mut context).unwrap();
1681 let initial = invocation.action(context.clone()).unwrap();
1682 let prediction = invocation.prediction(&context, &original).unwrap();
1683 context.inputs.clear();
1684 let mut previous = initial.digest;
1685 for change in ["contents", "add", "retarget", "remove"] {
1686 match change {
1687 "contents" => std::fs::write(native.join("libssl.3.dylib"), "updated").unwrap(),
1688 "add" => std::fs::write(native.join("libssl.4.dylib"), "replacement").unwrap(),
1689 "retarget" => {
1690 std::fs::remove_file(native.join("libssl.dylib")).unwrap();
1691 symlink("libssl.4.dylib", native.join("libssl.dylib")).unwrap();
1692 }
1693 _ => std::fs::remove_file(native.join("libssl.3.dylib")).unwrap(),
1694 }
1695 let predicted = prediction
1696 .discover(&workspace, &mappings, &mbx_cache_core::NoFileDigestCache)
1697 .unwrap();
1698 let mut predicted_context = context.clone();
1699 predicted.apply_to(&mut predicted_context).unwrap();
1700 let predicted_action = invocation.action(predicted_context).unwrap();
1701 let mut discovered_context = context.clone();
1702 discover().apply_to(&mut discovered_context).unwrap();
1703 assert_eq!(
1704 predicted_action,
1705 invocation.action(discovered_context).unwrap()
1706 );
1707 assert_ne!(predicted_action.digest, previous, "{change}");
1708 previous = predicted_action.digest;
1709 }
1710 std::fs::remove_file(native.join("libssl.dylib")).unwrap();
1711 let outside = directory.path().join("outside");
1712 std::fs::write(&outside, "untracked").unwrap();
1713 symlink(&outside, native.join("libssl.dylib")).unwrap();
1714 assert!(
1715 prediction
1716 .discover(&workspace, &mappings, &mbx_cache_core::NoFileDigestCache)
1717 .is_err()
1718 );
1719 std::fs::remove_file(native.join("libssl.dylib")).unwrap();
1720 symlink(".", native.join("loop")).unwrap();
1721 assert!(
1722 prediction
1723 .discover(&workspace, &mappings, &mbx_cache_core::NoFileDigestCache)
1724 .is_err()
1725 );
1726 }
1727}