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 let mut metadata_changed = None;
312 for input in &self.inputs {
313 if let Some(previous) = before.get(&input.path)
314 && previous.proves_content_change()
315 {
316 let metadata =
317 std::fs::metadata(&input.path).map_err(|error| BypassReason::InputRead {
318 path: input.path.clone(),
319 message: error.to_string(),
320 })?;
321 let identity = FileIdentity::for_digest_cache(&input.path, &metadata)
322 .map_err(|error| BypassReason::InputRead {
323 path: input.path.clone(),
324 message: error.to_string(),
325 })?
326 .or_else(|| FileIdentity::describe(&input.path, &metadata));
327 if previous.matches(identity.as_ref(), &input.digest) {
328 continue;
329 }
330 if previous.differs_only_in_change_token(identity.as_ref()) {
331 metadata_changed.get_or_insert_with(|| input.path.clone());
332 continue;
333 }
334 return Err(BypassReason::InputModifiedDuringCompilation(
335 input.path.clone(),
336 ));
337 }
338 let metadata =
339 std::fs::metadata(&input.path).map_err(|error| BypassReason::InputRead {
340 path: input.path.clone(),
341 message: error.to_string(),
342 })?;
343 let modified = metadata
344 .modified()
345 .map_err(|error| BypassReason::InputRead {
346 path: input.path.clone(),
347 message: error.to_string(),
348 })?;
349 if modified >= started_at {
350 return Err(BypassReason::InputModifiedDuringCompilation(
351 input.path.clone(),
352 ));
353 }
354 }
355 metadata_changed.map_or(Ok(()), |path| {
356 Err(BypassReason::InputMetadataChangedDuringCompilation(path))
357 })
358 }
359
360 pub fn verify_not_modified_since_with_identities(
362 &self,
363 started_at: SystemTime,
364 before: &BTreeMap<PathBuf, FileIdentity>,
365 ) -> Result<(), BypassReason> {
366 let snapshots = before
367 .iter()
368 .map(|(path, identity)| (path.clone(), identity.clone().into()))
369 .collect();
370 self.verify_not_modified_since_with_snapshots(started_at, &snapshots)
371 }
372
373 pub fn verify(&self) -> Result<(), BypassReason> {
377 for (index, input) in self.inputs.iter().enumerate() {
378 let read_error = |error: std::io::Error| BypassReason::InputRead {
379 path: input.path.clone(),
380 message: error.to_string(),
381 };
382 if let Some(Some(identity)) = self.identities.get(index)
393 && identity.can_skip_content_verification()
394 && identity.still_describes().map_err(read_error)?
395 {
396 continue;
397 }
398 let matches = input.digest.matches_file(&input.path).map_err(|error| {
399 BypassReason::InputRead {
400 path: input.path.clone(),
401 message: error.to_string(),
402 }
403 })?;
404 if !matches {
405 return Err(BypassReason::InputChanged(input.path.clone()));
406 }
407 }
408 Ok(())
409 }
410
411 pub fn apply_to(self, context: &mut ActionContext) -> Result<(), BypassReason> {
414 if normalize_components(&context.working_dir) != self.working_dir {
415 return Err(BypassReason::DiscoveryWorkingDirectory);
416 }
417 for (name, value) in &self.environment {
418 if context
419 .environment
420 .get(name)
421 .is_some_and(|previous| previous != value)
422 {
423 return Err(BypassReason::ConflictingEnvironment(name.clone()));
424 }
425 }
426 context.environment.extend(self.environment);
427 context.inputs.extend(self.inputs);
428 Ok(())
429 }
430}
431
432impl RustcInvocation {
433 pub fn dep_info_command(&self, output: &Path) -> Result<DepInfoCommand, BypassReason> {
435 if !output.is_absolute() {
436 return Err(BypassReason::RelativeDepInfoPath(output.to_path_buf()));
437 }
438 let output_text = output
439 .to_str()
440 .ok_or_else(|| BypassReason::NonUtf8Path(output.to_path_buf()))?;
441 if output_text.contains(',') {
442 return Err(BypassReason::UnsafeDepInfoPath(output.to_path_buf()));
443 }
444
445 let mut arguments = Vec::new();
446 for argument in &self.arguments {
447 match argument {
448 Argument::Emit(_) => {}
449 Argument::Path { flag, .. } if flag == "--out-dir" || flag == "-o" => {}
450 argument => arguments.push(render_argument(argument)?),
451 }
452 }
453 arguments.push(format!("--emit=dep-info={output_text}").into());
454 arguments.push(self.source.clone().into_os_string());
455 Ok(DepInfoCommand {
456 arguments,
457 output: output.to_path_buf(),
458 })
459 }
460
461 pub fn discover_inputs(
464 &self,
465 dep_info: &RustcDepInfo,
466 working_dir: &Path,
467 ) -> Result<DiscoveredInputs, BypassReason> {
468 self.discover_inputs_with_mappings(
469 dep_info,
470 working_dir,
471 &[],
472 &mbx_cache_core::NoFileDigestCache,
473 )
474 }
475
476 pub fn discover_inputs_with_mappings(
482 &self,
483 dep_info: &RustcDepInfo,
484 working_dir: &Path,
485 path_mappings: &[PathMapping],
486 digests: &dyn FileDigestCache,
487 ) -> Result<DiscoveredInputs, BypassReason> {
488 self.discover(dep_info, working_dir, Some(path_mappings), digests)
489 }
490
491 pub fn discover_source_inputs(
500 &self,
501 dep_info: &RustcDepInfo,
502 working_dir: &Path,
503 digests: &dyn FileDigestCache,
504 ) -> Result<DiscoveredInputs, BypassReason> {
505 self.discover(dep_info, working_dir, None, digests)
506 }
507
508 fn discover(
510 &self,
511 dep_info: &RustcDepInfo,
512 working_dir: &Path,
513 path_mappings: Option<&[PathMapping]>,
514 digests: &dyn FileDigestCache,
515 ) -> Result<DiscoveredInputs, BypassReason> {
516 if !working_dir.is_absolute() {
517 return Err(BypassReason::RelativeWorkingDirectory(
518 working_dir.to_path_buf(),
519 ));
520 }
521 let working_dir = normalize_components(working_dir);
522 let absolute = |path: &PathBuf| {
523 let absolute = if path.is_absolute() {
524 path.to_path_buf()
525 } else {
526 working_dir.join(path)
527 };
528 normalize_components(&absolute)
529 };
530 let sources = dep_info.files.iter().map(absolute).collect::<BTreeSet<_>>();
536 let mut paths = sources.clone();
537 paths.extend(self.required_inputs.iter().map(absolute));
538 let admitted_roots = native_input_roots(&working_dir, path_mappings.unwrap_or_default());
539 let mut native_bytes = 0_u64;
540 let mut counted = BTreeSet::new();
541 for argument in &self.arguments {
542 if path_mappings.is_some()
543 && let Argument::SearchPath { kind, path } = argument
544 && kind == "native"
545 {
546 let directory = if path.is_absolute() {
547 path.clone()
548 } else {
549 working_dir.join(path)
550 };
551 collect_native_directory(
552 &directory,
553 &admitted_roots,
554 &mut paths,
555 &mut native_bytes,
556 &mut counted,
557 )?;
558 }
559 }
560 let native_directories = self
561 .arguments
562 .iter()
563 .filter_map(|argument| match argument {
564 Argument::SearchPath { kind, path } if kind == "native" => Some(absolute(path)),
565 _ => None,
566 })
567 .collect::<Vec<_>>();
568 let native_only = paths
569 .difference(&sources)
570 .filter(|path| native_directories.iter().any(|root| path.starts_with(root)))
571 .cloned()
572 .collect();
573 Ok(DiscoveredInputs::from_paths(
574 &working_dir,
575 paths,
576 dep_info.environment.clone(),
577 digests,
578 )?
579 .with_native_only(native_only))
580 }
581}
582
583pub(super) fn native_input_roots(
585 working_dir: &Path,
586 path_mappings: &[PathMapping],
587) -> Vec<PathBuf> {
588 std::iter::once(working_dir)
589 .chain(path_mappings.iter().map(|mapping| mapping.root.as_path()))
590 .map(normalize_components)
591 .collect()
592}
593
594pub(super) fn collect_native_directory(
599 directory: &Path,
600 admitted_roots: &[PathBuf],
601 paths: &mut BTreeSet<PathBuf>,
602 native_bytes: &mut u64,
603 counted: &mut BTreeSet<PathBuf>,
604) -> Result<(), BypassReason> {
605 let directory = normalize_components(directory);
606 if !admitted_roots
607 .iter()
608 .any(|root| directory.starts_with(root))
609 {
610 return Err(BypassReason::UnsupportedSearchPath("native".into()));
611 }
612
613 let canonical_root = match directory.canonicalize() {
614 Ok(root) => root,
615 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
616 Err(error) => {
617 return Err(BypassReason::InputRead {
618 path: directory,
619 message: error.to_string(),
620 });
621 }
622 };
623 let mut pending = vec![directory];
624 while let Some(directory) = pending.pop() {
625 let entries = match std::fs::read_dir(&directory) {
626 Ok(entries) => entries,
627 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
628 Err(error) => {
629 return Err(BypassReason::InputRead {
630 path: directory,
631 message: error.to_string(),
632 });
633 }
634 };
635 for entry in entries {
636 let entry = entry.map_err(|error| BypassReason::InputRead {
637 path: directory.clone(),
638 message: error.to_string(),
639 })?;
640 let path = entry.path();
641 let file_type = entry.file_type().map_err(|error| BypassReason::InputRead {
642 path: path.clone(),
643 message: error.to_string(),
644 })?;
645 if file_type.is_dir() {
646 if !is_linker_irrelevant_directory(&path) {
647 pending.push(path);
648 }
649 } else if is_linker_irrelevant_file(&path) {
650 continue;
651 } else if file_type.is_file() || file_type.is_symlink() {
652 if file_type.is_symlink() {
656 let resolved =
657 path.canonicalize()
658 .map_err(|error| BypassReason::InputRead {
659 path: path.clone(),
660 message: error.to_string(),
661 })?;
662 if !resolved.starts_with(&canonical_root) || !resolved.is_file() {
663 return Err(BypassReason::UnsupportedSearchPath("native".into()));
664 }
665 }
666 let len = std::fs::metadata(&path)
667 .map_err(|error| BypassReason::InputRead {
668 path: path.clone(),
669 message: error.to_string(),
670 })?
671 .len();
672 paths.insert(path.clone());
676 if counted.insert(path) {
677 *native_bytes = native_bytes
678 .checked_add(len)
679 .ok_or_else(|| BypassReason::UnsupportedSearchPath("native".into()))?;
680 }
681 } else {
682 return Err(BypassReason::UnsupportedSearchPath("native".into()));
683 }
684 if paths.len() > MAX_PREDICTED_INPUTS || *native_bytes > MAX_NATIVE_INPUT_BYTES {
685 return Err(BypassReason::UnsupportedSearchPath("native".into()));
686 }
687 }
688 }
689 Ok(())
690}
691
692fn is_linker_irrelevant_directory(path: &Path) -> bool {
697 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
698 return false;
699 };
700 name == "pkgconfig" || name == "cmake" || name.ends_with(".dSYM")
701}
702
703fn is_linker_irrelevant_file(path: &Path) -> bool {
706 path.extension()
707 .and_then(|extension| extension.to_str())
708 .is_some_and(|extension| {
709 ["pc", "cmake"]
710 .iter()
711 .any(|ignored| extension.eq_ignore_ascii_case(ignored))
712 })
713}
714
715fn render_argument(argument: &Argument) -> Result<OsString, BypassReason> {
716 let rendered = match argument {
717 Argument::Plain(value) => value.clone(),
718 Argument::Path { flag, path } => format!(
719 "{flag}={}",
720 path.to_str()
721 .ok_or_else(|| BypassReason::NonUtf8Path(path.clone()))?
722 ),
723 Argument::SearchPath { kind, path } => format!(
724 "-L{kind}={}",
725 path.to_str()
726 .ok_or_else(|| BypassReason::NonUtf8Path(path.clone()))?
727 ),
728 Argument::Extern { name, path } => match path {
729 Some(path) => format!(
730 "--extern={name}={}",
731 path.to_str()
732 .ok_or_else(|| BypassReason::NonUtf8Path(path.clone()))?
733 ),
734 None => format!("--extern={name}"),
735 },
736 Argument::Emit(_) => unreachable!("emit arguments are removed before rendering"),
737 Argument::RemapPath { from, to } => format!(
738 "--remap-path-prefix={}={to}",
739 from.to_str()
740 .ok_or_else(|| BypassReason::NonUtf8Path(from.clone()))?
741 ),
742 Argument::OsoPrefix {
745 path,
746 trailing_slash,
747 } => format!(
748 "--codegen=link-arg=-Wl,-oso_prefix,{}{}",
749 path.to_str()
750 .ok_or_else(|| BypassReason::NonUtf8Path(path.clone()))?,
751 if *trailing_slash { "/" } else { "" }
752 ),
753 Argument::InstallName(name) => {
756 format!("--codegen=link-arg=-Wl,-install_name,@rpath/{name}")
757 }
758 Argument::FuseLd(selection) => format!("--codegen=link-arg=-fuse-ld={selection}"),
759 };
760 Ok(rendered.into())
761}
762
763fn unescape_environment(value: &str) -> Result<String, BypassReason> {
764 let mut output = String::with_capacity(value.len());
765 let mut characters = value.chars();
766 while let Some(character) = characters.next() {
767 if character != '\\' {
768 output.push(character);
769 continue;
770 }
771 match characters.next() {
772 Some('\\') => output.push('\\'),
773 Some('n') => output.push('\n'),
774 Some('r') => output.push('\r'),
775 Some(character) => {
776 return Err(BypassReason::MalformedDepInfo(format!(
777 "unknown environment escape \\{character}"
778 )));
779 }
780 None => {
781 return Err(BypassReason::MalformedDepInfo(
782 "environment input ends with an unterminated escape".into(),
783 ));
784 }
785 }
786 }
787 Ok(output)
788}
789
790#[cfg(test)]
791mod tests {
792 use super::*;
793 use std::process::Command;
794
795 fn args(values: &[&str]) -> Vec<OsString> {
796 values.iter().map(OsString::from).collect()
797 }
798
799 #[test]
800 fn parses_files_spaces_and_environment_records() {
801 let parsed = RustcDepInfo::parse(
802 "target/lib.rlib: src/lib.rs src/a\\ file.rs generated.rs\n\
803 src/lib.rs:\n\
804 # env-dep:SET=value\\nnext\n\
805 # env-dep:UNSET\n\
806 # env-dep:SLASH=a\\\\b\n",
807 )
808 .unwrap();
809 assert_eq!(
810 parsed.files,
811 vec![
812 PathBuf::from("generated.rs"),
813 PathBuf::from("src/a file.rs"),
814 PathBuf::from("src/lib.rs"),
815 ]
816 );
817 assert_eq!(parsed.environment["SET"], Some("value\nnext".into()));
818 assert_eq!(parsed.environment["UNSET"], None);
819 assert_eq!(parsed.environment["SLASH"], Some(r"a\b".into()));
820 }
821
822 #[test]
823 fn malformed_dep_info_bypasses_caching() {
824 for contents in [
825 "",
826 "target: ",
827 "target: src/trailing\\\n",
828 "target: src/lib.rs\n# env-dep:NAME=bad\\q\n",
829 ] {
830 assert!(RustcDepInfo::parse(contents).is_err(), "{contents:?}");
831 }
832 }
833
834 #[test]
835 fn native_directory_byte_limit_is_cumulative() {
836 let directory = tempfile::tempdir().unwrap();
837 let native = directory.path().join("native");
838 std::fs::create_dir_all(&native).unwrap();
839 std::fs::write(native.join("input.lib"), b"xx").unwrap();
840 let roots = native_input_roots(directory.path(), &[]);
841 let mut paths = BTreeSet::new();
842 let mut native_bytes = MAX_NATIVE_INPUT_BYTES - 1;
843 let mut counted = BTreeSet::new();
844
845 assert_eq!(
846 collect_native_directory(&native, &roots, &mut paths, &mut native_bytes, &mut counted),
847 Err(BypassReason::UnsupportedSearchPath("native".into()))
848 );
849 }
850
851 #[test]
852 fn native_directory_walked_twice_counts_bytes_once() {
853 let directory = tempfile::tempdir().unwrap();
854 let native = directory.path().join("native");
855 std::fs::create_dir_all(native.join("lib")).unwrap();
856 std::fs::write(native.join("input.lib"), b"xx").unwrap();
857 std::fs::write(native.join("lib").join("nested.lib"), b"yyy").unwrap();
858 let roots = native_input_roots(directory.path(), &[]);
859 let mut paths = BTreeSet::new();
860 let mut native_bytes = 0;
861 let mut counted = BTreeSet::new();
862
863 for dir in [&native, &native, &native.join("lib")] {
864 collect_native_directory(dir, &roots, &mut paths, &mut native_bytes, &mut counted)
865 .unwrap();
866 }
867
868 assert_eq!(paths.len(), 2);
869 assert_eq!(native_bytes, 5);
870 }
871
872 #[test]
873 fn native_directory_charges_files_already_named_as_inputs() {
874 let directory = tempfile::tempdir().unwrap();
875 let native = directory.path().join("native");
876 std::fs::create_dir_all(&native).unwrap();
877 let archive = native.join("libfoo.a");
878 std::fs::write(&archive, b"xx").unwrap();
879 let roots = native_input_roots(directory.path(), &[]);
880 let mut paths = BTreeSet::from([archive]);
881 let mut native_bytes = 0;
882 let mut counted = BTreeSet::new();
883
884 collect_native_directory(&native, &roots, &mut paths, &mut native_bytes, &mut counted)
885 .unwrap();
886
887 assert_eq!(native_bytes, 2);
888 }
889
890 #[test]
891 fn native_directory_skips_files_the_linker_never_reads() {
892 let directory = tempfile::tempdir().unwrap();
893 let native = directory.path().join("native");
894 for ignored in [
895 "lib/pkgconfig/zlib.pc",
896 "check.dSYM/Contents/Resources/Relocations/aarch64/check.yml",
897 "cmake/Config.cmake",
898 "top.pc",
899 ] {
900 let path = native.join(ignored);
901 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
902 std::fs::write(path, b"prefix=/abs/target\n").unwrap();
903 }
904 std::fs::write(native.join("libz.a"), b"!<arch>\n").unwrap();
905 std::fs::create_dir_all(native.join("lib")).unwrap();
906 std::fs::write(native.join("lib/libz.so.1"), b"elf").unwrap();
907 let roots = native_input_roots(directory.path(), &[]);
908 let mut paths = BTreeSet::new();
909 let mut native_bytes = 0;
910 let mut counted = BTreeSet::new();
911
912 collect_native_directory(&native, &roots, &mut paths, &mut native_bytes, &mut counted)
913 .unwrap();
914
915 assert_eq!(
916 paths.into_iter().collect::<Vec<_>>(),
917 vec![native.join("lib/libz.so.1"), native.join("libz.a")]
918 );
919 }
920
921 #[test]
922 fn discovery_command_removes_real_outputs() {
923 let invocation = RustcInvocation::parse(&args(&[
924 "--crate-name=widget",
925 "--crate-type=lib",
926 "--emit=dep-info,metadata,link",
927 "--out-dir=target/debug/deps",
928 "-o",
929 "target/debug/libwidget.rlib",
930 "src/lib.rs",
931 ]))
932 .unwrap();
933 let output = if cfg!(windows) {
934 PathBuf::from(r"C:\tmp\mbx cache\inputs.d")
935 } else {
936 PathBuf::from("/tmp/mbx cache/inputs.d")
937 };
938 let command = invocation.dep_info_command(&output).unwrap();
939 let arguments = command
940 .arguments()
941 .iter()
942 .map(|value| value.to_string_lossy())
943 .collect::<Vec<_>>();
944 assert_eq!(
945 arguments,
946 vec![
947 "--crate-name=widget",
948 "--crate-type=lib",
949 &format!("--emit=dep-info={}", output.display()),
950 "src/lib.rs",
951 ]
952 );
953 }
954
955 #[test]
956 fn discovery_hashes_externs_and_custom_targets() {
957 let directory = tempfile::tempdir().unwrap();
958 let root = directory.path();
959 let source = root.join("src/lib.rs");
960 let external = root.join("target/libdependency.rlib");
961 let target = root.join("targets/custom.json");
962 std::fs::create_dir_all(source.parent().unwrap()).unwrap();
963 std::fs::create_dir_all(external.parent().unwrap()).unwrap();
964 std::fs::create_dir_all(target.parent().unwrap()).unwrap();
965 std::fs::write(&source, "pub fn library() {}\n").unwrap();
966 std::fs::write(&external, "dependency artifact\n").unwrap();
967 std::fs::write(&target, "{}\n").unwrap();
968
969 let invocation = RustcInvocation::parse(&[
970 "--crate-name=widget".into(),
971 "--crate-type=lib".into(),
972 "--emit=metadata".into(),
973 format!("--extern=dependency={}", external.display()).into(),
974 format!("--target={}", target.display()).into(),
975 source.clone().into_os_string(),
976 ])
977 .unwrap();
978 let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
979 let discovered = invocation.discover_inputs(&dep_info, root).unwrap();
980 assert_eq!(discovered.inputs.len(), 3);
981
982 std::fs::remove_file(&external).unwrap();
983 assert!(matches!(
984 invocation.discover_inputs(&dep_info, root),
985 Err(BypassReason::InputRead { path, .. }) if path == external
986 ));
987 }
988
989 #[cfg(unix)]
994 #[test]
995 fn a_metadata_write_during_compilation_bypasses_without_discarding() {
996 use std::os::unix::fs::PermissionsExt as _;
997
998 let directory = tempfile::tempdir().unwrap();
999 let library = directory.path().join("lib.rs");
1000 let module = directory.path().join("module.rs");
1001 std::fs::write(&library, "mod module;\n").unwrap();
1002 std::fs::write(&module, "pub fn module() {}\n").unwrap();
1003 let invocation = RustcInvocation::parse(&[
1004 "--crate-name=widget".into(),
1005 "--crate-type=lib".into(),
1006 "--emit=dep-info,metadata".into(),
1007 library.clone().into_os_string(),
1008 ])
1009 .unwrap();
1010 let dep_info = RustcDepInfo::parse(&format!(
1011 "output: {} {}\n",
1012 library.display(),
1013 module.display()
1014 ))
1015 .unwrap();
1016 let identity = |path: &Path| {
1017 FileIdentity::for_digest_cache(path, &std::fs::metadata(path).unwrap()).unwrap()
1018 };
1019 let Some(settled) = identity(&library).filter(|identity| identity.changed.is_some()) else {
1021 return; };
1023 let before = BTreeMap::from([
1024 (library.clone(), FileSnapshot::from(settled.clone())),
1025 (
1026 module.clone(),
1027 FileSnapshot::from(identity(&module).unwrap()),
1028 ),
1029 ]);
1030
1031 let mode = std::fs::metadata(&library).unwrap().permissions().mode();
1032 std::fs::set_permissions(&library, std::fs::Permissions::from_mode(mode)).unwrap();
1033 if identity(&library) == Some(settled) {
1034 return; }
1036 let discovered = invocation
1037 .discover_inputs(&dep_info, directory.path())
1038 .unwrap();
1039 assert_eq!(
1040 discovered.verify_not_modified_since_with_snapshots(SystemTime::UNIX_EPOCH, &before),
1041 Err(BypassReason::InputMetadataChangedDuringCompilation(
1042 library.clone()
1043 ))
1044 );
1045
1046 std::fs::write(&module, "pub fn module() { let _ = 1; }\n").unwrap();
1049 let discovered = invocation
1050 .discover_inputs(&dep_info, directory.path())
1051 .unwrap();
1052 assert_eq!(
1053 discovered.verify_not_modified_since_with_snapshots(SystemTime::UNIX_EPOCH, &before),
1054 Err(BypassReason::InputModifiedDuringCompilation(module))
1055 );
1056 }
1057
1058 #[test]
1059 fn discovery_rejects_inputs_modified_during_compilation() {
1060 let directory = tempfile::tempdir().unwrap();
1061 let source = directory.path().join("lib.rs");
1062 std::fs::write(&source, "pub fn library() {}\n").unwrap();
1063 let invocation = RustcInvocation::parse(&[
1064 "--crate-name=widget".into(),
1065 "--crate-type=lib".into(),
1066 "--emit=dep-info,metadata".into(),
1067 source.clone().into_os_string(),
1068 ])
1069 .unwrap();
1070 let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
1071 let discovered = invocation
1072 .discover_inputs(&dep_info, directory.path())
1073 .unwrap();
1074 let modified = std::fs::metadata(&source).unwrap().modified().unwrap();
1075
1076 assert_eq!(
1077 discovered.verify_not_modified_since(modified),
1078 Err(BypassReason::InputModifiedDuringCompilation(source))
1079 );
1080 }
1081
1082 #[test]
1083 fn profile_use_is_discovered_verified_and_rehashed_by_prediction() {
1084 let directory = tempfile::tempdir().unwrap();
1085 let root = directory.path();
1086 let source = root.join("lib.rs");
1087 let profile = root.join("profile.profdata");
1088 std::fs::write(&source, "pub fn library() {}\n").unwrap();
1089 std::fs::write(&profile, "first profile").unwrap();
1090 let invocation = RustcInvocation::parse(&[
1091 "--crate-name=widget".into(),
1092 "--crate-type=lib".into(),
1093 "--emit=dep-info,metadata".into(),
1094 format!("-Cprofile-use={}", profile.display()).into(),
1095 "lib.rs".into(),
1096 ])
1097 .unwrap();
1098 let mappings = vec![PathMapping::new(root, "workspace")];
1099 let dep_info = RustcDepInfo::parse("widget.d: lib.rs\n").unwrap();
1100 let discover = || {
1101 invocation
1102 .discover_inputs_with_mappings(
1103 &dep_info,
1104 root,
1105 &mappings,
1106 &mbx_cache_core::NoFileDigestCache,
1107 )
1108 .unwrap()
1109 };
1110 let initial_inputs = discover();
1111 assert!(
1112 initial_inputs
1113 .inputs
1114 .iter()
1115 .any(|input| input.path == profile)
1116 );
1117 let mut context = library_context(root, mappings.clone());
1118 initial_inputs.clone().apply_to(&mut context).unwrap();
1119 let initial_action = invocation.action(context.clone()).unwrap();
1120 let prediction = invocation.prediction(&context, &initial_inputs).unwrap();
1121
1122 std::fs::write(&profile, "updated profile").unwrap();
1123 assert_eq!(
1124 initial_inputs.verify(),
1125 Err(BypassReason::InputChanged(profile.clone()))
1126 );
1127 context.inputs.clear();
1128 let predicted = prediction
1129 .discover(root, &mappings, &mbx_cache_core::NoFileDigestCache)
1130 .unwrap();
1131 predicted.clone().apply_to(&mut context).unwrap();
1132 let predicted_action = invocation.action(context.clone()).unwrap();
1133 assert_ne!(predicted_action.digest, initial_action.digest);
1134 context.inputs.clear();
1135 discover().apply_to(&mut context).unwrap();
1136 assert_eq!(predicted_action, invocation.action(context).unwrap());
1137
1138 let missing_profile = root.join("missing.profdata");
1139 let missing_invocation = RustcInvocation::parse(&[
1140 "--crate-name=widget".into(),
1141 "--crate-type=lib".into(),
1142 "--emit=dep-info,metadata".into(),
1143 format!("-Cprofile-use={}", missing_profile.display()).into(),
1144 "lib.rs".into(),
1145 ])
1146 .unwrap();
1147 assert!(matches!(
1148 missing_invocation.discover_inputs_with_mappings(
1149 &dep_info,
1150 root,
1151 &mappings,
1152 &mbx_cache_core::NoFileDigestCache,
1153 ),
1154 Err(BypassReason::InputRead { path, .. }) if path == missing_profile
1155 ));
1156 }
1157
1158 #[cfg(unix)]
1159 #[test]
1160 fn precompile_snapshot_does_not_depend_on_the_host_clock() {
1161 let directory = tempfile::tempdir().unwrap();
1162 let source = directory.path().join("lib.rs");
1163 std::fs::write(&source, "pub fn library() {0}\n").unwrap();
1164 let invocation = RustcInvocation::parse(&[
1165 "--crate-name=widget".into(),
1166 "--crate-type=lib".into(),
1167 "--emit=dep-info,metadata".into(),
1168 source.clone().into_os_string(),
1169 ])
1170 .unwrap();
1171 let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
1172 let discovered = invocation
1173 .discover_inputs(&dep_info, directory.path())
1174 .unwrap();
1175 let snapshot = FileSnapshot::capture(&source).unwrap().unwrap();
1176 let before = BTreeMap::from([(source.clone(), snapshot)]);
1177
1178 discovered
1181 .verify_not_modified_since_with_snapshots(SystemTime::UNIX_EPOCH, &before)
1182 .unwrap();
1183
1184 std::thread::sleep(std::time::Duration::from_millis(20));
1187 std::fs::write(&source, "pub fn library() {1}\n").unwrap();
1188 let discovered = invocation
1189 .discover_inputs(&dep_info, directory.path())
1190 .unwrap();
1191 assert_eq!(
1192 discovered.verify_not_modified_since_with_snapshots(
1193 SystemTime::now() + std::time::Duration::from_secs(60),
1194 &before,
1195 ),
1196 Err(BypassReason::InputModifiedDuringCompilation(source))
1197 );
1198 }
1199
1200 fn toolchain_native_directory(version: &str) -> PathBuf {
1202 if cfg!(windows) {
1203 PathBuf::from(format!(
1204 r"C:\Program Files\Microsoft Visual Studio\2022\BuildTools\VC\Tools\MSVC\{version}\lib\x64"
1205 ))
1206 } else {
1207 PathBuf::from(format!("/opt/msvc/{version}/lib/x64"))
1208 }
1209 }
1210
1211 fn library_with_native_search(source: &Path, directory: &Path) -> RustcInvocation {
1212 RustcInvocation::parse(&[
1213 "--crate-name=widget".into(),
1214 "--crate-type=lib".into(),
1215 "--emit=metadata,link".into(),
1216 format!("-Lnative={}", directory.display()).into(),
1217 source.to_path_buf().into_os_string(),
1218 ])
1219 .unwrap()
1220 }
1221
1222 fn library_context(root: &Path, mappings: Vec<PathMapping>) -> ActionContext {
1223 ActionContext {
1224 compiler: crate::CompilerIdentity {
1225 toolchain: "core:rust@test".into(),
1226 rustc_version: "test".into(),
1227 host: std::env::consts::ARCH.into(),
1228 driver: None,
1229 },
1230 working_dir: root.to_path_buf(),
1231 path_mappings: mappings,
1232 environment: BTreeMap::new(),
1233 portable_environment: BTreeSet::new(),
1234 inputs: Vec::new(),
1235 }
1236 }
1237
1238 #[cfg(windows)]
1239 #[test]
1240 fn unmapped_toolchain_directory_cannot_hide_a_static_archive() {
1241 let directory = tempfile::tempdir().unwrap();
1242 let root = directory.path();
1243 let source = root.join("lib.rs");
1244 std::fs::write(&source, "pub fn library() {}\n").unwrap();
1245 let toolchain = toolchain_native_directory("14.51.36231");
1246 let invocation = library_with_native_search(&source, &toolchain);
1247 let mappings = vec![PathMapping::new(root, "workspace")];
1248 let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
1249
1250 let context = library_context(root, mappings.clone());
1251 assert!(matches!(
1252 invocation.invocation_digest(&context),
1253 Err(BypassReason::UnmappedAbsolutePath(path)) if path == toolchain
1254 ));
1255 assert_eq!(
1256 invocation.discover_inputs_with_mappings(
1257 &dep_info,
1258 root,
1259 &mappings,
1260 &mbx_cache_core::NoFileDigestCache,
1261 ),
1262 Err(BypassReason::UnsupportedSearchPath("native".into()))
1263 );
1264 }
1265
1266 #[test]
1267 fn source_level_static_link_cannot_hide_an_unmapped_archive() {
1268 let directory = tempfile::tempdir().unwrap();
1269 let workspace = directory.path().join("workspace");
1270 let external = directory.path().join("external");
1271 std::fs::create_dir(&workspace).unwrap();
1272 std::fs::create_dir(&external).unwrap();
1273 let source = workspace.join("lib.rs");
1274 std::fs::write(
1275 &source,
1276 "#[link(name = \"foo\", kind = \"static\")] unsafe extern \"C\" {}\n",
1277 )
1278 .unwrap();
1279 std::fs::write(external.join("libfoo.a"), b"first archive").unwrap();
1280 let invocation = library_with_native_search(&source, &external);
1281 let mappings = vec![PathMapping::new(&workspace, "workspace")];
1282 let context = library_context(&workspace, mappings.clone());
1283 let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
1284
1285 assert!(matches!(
1288 invocation.invocation_digest(&context),
1289 Err(BypassReason::UnmappedAbsolutePath(path)) if path == external
1290 ));
1291 assert_eq!(
1292 invocation.discover_inputs_with_mappings(
1293 &dep_info,
1294 &workspace,
1295 &mappings,
1296 &mbx_cache_core::NoFileDigestCache,
1297 ),
1298 Err(BypassReason::UnsupportedSearchPath("native".into()))
1299 );
1300 }
1301
1302 #[cfg(windows)]
1303 #[test]
1304 fn project_directory_with_toolchain_suffix_is_not_trusted() {
1305 let directory = tempfile::tempdir().unwrap();
1306 let root = directory.path().join("workspace");
1307 std::fs::create_dir(&root).unwrap();
1308 let source = root.join("lib.rs");
1309 std::fs::write(&source, "pub fn library() {}\n").unwrap();
1310 let fake_toolchain = directory.path().join("MSVC/14.51.36231/lib/x64");
1311 let invocation = library_with_native_search(&source, &fake_toolchain);
1312 let context = library_context(&root, vec![PathMapping::new(&root, "workspace")]);
1313
1314 assert!(matches!(
1316 invocation.invocation_digest(&context),
1317 Err(BypassReason::UnmappedAbsolutePath(path)) if path == fake_toolchain
1318 ));
1319 }
1320
1321 #[test]
1322 fn unmapped_native_directory_still_refuses_a_native_link() {
1323 let directory = tempfile::tempdir().unwrap();
1324 let root = directory.path();
1325 let source = root.join("main.rs");
1326 std::fs::write(&source, "fn main() {}\n").unwrap();
1327 let toolchain = toolchain_native_directory("14.51.36231");
1328 let invocation = RustcInvocation::parse_with(
1329 &[
1330 "--crate-name=app".into(),
1331 "--crate-type=bin".into(),
1332 "--emit=link".into(),
1333 format!("-Lnative={}", toolchain.display()).into(),
1334 source.clone().into_os_string(),
1335 ],
1336 crate::ParseOptions::caching_native_links(true),
1337 )
1338 .unwrap();
1339 let mappings = vec![PathMapping::new(root, "workspace")];
1340
1341 let context = library_context(root, mappings.clone());
1344 assert!(matches!(
1345 invocation.invocation_digest(&context),
1346 Err(BypassReason::UnmappedAbsolutePath(_))
1347 ));
1348 let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
1349 assert_eq!(
1350 invocation.discover_inputs_with_mappings(
1351 &dep_info,
1352 root,
1353 &mappings,
1354 &mbx_cache_core::NoFileDigestCache
1355 ),
1356 Err(BypassReason::UnsupportedSearchPath("native".into()))
1357 );
1358 }
1359
1360 #[test]
1361 fn discovery_resolves_parent_components_against_the_working_directory() {
1362 let directory = tempfile::tempdir().unwrap();
1363 let root = directory.path().join("project");
1364 let shared = directory.path().join("shared.rs");
1365 std::fs::create_dir(&root).unwrap();
1366 std::fs::write(&shared, "pub fn shared() {}\n").unwrap();
1367
1368 let invocation = RustcInvocation::parse(&args(&[
1369 "--crate-name=widget",
1370 "--crate-type=lib",
1371 "--emit=metadata",
1372 "../shared.rs",
1373 ]))
1374 .unwrap();
1375 let dep_info = RustcDepInfo::parse("output: ../shared.rs\n").unwrap();
1376 let discovered = invocation.discover_inputs(&dep_info, &root).unwrap();
1377
1378 assert_eq!(discovered.inputs.len(), 1);
1379 assert_eq!(discovered.inputs[0].path, shared);
1380 }
1381
1382 #[test]
1383 fn rustc_dep_info_round_trip_discovers_real_inputs() {
1384 let directory = tempfile::tempdir().unwrap();
1385 let root = directory.path();
1386 std::fs::write(
1387 root.join("lib.rs"),
1388 "mod child; const _: &str = include_str!(\"data file.txt\"); \
1389 const _: &str = env!(\"MBX_DISCOVERY_TEST\"); \
1390 const _: Option<&str> = option_env!(\"MBX_DISCOVERY_UNSET\");",
1391 )
1392 .unwrap();
1393 std::fs::write(root.join("child.rs"), "pub fn child() {}\n").unwrap();
1394 std::fs::write(root.join("data file.txt"), "included\n").unwrap();
1395
1396 let invocation = RustcInvocation::parse(&args(&[
1397 "--crate-name=mbx_cache_discovery_test",
1398 "--crate-type=lib",
1399 "--emit=metadata,link",
1400 "lib.rs",
1401 ]))
1402 .unwrap();
1403 let dep_info_path = root.join("discovery inputs.d");
1404 let discovery_command = invocation.dep_info_command(&dep_info_path).unwrap();
1405 let rustc = std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into());
1406 let output = match Command::new(rustc)
1407 .args(discovery_command.arguments())
1408 .current_dir(root)
1409 .env("MBX_DISCOVERY_TEST", "observed")
1410 .env_remove("MBX_DISCOVERY_UNSET")
1411 .output()
1412 {
1413 Ok(output) => output,
1414 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return,
1415 Err(error) => panic!("failed to execute rustc: {error}"),
1416 };
1417 assert!(
1418 output.status.success(),
1419 "{}",
1420 String::from_utf8_lossy(&output.stderr)
1421 );
1422 let parsed = RustcDepInfo::read(&dep_info_path).unwrap();
1423 let discovered = invocation.discover_inputs(&parsed, root).unwrap();
1424 assert_eq!(
1425 discovered.environment["MBX_DISCOVERY_TEST"],
1426 Some("observed".into())
1427 );
1428 assert_eq!(discovered.environment["MBX_DISCOVERY_UNSET"], None);
1429 assert_eq!(discovered.inputs.len(), 3);
1430 assert!(
1431 discovered
1432 .inputs
1433 .iter()
1434 .all(|input| input.digest.algorithm == "blake3")
1435 );
1436 let mut context = ActionContext {
1437 compiler: crate::CompilerIdentity {
1438 toolchain: "core:rust@test".into(),
1439 rustc_version: "test".into(),
1440 host: std::env::consts::ARCH.into(),
1441 driver: None,
1442 },
1443 working_dir: root.to_path_buf(),
1444 path_mappings: vec![crate::PathMapping::new(root, "workspace")],
1445 environment: BTreeMap::new(),
1446 portable_environment: BTreeSet::new(),
1447 inputs: Vec::new(),
1448 };
1449 discovered.clone().apply_to(&mut context).unwrap();
1450 let action = invocation.action(context).unwrap();
1451 assert!(
1452 String::from_utf8(action.bytes)
1453 .unwrap()
1454 .contains(r#""MBX_DISCOVERY_TEST":"observed""#)
1455 );
1456 discovered.verify().unwrap();
1457 std::fs::write(root.join("child.rs"), "pub fn changed() {}\n").unwrap();
1458 assert_eq!(
1459 discovered.verify(),
1460 Err(BypassReason::InputChanged(root.join("child.rs")))
1461 );
1462 }
1463
1464 struct SentinelLedger {
1466 known: FileIdentity,
1467 digest: CacheDigest,
1468 recorded: std::sync::Mutex<Vec<RecordedFileDigest>>,
1469 }
1470
1471 impl FileDigestCache for SentinelLedger {
1472 fn find(&self, scope: FileDigestScope, files: &[FileIdentity]) -> Vec<Option<CacheDigest>> {
1473 assert_eq!(scope, FileDigestScope::Content);
1474 files
1475 .iter()
1476 .map(|file| (*file == self.known).then(|| self.digest.clone()))
1477 .collect()
1478 }
1479
1480 fn record(&self, scope: FileDigestScope, entries: Vec<RecordedFileDigest>) {
1481 assert_eq!(scope, FileDigestScope::Content);
1482 self.recorded.lock().unwrap().extend(entries);
1483 }
1484 }
1485
1486 #[cfg(unix)]
1487 struct ReplacingLedger {
1488 path: PathBuf,
1489 replacement: PathBuf,
1490 recorded: std::sync::Mutex<Vec<RecordedFileDigest>>,
1491 }
1492
1493 #[cfg(unix)]
1494 impl FileDigestCache for ReplacingLedger {
1495 fn find(&self, scope: FileDigestScope, files: &[FileIdentity]) -> Vec<Option<CacheDigest>> {
1496 assert_eq!(scope, FileDigestScope::Content);
1497 assert_eq!(files.len(), 1);
1498 std::fs::remove_file(&self.path).unwrap();
1499 std::fs::rename(&self.replacement, &self.path).unwrap();
1500 std::thread::sleep(std::time::Duration::from_millis(50));
1502 vec![None]
1503 }
1504
1505 fn record(&self, scope: FileDigestScope, entries: Vec<RecordedFileDigest>) {
1506 assert_eq!(scope, FileDigestScope::Content);
1507 self.recorded.lock().unwrap().extend(entries);
1508 }
1509 }
1510
1511 #[cfg(unix)]
1512 #[test]
1513 fn discovery_binds_a_replacement_digest_to_the_replacement_identity() {
1514 let directory = tempfile::tempdir().unwrap();
1515 let root = directory.path();
1516 let path = root.join("libdep.rlib");
1517 let replacement = root.join("replacement.rlib");
1518 std::fs::write(&path, b"original....").unwrap();
1519 std::fs::File::options()
1520 .write(true)
1521 .open(&path)
1522 .unwrap()
1523 .set_times(std::fs::FileTimes::new().set_modified(SystemTime::UNIX_EPOCH))
1524 .unwrap();
1525 std::fs::write(&replacement, b"replacement!").unwrap();
1526 std::fs::File::options()
1527 .write(true)
1528 .open(&replacement)
1529 .unwrap()
1530 .set_times(std::fs::FileTimes::new().set_modified(SystemTime::UNIX_EPOCH))
1531 .unwrap();
1532 let ledger = ReplacingLedger {
1533 path: path.clone(),
1534 replacement,
1535 recorded: std::sync::Mutex::new(Vec::new()),
1536 };
1537
1538 let discovered = DiscoveredInputs::from_paths(
1539 root,
1540 BTreeSet::from([path.clone()]),
1541 BTreeMap::new(),
1542 &ledger,
1543 )
1544 .unwrap();
1545 let current = FileIdentity::for_digest_cache(&path, &std::fs::metadata(&path).unwrap())
1546 .unwrap()
1547 .unwrap();
1548 let digest = CacheDigest::blake3_file(&path).unwrap();
1549
1550 assert_eq!(discovered.inputs[0].digest, digest);
1551 assert_eq!(discovered.identities[0].as_ref(), Some(¤t));
1552 assert_eq!(
1553 ledger.recorded.lock().unwrap().as_slice(),
1554 &[RecordedFileDigest {
1555 file: current,
1556 digest,
1557 }]
1558 );
1559 discovered.verify().unwrap();
1560 }
1561
1562 #[test]
1563 fn discovery_reuses_recorded_digests_and_records_fresh_ones() {
1564 let directory = tempfile::tempdir().unwrap();
1565 let root = directory.path();
1566 let known_path = root.join("libdep.rlib");
1567 let fresh_path = root.join("lib.rs");
1568 std::fs::write(&known_path, b"rlib bytes").unwrap();
1569 std::fs::write(&fresh_path, b"fn lib() {}").unwrap();
1570 std::thread::sleep(std::time::Duration::from_millis(50));
1572 let metadata = std::fs::metadata(&known_path).unwrap();
1573 let sentinel = CacheDigest {
1576 algorithm: "blake3".into(),
1577 hash: "c".repeat(64),
1578 size: metadata.len(),
1579 };
1580 let ledger = SentinelLedger {
1581 known: FileIdentity::describe(&known_path, &metadata).unwrap(),
1582 digest: sentinel.clone(),
1583 recorded: std::sync::Mutex::new(Vec::new()),
1584 };
1585
1586 let discovered = DiscoveredInputs::from_paths(
1587 root,
1588 BTreeSet::from([known_path.clone(), fresh_path.clone()]),
1589 BTreeMap::new(),
1590 &ledger,
1591 )
1592 .unwrap();
1593
1594 let by_path = |path: &Path| {
1595 discovered
1596 .inputs
1597 .iter()
1598 .find(|input| input.path == path)
1599 .unwrap()
1600 .digest
1601 .clone()
1602 };
1603 assert_eq!(
1604 by_path(&known_path),
1605 sentinel,
1606 "the recorded digest answers"
1607 );
1608 assert_eq!(
1609 by_path(&fresh_path),
1610 CacheDigest::blake3_file(&fresh_path).unwrap(),
1611 "an unrecorded file is hashed"
1612 );
1613 let recorded = ledger.recorded.lock().unwrap();
1614 assert_eq!(recorded.len(), 1, "only the fresh hash is recorded");
1615 assert_eq!(recorded[0].file.path, fresh_path);
1616 assert_eq!(recorded[0].digest, by_path(&fresh_path));
1617 }
1618
1619 #[cfg(unix)]
1624 #[test]
1625 fn verification_trusts_an_unchanged_identity_and_rereads_a_changed_one() {
1626 let directory = tempfile::tempdir().unwrap();
1627 let root = directory.path();
1628 let known_path = root.join("libdep.rlib");
1629 std::fs::write(&known_path, b"rlib bytes").unwrap();
1630 let metadata = std::fs::metadata(&known_path).unwrap();
1631 let sentinel = CacheDigest {
1634 algorithm: "blake3".into(),
1635 hash: "c".repeat(64),
1636 size: metadata.len(),
1637 };
1638 let ledger = SentinelLedger {
1639 known: FileIdentity::describe(&known_path, &metadata).unwrap(),
1640 digest: sentinel,
1641 recorded: std::sync::Mutex::new(Vec::new()),
1642 };
1643 let discovered = DiscoveredInputs::from_paths(
1644 root,
1645 BTreeSet::from([known_path.clone()]),
1646 BTreeMap::new(),
1647 &ledger,
1648 )
1649 .unwrap();
1650
1651 discovered.verify().unwrap();
1652
1653 std::thread::sleep(std::time::Duration::from_millis(20));
1656 std::fs::write(&known_path, b"RLIB BYTES").unwrap();
1657 assert_eq!(
1658 discovered.verify(),
1659 Err(BypassReason::InputChanged(known_path.clone()))
1660 );
1661
1662 std::fs::remove_file(&known_path).unwrap();
1663 assert!(matches!(
1664 discovered.verify(),
1665 Err(BypassReason::InputRead { path, .. }) if path == known_path
1666 ));
1667 }
1668
1669 #[cfg(unix)]
1673 #[test]
1674 fn a_disguised_rewrite_is_not_answered_from_the_ledger() {
1675 let directory = tempfile::tempdir().unwrap();
1676 let root = directory.path();
1677 let path = root.join("lib.rs");
1678 std::fs::write(&path, b"fn lib() -> u8 { 1 }").unwrap();
1679 let before = std::fs::metadata(&path).unwrap();
1680 let identity = FileIdentity::describe(&path, &before).unwrap();
1681
1682 std::thread::sleep(std::time::Duration::from_millis(20));
1686 std::fs::write(&path, b"fn lib() -> u8 { 2 }").unwrap();
1687 let file = std::fs::File::options().write(true).open(&path).unwrap();
1688 file.set_times(std::fs::FileTimes::new().set_modified(before.modified().unwrap()))
1689 .unwrap();
1690 drop(file);
1691 let after = std::fs::metadata(&path).unwrap();
1692 let disguised = FileIdentity::describe(&path, &after).unwrap();
1693 assert_eq!(disguised.len, identity.len);
1694 assert_eq!(disguised.modified, identity.modified);
1695 assert_ne!(
1696 disguised, identity,
1697 "the change token must expose the rewrite"
1698 );
1699
1700 let ledger = SentinelLedger {
1701 known: identity,
1702 digest: CacheDigest {
1703 algorithm: "blake3".into(),
1704 hash: "e".repeat(64),
1705 size: after.len(),
1706 },
1707 recorded: std::sync::Mutex::new(Vec::new()),
1708 };
1709 let discovered = DiscoveredInputs::from_paths(
1710 root,
1711 BTreeSet::from([path.clone()]),
1712 BTreeMap::new(),
1713 &ledger,
1714 )
1715 .unwrap();
1716 assert_eq!(
1717 discovered.inputs[0].digest,
1718 CacheDigest::blake3_file(&path).unwrap(),
1719 "the disguised rewrite is hashed, not answered from the ledger"
1720 );
1721 }
1722 #[cfg(unix)]
1723 #[test]
1724 fn native_file_symlinks_are_rehashed_on_prediction() {
1725 use std::os::unix::fs::symlink;
1726 let directory = tempfile::tempdir().unwrap();
1727 let workspace = directory.path().join("workspace");
1728 let native = directory.path().join("cellar/lib");
1729 std::fs::create_dir_all(&workspace).unwrap();
1730 std::fs::create_dir_all(&native).unwrap();
1731 let source = workspace.join("lib.rs");
1732 std::fs::write(&source, "pub fn value() {}\n").unwrap();
1733 std::fs::write(native.join("libssl.3.dylib"), "first").unwrap();
1734 symlink("libssl.3.dylib", native.join("libssl.dylib")).unwrap();
1735 let opt = directory.path().join("opt");
1736 symlink(&native, &opt).unwrap();
1737 let invocation = library_with_native_search(&source, &opt);
1738 let mappings = vec![
1739 PathMapping::new(&workspace, "workspace"),
1740 PathMapping::new(&opt, "native"),
1741 ];
1742 let dep_info = RustcDepInfo {
1743 files: vec![source],
1744 environment: BTreeMap::new(),
1745 };
1746 let discover = || {
1747 invocation
1748 .discover_inputs_with_mappings(
1749 &dep_info,
1750 &workspace,
1751 &mappings,
1752 &mbx_cache_core::NoFileDigestCache,
1753 )
1754 .unwrap()
1755 };
1756 let original = discover();
1757 let mut context = library_context(&workspace, mappings.clone());
1758 original.clone().apply_to(&mut context).unwrap();
1759 let initial = invocation.action(context.clone()).unwrap();
1760 let prediction = invocation.prediction(&context, &original).unwrap();
1761 context.inputs.clear();
1762 let mut previous = initial.digest;
1763 for change in ["contents", "add", "retarget", "remove"] {
1764 match change {
1765 "contents" => std::fs::write(native.join("libssl.3.dylib"), "updated").unwrap(),
1766 "add" => std::fs::write(native.join("libssl.4.dylib"), "replacement").unwrap(),
1767 "retarget" => {
1768 std::fs::remove_file(native.join("libssl.dylib")).unwrap();
1769 symlink("libssl.4.dylib", native.join("libssl.dylib")).unwrap();
1770 }
1771 _ => std::fs::remove_file(native.join("libssl.3.dylib")).unwrap(),
1772 }
1773 let predicted = prediction
1774 .discover(&workspace, &mappings, &mbx_cache_core::NoFileDigestCache)
1775 .unwrap();
1776 let mut predicted_context = context.clone();
1777 predicted.apply_to(&mut predicted_context).unwrap();
1778 let predicted_action = invocation.action(predicted_context).unwrap();
1779 let mut discovered_context = context.clone();
1780 discover().apply_to(&mut discovered_context).unwrap();
1781 assert_eq!(
1782 predicted_action,
1783 invocation.action(discovered_context).unwrap()
1784 );
1785 assert_ne!(predicted_action.digest, previous, "{change}");
1786 previous = predicted_action.digest;
1787 }
1788 std::fs::remove_file(native.join("libssl.dylib")).unwrap();
1789 let outside = directory.path().join("outside");
1790 std::fs::write(&outside, "untracked").unwrap();
1791 symlink(&outside, native.join("libssl.dylib")).unwrap();
1792 assert!(
1793 prediction
1794 .discover(&workspace, &mappings, &mbx_cache_core::NoFileDigestCache)
1795 .is_err()
1796 );
1797 std::fs::remove_file(native.join("libssl.dylib")).unwrap();
1798 symlink(".", native.join("loop")).unwrap();
1799 assert!(
1800 prediction
1801 .discover(&workspace, &mappings, &mbx_cache_core::NoFileDigestCache)
1802 .is_err()
1803 );
1804 }
1805}