1use super::{
2 ActionContext, ActionInput, Argument, BypassReason, MAX_NATIVE_INPUT_BYTES,
3 MAX_PREDICTED_INPUTS, PathMapping, RustcInvocation, normalize_components,
4};
5#[cfg(test)]
6use mbx_cache_core::CacheDigest;
7use mbx_cache_core::{
8 FileDigestCache, FileDigestResolution, FileDigestScope, FileIdentity, FileSnapshot,
9 RecordedFileDigest, digest_file,
10};
11use std::collections::{BTreeMap, BTreeSet};
12use std::ffi::OsString;
13use std::path::{Path, PathBuf};
14use std::time::SystemTime;
15
16#[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}
148
149impl DiscoveredInputs {
150 pub(crate) fn from_paths(
151 working_dir: &Path,
152 paths: BTreeSet<PathBuf>,
153 environment: BTreeMap<String, Option<String>>,
154 digests: &dyn FileDigestCache,
155 ) -> Result<Self, BypassReason> {
156 if !working_dir.is_absolute() {
157 return Err(BypassReason::RelativeWorkingDirectory(
158 working_dir.to_path_buf(),
159 ));
160 }
161 let working_dir = normalize_components(working_dir);
162 let mut identified = Vec::with_capacity(paths.len());
168 for path in paths {
169 let metadata = std::fs::metadata(&path).map_err(|error| BypassReason::InputRead {
170 path: path.clone(),
171 message: error.to_string(),
172 })?;
173 if !metadata.is_file() {
174 return Err(BypassReason::InputRead {
175 path,
176 message: "input is not a regular file".into(),
177 });
178 }
179 let identity = FileIdentity::for_digest_cache(&path, &metadata).map_err(|error| {
180 BypassReason::InputRead {
181 path: path.clone(),
182 message: error.to_string(),
183 }
184 })?;
185 identified.push((path, identity));
186 }
187 let queries = identified
188 .iter()
189 .filter_map(|(_, identity)| identity.clone())
190 .collect::<Vec<_>>();
191 let mut recorded = digests
192 .resolve(FileDigestScope::Content, &queries)
193 .into_iter();
194 let mut inputs = Vec::with_capacity(identified.len());
195 let mut identities = Vec::with_capacity(identified.len());
196 let mut fresh = Vec::new();
197 for (path, identity) in identified {
198 identities.push(identity.clone());
199 let resolution = identity
200 .as_ref()
201 .and_then(|_| recorded.next())
202 .unwrap_or(FileDigestResolution::Unresolved);
203 let digest = match resolution {
204 FileDigestResolution::Digest(digest)
205 if identity
206 .as_ref()
207 .is_some_and(|identity| identity.len == digest.size) =>
208 {
209 digest
210 }
211 FileDigestResolution::Digest(_)
212 | FileDigestResolution::EmbeddedTimestampMacro
213 | FileDigestResolution::Unresolved => {
214 let digest = digest_file(FileDigestScope::Content, &path)
215 .and_then(|resolution| {
216 resolution.into_digest().ok_or_else(|| {
217 std::io::Error::other(
218 "content digest resolution returned no digest",
219 )
220 })
221 })
222 .map_err(|error| BypassReason::InputRead {
223 path: path.clone(),
224 message: error.to_string(),
225 })?;
226 if let Some(identity) = identity
227 && identity.len == digest.size
228 {
229 fresh.push(RecordedFileDigest {
230 file: identity,
231 digest: digest.clone(),
232 });
233 }
234 digest
235 }
236 };
237 inputs.push(ActionInput { path, digest });
238 }
239 if !fresh.is_empty() {
240 digests.record(FileDigestScope::Content, fresh);
241 }
242 Ok(Self {
243 working_dir,
244 inputs,
245 environment,
246 identities,
247 })
248 }
249
250 pub fn verify_not_modified_since(&self, started_at: SystemTime) -> Result<(), BypassReason> {
257 self.verify_not_modified_since_with_snapshots(started_at, &BTreeMap::new())
258 }
259
260 pub fn verify_not_modified_since_with_snapshots(
267 &self,
268 started_at: SystemTime,
269 before: &BTreeMap<PathBuf, FileSnapshot>,
270 ) -> Result<(), BypassReason> {
271 for input in &self.inputs {
272 if let Some(previous) = before.get(&input.path)
273 && previous.proves_content_change()
274 {
275 let metadata =
276 std::fs::metadata(&input.path).map_err(|error| BypassReason::InputRead {
277 path: input.path.clone(),
278 message: error.to_string(),
279 })?;
280 let identity = FileIdentity::for_digest_cache(&input.path, &metadata)
281 .map_err(|error| BypassReason::InputRead {
282 path: input.path.clone(),
283 message: error.to_string(),
284 })?
285 .or_else(|| FileIdentity::describe(&input.path, &metadata));
286 if previous.matches(identity.as_ref(), &input.digest) {
287 continue;
288 }
289 return Err(BypassReason::InputModifiedDuringCompilation(
290 input.path.clone(),
291 ));
292 }
293 let metadata =
294 std::fs::metadata(&input.path).map_err(|error| BypassReason::InputRead {
295 path: input.path.clone(),
296 message: error.to_string(),
297 })?;
298 let modified = metadata
299 .modified()
300 .map_err(|error| BypassReason::InputRead {
301 path: input.path.clone(),
302 message: error.to_string(),
303 })?;
304 if modified >= started_at {
305 return Err(BypassReason::InputModifiedDuringCompilation(
306 input.path.clone(),
307 ));
308 }
309 }
310 Ok(())
311 }
312
313 pub fn verify_not_modified_since_with_identities(
315 &self,
316 started_at: SystemTime,
317 before: &BTreeMap<PathBuf, FileIdentity>,
318 ) -> Result<(), BypassReason> {
319 let snapshots = before
320 .iter()
321 .map(|(path, identity)| (path.clone(), identity.clone().into()))
322 .collect();
323 self.verify_not_modified_since_with_snapshots(started_at, &snapshots)
324 }
325
326 pub fn verify(&self) -> Result<(), BypassReason> {
330 for (index, input) in self.inputs.iter().enumerate() {
331 let read_error = |error: std::io::Error| BypassReason::InputRead {
332 path: input.path.clone(),
333 message: error.to_string(),
334 };
335 if let Some(Some(identity)) = self.identities.get(index)
346 && identity.can_skip_content_verification()
347 && identity.still_describes().map_err(read_error)?
348 {
349 continue;
350 }
351 let matches = input.digest.matches_file(&input.path).map_err(|error| {
352 BypassReason::InputRead {
353 path: input.path.clone(),
354 message: error.to_string(),
355 }
356 })?;
357 if !matches {
358 return Err(BypassReason::InputChanged(input.path.clone()));
359 }
360 }
361 Ok(())
362 }
363
364 pub fn apply_to(self, context: &mut ActionContext) -> Result<(), BypassReason> {
367 if normalize_components(&context.working_dir) != self.working_dir {
368 return Err(BypassReason::DiscoveryWorkingDirectory);
369 }
370 for (name, value) in &self.environment {
371 if context
372 .environment
373 .get(name)
374 .is_some_and(|previous| previous != value)
375 {
376 return Err(BypassReason::ConflictingEnvironment(name.clone()));
377 }
378 }
379 context.environment.extend(self.environment);
380 context.inputs.extend(self.inputs);
381 Ok(())
382 }
383}
384
385impl RustcInvocation {
386 pub fn dep_info_command(&self, output: &Path) -> Result<DepInfoCommand, BypassReason> {
388 if !output.is_absolute() {
389 return Err(BypassReason::RelativeDepInfoPath(output.to_path_buf()));
390 }
391 let output_text = output
392 .to_str()
393 .ok_or_else(|| BypassReason::NonUtf8Path(output.to_path_buf()))?;
394 if output_text.contains(',') {
395 return Err(BypassReason::UnsafeDepInfoPath(output.to_path_buf()));
396 }
397
398 let mut arguments = Vec::new();
399 for argument in &self.arguments {
400 match argument {
401 Argument::Emit(_) => {}
402 Argument::Path { flag, .. } if flag == "--out-dir" || flag == "-o" => {}
403 argument => arguments.push(render_argument(argument)?),
404 }
405 }
406 arguments.push(format!("--emit=dep-info={output_text}").into());
407 arguments.push(self.source.clone().into_os_string());
408 Ok(DepInfoCommand {
409 arguments,
410 output: output.to_path_buf(),
411 })
412 }
413
414 pub fn discover_inputs(
417 &self,
418 dep_info: &RustcDepInfo,
419 working_dir: &Path,
420 ) -> Result<DiscoveredInputs, BypassReason> {
421 self.discover_inputs_with_mappings(
422 dep_info,
423 working_dir,
424 &[],
425 &mbx_cache_core::NoFileDigestCache,
426 )
427 }
428
429 pub fn discover_inputs_with_mappings(
435 &self,
436 dep_info: &RustcDepInfo,
437 working_dir: &Path,
438 path_mappings: &[PathMapping],
439 digests: &dyn FileDigestCache,
440 ) -> Result<DiscoveredInputs, BypassReason> {
441 if !working_dir.is_absolute() {
442 return Err(BypassReason::RelativeWorkingDirectory(
443 working_dir.to_path_buf(),
444 ));
445 }
446 let working_dir = normalize_components(working_dir);
447 let mut paths = dep_info
448 .files
449 .iter()
450 .chain(&self.required_inputs)
451 .map(|path| {
452 let absolute = if path.is_absolute() {
453 path.to_path_buf()
454 } else {
455 working_dir.join(path)
456 };
457 normalize_components(&absolute)
458 })
459 .collect::<BTreeSet<_>>();
460 let admitted_roots = native_input_roots(&working_dir, path_mappings);
461 let mut native_bytes = 0_u64;
462 for argument in &self.arguments {
463 if let Argument::SearchPath { kind, path } = argument
464 && kind == "native"
465 {
466 let directory = if path.is_absolute() {
467 path.clone()
468 } else {
469 working_dir.join(path)
470 };
471 if self.native_search_is_inert()
476 && matches!(
477 super::normalize_mapped_path(&directory, &working_dir, path_mappings),
478 Err(BypassReason::UnmappedAbsolutePath(_))
479 )
480 {
481 continue;
482 }
483 collect_native_directory(
484 &directory,
485 &admitted_roots,
486 &mut paths,
487 &mut native_bytes,
488 )?;
489 }
490 }
491 DiscoveredInputs::from_paths(&working_dir, paths, dep_info.environment.clone(), digests)
492 }
493}
494
495pub(super) fn native_input_roots(
497 working_dir: &Path,
498 path_mappings: &[PathMapping],
499) -> Vec<PathBuf> {
500 std::iter::once(working_dir)
501 .chain(path_mappings.iter().map(|mapping| mapping.root.as_path()))
502 .map(normalize_components)
503 .collect()
504}
505
506pub(super) fn collect_native_directory(
509 directory: &Path,
510 admitted_roots: &[PathBuf],
511 paths: &mut BTreeSet<PathBuf>,
512 native_bytes: &mut u64,
513) -> Result<(), BypassReason> {
514 let directory = normalize_components(directory);
515 if !admitted_roots
516 .iter()
517 .any(|root| directory.starts_with(root))
518 {
519 return Err(BypassReason::UnsupportedSearchPath("native".into()));
520 }
521
522 let mut pending = vec![directory];
523 while let Some(directory) = pending.pop() {
524 let entries = match std::fs::read_dir(&directory) {
525 Ok(entries) => entries,
526 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
527 Err(error) => {
528 return Err(BypassReason::InputRead {
529 path: directory,
530 message: error.to_string(),
531 });
532 }
533 };
534 for entry in entries {
535 let entry = entry.map_err(|error| BypassReason::InputRead {
536 path: directory.clone(),
537 message: error.to_string(),
538 })?;
539 let path = entry.path();
540 let file_type = entry.file_type().map_err(|error| BypassReason::InputRead {
541 path: path.clone(),
542 message: error.to_string(),
543 })?;
544 if file_type.is_dir() {
545 pending.push(path);
546 } else if file_type.is_file() {
547 *native_bytes = native_bytes
548 .checked_add(
549 entry
550 .metadata()
551 .map_err(|error| BypassReason::InputRead {
552 path: path.clone(),
553 message: error.to_string(),
554 })?
555 .len(),
556 )
557 .ok_or_else(|| BypassReason::UnsupportedSearchPath("native".into()))?;
558 paths.insert(path);
559 } else {
560 return Err(BypassReason::UnsupportedSearchPath("native".into()));
561 }
562 if paths.len() > MAX_PREDICTED_INPUTS || *native_bytes > MAX_NATIVE_INPUT_BYTES {
563 return Err(BypassReason::UnsupportedSearchPath("native".into()));
564 }
565 }
566 }
567 Ok(())
568}
569
570fn render_argument(argument: &Argument) -> Result<OsString, BypassReason> {
571 let rendered = match argument {
572 Argument::Plain(value) => value.clone(),
573 Argument::Path { flag, path } => format!(
574 "{flag}={}",
575 path.to_str()
576 .ok_or_else(|| BypassReason::NonUtf8Path(path.clone()))?
577 ),
578 Argument::SearchPath { kind, path } => format!(
579 "-L{kind}={}",
580 path.to_str()
581 .ok_or_else(|| BypassReason::NonUtf8Path(path.clone()))?
582 ),
583 Argument::Extern { name, path } => match path {
584 Some(path) => format!(
585 "--extern={name}={}",
586 path.to_str()
587 .ok_or_else(|| BypassReason::NonUtf8Path(path.clone()))?
588 ),
589 None => format!("--extern={name}"),
590 },
591 Argument::Emit(_) => unreachable!("emit arguments are removed before rendering"),
592 Argument::RemapPath { from, to } => format!(
593 "--remap-path-prefix={}={to}",
594 from.to_str()
595 .ok_or_else(|| BypassReason::NonUtf8Path(from.clone()))?
596 ),
597 Argument::OsoPrefix {
600 path,
601 trailing_slash,
602 } => format!(
603 "--codegen=link-arg=-Wl,-oso_prefix,{}{}",
604 path.to_str()
605 .ok_or_else(|| BypassReason::NonUtf8Path(path.clone()))?,
606 if *trailing_slash { "/" } else { "" }
607 ),
608 Argument::FuseLd(selection) => format!("--codegen=link-arg=-fuse-ld={selection}"),
611 };
612 Ok(rendered.into())
613}
614
615fn unescape_environment(value: &str) -> Result<String, BypassReason> {
616 let mut output = String::with_capacity(value.len());
617 let mut characters = value.chars();
618 while let Some(character) = characters.next() {
619 if character != '\\' {
620 output.push(character);
621 continue;
622 }
623 match characters.next() {
624 Some('\\') => output.push('\\'),
625 Some('n') => output.push('\n'),
626 Some('r') => output.push('\r'),
627 Some(character) => {
628 return Err(BypassReason::MalformedDepInfo(format!(
629 "unknown environment escape \\{character}"
630 )));
631 }
632 None => {
633 return Err(BypassReason::MalformedDepInfo(
634 "environment input ends with an unterminated escape".into(),
635 ));
636 }
637 }
638 }
639 Ok(output)
640}
641
642#[cfg(test)]
643mod tests {
644 use super::*;
645 use std::process::Command;
646
647 fn args(values: &[&str]) -> Vec<OsString> {
648 values.iter().map(OsString::from).collect()
649 }
650
651 #[test]
652 fn parses_files_spaces_and_environment_records() {
653 let parsed = RustcDepInfo::parse(
654 "target/lib.rlib: src/lib.rs src/a\\ file.rs generated.rs\n\
655 src/lib.rs:\n\
656 # env-dep:SET=value\\nnext\n\
657 # env-dep:UNSET\n\
658 # env-dep:SLASH=a\\\\b\n",
659 )
660 .unwrap();
661 assert_eq!(
662 parsed.files,
663 vec![
664 PathBuf::from("generated.rs"),
665 PathBuf::from("src/a file.rs"),
666 PathBuf::from("src/lib.rs"),
667 ]
668 );
669 assert_eq!(parsed.environment["SET"], Some("value\nnext".into()));
670 assert_eq!(parsed.environment["UNSET"], None);
671 assert_eq!(parsed.environment["SLASH"], Some(r"a\b".into()));
672 }
673
674 #[test]
675 fn malformed_dep_info_bypasses_caching() {
676 for contents in [
677 "",
678 "target: ",
679 "target: src/trailing\\\n",
680 "target: src/lib.rs\n# env-dep:NAME=bad\\q\n",
681 ] {
682 assert!(RustcDepInfo::parse(contents).is_err(), "{contents:?}");
683 }
684 }
685
686 #[test]
687 fn native_directory_byte_limit_is_cumulative() {
688 let directory = tempfile::tempdir().unwrap();
689 let native = directory.path().join("native");
690 std::fs::create_dir_all(&native).unwrap();
691 std::fs::write(native.join("input.lib"), b"xx").unwrap();
692 let roots = native_input_roots(directory.path(), &[]);
693 let mut paths = BTreeSet::new();
694 let mut native_bytes = MAX_NATIVE_INPUT_BYTES - 1;
695
696 assert_eq!(
697 collect_native_directory(&native, &roots, &mut paths, &mut native_bytes),
698 Err(BypassReason::UnsupportedSearchPath("native".into()))
699 );
700 }
701
702 #[test]
703 fn discovery_command_removes_real_outputs() {
704 let invocation = RustcInvocation::parse(&args(&[
705 "--crate-name=widget",
706 "--crate-type=lib",
707 "--emit=dep-info,metadata,link",
708 "--out-dir=target/debug/deps",
709 "-o",
710 "target/debug/libwidget.rlib",
711 "src/lib.rs",
712 ]))
713 .unwrap();
714 let output = if cfg!(windows) {
715 PathBuf::from(r"C:\tmp\mbx cache\inputs.d")
716 } else {
717 PathBuf::from("/tmp/mbx cache/inputs.d")
718 };
719 let command = invocation.dep_info_command(&output).unwrap();
720 let arguments = command
721 .arguments()
722 .iter()
723 .map(|value| value.to_string_lossy())
724 .collect::<Vec<_>>();
725 assert_eq!(
726 arguments,
727 vec![
728 "--crate-name=widget",
729 "--crate-type=lib",
730 &format!("--emit=dep-info={}", output.display()),
731 "src/lib.rs",
732 ]
733 );
734 }
735
736 #[test]
737 fn discovery_hashes_externs_and_custom_targets() {
738 let directory = tempfile::tempdir().unwrap();
739 let root = directory.path();
740 let source = root.join("src/lib.rs");
741 let external = root.join("target/libdependency.rlib");
742 let target = root.join("targets/custom.json");
743 std::fs::create_dir_all(source.parent().unwrap()).unwrap();
744 std::fs::create_dir_all(external.parent().unwrap()).unwrap();
745 std::fs::create_dir_all(target.parent().unwrap()).unwrap();
746 std::fs::write(&source, "pub fn library() {}\n").unwrap();
747 std::fs::write(&external, "dependency artifact\n").unwrap();
748 std::fs::write(&target, "{}\n").unwrap();
749
750 let invocation = RustcInvocation::parse(&[
751 "--crate-name=widget".into(),
752 "--crate-type=lib".into(),
753 "--emit=metadata".into(),
754 format!("--extern=dependency={}", external.display()).into(),
755 format!("--target={}", target.display()).into(),
756 source.clone().into_os_string(),
757 ])
758 .unwrap();
759 let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
760 let discovered = invocation.discover_inputs(&dep_info, root).unwrap();
761 assert_eq!(discovered.inputs.len(), 3);
762
763 std::fs::remove_file(&external).unwrap();
764 assert!(matches!(
765 invocation.discover_inputs(&dep_info, root),
766 Err(BypassReason::InputRead { path, .. }) if path == external
767 ));
768 }
769
770 #[test]
771 fn discovery_rejects_inputs_modified_during_compilation() {
772 let directory = tempfile::tempdir().unwrap();
773 let source = directory.path().join("lib.rs");
774 std::fs::write(&source, "pub fn library() {}\n").unwrap();
775 let invocation = RustcInvocation::parse(&[
776 "--crate-name=widget".into(),
777 "--crate-type=lib".into(),
778 "--emit=dep-info,metadata".into(),
779 source.clone().into_os_string(),
780 ])
781 .unwrap();
782 let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
783 let discovered = invocation
784 .discover_inputs(&dep_info, directory.path())
785 .unwrap();
786 let modified = std::fs::metadata(&source).unwrap().modified().unwrap();
787
788 assert_eq!(
789 discovered.verify_not_modified_since(modified),
790 Err(BypassReason::InputModifiedDuringCompilation(source))
791 );
792 }
793
794 #[cfg(unix)]
795 #[test]
796 fn precompile_snapshot_does_not_depend_on_the_host_clock() {
797 let directory = tempfile::tempdir().unwrap();
798 let source = directory.path().join("lib.rs");
799 std::fs::write(&source, "pub fn library() {0}\n").unwrap();
800 let invocation = RustcInvocation::parse(&[
801 "--crate-name=widget".into(),
802 "--crate-type=lib".into(),
803 "--emit=dep-info,metadata".into(),
804 source.clone().into_os_string(),
805 ])
806 .unwrap();
807 let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
808 let discovered = invocation
809 .discover_inputs(&dep_info, directory.path())
810 .unwrap();
811 let snapshot = FileSnapshot::capture(&source).unwrap().unwrap();
812 let before = BTreeMap::from([(source.clone(), snapshot)]);
813
814 discovered
817 .verify_not_modified_since_with_snapshots(SystemTime::UNIX_EPOCH, &before)
818 .unwrap();
819
820 std::fs::write(&source, "pub fn library() {1}\n").unwrap();
822 assert_eq!(
823 discovered.verify_not_modified_since_with_snapshots(
824 SystemTime::now() + std::time::Duration::from_secs(60),
825 &before,
826 ),
827 Err(BypassReason::InputModifiedDuringCompilation(source))
828 );
829 }
830
831 fn toolchain_native_directory(version: &str) -> PathBuf {
835 if cfg!(windows) {
836 PathBuf::from(format!(r"C:\Program Files\MSVC\{version}\lib\x64"))
837 } else {
838 PathBuf::from(format!("/opt/msvc/{version}/lib/x64"))
839 }
840 }
841
842 fn library_with_native_search(source: &Path, directory: &Path) -> RustcInvocation {
843 RustcInvocation::parse(&[
844 "--crate-name=widget".into(),
845 "--crate-type=lib".into(),
846 "--emit=metadata,link".into(),
847 format!("-Lnative={}", directory.display()).into(),
848 source.to_path_buf().into_os_string(),
849 ])
850 .unwrap()
851 }
852
853 fn library_context(root: &Path, mappings: Vec<PathMapping>) -> ActionContext {
854 ActionContext {
855 compiler: crate::CompilerIdentity {
856 toolchain: "core:rust@test".into(),
857 rustc_version: "test".into(),
858 host: std::env::consts::ARCH.into(),
859 driver: None,
860 },
861 working_dir: root.to_path_buf(),
862 path_mappings: mappings,
863 environment: BTreeMap::new(),
864 portable_environment: BTreeSet::new(),
865 inputs: Vec::new(),
866 }
867 }
868
869 #[test]
870 fn unmapped_native_directory_is_keyed_by_path_for_library_emits() {
871 let directory = tempfile::tempdir().unwrap();
872 let root = directory.path();
873 let source = root.join("lib.rs");
874 std::fs::write(&source, "pub fn library() {}\n").unwrap();
875 let toolchain = toolchain_native_directory("14.51.36231");
876 let invocation = library_with_native_search(&source, &toolchain);
877 let mappings = vec![PathMapping::new(root, "workspace")];
878 let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
879
880 let discovered = invocation
882 .discover_inputs_with_mappings(
883 &dep_info,
884 root,
885 &mappings,
886 &mbx_cache_core::NoFileDigestCache,
887 )
888 .unwrap();
889 assert_eq!(discovered.inputs.len(), 1);
890 assert_eq!(discovered.inputs[0].path, source);
891
892 let context = library_context(root, mappings.clone());
894 let digest = invocation.invocation_digest(&context).unwrap();
895 let updated = library_with_native_search(&source, &toolchain_native_directory("14.52.0"));
896 assert_ne!(digest, updated.invocation_digest(&context).unwrap());
897
898 let mut recorded = context.clone();
901 discovered.clone().apply_to(&mut recorded).unwrap();
902 let action = invocation.action(recorded).unwrap();
903 let prediction = invocation.prediction(&context, &discovered).unwrap();
904 let replayed = prediction
905 .discover(
906 root,
907 &context.path_mappings,
908 &mbx_cache_core::NoFileDigestCache,
909 )
910 .unwrap();
911 let mut replay_context = context.clone();
912 replayed.apply_to(&mut replay_context).unwrap();
913 assert_eq!(
914 invocation.action(replay_context).unwrap().digest,
915 action.digest
916 );
917 }
918
919 #[test]
920 fn unmapped_native_directory_still_refuses_a_native_link() {
921 let directory = tempfile::tempdir().unwrap();
922 let root = directory.path();
923 let source = root.join("main.rs");
924 std::fs::write(&source, "fn main() {}\n").unwrap();
925 let toolchain = toolchain_native_directory("14.51.36231");
926 let invocation = RustcInvocation::parse_with(
927 &[
928 "--crate-name=app".into(),
929 "--crate-type=bin".into(),
930 "--emit=link".into(),
931 format!("-Lnative={}", toolchain.display()).into(),
932 source.clone().into_os_string(),
933 ],
934 crate::ParseOptions::caching_native_links(true),
935 )
936 .unwrap();
937 let mappings = vec![PathMapping::new(root, "workspace")];
938
939 let context = library_context(root, mappings.clone());
942 assert!(matches!(
943 invocation.invocation_digest(&context),
944 Err(BypassReason::UnmappedAbsolutePath(_))
945 ));
946 let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
947 assert_eq!(
948 invocation.discover_inputs_with_mappings(
949 &dep_info,
950 root,
951 &mappings,
952 &mbx_cache_core::NoFileDigestCache
953 ),
954 Err(BypassReason::UnsupportedSearchPath("native".into()))
955 );
956 }
957
958 #[test]
959 fn discovery_resolves_parent_components_against_the_working_directory() {
960 let directory = tempfile::tempdir().unwrap();
961 let root = directory.path().join("project");
962 let shared = directory.path().join("shared.rs");
963 std::fs::create_dir(&root).unwrap();
964 std::fs::write(&shared, "pub fn shared() {}\n").unwrap();
965
966 let invocation = RustcInvocation::parse(&args(&[
967 "--crate-name=widget",
968 "--crate-type=lib",
969 "--emit=metadata",
970 "../shared.rs",
971 ]))
972 .unwrap();
973 let dep_info = RustcDepInfo::parse("output: ../shared.rs\n").unwrap();
974 let discovered = invocation.discover_inputs(&dep_info, &root).unwrap();
975
976 assert_eq!(discovered.inputs.len(), 1);
977 assert_eq!(discovered.inputs[0].path, shared);
978 }
979
980 #[test]
981 fn rustc_dep_info_round_trip_discovers_real_inputs() {
982 let directory = tempfile::tempdir().unwrap();
983 let root = directory.path();
984 std::fs::write(
985 root.join("lib.rs"),
986 "mod child; const _: &str = include_str!(\"data file.txt\"); \
987 const _: &str = env!(\"MBX_DISCOVERY_TEST\"); \
988 const _: Option<&str> = option_env!(\"MBX_DISCOVERY_UNSET\");",
989 )
990 .unwrap();
991 std::fs::write(root.join("child.rs"), "pub fn child() {}\n").unwrap();
992 std::fs::write(root.join("data file.txt"), "included\n").unwrap();
993
994 let invocation = RustcInvocation::parse(&args(&[
995 "--crate-name=mbx_cache_discovery_test",
996 "--crate-type=lib",
997 "--emit=metadata,link",
998 "lib.rs",
999 ]))
1000 .unwrap();
1001 let dep_info_path = root.join("discovery inputs.d");
1002 let discovery_command = invocation.dep_info_command(&dep_info_path).unwrap();
1003 let rustc = std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into());
1004 let output = match Command::new(rustc)
1005 .args(discovery_command.arguments())
1006 .current_dir(root)
1007 .env("MBX_DISCOVERY_TEST", "observed")
1008 .env_remove("MBX_DISCOVERY_UNSET")
1009 .output()
1010 {
1011 Ok(output) => output,
1012 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return,
1013 Err(error) => panic!("failed to execute rustc: {error}"),
1014 };
1015 assert!(
1016 output.status.success(),
1017 "{}",
1018 String::from_utf8_lossy(&output.stderr)
1019 );
1020 let parsed = RustcDepInfo::read(&dep_info_path).unwrap();
1021 let discovered = invocation.discover_inputs(&parsed, root).unwrap();
1022 assert_eq!(
1023 discovered.environment["MBX_DISCOVERY_TEST"],
1024 Some("observed".into())
1025 );
1026 assert_eq!(discovered.environment["MBX_DISCOVERY_UNSET"], None);
1027 assert_eq!(discovered.inputs.len(), 3);
1028 assert!(
1029 discovered
1030 .inputs
1031 .iter()
1032 .all(|input| input.digest.algorithm == "blake3")
1033 );
1034 let mut context = ActionContext {
1035 compiler: crate::CompilerIdentity {
1036 toolchain: "core:rust@test".into(),
1037 rustc_version: "test".into(),
1038 host: std::env::consts::ARCH.into(),
1039 driver: None,
1040 },
1041 working_dir: root.to_path_buf(),
1042 path_mappings: vec![crate::PathMapping::new(root, "workspace")],
1043 environment: BTreeMap::new(),
1044 portable_environment: BTreeSet::new(),
1045 inputs: Vec::new(),
1046 };
1047 discovered.clone().apply_to(&mut context).unwrap();
1048 let action = invocation.action(context).unwrap();
1049 assert!(
1050 String::from_utf8(action.bytes)
1051 .unwrap()
1052 .contains(r#""MBX_DISCOVERY_TEST":"observed""#)
1053 );
1054 discovered.verify().unwrap();
1055 std::fs::write(root.join("child.rs"), "pub fn changed() {}\n").unwrap();
1056 assert_eq!(
1057 discovered.verify(),
1058 Err(BypassReason::InputChanged(root.join("child.rs")))
1059 );
1060 }
1061
1062 struct SentinelLedger {
1064 known: FileIdentity,
1065 digest: CacheDigest,
1066 recorded: std::sync::Mutex<Vec<RecordedFileDigest>>,
1067 }
1068
1069 impl FileDigestCache for SentinelLedger {
1070 fn find(&self, scope: FileDigestScope, files: &[FileIdentity]) -> Vec<Option<CacheDigest>> {
1071 assert_eq!(scope, FileDigestScope::Content);
1072 files
1073 .iter()
1074 .map(|file| (*file == self.known).then(|| self.digest.clone()))
1075 .collect()
1076 }
1077
1078 fn record(&self, scope: FileDigestScope, entries: Vec<RecordedFileDigest>) {
1079 assert_eq!(scope, FileDigestScope::Content);
1080 self.recorded.lock().unwrap().extend(entries);
1081 }
1082 }
1083
1084 #[test]
1085 fn discovery_reuses_recorded_digests_and_records_fresh_ones() {
1086 let directory = tempfile::tempdir().unwrap();
1087 let root = directory.path();
1088 let known_path = root.join("libdep.rlib");
1089 let fresh_path = root.join("lib.rs");
1090 std::fs::write(&known_path, b"rlib bytes").unwrap();
1091 std::fs::write(&fresh_path, b"fn lib() {}").unwrap();
1092 let metadata = std::fs::metadata(&known_path).unwrap();
1093 let sentinel = CacheDigest {
1096 algorithm: "blake3".into(),
1097 hash: "c".repeat(64),
1098 size: metadata.len(),
1099 };
1100 let ledger = SentinelLedger {
1101 known: FileIdentity::describe(&known_path, &metadata).unwrap(),
1102 digest: sentinel.clone(),
1103 recorded: std::sync::Mutex::new(Vec::new()),
1104 };
1105
1106 let discovered = DiscoveredInputs::from_paths(
1107 root,
1108 BTreeSet::from([known_path.clone(), fresh_path.clone()]),
1109 BTreeMap::new(),
1110 &ledger,
1111 )
1112 .unwrap();
1113
1114 let by_path = |path: &Path| {
1115 discovered
1116 .inputs
1117 .iter()
1118 .find(|input| input.path == path)
1119 .unwrap()
1120 .digest
1121 .clone()
1122 };
1123 assert_eq!(
1124 by_path(&known_path),
1125 sentinel,
1126 "the recorded digest answers"
1127 );
1128 assert_eq!(
1129 by_path(&fresh_path),
1130 CacheDigest::blake3_file(&fresh_path).unwrap(),
1131 "an unrecorded file is hashed"
1132 );
1133 let recorded = ledger.recorded.lock().unwrap();
1134 assert_eq!(recorded.len(), 1, "only the fresh hash is recorded");
1135 assert_eq!(recorded[0].file.path, fresh_path);
1136 assert_eq!(recorded[0].digest, by_path(&fresh_path));
1137 }
1138
1139 #[cfg(unix)]
1144 #[test]
1145 fn verification_trusts_an_unchanged_identity_and_rereads_a_changed_one() {
1146 let directory = tempfile::tempdir().unwrap();
1147 let root = directory.path();
1148 let known_path = root.join("libdep.rlib");
1149 std::fs::write(&known_path, b"rlib bytes").unwrap();
1150 let metadata = std::fs::metadata(&known_path).unwrap();
1151 let sentinel = CacheDigest {
1154 algorithm: "blake3".into(),
1155 hash: "c".repeat(64),
1156 size: metadata.len(),
1157 };
1158 let ledger = SentinelLedger {
1159 known: FileIdentity::describe(&known_path, &metadata).unwrap(),
1160 digest: sentinel,
1161 recorded: std::sync::Mutex::new(Vec::new()),
1162 };
1163 let discovered = DiscoveredInputs::from_paths(
1164 root,
1165 BTreeSet::from([known_path.clone()]),
1166 BTreeMap::new(),
1167 &ledger,
1168 )
1169 .unwrap();
1170
1171 discovered.verify().unwrap();
1172
1173 std::thread::sleep(std::time::Duration::from_millis(20));
1176 std::fs::write(&known_path, b"RLIB BYTES").unwrap();
1177 assert_eq!(
1178 discovered.verify(),
1179 Err(BypassReason::InputChanged(known_path.clone()))
1180 );
1181
1182 std::fs::remove_file(&known_path).unwrap();
1183 assert!(matches!(
1184 discovered.verify(),
1185 Err(BypassReason::InputRead { path, .. }) if path == known_path
1186 ));
1187 }
1188
1189 #[cfg(unix)]
1193 #[test]
1194 fn a_disguised_rewrite_is_not_answered_from_the_ledger() {
1195 let directory = tempfile::tempdir().unwrap();
1196 let root = directory.path();
1197 let path = root.join("lib.rs");
1198 std::fs::write(&path, b"fn lib() -> u8 { 1 }").unwrap();
1199 let before = std::fs::metadata(&path).unwrap();
1200 let identity = FileIdentity::describe(&path, &before).unwrap();
1201
1202 std::fs::write(&path, b"fn lib() -> u8 { 2 }").unwrap();
1205 let file = std::fs::File::options().write(true).open(&path).unwrap();
1206 file.set_times(std::fs::FileTimes::new().set_modified(before.modified().unwrap()))
1207 .unwrap();
1208 drop(file);
1209 let after = std::fs::metadata(&path).unwrap();
1210 let disguised = FileIdentity::describe(&path, &after).unwrap();
1211 assert_eq!(disguised.len, identity.len);
1212 assert_eq!(disguised.modified, identity.modified);
1213 assert_ne!(
1214 disguised, identity,
1215 "the change token must expose the rewrite"
1216 );
1217
1218 let ledger = SentinelLedger {
1219 known: identity,
1220 digest: CacheDigest {
1221 algorithm: "blake3".into(),
1222 hash: "e".repeat(64),
1223 size: after.len(),
1224 },
1225 recorded: std::sync::Mutex::new(Vec::new()),
1226 };
1227 let discovered = DiscoveredInputs::from_paths(
1228 root,
1229 BTreeSet::from([path.clone()]),
1230 BTreeMap::new(),
1231 &ledger,
1232 )
1233 .unwrap();
1234 assert_eq!(
1235 discovered.inputs[0].digest,
1236 CacheDigest::blake3_file(&path).unwrap(),
1237 "the disguised rewrite is hashed, not answered from the ledger"
1238 );
1239 }
1240}