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