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