1use std::path::{Path, PathBuf};
24use std::sync::Arc;
25
26use crate::datastore::CommandRunner;
27use crate::fs::Fs;
28use crate::preprocessing::{ExpandedFile, Preprocessor, TransformType};
29use crate::{DodotError, Result};
30
31pub struct GpgPreprocessor {
38 runner: Arc<dyn CommandRunner>,
39 extensions: Vec<String>,
40}
41
42impl GpgPreprocessor {
43 pub fn new(runner: Arc<dyn CommandRunner>, extensions: Vec<String>) -> Self {
44 let extensions: Vec<String> = extensions
45 .into_iter()
46 .map(|e| e.trim_start_matches('.').to_string())
47 .collect();
48 Self { runner, extensions }
49 }
50
51 pub fn from_env(runner: Arc<dyn CommandRunner>) -> Self {
52 Self::new(runner, vec!["gpg".into()])
57 }
58}
59
60impl Preprocessor for GpgPreprocessor {
61 fn name(&self) -> &str {
62 "gpg"
63 }
64
65 fn transform_type(&self) -> TransformType {
66 TransformType::Opaque
67 }
68
69 fn matches_extension(&self, filename: &str) -> bool {
70 self.extensions.iter().any(|ext| {
71 filename
72 .strip_suffix(ext.as_str())
73 .is_some_and(|prefix| prefix.ends_with('.'))
74 })
75 }
76
77 fn stripped_name(&self, filename: &str) -> String {
78 self.extensions
79 .iter()
80 .filter_map(|ext| {
81 filename
82 .strip_suffix(ext.as_str())
83 .and_then(|prefix| prefix.strip_suffix('.'))
84 .map(|stripped| (ext.len(), stripped))
85 })
86 .max_by_key(|(len, _)| *len)
87 .map(|(_, stripped)| stripped.to_string())
88 .unwrap_or_else(|| filename.to_string())
89 }
90
91 fn expand(&self, source: &Path, _fs: &dyn Fs) -> Result<Vec<ExpandedFile>> {
92 let out = self.runner.run_bytes(
102 "gpg",
103 &[
104 "--decrypt".into(),
105 "--quiet".into(),
106 "--batch".into(),
107 source.to_string_lossy().to_string(),
108 ],
109 )?;
110 if out.exit_code != 0 {
111 let stderr = out.stderr.trim();
112 let msg = if stderr.contains("decryption failed") && stderr.contains("No secret key") {
113 format!(
114 "gpg: no secret key for `{}`. \
115 The recipient this file was encrypted to isn't in your \
116 keyring. Import the matching private key (`gpg --import`) \
117 or re-encrypt with `gpg --encrypt --recipient <id>`.",
118 source.display()
119 )
120 } else if stderr.contains("gpg-agent") || stderr.contains("agent_genkey failed") {
121 format!(
122 "gpg: gpg-agent isn't responsive for `{}`. \
123 Start it with `gpgconf --launch gpg-agent`, or check \
124 `~/.gnupg/gpg-agent.conf` and restart your session.",
125 source.display()
126 )
127 } else if stderr.contains("Bad session key") || stderr.contains("Bad passphrase") {
128 format!(
129 "gpg: bad passphrase / session key for `{}`. \
130 gpg's `--batch` mode does not prompt; cache the \
131 passphrase in gpg-agent first (e.g. by decrypting \
132 interactively once) and retry.",
133 source.display()
134 )
135 } else if stderr.contains("No such file") || stderr.contains("can't open") {
136 format!(
137 "gpg: source file `{}` not found or not readable.",
138 source.display()
139 )
140 } else if stderr.is_empty() {
141 format!(
142 "gpg decryption of `{}` exited {} (no diagnostic output)",
143 source.display(),
144 out.exit_code
145 )
146 } else {
147 format!(
148 "gpg decryption of `{}` failed (exit {}): {stderr}",
149 source.display(),
150 out.exit_code
151 )
152 };
153 return Err(DodotError::PreprocessorError {
154 preprocessor: "gpg".into(),
155 source_file: source.to_path_buf(),
156 message: msg,
157 });
158 }
159 let filename = source
160 .file_name()
161 .unwrap_or_default()
162 .to_string_lossy()
163 .into_owned();
164 let stripped = self.stripped_name(&filename);
165 Ok(vec![ExpandedFile {
166 relative_path: PathBuf::from(stripped),
167 content: out.stdout,
168 is_dir: false,
169 tracked_render: None,
170 context_hash: None,
171 secret_line_ranges: Vec::new(),
172 deploy_mode: Some(0o600),
173 }])
174 }
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180 use crate::datastore::CommandOutput;
181 use std::sync::Mutex;
182
183 type ScriptedResponse = (
184 String,
185 Vec<String>,
186 std::result::Result<CommandOutput, String>,
187 );
188
189 struct ScriptedRunner {
190 responses: Mutex<Vec<ScriptedResponse>>,
191 }
192 impl ScriptedRunner {
193 fn new() -> Self {
194 Self {
195 responses: Mutex::new(Vec::new()),
196 }
197 }
198 fn expect(
199 self,
200 exe: impl Into<String>,
201 args: Vec<String>,
202 response: std::result::Result<CommandOutput, String>,
203 ) -> Self {
204 self.responses
205 .lock()
206 .unwrap()
207 .push((exe.into(), args, response));
208 self
209 }
210 }
211 impl CommandRunner for ScriptedRunner {
212 fn run(&self, exe: &str, args: &[String]) -> Result<CommandOutput> {
213 let mut r = self.responses.lock().unwrap();
214 if r.is_empty() {
215 return Err(DodotError::Other(format!(
216 "ScriptedRunner: unexpected `{exe} {args:?}`"
217 )));
218 }
219 let (e, a, out) = r.remove(0);
220 assert_eq!(exe, e);
221 assert_eq!(args, a.as_slice());
222 out.map_err(DodotError::Other)
223 }
224 }
225 fn ok(stdout: &str) -> std::result::Result<CommandOutput, String> {
226 Ok(CommandOutput {
227 exit_code: 0,
228 stdout: stdout.into(),
229 stderr: String::new(),
230 })
231 }
232 fn err_out(exit: i32, stderr: &str) -> std::result::Result<CommandOutput, String> {
233 Ok(CommandOutput {
234 exit_code: exit,
235 stdout: String::new(),
236 stderr: stderr.into(),
237 })
238 }
239 fn make_pp(runner: Arc<dyn CommandRunner>) -> GpgPreprocessor {
240 GpgPreprocessor::new(runner, vec!["gpg".into(), "asc".into()])
241 }
242 fn null_fs() -> crate::fs::OsFs {
243 crate::fs::OsFs::new()
244 }
245
246 #[test]
249 fn matches_extension_handles_both_gpg_and_asc() {
250 let p = make_pp(Arc::new(ScriptedRunner::new()));
251 assert!(p.matches_extension("Brewfile.gpg"));
252 assert!(p.matches_extension("notes.txt.asc"));
253 assert!(!p.matches_extension("plain.txt"));
254 assert!(!p.matches_extension("foogpg"));
255 }
256
257 #[test]
258 fn stripped_name_drops_either_extension() {
259 let p = make_pp(Arc::new(ScriptedRunner::new()));
260 assert_eq!(p.stripped_name("Brewfile.gpg"), "Brewfile");
261 assert_eq!(p.stripped_name("notes.txt.asc"), "notes.txt");
262 }
263
264 #[test]
267 fn expand_invokes_gpg_with_decrypt_quiet_batch() {
268 let runner = Arc::new(ScriptedRunner::new().expect(
269 "gpg",
270 vec![
271 "--decrypt".into(),
272 "--quiet".into(),
273 "--batch".into(),
274 "/pack/Brewfile.gpg".into(),
275 ],
276 ok("brew \"ripgrep\"\n"),
277 ));
278 let p = make_pp(runner);
279 let out = p
280 .expand(Path::new("/pack/Brewfile.gpg"), &null_fs())
281 .unwrap();
282 assert_eq!(out.len(), 1);
283 assert_eq!(out[0].relative_path, PathBuf::from("Brewfile"));
284 assert_eq!(out[0].content, b"brew \"ripgrep\"\n");
285 assert_eq!(out[0].deploy_mode, Some(0o600));
286 assert!(out[0].tracked_render.is_none());
287 }
288
289 #[test]
290 fn expand_strips_asc_extension_when_used() {
291 let runner = Arc::new(ScriptedRunner::new().expect(
292 "gpg",
293 vec![
294 "--decrypt".into(),
295 "--quiet".into(),
296 "--batch".into(),
297 "/pack/notes.txt.asc".into(),
298 ],
299 ok("private notes\n"),
300 ));
301 let p = make_pp(runner);
302 let out = p
303 .expand(Path::new("/pack/notes.txt.asc"), &null_fs())
304 .unwrap();
305 assert_eq!(out[0].relative_path, PathBuf::from("notes.txt"));
306 }
307
308 #[test]
309 fn expand_maps_no_secret_key_to_keyring_diagnostic() {
310 let runner = Arc::new(ScriptedRunner::new().expect(
311 "gpg",
312 vec![
313 "--decrypt".into(),
314 "--quiet".into(),
315 "--batch".into(),
316 "/pack/x.gpg".into(),
317 ],
318 err_out(2, "gpg: decryption failed: No secret key"),
319 ));
320 let p = make_pp(runner);
321 let e = p
322 .expand(Path::new("/pack/x.gpg"), &null_fs())
323 .unwrap_err()
324 .to_string();
325 assert!(e.contains("no secret key"));
326 assert!(e.contains("gpg --import"));
327 }
328
329 #[test]
330 fn expand_maps_agent_failure_to_agent_diagnostic() {
331 let runner = Arc::new(ScriptedRunner::new().expect(
332 "gpg",
333 vec![
334 "--decrypt".into(),
335 "--quiet".into(),
336 "--batch".into(),
337 "/pack/x.gpg".into(),
338 ],
339 err_out(2, "gpg: agent_genkey failed: end of file"),
340 ));
341 let p = make_pp(runner);
342 let e = p
343 .expand(Path::new("/pack/x.gpg"), &null_fs())
344 .unwrap_err()
345 .to_string();
346 assert!(e.contains("gpg-agent"));
347 assert!(e.contains("gpgconf --launch"));
348 }
349
350 #[test]
351 fn expand_maps_bad_passphrase_to_batch_caching_hint() {
352 let runner = Arc::new(ScriptedRunner::new().expect(
353 "gpg",
354 vec![
355 "--decrypt".into(),
356 "--quiet".into(),
357 "--batch".into(),
358 "/pack/x.gpg".into(),
359 ],
360 err_out(2, "gpg: public key decryption failed: Bad passphrase"),
361 ));
362 let p = make_pp(runner);
363 let e = p
364 .expand(Path::new("/pack/x.gpg"), &null_fs())
365 .unwrap_err()
366 .to_string();
367 assert!(e.contains("bad passphrase"));
368 assert!(e.contains("--batch"));
369 assert!(e.contains("cache the passphrase"));
370 }
371
372 #[test]
373 fn expand_maps_missing_source_to_file_diagnostic() {
374 let runner = Arc::new(ScriptedRunner::new().expect(
375 "gpg",
376 vec![
377 "--decrypt".into(),
378 "--quiet".into(),
379 "--batch".into(),
380 "/pack/missing.gpg".into(),
381 ],
382 err_out(
383 2,
384 "gpg: can't open '/pack/missing.gpg': No such file or directory",
385 ),
386 ));
387 let p = make_pp(runner);
388 let e = p
389 .expand(Path::new("/pack/missing.gpg"), &null_fs())
390 .unwrap_err()
391 .to_string();
392 assert!(e.contains("source file"));
393 assert!(e.contains("not found"));
394 }
395
396 #[test]
397 fn expand_passes_unrecognized_stderr_through_with_command_context() {
398 let runner = Arc::new(ScriptedRunner::new().expect(
399 "gpg",
400 vec![
401 "--decrypt".into(),
402 "--quiet".into(),
403 "--batch".into(),
404 "/pack/x.gpg".into(),
405 ],
406 err_out(2, "gpg: weird internal failure"),
407 ));
408 let p = make_pp(runner);
409 let e = p
410 .expand(Path::new("/pack/x.gpg"), &null_fs())
411 .unwrap_err()
412 .to_string();
413 assert!(e.contains("weird internal failure"));
414 assert!(e.contains("gpg decryption"));
415 assert!(e.contains("exit 2"));
416 }
417
418 #[test]
419 fn expand_handles_empty_stderr_failure() {
420 let runner = Arc::new(ScriptedRunner::new().expect(
421 "gpg",
422 vec![
423 "--decrypt".into(),
424 "--quiet".into(),
425 "--batch".into(),
426 "/pack/x.gpg".into(),
427 ],
428 err_out(2, ""),
429 ));
430 let p = make_pp(runner);
431 let e = p
432 .expand(Path::new("/pack/x.gpg"), &null_fs())
433 .unwrap_err()
434 .to_string();
435 assert!(e.contains("exited 2"));
436 }
437}