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 _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
93fn 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 Ok(hex::encode(&hash[..8]))
111}
112
113mod 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); }
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}