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            // Sentinel hashing prefers in-memory rendered bytes when
66            // they're available (preprocessor-produced files); falls
67            // back to a disk read for plain on-disk files. The
68            // in-memory path is the §7.4 enabler — `dodot status`
69            // and `up --dry-run` need a correct sentinel for
70            // templated install scripts without writing the
71            // rendered file to disk. See issue #121.
72            //
73            // First-time-pack passive case: a templated `install.sh`
74            // with no baseline yet lands here as a placeholder match
75            // (no bytes, no file on disk). We can't compute a
76            // sentinel without rendering, and rendering is the §7.4
77            // violation we're refusing to do. Skip intent generation
78            // for this match — status / dry-run will report the file
79            // as pending via the symlink chain instead, and the next
80            // real `dodot up` plans the Run intent normally.
81            let checksum = match m.rendered_bytes.as_deref() {
82                Some(bytes) => file_checksum_bytes(bytes),
83                None => match self.fs.exists(&m.absolute_path) {
84                    true => file_checksum(self.fs, &m.absolute_path)?,
85                    false => {
86                        tracing::debug!(
87                            pack = %m.pack,
88                            file = %m.absolute_path.display(),
89                            "skipping install intent — no rendered bytes and no on-disk file \
90                             (first-time-pack passive placeholder)"
91                        );
92                        continue;
93                    }
94                },
95            };
96            let filename = m
97                .relative_path
98                .file_name()
99                .unwrap_or_default()
100                .to_string_lossy();
101            let sentinel = format!("{filename}-{checksum}");
102
103            intents.push(HandlerIntent::Run {
104                pack: m.pack.clone(),
105                handler: HANDLER_INSTALL.into(),
106                executable: interpreter_for(&m.absolute_path).into(),
107                arguments: vec!["--".into(), m.absolute_path.to_string_lossy().into_owned()],
108                sentinel,
109            });
110        }
111
112        Ok(intents)
113    }
114
115    fn check_status(
116        &self,
117        file: &Path,
118        pack: &str,
119        datastore: &dyn DataStore,
120    ) -> Result<HandlerStatus> {
121        let checksum = file_checksum(self.fs, file)?;
122        let filename = file.file_name().unwrap_or_default().to_string_lossy();
123        let sentinel = format!("{filename}-{checksum}");
124        let has_sentinel = datastore.has_sentinel(pack, HANDLER_INSTALL, &sentinel)?;
125
126        Ok(HandlerStatus {
127            file: file.to_string_lossy().into_owned(),
128            handler: HANDLER_INSTALL.into(),
129            deployed: has_sentinel,
130            message: if has_sentinel {
131                "installed".into()
132            } else {
133                "never run".into()
134            },
135        })
136    }
137}
138
139/// Pick the interpreter for an install script based on its extension.
140///
141/// Module-level docs explain why extension — not the user's login shell —
142/// is the right signal.
143fn interpreter_for(path: &Path) -> &'static str {
144    match path.extension().and_then(|e| e.to_str()) {
145        Some("zsh") => "zsh",
146        _ => "bash",
147    }
148}
149
150/// Compute a short SHA-256 hex digest of a file's contents.
151fn file_checksum(fs: &dyn Fs, path: &Path) -> Result<String> {
152    let mut reader = fs.open_read(path)?;
153    let mut hasher = Sha256::new();
154    let mut buf = [0u8; 8192];
155    loop {
156        let n = reader.read(&mut buf).map_err(|e| crate::DodotError::Fs {
157            path: path.to_path_buf(),
158            source: e,
159        })?;
160        if n == 0 {
161            break;
162        }
163        hasher.update(&buf[..n]);
164    }
165    let hash = hasher.finalize();
166    // Use first 8 bytes (16 hex chars) for a short but unique sentinel
167    Ok(hex::encode(&hash[..8]))
168}
169
170/// Same digest format as [`file_checksum`], but over an in-memory
171/// byte slice — used when the rendered content is available without
172/// a disk read.
173fn file_checksum_bytes(bytes: &[u8]) -> String {
174    let mut hasher = Sha256::new();
175    hasher.update(bytes);
176    let hash = hasher.finalize();
177    hex::encode(&hash[..8])
178}
179
180/// Minimal hex encoding (avoids pulling in the `hex` crate).
181mod hex {
182    pub fn encode(bytes: &[u8]) -> String {
183        bytes.iter().map(|b| format!("{b:02x}")).collect()
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use crate::testing::TempEnvironment;
191
192    #[test]
193    fn checksum_is_deterministic() {
194        let env = TempEnvironment::builder()
195            .pack("test")
196            .file("install.sh", "#!/bin/sh\necho hello")
197            .done()
198            .build();
199
200        let path = env.dotfiles_root.join("test/install.sh");
201        let c1 = file_checksum(env.fs.as_ref(), &path).unwrap();
202        let c2 = file_checksum(env.fs.as_ref(), &path).unwrap();
203        assert_eq!(c1, c2);
204        assert_eq!(c1.len(), 16); // 8 bytes = 16 hex chars
205    }
206
207    #[test]
208    fn checksum_changes_with_content() {
209        let env = TempEnvironment::builder()
210            .pack("test")
211            .file("a.sh", "version 1")
212            .file("b.sh", "version 2")
213            .done()
214            .build();
215
216        let ca = file_checksum(env.fs.as_ref(), &env.dotfiles_root.join("test/a.sh")).unwrap();
217        let cb = file_checksum(env.fs.as_ref(), &env.dotfiles_root.join("test/b.sh")).unwrap();
218        assert_ne!(ca, cb);
219    }
220
221    #[test]
222    fn to_intents_produces_run_with_sentinel() {
223        let env = TempEnvironment::builder()
224            .pack("vim")
225            .file("install.sh", "#!/bin/sh\nsetup")
226            .done()
227            .build();
228
229        let handler = InstallHandler::new(env.fs.as_ref());
230        let matches = vec![crate::rules::RuleMatch {
231            relative_path: "install.sh".into(),
232            absolute_path: env.dotfiles_root.join("vim/install.sh"),
233            pack: "vim".into(),
234            handler: "install".into(),
235            is_dir: false,
236            options: std::collections::HashMap::new(),
237            preprocessor_source: None,
238            rendered_bytes: None,
239        }];
240
241        let pather = crate::paths::XdgPather::builder()
242            .home(&env.home)
243            .dotfiles_root(&env.dotfiles_root)
244            .build()
245            .unwrap();
246
247        let intents = handler
248            .to_intents(
249                &matches,
250                &HandlerConfig::default(),
251                &pather,
252                env.fs.as_ref(),
253            )
254            .unwrap();
255
256        assert_eq!(intents.len(), 1);
257        match &intents[0] {
258            HandlerIntent::Run {
259                executable,
260                arguments,
261                sentinel,
262                ..
263            } => {
264                assert_eq!(executable, "bash");
265                assert_eq!(arguments[0], "--");
266                assert!(arguments[1].contains("install.sh"));
267                assert!(sentinel.starts_with("install.sh-"));
268                assert_eq!(sentinel.len(), "install.sh-".len() + 16);
269            }
270            other => panic!("expected Run, got {other:?}"),
271        }
272    }
273
274    #[test]
275    fn interpreter_for_selects_by_extension() {
276        assert_eq!(interpreter_for(Path::new("install.sh")), "bash");
277        assert_eq!(interpreter_for(Path::new("install.bash")), "bash");
278        assert_eq!(interpreter_for(Path::new("install.zsh")), "zsh");
279        // Unknown / missing extension falls back to bash.
280        assert_eq!(interpreter_for(Path::new("install")), "bash");
281        assert_eq!(interpreter_for(Path::new("install.ksh")), "bash");
282        // Path components don't interfere with extension lookup.
283        assert_eq!(interpreter_for(Path::new("/a/b/install.zsh")), "zsh");
284    }
285
286    #[test]
287    fn to_intents_picks_interpreter_per_script() {
288        let env = TempEnvironment::builder()
289            .pack("vim")
290            .file("install.sh", "echo sh")
291            .file("install.bash", "echo bash")
292            .file("install.zsh", "echo zsh")
293            .done()
294            .build();
295
296        let handler = InstallHandler::new(env.fs.as_ref());
297        let make_match = |name: &str| crate::rules::RuleMatch {
298            relative_path: name.into(),
299            absolute_path: env.dotfiles_root.join(format!("vim/{name}")),
300            pack: "vim".into(),
301            handler: "install".into(),
302            is_dir: false,
303            options: std::collections::HashMap::new(),
304            preprocessor_source: None,
305            rendered_bytes: None,
306        };
307        let matches = vec![
308            make_match("install.sh"),
309            make_match("install.bash"),
310            make_match("install.zsh"),
311        ];
312
313        let pather = crate::paths::XdgPather::builder()
314            .home(&env.home)
315            .dotfiles_root(&env.dotfiles_root)
316            .build()
317            .unwrap();
318
319        let intents = handler
320            .to_intents(
321                &matches,
322                &HandlerConfig::default(),
323                &pather,
324                env.fs.as_ref(),
325            )
326            .unwrap();
327
328        let chosen: Vec<(String, String)> = intents
329            .iter()
330            .map(|i| match i {
331                HandlerIntent::Run {
332                    executable,
333                    arguments,
334                    ..
335                } => (
336                    executable.clone(),
337                    arguments
338                        .last()
339                        .cloned()
340                        .and_then(|p| {
341                            std::path::Path::new(&p)
342                                .file_name()
343                                .map(|n| n.to_string_lossy().into_owned())
344                        })
345                        .unwrap_or_default(),
346                ),
347                other => panic!("expected Run, got {other:?}"),
348            })
349            .collect();
350
351        assert!(chosen.contains(&("bash".into(), "install.sh".into())));
352        assert!(chosen.contains(&("bash".into(), "install.bash".into())));
353        assert!(chosen.contains(&("zsh".into(), "install.zsh".into())));
354    }
355}