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 collect_native_directory(
494 &directory,
495 &admitted_roots,
496 &mut paths,
497 &mut native_bytes,
498 )?;
499 }
500 }
501 DiscoveredInputs::from_paths(&working_dir, paths, dep_info.environment.clone(), digests)
502 }
503}
504
505pub(super) fn native_input_roots(
507 working_dir: &Path,
508 path_mappings: &[PathMapping],
509) -> Vec<PathBuf> {
510 std::iter::once(working_dir)
511 .chain(path_mappings.iter().map(|mapping| mapping.root.as_path()))
512 .map(normalize_components)
513 .collect()
514}
515
516pub(super) fn collect_native_directory(
519 directory: &Path,
520 admitted_roots: &[PathBuf],
521 paths: &mut BTreeSet<PathBuf>,
522 native_bytes: &mut u64,
523) -> Result<(), BypassReason> {
524 let directory = normalize_components(directory);
525 if !admitted_roots
526 .iter()
527 .any(|root| directory.starts_with(root))
528 {
529 return Err(BypassReason::UnsupportedSearchPath("native".into()));
530 }
531
532 let canonical_root = match directory.canonicalize() {
533 Ok(root) => root,
534 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
535 Err(error) => {
536 return Err(BypassReason::InputRead {
537 path: directory,
538 message: error.to_string(),
539 });
540 }
541 };
542 let mut pending = vec![directory];
543 while let Some(directory) = pending.pop() {
544 let entries = match std::fs::read_dir(&directory) {
545 Ok(entries) => entries,
546 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
547 Err(error) => {
548 return Err(BypassReason::InputRead {
549 path: directory,
550 message: error.to_string(),
551 });
552 }
553 };
554 for entry in entries {
555 let entry = entry.map_err(|error| BypassReason::InputRead {
556 path: directory.clone(),
557 message: error.to_string(),
558 })?;
559 let path = entry.path();
560 let file_type = entry.file_type().map_err(|error| BypassReason::InputRead {
561 path: path.clone(),
562 message: error.to_string(),
563 })?;
564 if file_type.is_dir() {
565 pending.push(path);
566 } else if file_type.is_file() || file_type.is_symlink() {
567 if file_type.is_symlink() {
571 let resolved =
572 path.canonicalize()
573 .map_err(|error| BypassReason::InputRead {
574 path: path.clone(),
575 message: error.to_string(),
576 })?;
577 if !resolved.starts_with(&canonical_root) || !resolved.is_file() {
578 return Err(BypassReason::UnsupportedSearchPath("native".into()));
579 }
580 }
581 *native_bytes = native_bytes
582 .checked_add(
583 std::fs::metadata(&path)
584 .map_err(|error| BypassReason::InputRead {
585 path: path.clone(),
586 message: error.to_string(),
587 })?
588 .len(),
589 )
590 .ok_or_else(|| BypassReason::UnsupportedSearchPath("native".into()))?;
591 paths.insert(path);
592 } else {
593 return Err(BypassReason::UnsupportedSearchPath("native".into()));
594 }
595 if paths.len() > MAX_PREDICTED_INPUTS || *native_bytes > MAX_NATIVE_INPUT_BYTES {
596 return Err(BypassReason::UnsupportedSearchPath("native".into()));
597 }
598 }
599 }
600 Ok(())
601}
602
603fn render_argument(argument: &Argument) -> Result<OsString, BypassReason> {
604 let rendered = match argument {
605 Argument::Plain(value) => value.clone(),
606 Argument::Path { flag, path } => format!(
607 "{flag}={}",
608 path.to_str()
609 .ok_or_else(|| BypassReason::NonUtf8Path(path.clone()))?
610 ),
611 Argument::SearchPath { kind, path } => format!(
612 "-L{kind}={}",
613 path.to_str()
614 .ok_or_else(|| BypassReason::NonUtf8Path(path.clone()))?
615 ),
616 Argument::Extern { name, path } => match path {
617 Some(path) => format!(
618 "--extern={name}={}",
619 path.to_str()
620 .ok_or_else(|| BypassReason::NonUtf8Path(path.clone()))?
621 ),
622 None => format!("--extern={name}"),
623 },
624 Argument::Emit(_) => unreachable!("emit arguments are removed before rendering"),
625 Argument::RemapPath { from, to } => format!(
626 "--remap-path-prefix={}={to}",
627 from.to_str()
628 .ok_or_else(|| BypassReason::NonUtf8Path(from.clone()))?
629 ),
630 Argument::OsoPrefix {
633 path,
634 trailing_slash,
635 } => format!(
636 "--codegen=link-arg=-Wl,-oso_prefix,{}{}",
637 path.to_str()
638 .ok_or_else(|| BypassReason::NonUtf8Path(path.clone()))?,
639 if *trailing_slash { "/" } else { "" }
640 ),
641 Argument::InstallName(name) => {
644 format!("--codegen=link-arg=-Wl,-install_name,@rpath/{name}")
645 }
646 Argument::FuseLd(selection) => format!("--codegen=link-arg=-fuse-ld={selection}"),
647 };
648 Ok(rendered.into())
649}
650
651fn unescape_environment(value: &str) -> Result<String, BypassReason> {
652 let mut output = String::with_capacity(value.len());
653 let mut characters = value.chars();
654 while let Some(character) = characters.next() {
655 if character != '\\' {
656 output.push(character);
657 continue;
658 }
659 match characters.next() {
660 Some('\\') => output.push('\\'),
661 Some('n') => output.push('\n'),
662 Some('r') => output.push('\r'),
663 Some(character) => {
664 return Err(BypassReason::MalformedDepInfo(format!(
665 "unknown environment escape \\{character}"
666 )));
667 }
668 None => {
669 return Err(BypassReason::MalformedDepInfo(
670 "environment input ends with an unterminated escape".into(),
671 ));
672 }
673 }
674 }
675 Ok(output)
676}
677
678#[cfg(test)]
679mod tests {
680 use super::*;
681 use std::process::Command;
682
683 fn args(values: &[&str]) -> Vec<OsString> {
684 values.iter().map(OsString::from).collect()
685 }
686
687 #[test]
688 fn parses_files_spaces_and_environment_records() {
689 let parsed = RustcDepInfo::parse(
690 "target/lib.rlib: src/lib.rs src/a\\ file.rs generated.rs\n\
691 src/lib.rs:\n\
692 # env-dep:SET=value\\nnext\n\
693 # env-dep:UNSET\n\
694 # env-dep:SLASH=a\\\\b\n",
695 )
696 .unwrap();
697 assert_eq!(
698 parsed.files,
699 vec![
700 PathBuf::from("generated.rs"),
701 PathBuf::from("src/a file.rs"),
702 PathBuf::from("src/lib.rs"),
703 ]
704 );
705 assert_eq!(parsed.environment["SET"], Some("value\nnext".into()));
706 assert_eq!(parsed.environment["UNSET"], None);
707 assert_eq!(parsed.environment["SLASH"], Some(r"a\b".into()));
708 }
709
710 #[test]
711 fn malformed_dep_info_bypasses_caching() {
712 for contents in [
713 "",
714 "target: ",
715 "target: src/trailing\\\n",
716 "target: src/lib.rs\n# env-dep:NAME=bad\\q\n",
717 ] {
718 assert!(RustcDepInfo::parse(contents).is_err(), "{contents:?}");
719 }
720 }
721
722 #[test]
723 fn native_directory_byte_limit_is_cumulative() {
724 let directory = tempfile::tempdir().unwrap();
725 let native = directory.path().join("native");
726 std::fs::create_dir_all(&native).unwrap();
727 std::fs::write(native.join("input.lib"), b"xx").unwrap();
728 let roots = native_input_roots(directory.path(), &[]);
729 let mut paths = BTreeSet::new();
730 let mut native_bytes = MAX_NATIVE_INPUT_BYTES - 1;
731
732 assert_eq!(
733 collect_native_directory(&native, &roots, &mut paths, &mut native_bytes),
734 Err(BypassReason::UnsupportedSearchPath("native".into()))
735 );
736 }
737
738 #[test]
739 fn discovery_command_removes_real_outputs() {
740 let invocation = RustcInvocation::parse(&args(&[
741 "--crate-name=widget",
742 "--crate-type=lib",
743 "--emit=dep-info,metadata,link",
744 "--out-dir=target/debug/deps",
745 "-o",
746 "target/debug/libwidget.rlib",
747 "src/lib.rs",
748 ]))
749 .unwrap();
750 let output = if cfg!(windows) {
751 PathBuf::from(r"C:\tmp\mbx cache\inputs.d")
752 } else {
753 PathBuf::from("/tmp/mbx cache/inputs.d")
754 };
755 let command = invocation.dep_info_command(&output).unwrap();
756 let arguments = command
757 .arguments()
758 .iter()
759 .map(|value| value.to_string_lossy())
760 .collect::<Vec<_>>();
761 assert_eq!(
762 arguments,
763 vec![
764 "--crate-name=widget",
765 "--crate-type=lib",
766 &format!("--emit=dep-info={}", output.display()),
767 "src/lib.rs",
768 ]
769 );
770 }
771
772 #[test]
773 fn discovery_hashes_externs_and_custom_targets() {
774 let directory = tempfile::tempdir().unwrap();
775 let root = directory.path();
776 let source = root.join("src/lib.rs");
777 let external = root.join("target/libdependency.rlib");
778 let target = root.join("targets/custom.json");
779 std::fs::create_dir_all(source.parent().unwrap()).unwrap();
780 std::fs::create_dir_all(external.parent().unwrap()).unwrap();
781 std::fs::create_dir_all(target.parent().unwrap()).unwrap();
782 std::fs::write(&source, "pub fn library() {}\n").unwrap();
783 std::fs::write(&external, "dependency artifact\n").unwrap();
784 std::fs::write(&target, "{}\n").unwrap();
785
786 let invocation = RustcInvocation::parse(&[
787 "--crate-name=widget".into(),
788 "--crate-type=lib".into(),
789 "--emit=metadata".into(),
790 format!("--extern=dependency={}", external.display()).into(),
791 format!("--target={}", target.display()).into(),
792 source.clone().into_os_string(),
793 ])
794 .unwrap();
795 let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
796 let discovered = invocation.discover_inputs(&dep_info, root).unwrap();
797 assert_eq!(discovered.inputs.len(), 3);
798
799 std::fs::remove_file(&external).unwrap();
800 assert!(matches!(
801 invocation.discover_inputs(&dep_info, root),
802 Err(BypassReason::InputRead { path, .. }) if path == external
803 ));
804 }
805
806 #[test]
807 fn discovery_rejects_inputs_modified_during_compilation() {
808 let directory = tempfile::tempdir().unwrap();
809 let source = directory.path().join("lib.rs");
810 std::fs::write(&source, "pub fn library() {}\n").unwrap();
811 let invocation = RustcInvocation::parse(&[
812 "--crate-name=widget".into(),
813 "--crate-type=lib".into(),
814 "--emit=dep-info,metadata".into(),
815 source.clone().into_os_string(),
816 ])
817 .unwrap();
818 let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
819 let discovered = invocation
820 .discover_inputs(&dep_info, directory.path())
821 .unwrap();
822 let modified = std::fs::metadata(&source).unwrap().modified().unwrap();
823
824 assert_eq!(
825 discovered.verify_not_modified_since(modified),
826 Err(BypassReason::InputModifiedDuringCompilation(source))
827 );
828 }
829
830 #[cfg(unix)]
831 #[test]
832 fn precompile_snapshot_does_not_depend_on_the_host_clock() {
833 let directory = tempfile::tempdir().unwrap();
834 let source = directory.path().join("lib.rs");
835 std::fs::write(&source, "pub fn library() {0}\n").unwrap();
836 let invocation = RustcInvocation::parse(&[
837 "--crate-name=widget".into(),
838 "--crate-type=lib".into(),
839 "--emit=dep-info,metadata".into(),
840 source.clone().into_os_string(),
841 ])
842 .unwrap();
843 let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
844 let discovered = invocation
845 .discover_inputs(&dep_info, directory.path())
846 .unwrap();
847 let snapshot = FileSnapshot::capture(&source).unwrap().unwrap();
848 let before = BTreeMap::from([(source.clone(), snapshot)]);
849
850 discovered
853 .verify_not_modified_since_with_snapshots(SystemTime::UNIX_EPOCH, &before)
854 .unwrap();
855
856 std::fs::write(&source, "pub fn library() {1}\n").unwrap();
858 assert_eq!(
859 discovered.verify_not_modified_since_with_snapshots(
860 SystemTime::now() + std::time::Duration::from_secs(60),
861 &before,
862 ),
863 Err(BypassReason::InputModifiedDuringCompilation(source))
864 );
865 }
866
867 fn toolchain_native_directory(version: &str) -> PathBuf {
869 if cfg!(windows) {
870 PathBuf::from(format!(
871 r"C:\Program Files\Microsoft Visual Studio\2022\BuildTools\VC\Tools\MSVC\{version}\lib\x64"
872 ))
873 } else {
874 PathBuf::from(format!("/opt/msvc/{version}/lib/x64"))
875 }
876 }
877
878 fn library_with_native_search(source: &Path, directory: &Path) -> RustcInvocation {
879 RustcInvocation::parse(&[
880 "--crate-name=widget".into(),
881 "--crate-type=lib".into(),
882 "--emit=metadata,link".into(),
883 format!("-Lnative={}", directory.display()).into(),
884 source.to_path_buf().into_os_string(),
885 ])
886 .unwrap()
887 }
888
889 fn library_context(root: &Path, mappings: Vec<PathMapping>) -> ActionContext {
890 ActionContext {
891 compiler: crate::CompilerIdentity {
892 toolchain: "core:rust@test".into(),
893 rustc_version: "test".into(),
894 host: std::env::consts::ARCH.into(),
895 driver: None,
896 },
897 working_dir: root.to_path_buf(),
898 path_mappings: mappings,
899 environment: BTreeMap::new(),
900 portable_environment: BTreeSet::new(),
901 inputs: Vec::new(),
902 }
903 }
904
905 #[cfg(windows)]
906 #[test]
907 fn unmapped_toolchain_directory_cannot_hide_a_static_archive() {
908 let directory = tempfile::tempdir().unwrap();
909 let root = directory.path();
910 let source = root.join("lib.rs");
911 std::fs::write(&source, "pub fn library() {}\n").unwrap();
912 let toolchain = toolchain_native_directory("14.51.36231");
913 let invocation = library_with_native_search(&source, &toolchain);
914 let mappings = vec![PathMapping::new(root, "workspace")];
915 let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
916
917 let context = library_context(root, mappings.clone());
918 assert!(matches!(
919 invocation.invocation_digest(&context),
920 Err(BypassReason::UnmappedAbsolutePath(path)) if path == toolchain
921 ));
922 assert_eq!(
923 invocation.discover_inputs_with_mappings(
924 &dep_info,
925 root,
926 &mappings,
927 &mbx_cache_core::NoFileDigestCache,
928 ),
929 Err(BypassReason::UnsupportedSearchPath("native".into()))
930 );
931 }
932
933 #[test]
934 fn source_level_static_link_cannot_hide_an_unmapped_archive() {
935 let directory = tempfile::tempdir().unwrap();
936 let workspace = directory.path().join("workspace");
937 let external = directory.path().join("external");
938 std::fs::create_dir(&workspace).unwrap();
939 std::fs::create_dir(&external).unwrap();
940 let source = workspace.join("lib.rs");
941 std::fs::write(
942 &source,
943 "#[link(name = \"foo\", kind = \"static\")] unsafe extern \"C\" {}\n",
944 )
945 .unwrap();
946 std::fs::write(external.join("libfoo.a"), b"first archive").unwrap();
947 let invocation = library_with_native_search(&source, &external);
948 let mappings = vec![PathMapping::new(&workspace, "workspace")];
949 let context = library_context(&workspace, mappings.clone());
950 let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
951
952 assert!(matches!(
955 invocation.invocation_digest(&context),
956 Err(BypassReason::UnmappedAbsolutePath(path)) if path == external
957 ));
958 assert_eq!(
959 invocation.discover_inputs_with_mappings(
960 &dep_info,
961 &workspace,
962 &mappings,
963 &mbx_cache_core::NoFileDigestCache,
964 ),
965 Err(BypassReason::UnsupportedSearchPath("native".into()))
966 );
967 }
968
969 #[cfg(windows)]
970 #[test]
971 fn project_directory_with_toolchain_suffix_is_not_trusted() {
972 let directory = tempfile::tempdir().unwrap();
973 let root = directory.path().join("workspace");
974 std::fs::create_dir(&root).unwrap();
975 let source = root.join("lib.rs");
976 std::fs::write(&source, "pub fn library() {}\n").unwrap();
977 let fake_toolchain = directory.path().join("MSVC/14.51.36231/lib/x64");
978 let invocation = library_with_native_search(&source, &fake_toolchain);
979 let context = library_context(&root, vec![PathMapping::new(&root, "workspace")]);
980
981 assert!(matches!(
983 invocation.invocation_digest(&context),
984 Err(BypassReason::UnmappedAbsolutePath(path)) if path == fake_toolchain
985 ));
986 }
987
988 #[test]
989 fn unmapped_native_directory_still_refuses_a_native_link() {
990 let directory = tempfile::tempdir().unwrap();
991 let root = directory.path();
992 let source = root.join("main.rs");
993 std::fs::write(&source, "fn main() {}\n").unwrap();
994 let toolchain = toolchain_native_directory("14.51.36231");
995 let invocation = RustcInvocation::parse_with(
996 &[
997 "--crate-name=app".into(),
998 "--crate-type=bin".into(),
999 "--emit=link".into(),
1000 format!("-Lnative={}", toolchain.display()).into(),
1001 source.clone().into_os_string(),
1002 ],
1003 crate::ParseOptions::caching_native_links(true),
1004 )
1005 .unwrap();
1006 let mappings = vec![PathMapping::new(root, "workspace")];
1007
1008 let context = library_context(root, mappings.clone());
1011 assert!(matches!(
1012 invocation.invocation_digest(&context),
1013 Err(BypassReason::UnmappedAbsolutePath(_))
1014 ));
1015 let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
1016 assert_eq!(
1017 invocation.discover_inputs_with_mappings(
1018 &dep_info,
1019 root,
1020 &mappings,
1021 &mbx_cache_core::NoFileDigestCache
1022 ),
1023 Err(BypassReason::UnsupportedSearchPath("native".into()))
1024 );
1025 }
1026
1027 #[test]
1028 fn discovery_resolves_parent_components_against_the_working_directory() {
1029 let directory = tempfile::tempdir().unwrap();
1030 let root = directory.path().join("project");
1031 let shared = directory.path().join("shared.rs");
1032 std::fs::create_dir(&root).unwrap();
1033 std::fs::write(&shared, "pub fn shared() {}\n").unwrap();
1034
1035 let invocation = RustcInvocation::parse(&args(&[
1036 "--crate-name=widget",
1037 "--crate-type=lib",
1038 "--emit=metadata",
1039 "../shared.rs",
1040 ]))
1041 .unwrap();
1042 let dep_info = RustcDepInfo::parse("output: ../shared.rs\n").unwrap();
1043 let discovered = invocation.discover_inputs(&dep_info, &root).unwrap();
1044
1045 assert_eq!(discovered.inputs.len(), 1);
1046 assert_eq!(discovered.inputs[0].path, shared);
1047 }
1048
1049 #[test]
1050 fn rustc_dep_info_round_trip_discovers_real_inputs() {
1051 let directory = tempfile::tempdir().unwrap();
1052 let root = directory.path();
1053 std::fs::write(
1054 root.join("lib.rs"),
1055 "mod child; const _: &str = include_str!(\"data file.txt\"); \
1056 const _: &str = env!(\"MBX_DISCOVERY_TEST\"); \
1057 const _: Option<&str> = option_env!(\"MBX_DISCOVERY_UNSET\");",
1058 )
1059 .unwrap();
1060 std::fs::write(root.join("child.rs"), "pub fn child() {}\n").unwrap();
1061 std::fs::write(root.join("data file.txt"), "included\n").unwrap();
1062
1063 let invocation = RustcInvocation::parse(&args(&[
1064 "--crate-name=mbx_cache_discovery_test",
1065 "--crate-type=lib",
1066 "--emit=metadata,link",
1067 "lib.rs",
1068 ]))
1069 .unwrap();
1070 let dep_info_path = root.join("discovery inputs.d");
1071 let discovery_command = invocation.dep_info_command(&dep_info_path).unwrap();
1072 let rustc = std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into());
1073 let output = match Command::new(rustc)
1074 .args(discovery_command.arguments())
1075 .current_dir(root)
1076 .env("MBX_DISCOVERY_TEST", "observed")
1077 .env_remove("MBX_DISCOVERY_UNSET")
1078 .output()
1079 {
1080 Ok(output) => output,
1081 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return,
1082 Err(error) => panic!("failed to execute rustc: {error}"),
1083 };
1084 assert!(
1085 output.status.success(),
1086 "{}",
1087 String::from_utf8_lossy(&output.stderr)
1088 );
1089 let parsed = RustcDepInfo::read(&dep_info_path).unwrap();
1090 let discovered = invocation.discover_inputs(&parsed, root).unwrap();
1091 assert_eq!(
1092 discovered.environment["MBX_DISCOVERY_TEST"],
1093 Some("observed".into())
1094 );
1095 assert_eq!(discovered.environment["MBX_DISCOVERY_UNSET"], None);
1096 assert_eq!(discovered.inputs.len(), 3);
1097 assert!(
1098 discovered
1099 .inputs
1100 .iter()
1101 .all(|input| input.digest.algorithm == "blake3")
1102 );
1103 let mut context = ActionContext {
1104 compiler: crate::CompilerIdentity {
1105 toolchain: "core:rust@test".into(),
1106 rustc_version: "test".into(),
1107 host: std::env::consts::ARCH.into(),
1108 driver: None,
1109 },
1110 working_dir: root.to_path_buf(),
1111 path_mappings: vec![crate::PathMapping::new(root, "workspace")],
1112 environment: BTreeMap::new(),
1113 portable_environment: BTreeSet::new(),
1114 inputs: Vec::new(),
1115 };
1116 discovered.clone().apply_to(&mut context).unwrap();
1117 let action = invocation.action(context).unwrap();
1118 assert!(
1119 String::from_utf8(action.bytes)
1120 .unwrap()
1121 .contains(r#""MBX_DISCOVERY_TEST":"observed""#)
1122 );
1123 discovered.verify().unwrap();
1124 std::fs::write(root.join("child.rs"), "pub fn changed() {}\n").unwrap();
1125 assert_eq!(
1126 discovered.verify(),
1127 Err(BypassReason::InputChanged(root.join("child.rs")))
1128 );
1129 }
1130
1131 struct SentinelLedger {
1133 known: FileIdentity,
1134 digest: CacheDigest,
1135 recorded: std::sync::Mutex<Vec<RecordedFileDigest>>,
1136 }
1137
1138 impl FileDigestCache for SentinelLedger {
1139 fn find(&self, scope: FileDigestScope, files: &[FileIdentity]) -> Vec<Option<CacheDigest>> {
1140 assert_eq!(scope, FileDigestScope::Content);
1141 files
1142 .iter()
1143 .map(|file| (*file == self.known).then(|| self.digest.clone()))
1144 .collect()
1145 }
1146
1147 fn record(&self, scope: FileDigestScope, entries: Vec<RecordedFileDigest>) {
1148 assert_eq!(scope, FileDigestScope::Content);
1149 self.recorded.lock().unwrap().extend(entries);
1150 }
1151 }
1152
1153 #[cfg(unix)]
1154 struct ReplacingLedger {
1155 path: PathBuf,
1156 replacement: PathBuf,
1157 recorded: std::sync::Mutex<Vec<RecordedFileDigest>>,
1158 }
1159
1160 #[cfg(unix)]
1161 impl FileDigestCache for ReplacingLedger {
1162 fn find(&self, scope: FileDigestScope, files: &[FileIdentity]) -> Vec<Option<CacheDigest>> {
1163 assert_eq!(scope, FileDigestScope::Content);
1164 assert_eq!(files.len(), 1);
1165 std::fs::remove_file(&self.path).unwrap();
1166 std::fs::rename(&self.replacement, &self.path).unwrap();
1167 vec![None]
1168 }
1169
1170 fn record(&self, scope: FileDigestScope, entries: Vec<RecordedFileDigest>) {
1171 assert_eq!(scope, FileDigestScope::Content);
1172 self.recorded.lock().unwrap().extend(entries);
1173 }
1174 }
1175
1176 #[cfg(unix)]
1177 #[test]
1178 fn discovery_binds_a_replacement_digest_to_the_replacement_identity() {
1179 let directory = tempfile::tempdir().unwrap();
1180 let root = directory.path();
1181 let path = root.join("libdep.rlib");
1182 let replacement = root.join("replacement.rlib");
1183 std::fs::write(&path, b"original....").unwrap();
1184 std::fs::File::options()
1185 .write(true)
1186 .open(&path)
1187 .unwrap()
1188 .set_times(std::fs::FileTimes::new().set_modified(SystemTime::UNIX_EPOCH))
1189 .unwrap();
1190 std::fs::write(&replacement, b"replacement!").unwrap();
1191 std::fs::File::options()
1192 .write(true)
1193 .open(&replacement)
1194 .unwrap()
1195 .set_times(std::fs::FileTimes::new().set_modified(SystemTime::UNIX_EPOCH))
1196 .unwrap();
1197 let ledger = ReplacingLedger {
1198 path: path.clone(),
1199 replacement,
1200 recorded: std::sync::Mutex::new(Vec::new()),
1201 };
1202
1203 let discovered = DiscoveredInputs::from_paths(
1204 root,
1205 BTreeSet::from([path.clone()]),
1206 BTreeMap::new(),
1207 &ledger,
1208 )
1209 .unwrap();
1210 let current = FileIdentity::for_digest_cache(&path, &std::fs::metadata(&path).unwrap())
1211 .unwrap()
1212 .unwrap();
1213 let digest = CacheDigest::blake3_file(&path).unwrap();
1214
1215 assert_eq!(discovered.inputs[0].digest, digest);
1216 assert_eq!(discovered.identities[0].as_ref(), Some(¤t));
1217 assert_eq!(
1218 ledger.recorded.lock().unwrap().as_slice(),
1219 &[RecordedFileDigest {
1220 file: current,
1221 digest,
1222 }]
1223 );
1224 discovered.verify().unwrap();
1225 }
1226
1227 #[test]
1228 fn discovery_reuses_recorded_digests_and_records_fresh_ones() {
1229 let directory = tempfile::tempdir().unwrap();
1230 let root = directory.path();
1231 let known_path = root.join("libdep.rlib");
1232 let fresh_path = root.join("lib.rs");
1233 std::fs::write(&known_path, b"rlib bytes").unwrap();
1234 std::fs::write(&fresh_path, b"fn lib() {}").unwrap();
1235 let metadata = std::fs::metadata(&known_path).unwrap();
1236 let sentinel = CacheDigest {
1239 algorithm: "blake3".into(),
1240 hash: "c".repeat(64),
1241 size: metadata.len(),
1242 };
1243 let ledger = SentinelLedger {
1244 known: FileIdentity::describe(&known_path, &metadata).unwrap(),
1245 digest: sentinel.clone(),
1246 recorded: std::sync::Mutex::new(Vec::new()),
1247 };
1248
1249 let discovered = DiscoveredInputs::from_paths(
1250 root,
1251 BTreeSet::from([known_path.clone(), fresh_path.clone()]),
1252 BTreeMap::new(),
1253 &ledger,
1254 )
1255 .unwrap();
1256
1257 let by_path = |path: &Path| {
1258 discovered
1259 .inputs
1260 .iter()
1261 .find(|input| input.path == path)
1262 .unwrap()
1263 .digest
1264 .clone()
1265 };
1266 assert_eq!(
1267 by_path(&known_path),
1268 sentinel,
1269 "the recorded digest answers"
1270 );
1271 assert_eq!(
1272 by_path(&fresh_path),
1273 CacheDigest::blake3_file(&fresh_path).unwrap(),
1274 "an unrecorded file is hashed"
1275 );
1276 let recorded = ledger.recorded.lock().unwrap();
1277 assert_eq!(recorded.len(), 1, "only the fresh hash is recorded");
1278 assert_eq!(recorded[0].file.path, fresh_path);
1279 assert_eq!(recorded[0].digest, by_path(&fresh_path));
1280 }
1281
1282 #[cfg(unix)]
1287 #[test]
1288 fn verification_trusts_an_unchanged_identity_and_rereads_a_changed_one() {
1289 let directory = tempfile::tempdir().unwrap();
1290 let root = directory.path();
1291 let known_path = root.join("libdep.rlib");
1292 std::fs::write(&known_path, b"rlib bytes").unwrap();
1293 let metadata = std::fs::metadata(&known_path).unwrap();
1294 let sentinel = CacheDigest {
1297 algorithm: "blake3".into(),
1298 hash: "c".repeat(64),
1299 size: metadata.len(),
1300 };
1301 let ledger = SentinelLedger {
1302 known: FileIdentity::describe(&known_path, &metadata).unwrap(),
1303 digest: sentinel,
1304 recorded: std::sync::Mutex::new(Vec::new()),
1305 };
1306 let discovered = DiscoveredInputs::from_paths(
1307 root,
1308 BTreeSet::from([known_path.clone()]),
1309 BTreeMap::new(),
1310 &ledger,
1311 )
1312 .unwrap();
1313
1314 discovered.verify().unwrap();
1315
1316 std::thread::sleep(std::time::Duration::from_millis(20));
1319 std::fs::write(&known_path, b"RLIB BYTES").unwrap();
1320 assert_eq!(
1321 discovered.verify(),
1322 Err(BypassReason::InputChanged(known_path.clone()))
1323 );
1324
1325 std::fs::remove_file(&known_path).unwrap();
1326 assert!(matches!(
1327 discovered.verify(),
1328 Err(BypassReason::InputRead { path, .. }) if path == known_path
1329 ));
1330 }
1331
1332 #[cfg(unix)]
1336 #[test]
1337 fn a_disguised_rewrite_is_not_answered_from_the_ledger() {
1338 let directory = tempfile::tempdir().unwrap();
1339 let root = directory.path();
1340 let path = root.join("lib.rs");
1341 std::fs::write(&path, b"fn lib() -> u8 { 1 }").unwrap();
1342 let before = std::fs::metadata(&path).unwrap();
1343 let identity = FileIdentity::describe(&path, &before).unwrap();
1344
1345 std::fs::write(&path, b"fn lib() -> u8 { 2 }").unwrap();
1348 let file = std::fs::File::options().write(true).open(&path).unwrap();
1349 file.set_times(std::fs::FileTimes::new().set_modified(before.modified().unwrap()))
1350 .unwrap();
1351 drop(file);
1352 let after = std::fs::metadata(&path).unwrap();
1353 let disguised = FileIdentity::describe(&path, &after).unwrap();
1354 assert_eq!(disguised.len, identity.len);
1355 assert_eq!(disguised.modified, identity.modified);
1356 assert_ne!(
1357 disguised, identity,
1358 "the change token must expose the rewrite"
1359 );
1360
1361 let ledger = SentinelLedger {
1362 known: identity,
1363 digest: CacheDigest {
1364 algorithm: "blake3".into(),
1365 hash: "e".repeat(64),
1366 size: after.len(),
1367 },
1368 recorded: std::sync::Mutex::new(Vec::new()),
1369 };
1370 let discovered = DiscoveredInputs::from_paths(
1371 root,
1372 BTreeSet::from([path.clone()]),
1373 BTreeMap::new(),
1374 &ledger,
1375 )
1376 .unwrap();
1377 assert_eq!(
1378 discovered.inputs[0].digest,
1379 CacheDigest::blake3_file(&path).unwrap(),
1380 "the disguised rewrite is hashed, not answered from the ledger"
1381 );
1382 }
1383 #[cfg(unix)]
1384 #[test]
1385 fn native_file_symlinks_are_rehashed_on_prediction() {
1386 use std::os::unix::fs::symlink;
1387 let directory = tempfile::tempdir().unwrap();
1388 let workspace = directory.path().join("workspace");
1389 let native = directory.path().join("cellar/lib");
1390 std::fs::create_dir_all(&workspace).unwrap();
1391 std::fs::create_dir_all(&native).unwrap();
1392 let source = workspace.join("lib.rs");
1393 std::fs::write(&source, "pub fn value() {}\n").unwrap();
1394 std::fs::write(native.join("libssl.3.dylib"), "first").unwrap();
1395 symlink("libssl.3.dylib", native.join("libssl.dylib")).unwrap();
1396 let opt = directory.path().join("opt");
1397 symlink(&native, &opt).unwrap();
1398 let invocation = library_with_native_search(&source, &opt);
1399 let mappings = vec![
1400 PathMapping::new(&workspace, "workspace"),
1401 PathMapping::new(&opt, "native"),
1402 ];
1403 let dep_info = RustcDepInfo {
1404 files: vec![source],
1405 environment: BTreeMap::new(),
1406 };
1407 let discover = || {
1408 invocation
1409 .discover_inputs_with_mappings(
1410 &dep_info,
1411 &workspace,
1412 &mappings,
1413 &mbx_cache_core::NoFileDigestCache,
1414 )
1415 .unwrap()
1416 };
1417 let original = discover();
1418 let mut context = library_context(&workspace, mappings.clone());
1419 original.clone().apply_to(&mut context).unwrap();
1420 let initial = invocation.action(context.clone()).unwrap();
1421 let prediction = invocation.prediction(&context, &original).unwrap();
1422 context.inputs.clear();
1423 let mut previous = initial.digest;
1424 for change in ["contents", "add", "retarget", "remove"] {
1425 match change {
1426 "contents" => std::fs::write(native.join("libssl.3.dylib"), "updated").unwrap(),
1427 "add" => std::fs::write(native.join("libssl.4.dylib"), "replacement").unwrap(),
1428 "retarget" => {
1429 std::fs::remove_file(native.join("libssl.dylib")).unwrap();
1430 symlink("libssl.4.dylib", native.join("libssl.dylib")).unwrap();
1431 }
1432 _ => std::fs::remove_file(native.join("libssl.3.dylib")).unwrap(),
1433 }
1434 let predicted = prediction
1435 .discover(&workspace, &mappings, &mbx_cache_core::NoFileDigestCache)
1436 .unwrap();
1437 let mut predicted_context = context.clone();
1438 predicted.apply_to(&mut predicted_context).unwrap();
1439 let predicted_action = invocation.action(predicted_context).unwrap();
1440 let mut discovered_context = context.clone();
1441 discover().apply_to(&mut discovered_context).unwrap();
1442 assert_eq!(
1443 predicted_action,
1444 invocation.action(discovered_context).unwrap()
1445 );
1446 assert_ne!(predicted_action.digest, previous, "{change}");
1447 previous = predicted_action.digest;
1448 }
1449 std::fs::remove_file(native.join("libssl.dylib")).unwrap();
1450 let outside = directory.path().join("outside");
1451 std::fs::write(&outside, "untracked").unwrap();
1452 symlink(&outside, native.join("libssl.dylib")).unwrap();
1453 assert!(
1454 prediction
1455 .discover(&workspace, &mappings, &mbx_cache_core::NoFileDigestCache)
1456 .is_err()
1457 );
1458 std::fs::remove_file(native.join("libssl.dylib")).unwrap();
1459 symlink(".", native.join("loop")).unwrap();
1460 assert!(
1461 prediction
1462 .discover(&workspace, &mappings, &mbx_cache_core::NoFileDigestCache)
1463 .is_err()
1464 );
1465 }
1466}