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