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