1use super::{
2 ActionContext, ActionInput, Argument, BypassReason, RustcInvocation, normalize_components,
3};
4use mise_cache_core::CacheDigest;
5use std::collections::{BTreeMap, BTreeSet};
6use std::ffi::OsString;
7use std::path::{Path, PathBuf};
8use std::time::SystemTime;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct DepInfoCommand {
13 arguments: Vec<OsString>,
14 output: PathBuf,
15}
16
17impl DepInfoCommand {
18 pub fn arguments(&self) -> &[OsString] {
20 &self.arguments
21 }
22
23 pub fn output(&self) -> &Path {
25 &self.output
26 }
27}
28
29#[derive(Debug, Clone, Default, PartialEq, Eq)]
31pub struct RustcDepInfo {
32 pub files: Vec<PathBuf>,
33 pub environment: BTreeMap<String, Option<String>>,
34}
35
36impl RustcDepInfo {
37 pub fn read(path: &Path) -> Result<Self, BypassReason> {
40 let contents =
41 std::fs::read_to_string(path).map_err(|error| BypassReason::DepInfoRead {
42 path: path.to_path_buf(),
43 message: error.to_string(),
44 })?;
45 Self::parse(&contents)
46 }
47
48 pub fn parse(contents: &str) -> Result<Self, BypassReason> {
55 let mut files = BTreeSet::new();
56 let mut environment = BTreeMap::new();
57 let mut found_dependencies = false;
58
59 for line in contents.lines() {
60 if let Some(record) = line.strip_prefix("# env-dep:") {
61 let (name, value) = record
62 .split_once('=')
63 .map_or((record, None), |(name, value)| (name, Some(value)));
64 let name = unescape_environment(name)?;
65 if name.is_empty() {
66 return Err(BypassReason::MalformedDepInfo(
67 "environment input has an empty name".into(),
68 ));
69 }
70 let value = value.map(unescape_environment).transpose()?;
71 if environment
72 .insert(name.clone(), value.clone())
73 .is_some_and(|previous| previous != value)
74 {
75 return Err(BypassReason::ConflictingEnvironment(name));
76 }
77 continue;
78 }
79
80 let Some(separator) = line.find(": ") else {
81 continue;
82 };
83 if found_dependencies {
84 continue;
85 }
86 found_dependencies = true;
87 let mut fragments = line[separator + 2..].split_whitespace();
88 while let Some(fragment) = fragments.next() {
89 let mut file = fragment.to_string();
90 while file.ends_with('\\') {
91 file.pop();
92 let continuation = fragments.next().ok_or_else(|| {
93 BypassReason::MalformedDepInfo(
94 "dependency path ends with an unterminated escape".into(),
95 )
96 })?;
97 file.push(' ');
98 file.push_str(continuation);
99 }
100 if file.is_empty() {
101 return Err(BypassReason::MalformedDepInfo(
102 "dependency path is empty".into(),
103 ));
104 }
105 files.insert(PathBuf::from(file));
106 }
107 }
108
109 if !found_dependencies {
110 return Err(BypassReason::MalformedDepInfo(
111 "dependency rule is missing".into(),
112 ));
113 }
114 if files.is_empty() {
115 return Err(BypassReason::MalformedDepInfo(
116 "dependency rule contains no inputs".into(),
117 ));
118 }
119 Ok(Self {
120 files: files.into_iter().collect(),
121 environment,
122 })
123 }
124}
125
126#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct DiscoveredInputs {
129 working_dir: PathBuf,
130 pub inputs: Vec<ActionInput>,
131 pub environment: BTreeMap<String, Option<String>>,
132}
133
134impl DiscoveredInputs {
135 pub(crate) fn from_paths(
136 working_dir: &Path,
137 paths: BTreeSet<PathBuf>,
138 environment: BTreeMap<String, Option<String>>,
139 ) -> Result<Self, BypassReason> {
140 if !working_dir.is_absolute() {
141 return Err(BypassReason::RelativeWorkingDirectory(
142 working_dir.to_path_buf(),
143 ));
144 }
145 let working_dir = normalize_components(working_dir);
146 let mut inputs = Vec::with_capacity(paths.len());
147 for path in paths {
148 let metadata = std::fs::metadata(&path).map_err(|error| BypassReason::InputRead {
149 path: path.clone(),
150 message: error.to_string(),
151 })?;
152 if !metadata.is_file() {
153 return Err(BypassReason::InputRead {
154 path,
155 message: "input is not a regular file".into(),
156 });
157 }
158 let digest =
159 CacheDigest::blake3_file(&path).map_err(|error| BypassReason::InputRead {
160 path: path.clone(),
161 message: error.to_string(),
162 })?;
163 inputs.push(ActionInput { path, digest });
164 }
165 Ok(Self {
166 working_dir,
167 inputs,
168 environment,
169 })
170 }
171
172 pub fn verify_not_modified_since(&self, started_at: SystemTime) -> Result<(), BypassReason> {
179 for input in &self.inputs {
180 let modified = std::fs::metadata(&input.path)
181 .and_then(|metadata| metadata.modified())
182 .map_err(|error| BypassReason::InputRead {
183 path: input.path.clone(),
184 message: error.to_string(),
185 })?;
186 if modified >= started_at {
187 return Err(BypassReason::InputModifiedDuringCompilation(
188 input.path.clone(),
189 ));
190 }
191 }
192 Ok(())
193 }
194
195 pub fn verify(&self) -> Result<(), BypassReason> {
199 for input in &self.inputs {
200 let matches = input.digest.matches_file(&input.path).map_err(|error| {
201 BypassReason::InputRead {
202 path: input.path.clone(),
203 message: error.to_string(),
204 }
205 })?;
206 if !matches {
207 return Err(BypassReason::InputChanged(input.path.clone()));
208 }
209 }
210 Ok(())
211 }
212
213 pub fn apply_to(self, context: &mut ActionContext) -> Result<(), BypassReason> {
216 if normalize_components(&context.working_dir) != self.working_dir {
217 return Err(BypassReason::DiscoveryWorkingDirectory);
218 }
219 for (name, value) in &self.environment {
220 if context
221 .environment
222 .get(name)
223 .is_some_and(|previous| previous != value)
224 {
225 return Err(BypassReason::ConflictingEnvironment(name.clone()));
226 }
227 }
228 context.environment.extend(self.environment);
229 context.inputs.extend(self.inputs);
230 Ok(())
231 }
232}
233
234impl RustcInvocation {
235 pub fn dep_info_command(&self, output: &Path) -> Result<DepInfoCommand, BypassReason> {
237 if !output.is_absolute() {
238 return Err(BypassReason::RelativeDepInfoPath(output.to_path_buf()));
239 }
240 let output_text = output
241 .to_str()
242 .ok_or_else(|| BypassReason::NonUtf8Path(output.to_path_buf()))?;
243 if output_text.contains(',') {
244 return Err(BypassReason::UnsafeDepInfoPath(output.to_path_buf()));
245 }
246
247 let mut arguments = Vec::new();
248 for argument in &self.arguments {
249 match argument {
250 Argument::Emit(_) => {}
251 Argument::Path { flag, .. } if flag == "--out-dir" || flag == "-o" => {}
252 argument => arguments.push(render_argument(argument)?),
253 }
254 }
255 arguments.push(format!("--emit=dep-info={output_text}").into());
256 arguments.push(self.source.clone().into_os_string());
257 Ok(DepInfoCommand {
258 arguments,
259 output: output.to_path_buf(),
260 })
261 }
262
263 pub fn discover_inputs(
266 &self,
267 dep_info: &RustcDepInfo,
268 working_dir: &Path,
269 ) -> Result<DiscoveredInputs, BypassReason> {
270 if !working_dir.is_absolute() {
271 return Err(BypassReason::RelativeWorkingDirectory(
272 working_dir.to_path_buf(),
273 ));
274 }
275 let working_dir = normalize_components(working_dir);
276 let paths = dep_info
277 .files
278 .iter()
279 .chain(&self.required_inputs)
280 .map(|path| {
281 let absolute = if path.is_absolute() {
282 path.to_path_buf()
283 } else {
284 working_dir.join(path)
285 };
286 normalize_components(&absolute)
287 })
288 .collect::<BTreeSet<_>>();
289 DiscoveredInputs::from_paths(&working_dir, paths, dep_info.environment.clone())
290 }
291}
292
293fn render_argument(argument: &Argument) -> Result<OsString, BypassReason> {
294 let rendered = match argument {
295 Argument::Plain(value) => value.clone(),
296 Argument::Path { flag, path } => format!(
297 "{flag}={}",
298 path.to_str()
299 .ok_or_else(|| BypassReason::NonUtf8Path(path.clone()))?
300 ),
301 Argument::SearchPath { kind, path } => format!(
302 "-L{kind}={}",
303 path.to_str()
304 .ok_or_else(|| BypassReason::NonUtf8Path(path.clone()))?
305 ),
306 Argument::Extern { name, path } => match path {
307 Some(path) => format!(
308 "--extern={name}={}",
309 path.to_str()
310 .ok_or_else(|| BypassReason::NonUtf8Path(path.clone()))?
311 ),
312 None => format!("--extern={name}"),
313 },
314 Argument::Emit(_) => unreachable!("emit arguments are removed before rendering"),
315 Argument::RemapPath { from, to } => format!(
316 "--remap-path-prefix={}={to}",
317 from.to_str()
318 .ok_or_else(|| BypassReason::NonUtf8Path(from.clone()))?
319 ),
320 };
321 Ok(rendered.into())
322}
323
324fn unescape_environment(value: &str) -> Result<String, BypassReason> {
325 let mut output = String::with_capacity(value.len());
326 let mut characters = value.chars();
327 while let Some(character) = characters.next() {
328 if character != '\\' {
329 output.push(character);
330 continue;
331 }
332 match characters.next() {
333 Some('\\') => output.push('\\'),
334 Some('n') => output.push('\n'),
335 Some('r') => output.push('\r'),
336 Some(character) => {
337 return Err(BypassReason::MalformedDepInfo(format!(
338 "unknown environment escape \\{character}"
339 )));
340 }
341 None => {
342 return Err(BypassReason::MalformedDepInfo(
343 "environment input ends with an unterminated escape".into(),
344 ));
345 }
346 }
347 }
348 Ok(output)
349}
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354 use std::process::Command;
355
356 fn args(values: &[&str]) -> Vec<OsString> {
357 values.iter().map(OsString::from).collect()
358 }
359
360 #[test]
361 fn parses_files_spaces_and_environment_records() {
362 let parsed = RustcDepInfo::parse(
363 "target/lib.rlib: src/lib.rs src/a\\ file.rs generated.rs\n\
364 src/lib.rs:\n\
365 # env-dep:SET=value\\nnext\n\
366 # env-dep:UNSET\n\
367 # env-dep:SLASH=a\\\\b\n",
368 )
369 .unwrap();
370 assert_eq!(
371 parsed.files,
372 vec![
373 PathBuf::from("generated.rs"),
374 PathBuf::from("src/a file.rs"),
375 PathBuf::from("src/lib.rs"),
376 ]
377 );
378 assert_eq!(parsed.environment["SET"], Some("value\nnext".into()));
379 assert_eq!(parsed.environment["UNSET"], None);
380 assert_eq!(parsed.environment["SLASH"], Some(r"a\b".into()));
381 }
382
383 #[test]
384 fn malformed_dep_info_bypasses_caching() {
385 for contents in [
386 "",
387 "target: ",
388 "target: src/trailing\\\n",
389 "target: src/lib.rs\n# env-dep:NAME=bad\\q\n",
390 ] {
391 assert!(RustcDepInfo::parse(contents).is_err(), "{contents:?}");
392 }
393 }
394
395 #[test]
396 fn discovery_command_removes_real_outputs() {
397 let invocation = RustcInvocation::parse(&args(&[
398 "--crate-name=widget",
399 "--crate-type=lib",
400 "--emit=dep-info,metadata,link",
401 "--out-dir=target/debug/deps",
402 "-o",
403 "target/debug/libwidget.rlib",
404 "src/lib.rs",
405 ]))
406 .unwrap();
407 let output = if cfg!(windows) {
408 PathBuf::from(r"C:\tmp\mise cache\inputs.d")
409 } else {
410 PathBuf::from("/tmp/mise cache/inputs.d")
411 };
412 let command = invocation.dep_info_command(&output).unwrap();
413 let arguments = command
414 .arguments()
415 .iter()
416 .map(|value| value.to_string_lossy())
417 .collect::<Vec<_>>();
418 assert_eq!(
419 arguments,
420 vec![
421 "--crate-name=widget",
422 "--crate-type=lib",
423 &format!("--emit=dep-info={}", output.display()),
424 "src/lib.rs",
425 ]
426 );
427 }
428
429 #[test]
430 fn discovery_hashes_externs_and_custom_targets() {
431 let directory = tempfile::tempdir().unwrap();
432 let root = directory.path();
433 let source = root.join("src/lib.rs");
434 let external = root.join("target/libdependency.rlib");
435 let target = root.join("targets/custom.json");
436 std::fs::create_dir_all(source.parent().unwrap()).unwrap();
437 std::fs::create_dir_all(external.parent().unwrap()).unwrap();
438 std::fs::create_dir_all(target.parent().unwrap()).unwrap();
439 std::fs::write(&source, "pub fn library() {}\n").unwrap();
440 std::fs::write(&external, "dependency artifact\n").unwrap();
441 std::fs::write(&target, "{}\n").unwrap();
442
443 let invocation = RustcInvocation::parse(&[
444 "--crate-name=widget".into(),
445 "--crate-type=lib".into(),
446 "--emit=metadata".into(),
447 format!("--extern=dependency={}", external.display()).into(),
448 format!("--target={}", target.display()).into(),
449 source.clone().into_os_string(),
450 ])
451 .unwrap();
452 let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
453 let discovered = invocation.discover_inputs(&dep_info, root).unwrap();
454 assert_eq!(discovered.inputs.len(), 3);
455
456 std::fs::remove_file(&external).unwrap();
457 assert!(matches!(
458 invocation.discover_inputs(&dep_info, root),
459 Err(BypassReason::InputRead { path, .. }) if path == external
460 ));
461 }
462
463 #[test]
464 fn discovery_rejects_inputs_modified_during_compilation() {
465 let directory = tempfile::tempdir().unwrap();
466 let source = directory.path().join("lib.rs");
467 std::fs::write(&source, "pub fn library() {}\n").unwrap();
468 let invocation = RustcInvocation::parse(&[
469 "--crate-name=widget".into(),
470 "--crate-type=lib".into(),
471 "--emit=dep-info,metadata".into(),
472 source.clone().into_os_string(),
473 ])
474 .unwrap();
475 let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
476 let discovered = invocation
477 .discover_inputs(&dep_info, directory.path())
478 .unwrap();
479 let modified = std::fs::metadata(&source).unwrap().modified().unwrap();
480
481 assert_eq!(
482 discovered.verify_not_modified_since(modified),
483 Err(BypassReason::InputModifiedDuringCompilation(source))
484 );
485 }
486
487 #[test]
488 fn discovery_resolves_parent_components_against_the_working_directory() {
489 let directory = tempfile::tempdir().unwrap();
490 let root = directory.path().join("project");
491 let shared = directory.path().join("shared.rs");
492 std::fs::create_dir(&root).unwrap();
493 std::fs::write(&shared, "pub fn shared() {}\n").unwrap();
494
495 let invocation = RustcInvocation::parse(&args(&[
496 "--crate-name=widget",
497 "--crate-type=lib",
498 "--emit=metadata",
499 "../shared.rs",
500 ]))
501 .unwrap();
502 let dep_info = RustcDepInfo::parse("output: ../shared.rs\n").unwrap();
503 let discovered = invocation.discover_inputs(&dep_info, &root).unwrap();
504
505 assert_eq!(discovered.inputs.len(), 1);
506 assert_eq!(discovered.inputs[0].path, shared);
507 }
508
509 #[test]
510 fn rustc_dep_info_round_trip_discovers_real_inputs() {
511 let directory = tempfile::tempdir().unwrap();
512 let root = directory.path();
513 std::fs::write(
514 root.join("lib.rs"),
515 "mod child; const _: &str = include_str!(\"data file.txt\"); \
516 const _: &str = env!(\"MISE_CACHE_DISCOVERY_TEST\"); \
517 const _: Option<&str> = option_env!(\"MISE_CACHE_DISCOVERY_UNSET\");",
518 )
519 .unwrap();
520 std::fs::write(root.join("child.rs"), "pub fn child() {}\n").unwrap();
521 std::fs::write(root.join("data file.txt"), "included\n").unwrap();
522
523 let invocation = RustcInvocation::parse(&args(&[
524 "--crate-name=mise_cache_discovery_test",
525 "--crate-type=lib",
526 "--emit=metadata,link",
527 "lib.rs",
528 ]))
529 .unwrap();
530 let dep_info_path = root.join("discovery inputs.d");
531 let discovery_command = invocation.dep_info_command(&dep_info_path).unwrap();
532 let rustc = std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into());
533 let output = match Command::new(rustc)
534 .args(discovery_command.arguments())
535 .current_dir(root)
536 .env("MISE_CACHE_DISCOVERY_TEST", "observed")
537 .env_remove("MISE_CACHE_DISCOVERY_UNSET")
538 .output()
539 {
540 Ok(output) => output,
541 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return,
542 Err(error) => panic!("failed to execute rustc: {error}"),
543 };
544 assert!(
545 output.status.success(),
546 "{}",
547 String::from_utf8_lossy(&output.stderr)
548 );
549 let parsed = RustcDepInfo::read(&dep_info_path).unwrap();
550 let discovered = invocation.discover_inputs(&parsed, root).unwrap();
551 assert_eq!(
552 discovered.environment["MISE_CACHE_DISCOVERY_TEST"],
553 Some("observed".into())
554 );
555 assert_eq!(discovered.environment["MISE_CACHE_DISCOVERY_UNSET"], None);
556 assert_eq!(discovered.inputs.len(), 3);
557 assert!(
558 discovered
559 .inputs
560 .iter()
561 .all(|input| input.digest.algorithm == "blake3")
562 );
563 let mut context = ActionContext {
564 compiler: crate::CompilerIdentity {
565 toolchain: "core:rust@test".into(),
566 rustc_version: "test".into(),
567 host: std::env::consts::ARCH.into(),
568 },
569 working_dir: root.to_path_buf(),
570 path_mappings: vec![crate::PathMapping::new(root, "workspace")],
571 environment: BTreeMap::new(),
572 inputs: Vec::new(),
573 };
574 discovered.clone().apply_to(&mut context).unwrap();
575 let action = invocation.action(context).unwrap();
576 assert!(
577 String::from_utf8(action.bytes)
578 .unwrap()
579 .contains(r#""MISE_CACHE_DISCOVERY_TEST":"observed""#)
580 );
581 discovered.verify().unwrap();
582 std::fs::write(root.join("child.rs"), "pub fn changed() {}\n").unwrap();
583 assert_eq!(
584 discovered.verify(),
585 Err(BypassReason::InputChanged(root.join("child.rs")))
586 );
587 }
588}