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