dodot_lib/handlers/
install.rs1use 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 ) -> Result<Vec<HandlerIntent>> {
41 let mut intents = Vec::new();
42
43 for m in matches {
44 if m.is_dir {
45 continue;
46 }
47
48 let checksum = file_checksum(self.fs, &m.absolute_path)?;
49 let filename = m
50 .relative_path
51 .file_name()
52 .unwrap_or_default()
53 .to_string_lossy();
54 let sentinel = format!("{filename}-{checksum}");
55
56 intents.push(HandlerIntent::Run {
57 pack: m.pack.clone(),
58 handler: HANDLER_INSTALL.into(),
59 executable: "bash".into(),
60 arguments: vec!["--".into(), m.absolute_path.to_string_lossy().into_owned()],
61 sentinel,
62 });
63 }
64
65 Ok(intents)
66 }
67
68 fn check_status(
69 &self,
70 file: &Path,
71 pack: &str,
72 datastore: &dyn DataStore,
73 ) -> Result<HandlerStatus> {
74 let checksum = file_checksum(self.fs, file)?;
75 let filename = file.file_name().unwrap_or_default().to_string_lossy();
76 let sentinel = format!("{filename}-{checksum}");
77 let has_sentinel = datastore.has_sentinel(pack, HANDLER_INSTALL, &sentinel)?;
78
79 Ok(HandlerStatus {
80 file: file.to_string_lossy().into_owned(),
81 handler: HANDLER_INSTALL.into(),
82 deployed: has_sentinel,
83 message: if has_sentinel {
84 "installed".into()
85 } else {
86 "never run".into()
87 },
88 })
89 }
90}
91
92fn file_checksum(fs: &dyn Fs, path: &Path) -> Result<String> {
94 let mut reader = fs.open_read(path)?;
95 let mut hasher = Sha256::new();
96 let mut buf = [0u8; 8192];
97 loop {
98 let n = reader.read(&mut buf).map_err(|e| crate::DodotError::Fs {
99 path: path.to_path_buf(),
100 source: e,
101 })?;
102 if n == 0 {
103 break;
104 }
105 hasher.update(&buf[..n]);
106 }
107 let hash = hasher.finalize();
108 Ok(hex::encode(&hash[..8]))
110}
111
112mod hex {
114 pub fn encode(bytes: &[u8]) -> String {
115 bytes.iter().map(|b| format!("{b:02x}")).collect()
116 }
117}
118
119#[cfg(test)]
120mod tests {
121 use super::*;
122 use crate::testing::TempEnvironment;
123
124 #[test]
125 fn checksum_is_deterministic() {
126 let env = TempEnvironment::builder()
127 .pack("test")
128 .file("install.sh", "#!/bin/sh\necho hello")
129 .done()
130 .build();
131
132 let path = env.dotfiles_root.join("test/install.sh");
133 let c1 = file_checksum(env.fs.as_ref(), &path).unwrap();
134 let c2 = file_checksum(env.fs.as_ref(), &path).unwrap();
135 assert_eq!(c1, c2);
136 assert_eq!(c1.len(), 16); }
138
139 #[test]
140 fn checksum_changes_with_content() {
141 let env = TempEnvironment::builder()
142 .pack("test")
143 .file("a.sh", "version 1")
144 .file("b.sh", "version 2")
145 .done()
146 .build();
147
148 let ca = file_checksum(env.fs.as_ref(), &env.dotfiles_root.join("test/a.sh")).unwrap();
149 let cb = file_checksum(env.fs.as_ref(), &env.dotfiles_root.join("test/b.sh")).unwrap();
150 assert_ne!(ca, cb);
151 }
152
153 #[test]
154 fn to_intents_produces_run_with_sentinel() {
155 let env = TempEnvironment::builder()
156 .pack("vim")
157 .file("install.sh", "#!/bin/sh\nsetup")
158 .done()
159 .build();
160
161 let handler = InstallHandler::new(env.fs.as_ref());
162 let matches = vec![crate::rules::RuleMatch {
163 relative_path: "install.sh".into(),
164 absolute_path: env.dotfiles_root.join("vim/install.sh"),
165 pack: "vim".into(),
166 handler: "install".into(),
167 is_dir: false,
168 options: std::collections::HashMap::new(),
169 }];
170
171 let pather = crate::paths::XdgPather::builder()
172 .home(&env.home)
173 .dotfiles_root(&env.dotfiles_root)
174 .build()
175 .unwrap();
176
177 let intents = handler
178 .to_intents(&matches, &HandlerConfig::default(), &pather)
179 .unwrap();
180
181 assert_eq!(intents.len(), 1);
182 match &intents[0] {
183 HandlerIntent::Run {
184 executable,
185 arguments,
186 sentinel,
187 ..
188 } => {
189 assert_eq!(executable, "bash");
190 assert_eq!(arguments[0], "--");
191 assert!(arguments[1].contains("install.sh"));
192 assert!(sentinel.starts_with("install.sh-"));
193 assert_eq!(sentinel.len(), "install.sh-".len() + 16);
194 }
195 other => panic!("expected Run, got {other:?}"),
196 }
197 }
198}