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 #[test]
1005 fn profile_use_is_discovered_verified_and_rehashed_by_prediction() {
1006 let directory = tempfile::tempdir().unwrap();
1007 let root = directory.path();
1008 let source = root.join("lib.rs");
1009 let profile = root.join("profile.profdata");
1010 std::fs::write(&source, "pub fn library() {}\n").unwrap();
1011 std::fs::write(&profile, "first profile").unwrap();
1012 let invocation = RustcInvocation::parse(&[
1013 "--crate-name=widget".into(),
1014 "--crate-type=lib".into(),
1015 "--emit=dep-info,metadata".into(),
1016 format!("-Cprofile-use={}", profile.display()).into(),
1017 "lib.rs".into(),
1018 ])
1019 .unwrap();
1020 let mappings = vec![PathMapping::new(root, "workspace")];
1021 let dep_info = RustcDepInfo::parse("widget.d: lib.rs\n").unwrap();
1022 let discover = || {
1023 invocation
1024 .discover_inputs_with_mappings(
1025 &dep_info,
1026 root,
1027 &mappings,
1028 &mbx_cache_core::NoFileDigestCache,
1029 )
1030 .unwrap()
1031 };
1032 let initial_inputs = discover();
1033 assert!(
1034 initial_inputs
1035 .inputs
1036 .iter()
1037 .any(|input| input.path == profile)
1038 );
1039 let mut context = library_context(root, mappings.clone());
1040 initial_inputs.clone().apply_to(&mut context).unwrap();
1041 let initial_action = invocation.action(context.clone()).unwrap();
1042 let prediction = invocation.prediction(&context, &initial_inputs).unwrap();
1043
1044 std::fs::write(&profile, "updated profile").unwrap();
1045 assert_eq!(
1046 initial_inputs.verify(),
1047 Err(BypassReason::InputChanged(profile.clone()))
1048 );
1049 context.inputs.clear();
1050 let predicted = prediction
1051 .discover(root, &mappings, &mbx_cache_core::NoFileDigestCache)
1052 .unwrap();
1053 predicted.clone().apply_to(&mut context).unwrap();
1054 let predicted_action = invocation.action(context.clone()).unwrap();
1055 assert_ne!(predicted_action.digest, initial_action.digest);
1056 context.inputs.clear();
1057 discover().apply_to(&mut context).unwrap();
1058 assert_eq!(predicted_action, invocation.action(context).unwrap());
1059
1060 let missing_profile = root.join("missing.profdata");
1061 let missing_invocation = RustcInvocation::parse(&[
1062 "--crate-name=widget".into(),
1063 "--crate-type=lib".into(),
1064 "--emit=dep-info,metadata".into(),
1065 format!("-Cprofile-use={}", missing_profile.display()).into(),
1066 "lib.rs".into(),
1067 ])
1068 .unwrap();
1069 assert!(matches!(
1070 missing_invocation.discover_inputs_with_mappings(
1071 &dep_info,
1072 root,
1073 &mappings,
1074 &mbx_cache_core::NoFileDigestCache,
1075 ),
1076 Err(BypassReason::InputRead { path, .. }) if path == missing_profile
1077 ));
1078 }
1079
1080 #[cfg(unix)]
1081 #[test]
1082 fn precompile_snapshot_does_not_depend_on_the_host_clock() {
1083 let directory = tempfile::tempdir().unwrap();
1084 let source = directory.path().join("lib.rs");
1085 std::fs::write(&source, "pub fn library() {0}\n").unwrap();
1086 let invocation = RustcInvocation::parse(&[
1087 "--crate-name=widget".into(),
1088 "--crate-type=lib".into(),
1089 "--emit=dep-info,metadata".into(),
1090 source.clone().into_os_string(),
1091 ])
1092 .unwrap();
1093 let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
1094 let discovered = invocation
1095 .discover_inputs(&dep_info, directory.path())
1096 .unwrap();
1097 let snapshot = FileSnapshot::capture(&source).unwrap().unwrap();
1098 let before = BTreeMap::from([(source.clone(), snapshot)]);
1099
1100 discovered
1103 .verify_not_modified_since_with_snapshots(SystemTime::UNIX_EPOCH, &before)
1104 .unwrap();
1105
1106 std::fs::write(&source, "pub fn library() {1}\n").unwrap();
1108 assert_eq!(
1109 discovered.verify_not_modified_since_with_snapshots(
1110 SystemTime::now() + std::time::Duration::from_secs(60),
1111 &before,
1112 ),
1113 Err(BypassReason::InputModifiedDuringCompilation(source))
1114 );
1115 }
1116
1117 fn toolchain_native_directory(version: &str) -> PathBuf {
1119 if cfg!(windows) {
1120 PathBuf::from(format!(
1121 r"C:\Program Files\Microsoft Visual Studio\2022\BuildTools\VC\Tools\MSVC\{version}\lib\x64"
1122 ))
1123 } else {
1124 PathBuf::from(format!("/opt/msvc/{version}/lib/x64"))
1125 }
1126 }
1127
1128 fn library_with_native_search(source: &Path, directory: &Path) -> RustcInvocation {
1129 RustcInvocation::parse(&[
1130 "--crate-name=widget".into(),
1131 "--crate-type=lib".into(),
1132 "--emit=metadata,link".into(),
1133 format!("-Lnative={}", directory.display()).into(),
1134 source.to_path_buf().into_os_string(),
1135 ])
1136 .unwrap()
1137 }
1138
1139 fn library_context(root: &Path, mappings: Vec<PathMapping>) -> ActionContext {
1140 ActionContext {
1141 compiler: crate::CompilerIdentity {
1142 toolchain: "core:rust@test".into(),
1143 rustc_version: "test".into(),
1144 host: std::env::consts::ARCH.into(),
1145 driver: None,
1146 },
1147 working_dir: root.to_path_buf(),
1148 path_mappings: mappings,
1149 environment: BTreeMap::new(),
1150 portable_environment: BTreeSet::new(),
1151 inputs: Vec::new(),
1152 }
1153 }
1154
1155 #[cfg(windows)]
1156 #[test]
1157 fn unmapped_toolchain_directory_cannot_hide_a_static_archive() {
1158 let directory = tempfile::tempdir().unwrap();
1159 let root = directory.path();
1160 let source = root.join("lib.rs");
1161 std::fs::write(&source, "pub fn library() {}\n").unwrap();
1162 let toolchain = toolchain_native_directory("14.51.36231");
1163 let invocation = library_with_native_search(&source, &toolchain);
1164 let mappings = vec![PathMapping::new(root, "workspace")];
1165 let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
1166
1167 let context = library_context(root, mappings.clone());
1168 assert!(matches!(
1169 invocation.invocation_digest(&context),
1170 Err(BypassReason::UnmappedAbsolutePath(path)) if path == toolchain
1171 ));
1172 assert_eq!(
1173 invocation.discover_inputs_with_mappings(
1174 &dep_info,
1175 root,
1176 &mappings,
1177 &mbx_cache_core::NoFileDigestCache,
1178 ),
1179 Err(BypassReason::UnsupportedSearchPath("native".into()))
1180 );
1181 }
1182
1183 #[test]
1184 fn source_level_static_link_cannot_hide_an_unmapped_archive() {
1185 let directory = tempfile::tempdir().unwrap();
1186 let workspace = directory.path().join("workspace");
1187 let external = directory.path().join("external");
1188 std::fs::create_dir(&workspace).unwrap();
1189 std::fs::create_dir(&external).unwrap();
1190 let source = workspace.join("lib.rs");
1191 std::fs::write(
1192 &source,
1193 "#[link(name = \"foo\", kind = \"static\")] unsafe extern \"C\" {}\n",
1194 )
1195 .unwrap();
1196 std::fs::write(external.join("libfoo.a"), b"first archive").unwrap();
1197 let invocation = library_with_native_search(&source, &external);
1198 let mappings = vec![PathMapping::new(&workspace, "workspace")];
1199 let context = library_context(&workspace, mappings.clone());
1200 let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
1201
1202 assert!(matches!(
1205 invocation.invocation_digest(&context),
1206 Err(BypassReason::UnmappedAbsolutePath(path)) if path == external
1207 ));
1208 assert_eq!(
1209 invocation.discover_inputs_with_mappings(
1210 &dep_info,
1211 &workspace,
1212 &mappings,
1213 &mbx_cache_core::NoFileDigestCache,
1214 ),
1215 Err(BypassReason::UnsupportedSearchPath("native".into()))
1216 );
1217 }
1218
1219 #[cfg(windows)]
1220 #[test]
1221 fn project_directory_with_toolchain_suffix_is_not_trusted() {
1222 let directory = tempfile::tempdir().unwrap();
1223 let root = directory.path().join("workspace");
1224 std::fs::create_dir(&root).unwrap();
1225 let source = root.join("lib.rs");
1226 std::fs::write(&source, "pub fn library() {}\n").unwrap();
1227 let fake_toolchain = directory.path().join("MSVC/14.51.36231/lib/x64");
1228 let invocation = library_with_native_search(&source, &fake_toolchain);
1229 let context = library_context(&root, vec![PathMapping::new(&root, "workspace")]);
1230
1231 assert!(matches!(
1233 invocation.invocation_digest(&context),
1234 Err(BypassReason::UnmappedAbsolutePath(path)) if path == fake_toolchain
1235 ));
1236 }
1237
1238 #[test]
1239 fn unmapped_native_directory_still_refuses_a_native_link() {
1240 let directory = tempfile::tempdir().unwrap();
1241 let root = directory.path();
1242 let source = root.join("main.rs");
1243 std::fs::write(&source, "fn main() {}\n").unwrap();
1244 let toolchain = toolchain_native_directory("14.51.36231");
1245 let invocation = RustcInvocation::parse_with(
1246 &[
1247 "--crate-name=app".into(),
1248 "--crate-type=bin".into(),
1249 "--emit=link".into(),
1250 format!("-Lnative={}", toolchain.display()).into(),
1251 source.clone().into_os_string(),
1252 ],
1253 crate::ParseOptions::caching_native_links(true),
1254 )
1255 .unwrap();
1256 let mappings = vec![PathMapping::new(root, "workspace")];
1257
1258 let context = library_context(root, mappings.clone());
1261 assert!(matches!(
1262 invocation.invocation_digest(&context),
1263 Err(BypassReason::UnmappedAbsolutePath(_))
1264 ));
1265 let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
1266 assert_eq!(
1267 invocation.discover_inputs_with_mappings(
1268 &dep_info,
1269 root,
1270 &mappings,
1271 &mbx_cache_core::NoFileDigestCache
1272 ),
1273 Err(BypassReason::UnsupportedSearchPath("native".into()))
1274 );
1275 }
1276
1277 #[test]
1278 fn discovery_resolves_parent_components_against_the_working_directory() {
1279 let directory = tempfile::tempdir().unwrap();
1280 let root = directory.path().join("project");
1281 let shared = directory.path().join("shared.rs");
1282 std::fs::create_dir(&root).unwrap();
1283 std::fs::write(&shared, "pub fn shared() {}\n").unwrap();
1284
1285 let invocation = RustcInvocation::parse(&args(&[
1286 "--crate-name=widget",
1287 "--crate-type=lib",
1288 "--emit=metadata",
1289 "../shared.rs",
1290 ]))
1291 .unwrap();
1292 let dep_info = RustcDepInfo::parse("output: ../shared.rs\n").unwrap();
1293 let discovered = invocation.discover_inputs(&dep_info, &root).unwrap();
1294
1295 assert_eq!(discovered.inputs.len(), 1);
1296 assert_eq!(discovered.inputs[0].path, shared);
1297 }
1298
1299 #[test]
1300 fn rustc_dep_info_round_trip_discovers_real_inputs() {
1301 let directory = tempfile::tempdir().unwrap();
1302 let root = directory.path();
1303 std::fs::write(
1304 root.join("lib.rs"),
1305 "mod child; const _: &str = include_str!(\"data file.txt\"); \
1306 const _: &str = env!(\"MBX_DISCOVERY_TEST\"); \
1307 const _: Option<&str> = option_env!(\"MBX_DISCOVERY_UNSET\");",
1308 )
1309 .unwrap();
1310 std::fs::write(root.join("child.rs"), "pub fn child() {}\n").unwrap();
1311 std::fs::write(root.join("data file.txt"), "included\n").unwrap();
1312
1313 let invocation = RustcInvocation::parse(&args(&[
1314 "--crate-name=mbx_cache_discovery_test",
1315 "--crate-type=lib",
1316 "--emit=metadata,link",
1317 "lib.rs",
1318 ]))
1319 .unwrap();
1320 let dep_info_path = root.join("discovery inputs.d");
1321 let discovery_command = invocation.dep_info_command(&dep_info_path).unwrap();
1322 let rustc = std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into());
1323 let output = match Command::new(rustc)
1324 .args(discovery_command.arguments())
1325 .current_dir(root)
1326 .env("MBX_DISCOVERY_TEST", "observed")
1327 .env_remove("MBX_DISCOVERY_UNSET")
1328 .output()
1329 {
1330 Ok(output) => output,
1331 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return,
1332 Err(error) => panic!("failed to execute rustc: {error}"),
1333 };
1334 assert!(
1335 output.status.success(),
1336 "{}",
1337 String::from_utf8_lossy(&output.stderr)
1338 );
1339 let parsed = RustcDepInfo::read(&dep_info_path).unwrap();
1340 let discovered = invocation.discover_inputs(&parsed, root).unwrap();
1341 assert_eq!(
1342 discovered.environment["MBX_DISCOVERY_TEST"],
1343 Some("observed".into())
1344 );
1345 assert_eq!(discovered.environment["MBX_DISCOVERY_UNSET"], None);
1346 assert_eq!(discovered.inputs.len(), 3);
1347 assert!(
1348 discovered
1349 .inputs
1350 .iter()
1351 .all(|input| input.digest.algorithm == "blake3")
1352 );
1353 let mut context = ActionContext {
1354 compiler: crate::CompilerIdentity {
1355 toolchain: "core:rust@test".into(),
1356 rustc_version: "test".into(),
1357 host: std::env::consts::ARCH.into(),
1358 driver: None,
1359 },
1360 working_dir: root.to_path_buf(),
1361 path_mappings: vec![crate::PathMapping::new(root, "workspace")],
1362 environment: BTreeMap::new(),
1363 portable_environment: BTreeSet::new(),
1364 inputs: Vec::new(),
1365 };
1366 discovered.clone().apply_to(&mut context).unwrap();
1367 let action = invocation.action(context).unwrap();
1368 assert!(
1369 String::from_utf8(action.bytes)
1370 .unwrap()
1371 .contains(r#""MBX_DISCOVERY_TEST":"observed""#)
1372 );
1373 discovered.verify().unwrap();
1374 std::fs::write(root.join("child.rs"), "pub fn changed() {}\n").unwrap();
1375 assert_eq!(
1376 discovered.verify(),
1377 Err(BypassReason::InputChanged(root.join("child.rs")))
1378 );
1379 }
1380
1381 struct SentinelLedger {
1383 known: FileIdentity,
1384 digest: CacheDigest,
1385 recorded: std::sync::Mutex<Vec<RecordedFileDigest>>,
1386 }
1387
1388 impl FileDigestCache for SentinelLedger {
1389 fn find(&self, scope: FileDigestScope, files: &[FileIdentity]) -> Vec<Option<CacheDigest>> {
1390 assert_eq!(scope, FileDigestScope::Content);
1391 files
1392 .iter()
1393 .map(|file| (*file == self.known).then(|| self.digest.clone()))
1394 .collect()
1395 }
1396
1397 fn record(&self, scope: FileDigestScope, entries: Vec<RecordedFileDigest>) {
1398 assert_eq!(scope, FileDigestScope::Content);
1399 self.recorded.lock().unwrap().extend(entries);
1400 }
1401 }
1402
1403 #[cfg(unix)]
1404 struct ReplacingLedger {
1405 path: PathBuf,
1406 replacement: PathBuf,
1407 recorded: std::sync::Mutex<Vec<RecordedFileDigest>>,
1408 }
1409
1410 #[cfg(unix)]
1411 impl FileDigestCache for ReplacingLedger {
1412 fn find(&self, scope: FileDigestScope, files: &[FileIdentity]) -> Vec<Option<CacheDigest>> {
1413 assert_eq!(scope, FileDigestScope::Content);
1414 assert_eq!(files.len(), 1);
1415 std::fs::remove_file(&self.path).unwrap();
1416 std::fs::rename(&self.replacement, &self.path).unwrap();
1417 std::thread::sleep(std::time::Duration::from_millis(50));
1419 vec![None]
1420 }
1421
1422 fn record(&self, scope: FileDigestScope, entries: Vec<RecordedFileDigest>) {
1423 assert_eq!(scope, FileDigestScope::Content);
1424 self.recorded.lock().unwrap().extend(entries);
1425 }
1426 }
1427
1428 #[cfg(unix)]
1429 #[test]
1430 fn discovery_binds_a_replacement_digest_to_the_replacement_identity() {
1431 let directory = tempfile::tempdir().unwrap();
1432 let root = directory.path();
1433 let path = root.join("libdep.rlib");
1434 let replacement = root.join("replacement.rlib");
1435 std::fs::write(&path, b"original....").unwrap();
1436 std::fs::File::options()
1437 .write(true)
1438 .open(&path)
1439 .unwrap()
1440 .set_times(std::fs::FileTimes::new().set_modified(SystemTime::UNIX_EPOCH))
1441 .unwrap();
1442 std::fs::write(&replacement, b"replacement!").unwrap();
1443 std::fs::File::options()
1444 .write(true)
1445 .open(&replacement)
1446 .unwrap()
1447 .set_times(std::fs::FileTimes::new().set_modified(SystemTime::UNIX_EPOCH))
1448 .unwrap();
1449 let ledger = ReplacingLedger {
1450 path: path.clone(),
1451 replacement,
1452 recorded: std::sync::Mutex::new(Vec::new()),
1453 };
1454
1455 let discovered = DiscoveredInputs::from_paths(
1456 root,
1457 BTreeSet::from([path.clone()]),
1458 BTreeMap::new(),
1459 &ledger,
1460 )
1461 .unwrap();
1462 let current = FileIdentity::for_digest_cache(&path, &std::fs::metadata(&path).unwrap())
1463 .unwrap()
1464 .unwrap();
1465 let digest = CacheDigest::blake3_file(&path).unwrap();
1466
1467 assert_eq!(discovered.inputs[0].digest, digest);
1468 assert_eq!(discovered.identities[0].as_ref(), Some(¤t));
1469 assert_eq!(
1470 ledger.recorded.lock().unwrap().as_slice(),
1471 &[RecordedFileDigest {
1472 file: current,
1473 digest,
1474 }]
1475 );
1476 discovered.verify().unwrap();
1477 }
1478
1479 #[test]
1480 fn discovery_reuses_recorded_digests_and_records_fresh_ones() {
1481 let directory = tempfile::tempdir().unwrap();
1482 let root = directory.path();
1483 let known_path = root.join("libdep.rlib");
1484 let fresh_path = root.join("lib.rs");
1485 std::fs::write(&known_path, b"rlib bytes").unwrap();
1486 std::fs::write(&fresh_path, b"fn lib() {}").unwrap();
1487 std::thread::sleep(std::time::Duration::from_millis(50));
1489 let metadata = std::fs::metadata(&known_path).unwrap();
1490 let sentinel = CacheDigest {
1493 algorithm: "blake3".into(),
1494 hash: "c".repeat(64),
1495 size: metadata.len(),
1496 };
1497 let ledger = SentinelLedger {
1498 known: FileIdentity::describe(&known_path, &metadata).unwrap(),
1499 digest: sentinel.clone(),
1500 recorded: std::sync::Mutex::new(Vec::new()),
1501 };
1502
1503 let discovered = DiscoveredInputs::from_paths(
1504 root,
1505 BTreeSet::from([known_path.clone(), fresh_path.clone()]),
1506 BTreeMap::new(),
1507 &ledger,
1508 )
1509 .unwrap();
1510
1511 let by_path = |path: &Path| {
1512 discovered
1513 .inputs
1514 .iter()
1515 .find(|input| input.path == path)
1516 .unwrap()
1517 .digest
1518 .clone()
1519 };
1520 assert_eq!(
1521 by_path(&known_path),
1522 sentinel,
1523 "the recorded digest answers"
1524 );
1525 assert_eq!(
1526 by_path(&fresh_path),
1527 CacheDigest::blake3_file(&fresh_path).unwrap(),
1528 "an unrecorded file is hashed"
1529 );
1530 let recorded = ledger.recorded.lock().unwrap();
1531 assert_eq!(recorded.len(), 1, "only the fresh hash is recorded");
1532 assert_eq!(recorded[0].file.path, fresh_path);
1533 assert_eq!(recorded[0].digest, by_path(&fresh_path));
1534 }
1535
1536 #[cfg(unix)]
1541 #[test]
1542 fn verification_trusts_an_unchanged_identity_and_rereads_a_changed_one() {
1543 let directory = tempfile::tempdir().unwrap();
1544 let root = directory.path();
1545 let known_path = root.join("libdep.rlib");
1546 std::fs::write(&known_path, b"rlib bytes").unwrap();
1547 let metadata = std::fs::metadata(&known_path).unwrap();
1548 let sentinel = CacheDigest {
1551 algorithm: "blake3".into(),
1552 hash: "c".repeat(64),
1553 size: metadata.len(),
1554 };
1555 let ledger = SentinelLedger {
1556 known: FileIdentity::describe(&known_path, &metadata).unwrap(),
1557 digest: sentinel,
1558 recorded: std::sync::Mutex::new(Vec::new()),
1559 };
1560 let discovered = DiscoveredInputs::from_paths(
1561 root,
1562 BTreeSet::from([known_path.clone()]),
1563 BTreeMap::new(),
1564 &ledger,
1565 )
1566 .unwrap();
1567
1568 discovered.verify().unwrap();
1569
1570 std::thread::sleep(std::time::Duration::from_millis(20));
1573 std::fs::write(&known_path, b"RLIB BYTES").unwrap();
1574 assert_eq!(
1575 discovered.verify(),
1576 Err(BypassReason::InputChanged(known_path.clone()))
1577 );
1578
1579 std::fs::remove_file(&known_path).unwrap();
1580 assert!(matches!(
1581 discovered.verify(),
1582 Err(BypassReason::InputRead { path, .. }) if path == known_path
1583 ));
1584 }
1585
1586 #[cfg(unix)]
1590 #[test]
1591 fn a_disguised_rewrite_is_not_answered_from_the_ledger() {
1592 let directory = tempfile::tempdir().unwrap();
1593 let root = directory.path();
1594 let path = root.join("lib.rs");
1595 std::fs::write(&path, b"fn lib() -> u8 { 1 }").unwrap();
1596 let before = std::fs::metadata(&path).unwrap();
1597 let identity = FileIdentity::describe(&path, &before).unwrap();
1598
1599 std::fs::write(&path, b"fn lib() -> u8 { 2 }").unwrap();
1602 let file = std::fs::File::options().write(true).open(&path).unwrap();
1603 file.set_times(std::fs::FileTimes::new().set_modified(before.modified().unwrap()))
1604 .unwrap();
1605 drop(file);
1606 let after = std::fs::metadata(&path).unwrap();
1607 let disguised = FileIdentity::describe(&path, &after).unwrap();
1608 assert_eq!(disguised.len, identity.len);
1609 assert_eq!(disguised.modified, identity.modified);
1610 assert_ne!(
1611 disguised, identity,
1612 "the change token must expose the rewrite"
1613 );
1614
1615 let ledger = SentinelLedger {
1616 known: identity,
1617 digest: CacheDigest {
1618 algorithm: "blake3".into(),
1619 hash: "e".repeat(64),
1620 size: after.len(),
1621 },
1622 recorded: std::sync::Mutex::new(Vec::new()),
1623 };
1624 let discovered = DiscoveredInputs::from_paths(
1625 root,
1626 BTreeSet::from([path.clone()]),
1627 BTreeMap::new(),
1628 &ledger,
1629 )
1630 .unwrap();
1631 assert_eq!(
1632 discovered.inputs[0].digest,
1633 CacheDigest::blake3_file(&path).unwrap(),
1634 "the disguised rewrite is hashed, not answered from the ledger"
1635 );
1636 }
1637 #[cfg(unix)]
1638 #[test]
1639 fn native_file_symlinks_are_rehashed_on_prediction() {
1640 use std::os::unix::fs::symlink;
1641 let directory = tempfile::tempdir().unwrap();
1642 let workspace = directory.path().join("workspace");
1643 let native = directory.path().join("cellar/lib");
1644 std::fs::create_dir_all(&workspace).unwrap();
1645 std::fs::create_dir_all(&native).unwrap();
1646 let source = workspace.join("lib.rs");
1647 std::fs::write(&source, "pub fn value() {}\n").unwrap();
1648 std::fs::write(native.join("libssl.3.dylib"), "first").unwrap();
1649 symlink("libssl.3.dylib", native.join("libssl.dylib")).unwrap();
1650 let opt = directory.path().join("opt");
1651 symlink(&native, &opt).unwrap();
1652 let invocation = library_with_native_search(&source, &opt);
1653 let mappings = vec![
1654 PathMapping::new(&workspace, "workspace"),
1655 PathMapping::new(&opt, "native"),
1656 ];
1657 let dep_info = RustcDepInfo {
1658 files: vec![source],
1659 environment: BTreeMap::new(),
1660 };
1661 let discover = || {
1662 invocation
1663 .discover_inputs_with_mappings(
1664 &dep_info,
1665 &workspace,
1666 &mappings,
1667 &mbx_cache_core::NoFileDigestCache,
1668 )
1669 .unwrap()
1670 };
1671 let original = discover();
1672 let mut context = library_context(&workspace, mappings.clone());
1673 original.clone().apply_to(&mut context).unwrap();
1674 let initial = invocation.action(context.clone()).unwrap();
1675 let prediction = invocation.prediction(&context, &original).unwrap();
1676 context.inputs.clear();
1677 let mut previous = initial.digest;
1678 for change in ["contents", "add", "retarget", "remove"] {
1679 match change {
1680 "contents" => std::fs::write(native.join("libssl.3.dylib"), "updated").unwrap(),
1681 "add" => std::fs::write(native.join("libssl.4.dylib"), "replacement").unwrap(),
1682 "retarget" => {
1683 std::fs::remove_file(native.join("libssl.dylib")).unwrap();
1684 symlink("libssl.4.dylib", native.join("libssl.dylib")).unwrap();
1685 }
1686 _ => std::fs::remove_file(native.join("libssl.3.dylib")).unwrap(),
1687 }
1688 let predicted = prediction
1689 .discover(&workspace, &mappings, &mbx_cache_core::NoFileDigestCache)
1690 .unwrap();
1691 let mut predicted_context = context.clone();
1692 predicted.apply_to(&mut predicted_context).unwrap();
1693 let predicted_action = invocation.action(predicted_context).unwrap();
1694 let mut discovered_context = context.clone();
1695 discover().apply_to(&mut discovered_context).unwrap();
1696 assert_eq!(
1697 predicted_action,
1698 invocation.action(discovered_context).unwrap()
1699 );
1700 assert_ne!(predicted_action.digest, previous, "{change}");
1701 previous = predicted_action.digest;
1702 }
1703 std::fs::remove_file(native.join("libssl.dylib")).unwrap();
1704 let outside = directory.path().join("outside");
1705 std::fs::write(&outside, "untracked").unwrap();
1706 symlink(&outside, native.join("libssl.dylib")).unwrap();
1707 assert!(
1708 prediction
1709 .discover(&workspace, &mappings, &mbx_cache_core::NoFileDigestCache)
1710 .is_err()
1711 );
1712 std::fs::remove_file(native.join("libssl.dylib")).unwrap();
1713 symlink(".", native.join("loop")).unwrap();
1714 assert!(
1715 prediction
1716 .discover(&workspace, &mappings, &mbx_cache_core::NoFileDigestCache)
1717 .is_err()
1718 );
1719 }
1720}