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