Skip to main content

dodot_lib/handlers/
install.rs

1//! Install handler — runs setup scripts with checksum-based sentinel tracking.
2//!
3//! # Interpreter selection
4//!
5//! The interpreter is chosen from the script's file extension rather than
6//! from the user's login shell. This keeps script execution predictable:
7//! a script runs in its own subprocess with a fresh environment, so the
8//! user's interactive shell (aliases, functions, options) is irrelevant
9//! to how the script behaves — only the interpreter is.
10//!
11//! - `.sh`, `.bash`, or unknown extension → `bash`
12//! - `.zsh` → `zsh`
13//!
14//! The extension is the contract the pack author declares. A script named
15//! `install.zsh` announces that it uses zsh-specific syntax; invoking it
16//! with bash would be incorrect. A script named `install.sh` announces
17//! portability and should work anywhere `bash` is available.
18
19use std::io::Read;
20use std::path::Path;
21
22use sha2::{Digest, Sha256};
23
24use crate::datastore::DataStore;
25use crate::fs::Fs;
26use crate::handlers::{ExecutionPhase, Handler, HandlerConfig, HandlerStatus, HANDLER_INSTALL};
27use crate::operations::HandlerIntent;
28use crate::paths::Pather;
29use crate::rules::RuleMatch;
30use crate::Result;
31
32pub struct InstallHandler<'a> {
33    fs: &'a dyn Fs,
34}
35
36impl<'a> InstallHandler<'a> {
37    pub fn new(fs: &'a dyn Fs) -> Self {
38        Self { fs }
39    }
40}
41
42impl Handler for InstallHandler<'_> {
43    fn name(&self) -> &str {
44        HANDLER_INSTALL
45    }
46
47    fn phase(&self) -> ExecutionPhase {
48        ExecutionPhase::Setup
49    }
50
51    fn to_intents(
52        &self,
53        matches: &[RuleMatch],
54        _config: &HandlerConfig,
55        _paths: &dyn Pather,
56        _fs: &dyn Fs,
57    ) -> Result<Vec<HandlerIntent>> {
58        let mut intents = Vec::new();
59
60        for m in matches {
61            if m.is_dir {
62                continue;
63            }
64
65            let checksum = file_checksum(self.fs, &m.absolute_path)?;
66            let filename = m
67                .relative_path
68                .file_name()
69                .unwrap_or_default()
70                .to_string_lossy();
71            let sentinel = format!("{filename}-{checksum}");
72
73            intents.push(HandlerIntent::Run {
74                pack: m.pack.clone(),
75                handler: HANDLER_INSTALL.into(),
76                executable: interpreter_for(&m.absolute_path).into(),
77                arguments: vec!["--".into(), m.absolute_path.to_string_lossy().into_owned()],
78                sentinel,
79            });
80        }
81
82        Ok(intents)
83    }
84
85    fn check_status(
86        &self,
87        file: &Path,
88        pack: &str,
89        datastore: &dyn DataStore,
90    ) -> Result<HandlerStatus> {
91        let checksum = file_checksum(self.fs, file)?;
92        let filename = file.file_name().unwrap_or_default().to_string_lossy();
93        let sentinel = format!("{filename}-{checksum}");
94        let has_sentinel = datastore.has_sentinel(pack, HANDLER_INSTALL, &sentinel)?;
95
96        Ok(HandlerStatus {
97            file: file.to_string_lossy().into_owned(),
98            handler: HANDLER_INSTALL.into(),
99            deployed: has_sentinel,
100            message: if has_sentinel {
101                "installed".into()
102            } else {
103                "never run".into()
104            },
105        })
106    }
107}
108
109/// Pick the interpreter for an install script based on its extension.
110///
111/// Module-level docs explain why extension — not the user's login shell —
112/// is the right signal.
113fn interpreter_for(path: &Path) -> &'static str {
114    match path.extension().and_then(|e| e.to_str()) {
115        Some("zsh") => "zsh",
116        _ => "bash",
117    }
118}
119
120/// Compute a short SHA-256 hex digest of a file's contents.
121fn file_checksum(fs: &dyn Fs, path: &Path) -> Result<String> {
122    let mut reader = fs.open_read(path)?;
123    let mut hasher = Sha256::new();
124    let mut buf = [0u8; 8192];
125    loop {
126        let n = reader.read(&mut buf).map_err(|e| crate::DodotError::Fs {
127            path: path.to_path_buf(),
128            source: e,
129        })?;
130        if n == 0 {
131            break;
132        }
133        hasher.update(&buf[..n]);
134    }
135    let hash = hasher.finalize();
136    // Use first 8 bytes (16 hex chars) for a short but unique sentinel
137    Ok(hex::encode(&hash[..8]))
138}
139
140/// Minimal hex encoding (avoids pulling in the `hex` crate).
141mod hex {
142    pub fn encode(bytes: &[u8]) -> String {
143        bytes.iter().map(|b| format!("{b:02x}")).collect()
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use crate::testing::TempEnvironment;
151
152    #[test]
153    fn checksum_is_deterministic() {
154        let env = TempEnvironment::builder()
155            .pack("test")
156            .file("install.sh", "#!/bin/sh\necho hello")
157            .done()
158            .build();
159
160        let path = env.dotfiles_root.join("test/install.sh");
161        let c1 = file_checksum(env.fs.as_ref(), &path).unwrap();
162        let c2 = file_checksum(env.fs.as_ref(), &path).unwrap();
163        assert_eq!(c1, c2);
164        assert_eq!(c1.len(), 16); // 8 bytes = 16 hex chars
165    }
166
167    #[test]
168    fn checksum_changes_with_content() {
169        let env = TempEnvironment::builder()
170            .pack("test")
171            .file("a.sh", "version 1")
172            .file("b.sh", "version 2")
173            .done()
174            .build();
175
176        let ca = file_checksum(env.fs.as_ref(), &env.dotfiles_root.join("test/a.sh")).unwrap();
177        let cb = file_checksum(env.fs.as_ref(), &env.dotfiles_root.join("test/b.sh")).unwrap();
178        assert_ne!(ca, cb);
179    }
180
181    #[test]
182    fn to_intents_produces_run_with_sentinel() {
183        let env = TempEnvironment::builder()
184            .pack("vim")
185            .file("install.sh", "#!/bin/sh\nsetup")
186            .done()
187            .build();
188
189        let handler = InstallHandler::new(env.fs.as_ref());
190        let matches = vec![crate::rules::RuleMatch {
191            relative_path: "install.sh".into(),
192            absolute_path: env.dotfiles_root.join("vim/install.sh"),
193            pack: "vim".into(),
194            handler: "install".into(),
195            is_dir: false,
196            options: std::collections::HashMap::new(),
197            preprocessor_source: None,
198        }];
199
200        let pather = crate::paths::XdgPather::builder()
201            .home(&env.home)
202            .dotfiles_root(&env.dotfiles_root)
203            .build()
204            .unwrap();
205
206        let intents = handler
207            .to_intents(
208                &matches,
209                &HandlerConfig::default(),
210                &pather,
211                env.fs.as_ref(),
212            )
213            .unwrap();
214
215        assert_eq!(intents.len(), 1);
216        match &intents[0] {
217            HandlerIntent::Run {
218                executable,
219                arguments,
220                sentinel,
221                ..
222            } => {
223                assert_eq!(executable, "bash");
224                assert_eq!(arguments[0], "--");
225                assert!(arguments[1].contains("install.sh"));
226                assert!(sentinel.starts_with("install.sh-"));
227                assert_eq!(sentinel.len(), "install.sh-".len() + 16);
228            }
229            other => panic!("expected Run, got {other:?}"),
230        }
231    }
232
233    #[test]
234    fn interpreter_for_selects_by_extension() {
235        assert_eq!(interpreter_for(Path::new("install.sh")), "bash");
236        assert_eq!(interpreter_for(Path::new("install.bash")), "bash");
237        assert_eq!(interpreter_for(Path::new("install.zsh")), "zsh");
238        // Unknown / missing extension falls back to bash.
239        assert_eq!(interpreter_for(Path::new("install")), "bash");
240        assert_eq!(interpreter_for(Path::new("install.ksh")), "bash");
241        // Path components don't interfere with extension lookup.
242        assert_eq!(interpreter_for(Path::new("/a/b/install.zsh")), "zsh");
243    }
244
245    #[test]
246    fn to_intents_picks_interpreter_per_script() {
247        let env = TempEnvironment::builder()
248            .pack("vim")
249            .file("install.sh", "echo sh")
250            .file("install.bash", "echo bash")
251            .file("install.zsh", "echo zsh")
252            .done()
253            .build();
254
255        let handler = InstallHandler::new(env.fs.as_ref());
256        let make_match = |name: &str| crate::rules::RuleMatch {
257            relative_path: name.into(),
258            absolute_path: env.dotfiles_root.join(format!("vim/{name}")),
259            pack: "vim".into(),
260            handler: "install".into(),
261            is_dir: false,
262            options: std::collections::HashMap::new(),
263            preprocessor_source: None,
264        };
265        let matches = vec![
266            make_match("install.sh"),
267            make_match("install.bash"),
268            make_match("install.zsh"),
269        ];
270
271        let pather = crate::paths::XdgPather::builder()
272            .home(&env.home)
273            .dotfiles_root(&env.dotfiles_root)
274            .build()
275            .unwrap();
276
277        let intents = handler
278            .to_intents(
279                &matches,
280                &HandlerConfig::default(),
281                &pather,
282                env.fs.as_ref(),
283            )
284            .unwrap();
285
286        let chosen: Vec<(String, String)> = intents
287            .iter()
288            .map(|i| match i {
289                HandlerIntent::Run {
290                    executable,
291                    arguments,
292                    ..
293                } => (
294                    executable.clone(),
295                    arguments
296                        .last()
297                        .cloned()
298                        .and_then(|p| {
299                            std::path::Path::new(&p)
300                                .file_name()
301                                .map(|n| n.to_string_lossy().into_owned())
302                        })
303                        .unwrap_or_default(),
304                ),
305                other => panic!("expected Run, got {other:?}"),
306            })
307            .collect();
308
309        assert!(chosen.contains(&("bash".into(), "install.sh".into())));
310        assert!(chosen.contains(&("bash".into(), "install.bash".into())));
311        assert!(chosen.contains(&("zsh".into(), "install.zsh".into())));
312    }
313}