Skip to main content

dodot_lib/handlers/
install.rs

1//! Install handler — runs setup scripts with checksum-based sentinel tracking.
2
3use std::io::Read;
4use std::path::Path;
5
6use sha2::{Digest, Sha256};
7
8use crate::datastore::DataStore;
9use crate::fs::Fs;
10use crate::handlers::{Handler, HandlerCategory, HandlerConfig, HandlerStatus, HANDLER_INSTALL};
11use crate::operations::HandlerIntent;
12use crate::paths::Pather;
13use crate::rules::RuleMatch;
14use crate::Result;
15
16pub struct InstallHandler<'a> {
17    fs: &'a dyn Fs,
18}
19
20impl<'a> InstallHandler<'a> {
21    pub fn new(fs: &'a dyn Fs) -> Self {
22        Self { fs }
23    }
24}
25
26impl Handler for InstallHandler<'_> {
27    fn name(&self) -> &str {
28        HANDLER_INSTALL
29    }
30
31    fn category(&self) -> HandlerCategory {
32        HandlerCategory::CodeExecution
33    }
34
35    fn to_intents(
36        &self,
37        matches: &[RuleMatch],
38        _config: &HandlerConfig,
39        _paths: &dyn Pather,
40        _fs: &dyn Fs,
41    ) -> Result<Vec<HandlerIntent>> {
42        let mut intents = Vec::new();
43
44        for m in matches {
45            if m.is_dir {
46                continue;
47            }
48
49            let checksum = file_checksum(self.fs, &m.absolute_path)?;
50            let filename = m
51                .relative_path
52                .file_name()
53                .unwrap_or_default()
54                .to_string_lossy();
55            let sentinel = format!("{filename}-{checksum}");
56
57            intents.push(HandlerIntent::Run {
58                pack: m.pack.clone(),
59                handler: HANDLER_INSTALL.into(),
60                executable: "bash".into(),
61                arguments: vec!["--".into(), m.absolute_path.to_string_lossy().into_owned()],
62                sentinel,
63            });
64        }
65
66        Ok(intents)
67    }
68
69    fn check_status(
70        &self,
71        file: &Path,
72        pack: &str,
73        datastore: &dyn DataStore,
74    ) -> Result<HandlerStatus> {
75        let checksum = file_checksum(self.fs, file)?;
76        let filename = file.file_name().unwrap_or_default().to_string_lossy();
77        let sentinel = format!("{filename}-{checksum}");
78        let has_sentinel = datastore.has_sentinel(pack, HANDLER_INSTALL, &sentinel)?;
79
80        Ok(HandlerStatus {
81            file: file.to_string_lossy().into_owned(),
82            handler: HANDLER_INSTALL.into(),
83            deployed: has_sentinel,
84            message: if has_sentinel {
85                "installed".into()
86            } else {
87                "never run".into()
88            },
89        })
90    }
91}
92
93/// Compute a short SHA-256 hex digest of a file's contents.
94fn file_checksum(fs: &dyn Fs, path: &Path) -> Result<String> {
95    let mut reader = fs.open_read(path)?;
96    let mut hasher = Sha256::new();
97    let mut buf = [0u8; 8192];
98    loop {
99        let n = reader.read(&mut buf).map_err(|e| crate::DodotError::Fs {
100            path: path.to_path_buf(),
101            source: e,
102        })?;
103        if n == 0 {
104            break;
105        }
106        hasher.update(&buf[..n]);
107    }
108    let hash = hasher.finalize();
109    // Use first 8 bytes (16 hex chars) for a short but unique sentinel
110    Ok(hex::encode(&hash[..8]))
111}
112
113/// Minimal hex encoding (avoids pulling in the `hex` crate).
114mod hex {
115    pub fn encode(bytes: &[u8]) -> String {
116        bytes.iter().map(|b| format!("{b:02x}")).collect()
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use crate::testing::TempEnvironment;
124
125    #[test]
126    fn checksum_is_deterministic() {
127        let env = TempEnvironment::builder()
128            .pack("test")
129            .file("install.sh", "#!/bin/sh\necho hello")
130            .done()
131            .build();
132
133        let path = env.dotfiles_root.join("test/install.sh");
134        let c1 = file_checksum(env.fs.as_ref(), &path).unwrap();
135        let c2 = file_checksum(env.fs.as_ref(), &path).unwrap();
136        assert_eq!(c1, c2);
137        assert_eq!(c1.len(), 16); // 8 bytes = 16 hex chars
138    }
139
140    #[test]
141    fn checksum_changes_with_content() {
142        let env = TempEnvironment::builder()
143            .pack("test")
144            .file("a.sh", "version 1")
145            .file("b.sh", "version 2")
146            .done()
147            .build();
148
149        let ca = file_checksum(env.fs.as_ref(), &env.dotfiles_root.join("test/a.sh")).unwrap();
150        let cb = file_checksum(env.fs.as_ref(), &env.dotfiles_root.join("test/b.sh")).unwrap();
151        assert_ne!(ca, cb);
152    }
153
154    #[test]
155    fn to_intents_produces_run_with_sentinel() {
156        let env = TempEnvironment::builder()
157            .pack("vim")
158            .file("install.sh", "#!/bin/sh\nsetup")
159            .done()
160            .build();
161
162        let handler = InstallHandler::new(env.fs.as_ref());
163        let matches = vec![crate::rules::RuleMatch {
164            relative_path: "install.sh".into(),
165            absolute_path: env.dotfiles_root.join("vim/install.sh"),
166            pack: "vim".into(),
167            handler: "install".into(),
168            is_dir: false,
169            options: std::collections::HashMap::new(),
170            preprocessor_source: None,
171        }];
172
173        let pather = crate::paths::XdgPather::builder()
174            .home(&env.home)
175            .dotfiles_root(&env.dotfiles_root)
176            .build()
177            .unwrap();
178
179        let intents = handler
180            .to_intents(
181                &matches,
182                &HandlerConfig::default(),
183                &pather,
184                env.fs.as_ref(),
185            )
186            .unwrap();
187
188        assert_eq!(intents.len(), 1);
189        match &intents[0] {
190            HandlerIntent::Run {
191                executable,
192                arguments,
193                sentinel,
194                ..
195            } => {
196                assert_eq!(executable, "bash");
197                assert_eq!(arguments[0], "--");
198                assert!(arguments[1].contains("install.sh"));
199                assert!(sentinel.starts_with("install.sh-"));
200                assert_eq!(sentinel.len(), "install.sh-".len() + 16);
201            }
202            other => panic!("expected Run, got {other:?}"),
203        }
204    }
205}