Skip to main content

mise_cache_rustc/
dep_info.rs

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/// A side-effect-minimized rustc invocation that emits only dependency data.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct DepInfoCommand {
13    arguments: Vec<OsString>,
14    output: PathBuf,
15}
16
17impl DepInfoCommand {
18    /// Arguments for the real compiler, excluding the compiler executable.
19    pub fn arguments(&self) -> &[OsString] {
20        &self.arguments
21    }
22
23    /// Exact file the compiler must populate with dep-info.
24    pub fn output(&self) -> &Path {
25        &self.output
26    }
27}
28
29/// The source and environment inputs reported by rustc's dep-info output.
30#[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    /// Read and parse a dep-info file, treating missing or non-UTF-8 output as
38    /// an explicit cache bypass.
39    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    /// Parse rustc's Makefile-style dep-info format.
49    ///
50    /// This intentionally follows Cargo's parser contract: the first target
51    /// rule contains all source dependencies, spaces are escaped with a
52    /// trailing backslash on each token fragment, and `# env-dep:` records
53    /// contain the environment observed by `env!` and `option_env!`.
54    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/// A complete, content-addressed compiler input manifest.
127#[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    /// Reject inputs whose modification time overlaps the compiler invocation.
173    ///
174    /// Input contents are first hashed after rustc reports their paths. This
175    /// timestamp barrier prevents a post-compile write from being mistaken for
176    /// the contents that produced the artifact. `verify` closes the remaining
177    /// race after hashing.
178    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    /// Rehash every discovered file after compilation and before publication.
196    /// This closes the discovery/compile race by degrading changed inputs to a
197    /// cache miss rather than storing outputs beneath a stale action key.
198    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    /// Merge the manifest into an action context after verifying that both use
214    /// the same compiler working directory.
215    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    /// Replace the original output flags with a single explicit dep-info file.
236    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    /// Hash dep-info sources plus every direct compiler input already modeled
264    /// by the invocation (`--extern` artifacts and custom target specs).
265    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}