Skip to main content

dodot_lib/preprocessing/
gpg.rs

1//! `gpg` whole-file preprocessor — decrypts `*.gpg` (and optionally
2//! `*.asc`) files at deploy time.
3//!
4//! Same shape as the age preprocessor: matches the configured
5//! extensions, runs `gpg --decrypt --quiet --batch <source>`,
6//! captures plaintext on stdout, and emits an [`ExpandedFile`]
7//! with `deploy_mode = Some(0o600)` per `secrets.lex` §4.3.
8//! `TransformType::Opaque` — no reverse path.
9//!
10//! Auth model differs from age: gpg picks up its identity from
11//! `gpg-agent` rather than an explicit identity-file argument. For
12//! a passphrase-protected key, the agent prompts (or pulls cached
13//! credentials); for a YubiKey-backed key, the smartcard daemon
14//! handles it. dodot doesn't introspect any of that — `--batch`
15//! makes the call non-interactive at dodot's end so we don't block
16//! a `dodot up` on a TTY-only prompt; if the agent isn't ready,
17//! `gpg` exits with a clear "gpg-agent" diagnostic which we
18//! surface.
19//!
20//! See `secrets.lex` §4.1–§4.3 and `preprocessing-pipeline.lex`
21//! §2.3 (Opaque transform semantics).
22
23use 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
31/// `gpg` decryption preprocessor. Constructed from
32/// `[preprocessor.gpg]` config + the shared `CommandRunner`.
33///
34/// Configurable extensions (default `["gpg", "asc"]`) cover both
35/// the binary-armored form (`.gpg`) and the ASCII-armored form
36/// (`.asc`); the same `gpg --decrypt` call handles both.
37pub 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        // Default to `["gpg"]` only. `.asc` is conventionally used
53        // for armored public keys / detached signatures, not
54        // encrypted payloads — opt in via config when your repo
55        // bucks that convention.
56        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        // `--batch` keeps gpg non-interactive at our end. `--quiet`
93        // suppresses the "encrypted with N MB key, ID ..." banner
94        // so stderr stays focused on real failures. The default
95        // homedir / agent socket is used; the user's normal gpg
96        // configuration applies.
97        //
98        // `run_bytes` (not `run`) so binary plaintext (PGP-encrypted
99        // tarballs, binary key material) round-trips verbatim. See
100        // the matching note in `age.rs::expand`.
101        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    // ── matches / stripped_name ─────────────────────────────────
247
248    #[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    // ── expand ──────────────────────────────────────────────────
265
266    #[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}