Skip to main content

rumdl_lib/code_block_tools/
fingerprint.rs

1//! What the lint tools a configuration names currently resolve to.
2//!
3//! A code-block tool's verdict depends on which binary runs, so a cached lint
4//! result is only valid for the binaries that produced it. The fingerprint
5//! records, for every binary a lint run could call, where it resolves on `PATH`
6//! and the identity of the file there (its target through any symlinks, its size
7//! and its modification time), or that it is missing. Installing, removing,
8//! upgrading or re-ordering a tool on `PATH` changes the fingerprint.
9//!
10//! It is a proxy, not a version: a wrapper that picks the real binary at run
11//! time (a mise or asdf shim, `npx`, `uvx`) keeps its identity when the version
12//! behind it changes.
13
14use std::collections::BTreeSet;
15use std::ffi::OsStr;
16use std::fmt::Write as _;
17use std::path::Path;
18use std::time::UNIX_EPOCH;
19
20use super::config::CodeBlockToolsConfig;
21use super::lookup;
22use super::registry::{ToolRegistry, ToolSlot};
23
24/// The fingerprint of every binary the `lint` slots of `config` could run.
25///
26/// Empty when code-block tools are disabled. Costs one `PATH` lookup and one
27/// `stat` per distinct binary, so it is computed once per configuration rather
28/// than once per file.
29pub fn lint_tools_fingerprint(config: &CodeBlockToolsConfig) -> String {
30    if !config.enabled {
31        return String::new();
32    }
33
34    let registry = ToolRegistry::new(config.tools.clone());
35    let binaries: BTreeSet<&str> = config
36        .languages
37        .values()
38        .filter(|language| language.enabled)
39        .flat_map(|language| &language.lint)
40        // A `rumdl` id is rumdl itself only in a markdown block and a user tool
41        // anywhere else, so it is fingerprinted like any other id: an extra
42        // lookup costs a `stat`, a missing one replays stale results.
43        .filter_map(|tool_id| registry.resolve(tool_id, ToolSlot::Lint))
44        .filter_map(|tool_def| tool_def.command.first().map(String::as_str))
45        .collect();
46
47    let search_path = std::env::var_os("PATH");
48    let mut fingerprint = String::new();
49    for binary in binaries {
50        let identity = lookup::resolve_program(OsStr::new(binary), search_path.as_deref())
51            .map_or_else(|| "missing".to_string(), |path| file_identity(&path));
52        let _ = writeln!(fingerprint, "{binary}\0{identity}");
53    }
54    fingerprint
55}
56
57fn file_identity(path: &Path) -> String {
58    let target = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
59    let Ok(metadata) = std::fs::metadata(&target) else {
60        return format!("{}\0unreadable", path.display());
61    };
62    let modified = metadata
63        .modified()
64        .ok()
65        .and_then(|time| time.duration_since(UNIX_EPOCH).ok())
66        .map_or_else(|| "unknown".to_string(), |age| age.as_nanos().to_string());
67    format!(
68        "{}\0{}\0{}\0{modified}",
69        path.display(),
70        target.display(),
71        metadata.len()
72    )
73}
74
75#[cfg(all(test, unix))]
76mod tests {
77    use super::*;
78    use crate::code_block_tools::config::{LanguageToolConfig, ToolDefinition};
79    use std::os::unix::fs::PermissionsExt;
80
81    fn config_with_lint_tool(command: &str) -> CodeBlockToolsConfig {
82        let mut config = CodeBlockToolsConfig {
83            enabled: true,
84            ..Default::default()
85        };
86        config.tools.insert(
87            "t".to_string(),
88            ToolDefinition {
89                command: vec![command.to_string()],
90                stdin: true,
91                stdout: true,
92                lint_args: vec![],
93                format_args: vec![],
94            },
95        );
96        config.languages.insert(
97            "json".to_string(),
98            LanguageToolConfig {
99                lint: vec!["t".to_string()],
100                ..Default::default()
101            },
102        );
103        config
104    }
105
106    fn write_executable(path: &Path, body: &str) {
107        std::fs::write(path, body).unwrap();
108        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
109    }
110
111    #[test]
112    fn disabled_tools_have_no_fingerprint() {
113        let mut config = config_with_lint_tool("/bin/sh");
114        config.enabled = false;
115        assert_eq!(lint_tools_fingerprint(&config), "");
116    }
117
118    #[test]
119    fn a_missing_binary_is_recorded_as_missing() {
120        let config = config_with_lint_tool("/nonexistent/rumdl-fingerprint-tool");
121        assert_eq!(
122            lint_tools_fingerprint(&config),
123            "/nonexistent/rumdl-fingerprint-tool\0missing\n"
124        );
125    }
126
127    #[test]
128    fn the_fingerprint_follows_the_binary() {
129        let dir = tempfile::tempdir().unwrap();
130        let tool = dir.path().join("tool");
131        let config = config_with_lint_tool(tool.to_str().unwrap());
132        let missing = lint_tools_fingerprint(&config);
133
134        write_executable(&tool, "#!/bin/sh\n");
135        let installed = lint_tools_fingerprint(&config);
136        assert_ne!(missing, installed);
137        assert_eq!(installed, lint_tools_fingerprint(&config), "not stable");
138
139        write_executable(&tool, "#!/bin/sh\nexit 0\n");
140        assert_ne!(installed, lint_tools_fingerprint(&config), "an upgrade went unnoticed");
141    }
142
143    #[test]
144    fn a_symlink_is_fingerprinted_by_its_target() {
145        let dir = tempfile::tempdir().unwrap();
146        let (a, b, link) = (dir.path().join("a"), dir.path().join("b"), dir.path().join("tool"));
147        write_executable(&a, "#!/bin/sh\n");
148        write_executable(&b, "#!/bin/sh\n");
149        let config = config_with_lint_tool(link.to_str().unwrap());
150
151        std::os::unix::fs::symlink(&a, &link).unwrap();
152        let to_a = lint_tools_fingerprint(&config);
153        std::fs::remove_file(&link).unwrap();
154        std::os::unix::fs::symlink(&b, &link).unwrap();
155
156        assert_ne!(to_a, lint_tools_fingerprint(&config));
157    }
158
159    /// Only a markdown block short-circuits a `rumdl` id to rumdl itself. For any
160    /// other language a user tool of that name is resolved and run like any other.
161    #[test]
162    fn a_user_tool_named_like_the_builtin_is_fingerprinted() {
163        let mut config = config_with_lint_tool("/nonexistent/custom-rumdl");
164        let tool = config.tools.remove("t").unwrap();
165        config.tools.insert("rumdl".to_string(), tool);
166        config.languages.get_mut("json").unwrap().lint = vec!["rumdl".to_string()];
167
168        assert!(
169            lint_tools_fingerprint(&config).contains("custom-rumdl"),
170            "{:?}",
171            lint_tools_fingerprint(&config)
172        );
173    }
174
175    #[test]
176    fn format_only_tools_are_not_fingerprinted() {
177        let mut config = config_with_lint_tool("/nonexistent/lint-tool");
178        config.languages.get_mut("json").unwrap().format = vec!["other".to_string()];
179        config.tools.insert(
180            "other".to_string(),
181            ToolDefinition {
182                command: vec!["/nonexistent/format-tool".to_string()],
183                stdin: true,
184                stdout: true,
185                lint_args: vec![],
186                format_args: vec![],
187            },
188        );
189        assert!(!lint_tools_fingerprint(&config).contains("format-tool"));
190    }
191}