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 native_only: BTreeSet<PathBuf>,
151}
152
153impl DiscoveredInputs {
154 pub(crate) fn from_paths(
155 working_dir: &Path,
156 paths: BTreeSet<PathBuf>,
157 environment: BTreeMap<String, Option<String>>,
158 digests: &dyn FileDigestCache,
159 ) -> Result<Self, BypassReason> {
160 if !working_dir.is_absolute() {
161 return Err(BypassReason::RelativeWorkingDirectory(
162 working_dir.to_path_buf(),
163 ));
164 }
165 let working_dir = normalize_components(working_dir);
166 let mut identified = Vec::with_capacity(paths.len());
172 for path in paths {
173 let metadata = std::fs::metadata(&path).map_err(|error| BypassReason::InputRead {
174 path: path.clone(),
175 message: error.to_string(),
176 })?;
177 if !metadata.is_file() {
178 return Err(BypassReason::InputRead {
179 path,
180 message: "input is not a regular file".into(),
181 });
182 }
183 let identity = FileIdentity::for_digest_cache(&path, &metadata).map_err(|error| {
184 BypassReason::InputRead {
185 path: path.clone(),
186 message: error.to_string(),
187 }
188 })?;
189 identified.push((path, identity));
190 }
191 let queries = identified
192 .iter()
193 .filter_map(|(_, identity)| identity.clone())
194 .collect::<Vec<_>>();
195 let mut recorded = digests
196 .resolve(FileDigestScope::Content, &queries)
197 .into_iter();
198 let mut inputs = Vec::with_capacity(identified.len());
199 let mut identities = Vec::with_capacity(identified.len());
200 let mut fresh = Vec::new();
201 for (path, identity) in identified {
202 let resolution = identity
203 .as_ref()
204 .and_then(|_| recorded.next())
205 .unwrap_or(FileDigestResolution::Unresolved);
206 let cached = match resolution {
207 FileDigestResolution::Digest(digest)
208 if identity.as_ref().is_some_and(|identity| {
209 identity.len == digest.size && identity.still_describes().unwrap_or(false)
210 }) =>
211 {
212 Some(digest)
213 }
214 FileDigestResolution::Digest(_)
215 | FileDigestResolution::EmbeddedTimestampMacro
216 | FileDigestResolution::Unresolved => None,
217 };
218 let digest = if let Some(digest) = cached {
219 identities.push(identity);
220 digest
221 } else {
222 let observed =
223 digest_file_validated(FileDigestScope::Content, &path).map_err(|error| {
224 BypassReason::InputRead {
225 path: path.clone(),
226 message: error.to_string(),
227 }
228 })?;
229 let (digest, observed_identity) = if let Some(observed) = observed {
230 let digest = observed.resolution.into_digest().ok_or_else(|| {
231 BypassReason::InputRead {
232 path: path.clone(),
233 message: "content digest resolution returned no digest".into(),
234 }
235 })?;
236 if let Some(file) = observed.cache_identity.clone()
237 && file.len == digest.size
238 {
239 fresh.push(RecordedFileDigest {
240 file,
241 digest: digest.clone(),
242 });
243 }
244 (digest, observed.cache_identity)
245 } else {
246 let digest = digest_file(FileDigestScope::Content, &path)
247 .and_then(|resolution| {
248 resolution.into_digest().ok_or_else(|| {
249 std::io::Error::other(
250 "content digest resolution returned no digest",
251 )
252 })
253 })
254 .map_err(|error| BypassReason::InputRead {
255 path: path.clone(),
256 message: error.to_string(),
257 })?;
258 (digest, None)
259 };
260 identities.push(observed_identity);
261 digest
262 };
263 inputs.push(ActionInput { path, digest });
264 }
265 if !fresh.is_empty() {
266 digests.record(FileDigestScope::Content, fresh);
267 }
268 Ok(Self {
269 working_dir,
270 inputs,
271 environment,
272 identities,
273 native_only: BTreeSet::new(),
274 })
275 }
276
277 pub(crate) fn with_native_only(mut self, native_only: BTreeSet<PathBuf>) -> Self {
279 self.native_only = native_only;
280 self
281 }
282
283 pub fn is_native_only(&self, path: &Path) -> bool {
285 self.native_only.contains(path)
286 }
287
288 pub fn verify_not_modified_since(&self, started_at: SystemTime) -> Result<(), BypassReason> {
295 self.verify_not_modified_since_with_snapshots(started_at, &BTreeMap::new())
296 }
297
298 pub fn verify_not_modified_since_with_snapshots(
305 &self,
306 started_at: SystemTime,
307 before: &BTreeMap<PathBuf, FileSnapshot>,
308 ) -> Result<(), BypassReason> {
309 for input in &self.inputs {
310 if let Some(previous) = before.get(&input.path)
311 && previous.proves_content_change()
312 {
313 let metadata =
314 std::fs::metadata(&input.path).map_err(|error| BypassReason::InputRead {
315 path: input.path.clone(),
316 message: error.to_string(),
317 })?;
318 let identity = FileIdentity::for_digest_cache(&input.path, &metadata)
319 .map_err(|error| BypassReason::InputRead {
320 path: input.path.clone(),
321 message: error.to_string(),
322 })?
323 .or_else(|| FileIdentity::describe(&input.path, &metadata));
324 if previous.matches(identity.as_ref(), &input.digest) {
325 continue;
326 }
327 return Err(BypassReason::InputModifiedDuringCompilation(
328 input.path.clone(),
329 ));
330 }
331 let metadata =
332 std::fs::metadata(&input.path).map_err(|error| BypassReason::InputRead {
333 path: input.path.clone(),
334 message: error.to_string(),
335 })?;
336 let modified = metadata
337 .modified()
338 .map_err(|error| BypassReason::InputRead {
339 path: input.path.clone(),
340 message: error.to_string(),
341 })?;
342 if modified >= started_at {
343 return Err(BypassReason::InputModifiedDuringCompilation(
344 input.path.clone(),
345 ));
346 }
347 }
348 Ok(())
349 }
350
351 pub fn verify_not_modified_since_with_identities(
353 &self,
354 started_at: SystemTime,
355 before: &BTreeMap<PathBuf, FileIdentity>,
356 ) -> Result<(), BypassReason> {
357 let snapshots = before
358 .iter()
359 .map(|(path, identity)| (path.clone(), identity.clone().into()))
360 .collect();
361 self.verify_not_modified_since_with_snapshots(started_at, &snapshots)
362 }
363
364 pub fn verify(&self) -> Result<(), BypassReason> {
368 for (index, input) in self.inputs.iter().enumerate() {
369 let read_error = |error: std::io::Error| BypassReason::InputRead {
370 path: input.path.clone(),
371 message: error.to_string(),
372 };
373 if let Some(Some(identity)) = self.identities.get(index)
384 && identity.can_skip_content_verification()
385 && identity.still_describes().map_err(read_error)?
386 {
387 continue;
388 }
389 let matches = input.digest.matches_file(&input.path).map_err(|error| {
390 BypassReason::InputRead {
391 path: input.path.clone(),
392 message: error.to_string(),
393 }
394 })?;
395 if !matches {
396 return Err(BypassReason::InputChanged(input.path.clone()));
397 }
398 }
399 Ok(())
400 }
401
402 pub fn apply_to(self, context: &mut ActionContext) -> Result<(), BypassReason> {
405 if normalize_components(&context.working_dir) != self.working_dir {
406 return Err(BypassReason::DiscoveryWorkingDirectory);
407 }
408 for (name, value) in &self.environment {
409 if context
410 .environment
411 .get(name)
412 .is_some_and(|previous| previous != value)
413 {
414 return Err(BypassReason::ConflictingEnvironment(name.clone()));
415 }
416 }
417 context.environment.extend(self.environment);
418 context.inputs.extend(self.inputs);
419 Ok(())
420 }
421}
422
423impl RustcInvocation {
424 pub fn dep_info_command(&self, output: &Path) -> Result<DepInfoCommand, BypassReason> {
426 if !output.is_absolute() {
427 return Err(BypassReason::RelativeDepInfoPath(output.to_path_buf()));
428 }
429 let output_text = output
430 .to_str()
431 .ok_or_else(|| BypassReason::NonUtf8Path(output.to_path_buf()))?;
432 if output_text.contains(',') {
433 return Err(BypassReason::UnsafeDepInfoPath(output.to_path_buf()));
434 }
435
436 let mut arguments = Vec::new();
437 for argument in &self.arguments {
438 match argument {
439 Argument::Emit(_) => {}
440 Argument::Path { flag, .. } if flag == "--out-dir" || flag == "-o" => {}
441 argument => arguments.push(render_argument(argument)?),
442 }
443 }
444 arguments.push(format!("--emit=dep-info={output_text}").into());
445 arguments.push(self.source.clone().into_os_string());
446 Ok(DepInfoCommand {
447 arguments,
448 output: output.to_path_buf(),
449 })
450 }
451
452 pub fn discover_inputs(
455 &self,
456 dep_info: &RustcDepInfo,
457 working_dir: &Path,
458 ) -> Result<DiscoveredInputs, BypassReason> {
459 self.discover_inputs_with_mappings(
460 dep_info,
461 working_dir,
462 &[],
463 &mbx_cache_core::NoFileDigestCache,
464 )
465 }
466
467 pub fn discover_inputs_with_mappings(
473 &self,
474 dep_info: &RustcDepInfo,
475 working_dir: &Path,
476 path_mappings: &[PathMapping],
477 digests: &dyn FileDigestCache,
478 ) -> Result<DiscoveredInputs, BypassReason> {
479 self.discover(dep_info, working_dir, Some(path_mappings), digests)
480 }
481
482 pub fn discover_source_inputs(
491 &self,
492 dep_info: &RustcDepInfo,
493 working_dir: &Path,
494 digests: &dyn FileDigestCache,
495 ) -> Result<DiscoveredInputs, BypassReason> {
496 self.discover(dep_info, working_dir, None, digests)
497 }
498
499 fn discover(
501 &self,
502 dep_info: &RustcDepInfo,
503 working_dir: &Path,
504 path_mappings: Option<&[PathMapping]>,
505 digests: &dyn FileDigestCache,
506 ) -> Result<DiscoveredInputs, BypassReason> {
507 if !working_dir.is_absolute() {
508 return Err(BypassReason::RelativeWorkingDirectory(
509 working_dir.to_path_buf(),
510 ));
511 }
512 let working_dir = normalize_components(working_dir);
513 let absolute = |path: &PathBuf| {
514 let absolute = if path.is_absolute() {
515 path.to_path_buf()
516 } else {
517 working_dir.join(path)
518 };
519 normalize_components(&absolute)
520 };
521 let sources = dep_info.files.iter().map(absolute).collect::<BTreeSet<_>>();
527 let mut paths = sources.clone();
528 paths.extend(self.required_inputs.iter().map(absolute));
529 let admitted_roots = native_input_roots(&working_dir, path_mappings.unwrap_or_default());
530 let mut native_bytes = 0_u64;
531 for argument in &self.arguments {
532 if path_mappings.is_some()
533 && let Argument::SearchPath { kind, path } = argument
534 && kind == "native"
535 {
536 let directory = if path.is_absolute() {
537 path.clone()
538 } else {
539 working_dir.join(path)
540 };
541 collect_native_directory(
542 &directory,
543 &admitted_roots,
544 &mut paths,
545 &mut native_bytes,
546 )?;
547 }
548 }
549 let native_directories = self
550 .arguments
551 .iter()
552 .filter_map(|argument| match argument {
553 Argument::SearchPath { kind, path } if kind == "native" => Some(absolute(path)),
554 _ => None,
555 })
556 .collect::<Vec<_>>();
557 let native_only = paths
558 .difference(&sources)
559 .filter(|path| native_directories.iter().any(|root| path.starts_with(root)))
560 .cloned()
561 .collect();
562 Ok(DiscoveredInputs::from_paths(
563 &working_dir,
564 paths,
565 dep_info.environment.clone(),
566 digests,
567 )?
568 .with_native_only(native_only))
569 }
570}
571
572pub(super) fn native_input_roots(
574 working_dir: &Path,
575 path_mappings: &[PathMapping],
576) -> Vec<PathBuf> {
577 std::iter::once(working_dir)
578 .chain(path_mappings.iter().map(|mapping| mapping.root.as_path()))
579 .map(normalize_components)
580 .collect()
581}
582
583pub(super) fn collect_native_directory(
586 directory: &Path,
587 admitted_roots: &[PathBuf],
588 paths: &mut BTreeSet<PathBuf>,
589 native_bytes: &mut u64,
590) -> Result<(), BypassReason> {
591 let directory = normalize_components(directory);
592 if !admitted_roots
593 .iter()
594 .any(|root| directory.starts_with(root))
595 {
596 return Err(BypassReason::UnsupportedSearchPath("native".into()));
597 }
598
599 let canonical_root = match directory.canonicalize() {
600 Ok(root) => root,
601 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
602 Err(error) => {
603 return Err(BypassReason::InputRead {
604 path: directory,
605 message: error.to_string(),
606 });
607 }
608 };
609 let mut pending = vec![directory];
610 while let Some(directory) = pending.pop() {
611 let entries = match std::fs::read_dir(&directory) {
612 Ok(entries) => entries,
613 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
614 Err(error) => {
615 return Err(BypassReason::InputRead {
616 path: directory,
617 message: error.to_string(),
618 });
619 }
620 };
621 for entry in entries {
622 let entry = entry.map_err(|error| BypassReason::InputRead {
623 path: directory.clone(),
624 message: error.to_string(),
625 })?;
626 let path = entry.path();
627 let file_type = entry.file_type().map_err(|error| BypassReason::InputRead {
628 path: path.clone(),
629 message: error.to_string(),
630 })?;
631 if file_type.is_dir() {
632 pending.push(path);
633 } else if file_type.is_file() || file_type.is_symlink() {
634 if file_type.is_symlink() {
638 let resolved =
639 path.canonicalize()
640 .map_err(|error| BypassReason::InputRead {
641 path: path.clone(),
642 message: error.to_string(),
643 })?;
644 if !resolved.starts_with(&canonical_root) || !resolved.is_file() {
645 return Err(BypassReason::UnsupportedSearchPath("native".into()));
646 }
647 }
648 *native_bytes = native_bytes
649 .checked_add(
650 std::fs::metadata(&path)
651 .map_err(|error| BypassReason::InputRead {
652 path: path.clone(),
653 message: error.to_string(),
654 })?
655 .len(),
656 )
657 .ok_or_else(|| BypassReason::UnsupportedSearchPath("native".into()))?;
658 paths.insert(path);
659 } else {
660 return Err(BypassReason::UnsupportedSearchPath("native".into()));
661 }
662 if paths.len() > MAX_PREDICTED_INPUTS || *native_bytes > MAX_NATIVE_INPUT_BYTES {
663 return Err(BypassReason::UnsupportedSearchPath("native".into()));
664 }
665 }
666 }
667 Ok(())
668}
669
670fn render_argument(argument: &Argument) -> Result<OsString, BypassReason> {
671 let rendered = match argument {
672 Argument::Plain(value) => value.clone(),
673 Argument::Path { flag, path } => format!(
674 "{flag}={}",
675 path.to_str()
676 .ok_or_else(|| BypassReason::NonUtf8Path(path.clone()))?
677 ),
678 Argument::SearchPath { kind, path } => format!(
679 "-L{kind}={}",
680 path.to_str()
681 .ok_or_else(|| BypassReason::NonUtf8Path(path.clone()))?
682 ),
683 Argument::Extern { name, path } => match path {
684 Some(path) => format!(
685 "--extern={name}={}",
686 path.to_str()
687 .ok_or_else(|| BypassReason::NonUtf8Path(path.clone()))?
688 ),
689 None => format!("--extern={name}"),
690 },
691 Argument::Emit(_) => unreachable!("emit arguments are removed before rendering"),
692 Argument::RemapPath { from, to } => format!(
693 "--remap-path-prefix={}={to}",
694 from.to_str()
695 .ok_or_else(|| BypassReason::NonUtf8Path(from.clone()))?
696 ),
697 Argument::OsoPrefix {
700 path,
701 trailing_slash,
702 } => format!(
703 "--codegen=link-arg=-Wl,-oso_prefix,{}{}",
704 path.to_str()
705 .ok_or_else(|| BypassReason::NonUtf8Path(path.clone()))?,
706 if *trailing_slash { "/" } else { "" }
707 ),
708 Argument::InstallName(name) => {
711 format!("--codegen=link-arg=-Wl,-install_name,@rpath/{name}")
712 }
713 Argument::FuseLd(selection) => format!("--codegen=link-arg=-fuse-ld={selection}"),
714 };
715 Ok(rendered.into())
716}
717
718fn unescape_environment(value: &str) -> Result<String, BypassReason> {
719 let mut output = String::with_capacity(value.len());
720 let mut characters = value.chars();
721 while let Some(character) = characters.next() {
722 if character != '\\' {
723 output.push(character);
724 continue;
725 }
726 match characters.next() {
727 Some('\\') => output.push('\\'),
728 Some('n') => output.push('\n'),
729 Some('r') => output.push('\r'),
730 Some(character) => {
731 return Err(BypassReason::MalformedDepInfo(format!(
732 "unknown environment escape \\{character}"
733 )));
734 }
735 None => {
736 return Err(BypassReason::MalformedDepInfo(
737 "environment input ends with an unterminated escape".into(),
738 ));
739 }
740 }
741 }
742 Ok(output)
743}
744
745#[cfg(test)]
746mod tests {
747 use super::*;
748 use std::process::Command;
749
750 fn args(values: &[&str]) -> Vec<OsString> {
751 values.iter().map(OsString::from).collect()
752 }
753
754 #[test]
755 fn parses_files_spaces_and_environment_records() {
756 let parsed = RustcDepInfo::parse(
757 "target/lib.rlib: src/lib.rs src/a\\ file.rs generated.rs\n\
758 src/lib.rs:\n\
759 # env-dep:SET=value\\nnext\n\
760 # env-dep:UNSET\n\
761 # env-dep:SLASH=a\\\\b\n",
762 )
763 .unwrap();
764 assert_eq!(
765 parsed.files,
766 vec![
767 PathBuf::from("generated.rs"),
768 PathBuf::from("src/a file.rs"),
769 PathBuf::from("src/lib.rs"),
770 ]
771 );
772 assert_eq!(parsed.environment["SET"], Some("value\nnext".into()));
773 assert_eq!(parsed.environment["UNSET"], None);
774 assert_eq!(parsed.environment["SLASH"], Some(r"a\b".into()));
775 }
776
777 #[test]
778 fn malformed_dep_info_bypasses_caching() {
779 for contents in [
780 "",
781 "target: ",
782 "target: src/trailing\\\n",
783 "target: src/lib.rs\n# env-dep:NAME=bad\\q\n",
784 ] {
785 assert!(RustcDepInfo::parse(contents).is_err(), "{contents:?}");
786 }
787 }
788
789 #[test]
790 fn native_directory_byte_limit_is_cumulative() {
791 let directory = tempfile::tempdir().unwrap();
792 let native = directory.path().join("native");
793 std::fs::create_dir_all(&native).unwrap();
794 std::fs::write(native.join("input.lib"), b"xx").unwrap();
795 let roots = native_input_roots(directory.path(), &[]);
796 let mut paths = BTreeSet::new();
797 let mut native_bytes = MAX_NATIVE_INPUT_BYTES - 1;
798
799 assert_eq!(
800 collect_native_directory(&native, &roots, &mut paths, &mut native_bytes),
801 Err(BypassReason::UnsupportedSearchPath("native".into()))
802 );
803 }
804
805 #[test]
806 fn discovery_command_removes_real_outputs() {
807 let invocation = RustcInvocation::parse(&args(&[
808 "--crate-name=widget",
809 "--crate-type=lib",
810 "--emit=dep-info,metadata,link",
811 "--out-dir=target/debug/deps",
812 "-o",
813 "target/debug/libwidget.rlib",
814 "src/lib.rs",
815 ]))
816 .unwrap();
817 let output = if cfg!(windows) {
818 PathBuf::from(r"C:\tmp\mbx cache\inputs.d")
819 } else {
820 PathBuf::from("/tmp/mbx cache/inputs.d")
821 };
822 let command = invocation.dep_info_command(&output).unwrap();
823 let arguments = command
824 .arguments()
825 .iter()
826 .map(|value| value.to_string_lossy())
827 .collect::<Vec<_>>();
828 assert_eq!(
829 arguments,
830 vec![
831 "--crate-name=widget",
832 "--crate-type=lib",
833 &format!("--emit=dep-info={}", output.display()),
834 "src/lib.rs",
835 ]
836 );
837 }
838
839 #[test]
840 fn discovery_hashes_externs_and_custom_targets() {
841 let directory = tempfile::tempdir().unwrap();
842 let root = directory.path();
843 let source = root.join("src/lib.rs");
844 let external = root.join("target/libdependency.rlib");
845 let target = root.join("targets/custom.json");
846 std::fs::create_dir_all(source.parent().unwrap()).unwrap();
847 std::fs::create_dir_all(external.parent().unwrap()).unwrap();
848 std::fs::create_dir_all(target.parent().unwrap()).unwrap();
849 std::fs::write(&source, "pub fn library() {}\n").unwrap();
850 std::fs::write(&external, "dependency artifact\n").unwrap();
851 std::fs::write(&target, "{}\n").unwrap();
852
853 let invocation = RustcInvocation::parse(&[
854 "--crate-name=widget".into(),
855 "--crate-type=lib".into(),
856 "--emit=metadata".into(),
857 format!("--extern=dependency={}", external.display()).into(),
858 format!("--target={}", target.display()).into(),
859 source.clone().into_os_string(),
860 ])
861 .unwrap();
862 let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
863 let discovered = invocation.discover_inputs(&dep_info, root).unwrap();
864 assert_eq!(discovered.inputs.len(), 3);
865
866 std::fs::remove_file(&external).unwrap();
867 assert!(matches!(
868 invocation.discover_inputs(&dep_info, root),
869 Err(BypassReason::InputRead { path, .. }) if path == external
870 ));
871 }
872
873 #[test]
874 fn discovery_rejects_inputs_modified_during_compilation() {
875 let directory = tempfile::tempdir().unwrap();
876 let source = directory.path().join("lib.rs");
877 std::fs::write(&source, "pub fn library() {}\n").unwrap();
878 let invocation = RustcInvocation::parse(&[
879 "--crate-name=widget".into(),
880 "--crate-type=lib".into(),
881 "--emit=dep-info,metadata".into(),
882 source.clone().into_os_string(),
883 ])
884 .unwrap();
885 let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
886 let discovered = invocation
887 .discover_inputs(&dep_info, directory.path())
888 .unwrap();
889 let modified = std::fs::metadata(&source).unwrap().modified().unwrap();
890
891 assert_eq!(
892 discovered.verify_not_modified_since(modified),
893 Err(BypassReason::InputModifiedDuringCompilation(source))
894 );
895 }
896
897 #[cfg(unix)]
898 #[test]
899 fn precompile_snapshot_does_not_depend_on_the_host_clock() {
900 let directory = tempfile::tempdir().unwrap();
901 let source = directory.path().join("lib.rs");
902 std::fs::write(&source, "pub fn library() {0}\n").unwrap();
903 let invocation = RustcInvocation::parse(&[
904 "--crate-name=widget".into(),
905 "--crate-type=lib".into(),
906 "--emit=dep-info,metadata".into(),
907 source.clone().into_os_string(),
908 ])
909 .unwrap();
910 let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
911 let discovered = invocation
912 .discover_inputs(&dep_info, directory.path())
913 .unwrap();
914 let snapshot = FileSnapshot::capture(&source).unwrap().unwrap();
915 let before = BTreeMap::from([(source.clone(), snapshot)]);
916
917 discovered
920 .verify_not_modified_since_with_snapshots(SystemTime::UNIX_EPOCH, &before)
921 .unwrap();
922
923 std::fs::write(&source, "pub fn library() {1}\n").unwrap();
925 assert_eq!(
926 discovered.verify_not_modified_since_with_snapshots(
927 SystemTime::now() + std::time::Duration::from_secs(60),
928 &before,
929 ),
930 Err(BypassReason::InputModifiedDuringCompilation(source))
931 );
932 }
933
934 fn toolchain_native_directory(version: &str) -> PathBuf {
936 if cfg!(windows) {
937 PathBuf::from(format!(
938 r"C:\Program Files\Microsoft Visual Studio\2022\BuildTools\VC\Tools\MSVC\{version}\lib\x64"
939 ))
940 } else {
941 PathBuf::from(format!("/opt/msvc/{version}/lib/x64"))
942 }
943 }
944
945 fn library_with_native_search(source: &Path, directory: &Path) -> RustcInvocation {
946 RustcInvocation::parse(&[
947 "--crate-name=widget".into(),
948 "--crate-type=lib".into(),
949 "--emit=metadata,link".into(),
950 format!("-Lnative={}", directory.display()).into(),
951 source.to_path_buf().into_os_string(),
952 ])
953 .unwrap()
954 }
955
956 fn library_context(root: &Path, mappings: Vec<PathMapping>) -> ActionContext {
957 ActionContext {
958 compiler: crate::CompilerIdentity {
959 toolchain: "core:rust@test".into(),
960 rustc_version: "test".into(),
961 host: std::env::consts::ARCH.into(),
962 driver: None,
963 },
964 working_dir: root.to_path_buf(),
965 path_mappings: mappings,
966 environment: BTreeMap::new(),
967 portable_environment: BTreeSet::new(),
968 inputs: Vec::new(),
969 }
970 }
971
972 #[cfg(windows)]
973 #[test]
974 fn unmapped_toolchain_directory_cannot_hide_a_static_archive() {
975 let directory = tempfile::tempdir().unwrap();
976 let root = directory.path();
977 let source = root.join("lib.rs");
978 std::fs::write(&source, "pub fn library() {}\n").unwrap();
979 let toolchain = toolchain_native_directory("14.51.36231");
980 let invocation = library_with_native_search(&source, &toolchain);
981 let mappings = vec![PathMapping::new(root, "workspace")];
982 let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
983
984 let context = library_context(root, mappings.clone());
985 assert!(matches!(
986 invocation.invocation_digest(&context),
987 Err(BypassReason::UnmappedAbsolutePath(path)) if path == toolchain
988 ));
989 assert_eq!(
990 invocation.discover_inputs_with_mappings(
991 &dep_info,
992 root,
993 &mappings,
994 &mbx_cache_core::NoFileDigestCache,
995 ),
996 Err(BypassReason::UnsupportedSearchPath("native".into()))
997 );
998 }
999
1000 #[test]
1001 fn source_level_static_link_cannot_hide_an_unmapped_archive() {
1002 let directory = tempfile::tempdir().unwrap();
1003 let workspace = directory.path().join("workspace");
1004 let external = directory.path().join("external");
1005 std::fs::create_dir(&workspace).unwrap();
1006 std::fs::create_dir(&external).unwrap();
1007 let source = workspace.join("lib.rs");
1008 std::fs::write(
1009 &source,
1010 "#[link(name = \"foo\", kind = \"static\")] unsafe extern \"C\" {}\n",
1011 )
1012 .unwrap();
1013 std::fs::write(external.join("libfoo.a"), b"first archive").unwrap();
1014 let invocation = library_with_native_search(&source, &external);
1015 let mappings = vec![PathMapping::new(&workspace, "workspace")];
1016 let context = library_context(&workspace, mappings.clone());
1017 let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
1018
1019 assert!(matches!(
1022 invocation.invocation_digest(&context),
1023 Err(BypassReason::UnmappedAbsolutePath(path)) if path == external
1024 ));
1025 assert_eq!(
1026 invocation.discover_inputs_with_mappings(
1027 &dep_info,
1028 &workspace,
1029 &mappings,
1030 &mbx_cache_core::NoFileDigestCache,
1031 ),
1032 Err(BypassReason::UnsupportedSearchPath("native".into()))
1033 );
1034 }
1035
1036 #[cfg(windows)]
1037 #[test]
1038 fn project_directory_with_toolchain_suffix_is_not_trusted() {
1039 let directory = tempfile::tempdir().unwrap();
1040 let root = directory.path().join("workspace");
1041 std::fs::create_dir(&root).unwrap();
1042 let source = root.join("lib.rs");
1043 std::fs::write(&source, "pub fn library() {}\n").unwrap();
1044 let fake_toolchain = directory.path().join("MSVC/14.51.36231/lib/x64");
1045 let invocation = library_with_native_search(&source, &fake_toolchain);
1046 let context = library_context(&root, vec![PathMapping::new(&root, "workspace")]);
1047
1048 assert!(matches!(
1050 invocation.invocation_digest(&context),
1051 Err(BypassReason::UnmappedAbsolutePath(path)) if path == fake_toolchain
1052 ));
1053 }
1054
1055 #[test]
1056 fn unmapped_native_directory_still_refuses_a_native_link() {
1057 let directory = tempfile::tempdir().unwrap();
1058 let root = directory.path();
1059 let source = root.join("main.rs");
1060 std::fs::write(&source, "fn main() {}\n").unwrap();
1061 let toolchain = toolchain_native_directory("14.51.36231");
1062 let invocation = RustcInvocation::parse_with(
1063 &[
1064 "--crate-name=app".into(),
1065 "--crate-type=bin".into(),
1066 "--emit=link".into(),
1067 format!("-Lnative={}", toolchain.display()).into(),
1068 source.clone().into_os_string(),
1069 ],
1070 crate::ParseOptions::caching_native_links(true),
1071 )
1072 .unwrap();
1073 let mappings = vec![PathMapping::new(root, "workspace")];
1074
1075 let context = library_context(root, mappings.clone());
1078 assert!(matches!(
1079 invocation.invocation_digest(&context),
1080 Err(BypassReason::UnmappedAbsolutePath(_))
1081 ));
1082 let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
1083 assert_eq!(
1084 invocation.discover_inputs_with_mappings(
1085 &dep_info,
1086 root,
1087 &mappings,
1088 &mbx_cache_core::NoFileDigestCache
1089 ),
1090 Err(BypassReason::UnsupportedSearchPath("native".into()))
1091 );
1092 }
1093
1094 #[test]
1095 fn discovery_resolves_parent_components_against_the_working_directory() {
1096 let directory = tempfile::tempdir().unwrap();
1097 let root = directory.path().join("project");
1098 let shared = directory.path().join("shared.rs");
1099 std::fs::create_dir(&root).unwrap();
1100 std::fs::write(&shared, "pub fn shared() {}\n").unwrap();
1101
1102 let invocation = RustcInvocation::parse(&args(&[
1103 "--crate-name=widget",
1104 "--crate-type=lib",
1105 "--emit=metadata",
1106 "../shared.rs",
1107 ]))
1108 .unwrap();
1109 let dep_info = RustcDepInfo::parse("output: ../shared.rs\n").unwrap();
1110 let discovered = invocation.discover_inputs(&dep_info, &root).unwrap();
1111
1112 assert_eq!(discovered.inputs.len(), 1);
1113 assert_eq!(discovered.inputs[0].path, shared);
1114 }
1115
1116 #[test]
1117 fn rustc_dep_info_round_trip_discovers_real_inputs() {
1118 let directory = tempfile::tempdir().unwrap();
1119 let root = directory.path();
1120 std::fs::write(
1121 root.join("lib.rs"),
1122 "mod child; const _: &str = include_str!(\"data file.txt\"); \
1123 const _: &str = env!(\"MBX_DISCOVERY_TEST\"); \
1124 const _: Option<&str> = option_env!(\"MBX_DISCOVERY_UNSET\");",
1125 )
1126 .unwrap();
1127 std::fs::write(root.join("child.rs"), "pub fn child() {}\n").unwrap();
1128 std::fs::write(root.join("data file.txt"), "included\n").unwrap();
1129
1130 let invocation = RustcInvocation::parse(&args(&[
1131 "--crate-name=mbx_cache_discovery_test",
1132 "--crate-type=lib",
1133 "--emit=metadata,link",
1134 "lib.rs",
1135 ]))
1136 .unwrap();
1137 let dep_info_path = root.join("discovery inputs.d");
1138 let discovery_command = invocation.dep_info_command(&dep_info_path).unwrap();
1139 let rustc = std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into());
1140 let output = match Command::new(rustc)
1141 .args(discovery_command.arguments())
1142 .current_dir(root)
1143 .env("MBX_DISCOVERY_TEST", "observed")
1144 .env_remove("MBX_DISCOVERY_UNSET")
1145 .output()
1146 {
1147 Ok(output) => output,
1148 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return,
1149 Err(error) => panic!("failed to execute rustc: {error}"),
1150 };
1151 assert!(
1152 output.status.success(),
1153 "{}",
1154 String::from_utf8_lossy(&output.stderr)
1155 );
1156 let parsed = RustcDepInfo::read(&dep_info_path).unwrap();
1157 let discovered = invocation.discover_inputs(&parsed, root).unwrap();
1158 assert_eq!(
1159 discovered.environment["MBX_DISCOVERY_TEST"],
1160 Some("observed".into())
1161 );
1162 assert_eq!(discovered.environment["MBX_DISCOVERY_UNSET"], None);
1163 assert_eq!(discovered.inputs.len(), 3);
1164 assert!(
1165 discovered
1166 .inputs
1167 .iter()
1168 .all(|input| input.digest.algorithm == "blake3")
1169 );
1170 let mut context = ActionContext {
1171 compiler: crate::CompilerIdentity {
1172 toolchain: "core:rust@test".into(),
1173 rustc_version: "test".into(),
1174 host: std::env::consts::ARCH.into(),
1175 driver: None,
1176 },
1177 working_dir: root.to_path_buf(),
1178 path_mappings: vec![crate::PathMapping::new(root, "workspace")],
1179 environment: BTreeMap::new(),
1180 portable_environment: BTreeSet::new(),
1181 inputs: Vec::new(),
1182 };
1183 discovered.clone().apply_to(&mut context).unwrap();
1184 let action = invocation.action(context).unwrap();
1185 assert!(
1186 String::from_utf8(action.bytes)
1187 .unwrap()
1188 .contains(r#""MBX_DISCOVERY_TEST":"observed""#)
1189 );
1190 discovered.verify().unwrap();
1191 std::fs::write(root.join("child.rs"), "pub fn changed() {}\n").unwrap();
1192 assert_eq!(
1193 discovered.verify(),
1194 Err(BypassReason::InputChanged(root.join("child.rs")))
1195 );
1196 }
1197
1198 struct SentinelLedger {
1200 known: FileIdentity,
1201 digest: CacheDigest,
1202 recorded: std::sync::Mutex<Vec<RecordedFileDigest>>,
1203 }
1204
1205 impl FileDigestCache for SentinelLedger {
1206 fn find(&self, scope: FileDigestScope, files: &[FileIdentity]) -> Vec<Option<CacheDigest>> {
1207 assert_eq!(scope, FileDigestScope::Content);
1208 files
1209 .iter()
1210 .map(|file| (*file == self.known).then(|| self.digest.clone()))
1211 .collect()
1212 }
1213
1214 fn record(&self, scope: FileDigestScope, entries: Vec<RecordedFileDigest>) {
1215 assert_eq!(scope, FileDigestScope::Content);
1216 self.recorded.lock().unwrap().extend(entries);
1217 }
1218 }
1219
1220 #[cfg(unix)]
1221 struct ReplacingLedger {
1222 path: PathBuf,
1223 replacement: PathBuf,
1224 recorded: std::sync::Mutex<Vec<RecordedFileDigest>>,
1225 }
1226
1227 #[cfg(unix)]
1228 impl FileDigestCache for ReplacingLedger {
1229 fn find(&self, scope: FileDigestScope, files: &[FileIdentity]) -> Vec<Option<CacheDigest>> {
1230 assert_eq!(scope, FileDigestScope::Content);
1231 assert_eq!(files.len(), 1);
1232 std::fs::remove_file(&self.path).unwrap();
1233 std::fs::rename(&self.replacement, &self.path).unwrap();
1234 std::thread::sleep(std::time::Duration::from_millis(50));
1236 vec![None]
1237 }
1238
1239 fn record(&self, scope: FileDigestScope, entries: Vec<RecordedFileDigest>) {
1240 assert_eq!(scope, FileDigestScope::Content);
1241 self.recorded.lock().unwrap().extend(entries);
1242 }
1243 }
1244
1245 #[cfg(unix)]
1246 #[test]
1247 fn discovery_binds_a_replacement_digest_to_the_replacement_identity() {
1248 let directory = tempfile::tempdir().unwrap();
1249 let root = directory.path();
1250 let path = root.join("libdep.rlib");
1251 let replacement = root.join("replacement.rlib");
1252 std::fs::write(&path, b"original....").unwrap();
1253 std::fs::File::options()
1254 .write(true)
1255 .open(&path)
1256 .unwrap()
1257 .set_times(std::fs::FileTimes::new().set_modified(SystemTime::UNIX_EPOCH))
1258 .unwrap();
1259 std::fs::write(&replacement, b"replacement!").unwrap();
1260 std::fs::File::options()
1261 .write(true)
1262 .open(&replacement)
1263 .unwrap()
1264 .set_times(std::fs::FileTimes::new().set_modified(SystemTime::UNIX_EPOCH))
1265 .unwrap();
1266 let ledger = ReplacingLedger {
1267 path: path.clone(),
1268 replacement,
1269 recorded: std::sync::Mutex::new(Vec::new()),
1270 };
1271
1272 let discovered = DiscoveredInputs::from_paths(
1273 root,
1274 BTreeSet::from([path.clone()]),
1275 BTreeMap::new(),
1276 &ledger,
1277 )
1278 .unwrap();
1279 let current = FileIdentity::for_digest_cache(&path, &std::fs::metadata(&path).unwrap())
1280 .unwrap()
1281 .unwrap();
1282 let digest = CacheDigest::blake3_file(&path).unwrap();
1283
1284 assert_eq!(discovered.inputs[0].digest, digest);
1285 assert_eq!(discovered.identities[0].as_ref(), Some(¤t));
1286 assert_eq!(
1287 ledger.recorded.lock().unwrap().as_slice(),
1288 &[RecordedFileDigest {
1289 file: current,
1290 digest,
1291 }]
1292 );
1293 discovered.verify().unwrap();
1294 }
1295
1296 #[test]
1297 fn discovery_reuses_recorded_digests_and_records_fresh_ones() {
1298 let directory = tempfile::tempdir().unwrap();
1299 let root = directory.path();
1300 let known_path = root.join("libdep.rlib");
1301 let fresh_path = root.join("lib.rs");
1302 std::fs::write(&known_path, b"rlib bytes").unwrap();
1303 std::fs::write(&fresh_path, b"fn lib() {}").unwrap();
1304 std::thread::sleep(std::time::Duration::from_millis(50));
1306 let metadata = std::fs::metadata(&known_path).unwrap();
1307 let sentinel = CacheDigest {
1310 algorithm: "blake3".into(),
1311 hash: "c".repeat(64),
1312 size: metadata.len(),
1313 };
1314 let ledger = SentinelLedger {
1315 known: FileIdentity::describe(&known_path, &metadata).unwrap(),
1316 digest: sentinel.clone(),
1317 recorded: std::sync::Mutex::new(Vec::new()),
1318 };
1319
1320 let discovered = DiscoveredInputs::from_paths(
1321 root,
1322 BTreeSet::from([known_path.clone(), fresh_path.clone()]),
1323 BTreeMap::new(),
1324 &ledger,
1325 )
1326 .unwrap();
1327
1328 let by_path = |path: &Path| {
1329 discovered
1330 .inputs
1331 .iter()
1332 .find(|input| input.path == path)
1333 .unwrap()
1334 .digest
1335 .clone()
1336 };
1337 assert_eq!(
1338 by_path(&known_path),
1339 sentinel,
1340 "the recorded digest answers"
1341 );
1342 assert_eq!(
1343 by_path(&fresh_path),
1344 CacheDigest::blake3_file(&fresh_path).unwrap(),
1345 "an unrecorded file is hashed"
1346 );
1347 let recorded = ledger.recorded.lock().unwrap();
1348 assert_eq!(recorded.len(), 1, "only the fresh hash is recorded");
1349 assert_eq!(recorded[0].file.path, fresh_path);
1350 assert_eq!(recorded[0].digest, by_path(&fresh_path));
1351 }
1352
1353 #[cfg(unix)]
1358 #[test]
1359 fn verification_trusts_an_unchanged_identity_and_rereads_a_changed_one() {
1360 let directory = tempfile::tempdir().unwrap();
1361 let root = directory.path();
1362 let known_path = root.join("libdep.rlib");
1363 std::fs::write(&known_path, b"rlib bytes").unwrap();
1364 let metadata = std::fs::metadata(&known_path).unwrap();
1365 let sentinel = CacheDigest {
1368 algorithm: "blake3".into(),
1369 hash: "c".repeat(64),
1370 size: metadata.len(),
1371 };
1372 let ledger = SentinelLedger {
1373 known: FileIdentity::describe(&known_path, &metadata).unwrap(),
1374 digest: sentinel,
1375 recorded: std::sync::Mutex::new(Vec::new()),
1376 };
1377 let discovered = DiscoveredInputs::from_paths(
1378 root,
1379 BTreeSet::from([known_path.clone()]),
1380 BTreeMap::new(),
1381 &ledger,
1382 )
1383 .unwrap();
1384
1385 discovered.verify().unwrap();
1386
1387 std::thread::sleep(std::time::Duration::from_millis(20));
1390 std::fs::write(&known_path, b"RLIB BYTES").unwrap();
1391 assert_eq!(
1392 discovered.verify(),
1393 Err(BypassReason::InputChanged(known_path.clone()))
1394 );
1395
1396 std::fs::remove_file(&known_path).unwrap();
1397 assert!(matches!(
1398 discovered.verify(),
1399 Err(BypassReason::InputRead { path, .. }) if path == known_path
1400 ));
1401 }
1402
1403 #[cfg(unix)]
1407 #[test]
1408 fn a_disguised_rewrite_is_not_answered_from_the_ledger() {
1409 let directory = tempfile::tempdir().unwrap();
1410 let root = directory.path();
1411 let path = root.join("lib.rs");
1412 std::fs::write(&path, b"fn lib() -> u8 { 1 }").unwrap();
1413 let before = std::fs::metadata(&path).unwrap();
1414 let identity = FileIdentity::describe(&path, &before).unwrap();
1415
1416 std::fs::write(&path, b"fn lib() -> u8 { 2 }").unwrap();
1419 let file = std::fs::File::options().write(true).open(&path).unwrap();
1420 file.set_times(std::fs::FileTimes::new().set_modified(before.modified().unwrap()))
1421 .unwrap();
1422 drop(file);
1423 let after = std::fs::metadata(&path).unwrap();
1424 let disguised = FileIdentity::describe(&path, &after).unwrap();
1425 assert_eq!(disguised.len, identity.len);
1426 assert_eq!(disguised.modified, identity.modified);
1427 assert_ne!(
1428 disguised, identity,
1429 "the change token must expose the rewrite"
1430 );
1431
1432 let ledger = SentinelLedger {
1433 known: identity,
1434 digest: CacheDigest {
1435 algorithm: "blake3".into(),
1436 hash: "e".repeat(64),
1437 size: after.len(),
1438 },
1439 recorded: std::sync::Mutex::new(Vec::new()),
1440 };
1441 let discovered = DiscoveredInputs::from_paths(
1442 root,
1443 BTreeSet::from([path.clone()]),
1444 BTreeMap::new(),
1445 &ledger,
1446 )
1447 .unwrap();
1448 assert_eq!(
1449 discovered.inputs[0].digest,
1450 CacheDigest::blake3_file(&path).unwrap(),
1451 "the disguised rewrite is hashed, not answered from the ledger"
1452 );
1453 }
1454 #[cfg(unix)]
1455 #[test]
1456 fn native_file_symlinks_are_rehashed_on_prediction() {
1457 use std::os::unix::fs::symlink;
1458 let directory = tempfile::tempdir().unwrap();
1459 let workspace = directory.path().join("workspace");
1460 let native = directory.path().join("cellar/lib");
1461 std::fs::create_dir_all(&workspace).unwrap();
1462 std::fs::create_dir_all(&native).unwrap();
1463 let source = workspace.join("lib.rs");
1464 std::fs::write(&source, "pub fn value() {}\n").unwrap();
1465 std::fs::write(native.join("libssl.3.dylib"), "first").unwrap();
1466 symlink("libssl.3.dylib", native.join("libssl.dylib")).unwrap();
1467 let opt = directory.path().join("opt");
1468 symlink(&native, &opt).unwrap();
1469 let invocation = library_with_native_search(&source, &opt);
1470 let mappings = vec![
1471 PathMapping::new(&workspace, "workspace"),
1472 PathMapping::new(&opt, "native"),
1473 ];
1474 let dep_info = RustcDepInfo {
1475 files: vec![source],
1476 environment: BTreeMap::new(),
1477 };
1478 let discover = || {
1479 invocation
1480 .discover_inputs_with_mappings(
1481 &dep_info,
1482 &workspace,
1483 &mappings,
1484 &mbx_cache_core::NoFileDigestCache,
1485 )
1486 .unwrap()
1487 };
1488 let original = discover();
1489 let mut context = library_context(&workspace, mappings.clone());
1490 original.clone().apply_to(&mut context).unwrap();
1491 let initial = invocation.action(context.clone()).unwrap();
1492 let prediction = invocation.prediction(&context, &original).unwrap();
1493 context.inputs.clear();
1494 let mut previous = initial.digest;
1495 for change in ["contents", "add", "retarget", "remove"] {
1496 match change {
1497 "contents" => std::fs::write(native.join("libssl.3.dylib"), "updated").unwrap(),
1498 "add" => std::fs::write(native.join("libssl.4.dylib"), "replacement").unwrap(),
1499 "retarget" => {
1500 std::fs::remove_file(native.join("libssl.dylib")).unwrap();
1501 symlink("libssl.4.dylib", native.join("libssl.dylib")).unwrap();
1502 }
1503 _ => std::fs::remove_file(native.join("libssl.3.dylib")).unwrap(),
1504 }
1505 let predicted = prediction
1506 .discover(&workspace, &mappings, &mbx_cache_core::NoFileDigestCache)
1507 .unwrap();
1508 let mut predicted_context = context.clone();
1509 predicted.apply_to(&mut predicted_context).unwrap();
1510 let predicted_action = invocation.action(predicted_context).unwrap();
1511 let mut discovered_context = context.clone();
1512 discover().apply_to(&mut discovered_context).unwrap();
1513 assert_eq!(
1514 predicted_action,
1515 invocation.action(discovered_context).unwrap()
1516 );
1517 assert_ne!(predicted_action.digest, previous, "{change}");
1518 previous = predicted_action.digest;
1519 }
1520 std::fs::remove_file(native.join("libssl.dylib")).unwrap();
1521 let outside = directory.path().join("outside");
1522 std::fs::write(&outside, "untracked").unwrap();
1523 symlink(&outside, native.join("libssl.dylib")).unwrap();
1524 assert!(
1525 prediction
1526 .discover(&workspace, &mappings, &mbx_cache_core::NoFileDigestCache)
1527 .is_err()
1528 );
1529 std::fs::remove_file(native.join("libssl.dylib")).unwrap();
1530 symlink(".", native.join("loop")).unwrap();
1531 assert!(
1532 prediction
1533 .discover(&workspace, &mappings, &mbx_cache_core::NoFileDigestCache)
1534 .is_err()
1535 );
1536 }
1537}