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