rumdl_lib/code_block_tools/
fingerprint.rs1use 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
24pub 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 .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 #[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}