Skip to main content

dodot_lib/preprocessing/
age.rs

1//! `age` whole-file preprocessor — decrypts `*.age` files at deploy
2//! time.
3//!
4//! Matches files ending in `.age`, runs `age --decrypt --identity
5//! <id_path> <source>`, and emits the plaintext as an
6//! [`ExpandedFile`] with `deploy_mode = Some(0o600)` so the pipeline
7//! chmods the rendered datastore file before the symlink lands at
8//! the user's home. No template expansion happens — this is a pure
9//! decrypt-and-emit operation.
10//!
11//! Reference flow (from `secrets.lex` §4.2):
12//!
13//!     1. Scan finds `ssh/id_ed25519.age`
14//!     2. AgePreprocessor strips `.age` → expanded filename `id_ed25519`
15//!     3. expand() shells out to age, captures plaintext
16//!     4. Pipeline writes the bytes to the datastore + chmods 0600
17//!     5. Symlink handler links it to `~/.ssh/id_ed25519`
18//!
19//! `age` reads its identity from the path passed via `--identity`.
20//! When the config doesn't set one explicitly, we fall back to
21//! `$AGE_IDENTITY` env var, then to `~/.config/age/identity.txt`
22//! (the conventional default the age docs use). When none of those
23//! exist, the preprocessor still attempts the call — `age` itself
24//! emits a clear "no identity" error which we forward verbatim.
25//!
26//! See `secrets.lex` §4.1–§4.3 (supported formats, deployment flow,
27//! mode 0600 enforcement) and `preprocessing-pipeline.lex` §2.3
28//! (Opaque transform semantics).
29
30use std::path::{Path, PathBuf};
31use std::sync::Arc;
32
33use crate::datastore::CommandRunner;
34use crate::fs::Fs;
35use crate::preprocessing::{ExpandedFile, Preprocessor, TransformType};
36use crate::{DodotError, Result};
37
38/// `age` decryption preprocessor. Constructed from
39/// `[preprocessor.age]` config + the shared `CommandRunner`.
40///
41/// Holds the identity path resolved at construction so every
42/// `expand()` call uses the same identity file (no re-reading of
43/// env vars per file). The path is **not** validated to exist at
44/// construction; `age` validates at decrypt time and emits a
45/// diagnostic we surface verbatim if the file is missing.
46pub struct AgePreprocessor {
47    runner: Arc<dyn CommandRunner>,
48    identity: PathBuf,
49    /// Configured extensions (default `["age"]`). Stored without
50    /// leading dots; `matches_extension` requires a literal `.`
51    /// before the extension to avoid `id.age` matching `id`.
52    extensions: Vec<String>,
53}
54
55impl AgePreprocessor {
56    pub fn new(runner: Arc<dyn CommandRunner>, identity: PathBuf, extensions: Vec<String>) -> Self {
57        let extensions: Vec<String> = extensions
58            .into_iter()
59            .map(|e| e.trim_start_matches('.').to_string())
60            .collect();
61        Self {
62            runner,
63            identity,
64            extensions,
65        }
66    }
67
68    /// Construct with the canonical `~/.config/age/identity.txt`
69    /// default identity and the default `["age"]` extension set —
70    /// matches what most users have installed via `age-keygen`.
71    pub fn from_env(runner: Arc<dyn CommandRunner>) -> Self {
72        let identity = std::env::var("AGE_IDENTITY")
73            .map(PathBuf::from)
74            .ok()
75            .or_else(|| {
76                std::env::var("HOME").ok().map(|h| {
77                    let mut p = PathBuf::from(h);
78                    p.push(".config/age/identity.txt");
79                    p
80                })
81            })
82            .unwrap_or_else(|| PathBuf::from("identity.txt"));
83        Self::new(runner, identity, vec!["age".to_string()])
84    }
85}
86
87impl Preprocessor for AgePreprocessor {
88    fn name(&self) -> &str {
89        "age"
90    }
91
92    fn transform_type(&self) -> TransformType {
93        TransformType::Opaque
94    }
95
96    fn matches_extension(&self, filename: &str) -> bool {
97        self.extensions.iter().any(|ext| {
98            filename
99                .strip_suffix(ext.as_str())
100                .is_some_and(|prefix| prefix.ends_with('.'))
101        })
102    }
103
104    fn stripped_name(&self, filename: &str) -> String {
105        // Prefer the longest matching extension so an `.age.bak`
106        // override (unlikely but possible per config) wins over a
107        // bare `.age`. Same shape as TemplatePreprocessor.
108        self.extensions
109            .iter()
110            .filter_map(|ext| {
111                filename
112                    .strip_suffix(ext.as_str())
113                    .and_then(|prefix| prefix.strip_suffix('.'))
114                    .map(|stripped| (ext.len(), stripped))
115            })
116            .max_by_key(|(len, _)| *len)
117            .map(|(_, stripped)| stripped.to_string())
118            .unwrap_or_else(|| filename.to_string())
119    }
120
121    fn expand(&self, source: &Path, _fs: &dyn Fs) -> Result<Vec<ExpandedFile>> {
122        // `run_bytes` (not `run`) so binary plaintext (raw key
123        // blobs, X.509 DER certs) round-trips verbatim. The
124        // `String::from_utf8_lossy` decode in the line-buffered
125        // `run` path corrupts non-UTF-8 bytes — fatal for
126        // whole-file secrets.
127        let out = self.runner.run_bytes(
128            "age",
129            &[
130                "--decrypt".into(),
131                "--identity".into(),
132                self.identity.to_string_lossy().to_string(),
133                source.to_string_lossy().to_string(),
134            ],
135        )?;
136        if out.exit_code != 0 {
137            let stderr = out.stderr.trim();
138            // Map the most common diagnostic shapes to actionable
139            // hints. age's stderr is short and stable; we surface
140            // verbatim text where mapping isn't clear.
141            let msg = if stderr.contains("no identity matched") {
142                format!(
143                    "age: no identity matched any of the recipients for `{}`. \
144                     The decryption key in `{}` doesn't match the recipient \
145                     this file was encrypted to. Re-encrypt the file with the \
146                     correct recipient (`age -r <pubkey> -e ...`) or point \
147                     `[preprocessor.age] identity` at the right key file.",
148                    source.display(),
149                    self.identity.display()
150                )
151            } else if stderr.contains("no such file")
152                || stderr.contains("identity") && stderr.contains("does not exist")
153            {
154                format!(
155                    "age: identity file `{}` not found. \
156                     Generate one with `age-keygen -o {}`, or set \
157                     `[preprocessor.age] identity` to point at an existing key.",
158                    self.identity.display(),
159                    self.identity.display()
160                )
161            } else if stderr.is_empty() {
162                format!(
163                    "age decryption of `{}` exited {} (no diagnostic output)",
164                    source.display(),
165                    out.exit_code
166                )
167            } else {
168                // age's stderr does not echo plaintext; surfacing it
169                // verbatim is safe and aids diagnosis.
170                format!(
171                    "age decryption of `{}` failed (exit {}): {stderr}",
172                    source.display(),
173                    out.exit_code
174                )
175            };
176            return Err(DodotError::PreprocessorError {
177                preprocessor: "age".into(),
178                source_file: source.to_path_buf(),
179                message: msg,
180            });
181        }
182        let filename = source
183            .file_name()
184            .unwrap_or_default()
185            .to_string_lossy()
186            .into_owned();
187        let stripped = self.stripped_name(&filename);
188        Ok(vec![ExpandedFile {
189            relative_path: PathBuf::from(stripped),
190            content: out.stdout,
191            is_dir: false,
192            tracked_render: None,
193            context_hash: None,
194            secret_line_ranges: Vec::new(),
195            // Per `secrets.lex` §4.3: rendered whole-file secrets
196            // are 0600 regardless of the source file's mode.
197            deploy_mode: Some(0o600),
198        }])
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205    use crate::datastore::CommandOutput;
206    use std::sync::Mutex;
207
208    type ScriptedResponse = (
209        String,
210        Vec<String>,
211        std::result::Result<CommandOutput, String>,
212    );
213
214    struct ScriptedRunner {
215        responses: Mutex<Vec<ScriptedResponse>>,
216    }
217    impl ScriptedRunner {
218        fn new() -> Self {
219            Self {
220                responses: Mutex::new(Vec::new()),
221            }
222        }
223        fn expect(
224            self,
225            exe: impl Into<String>,
226            args: Vec<String>,
227            response: std::result::Result<CommandOutput, String>,
228        ) -> Self {
229            self.responses
230                .lock()
231                .unwrap()
232                .push((exe.into(), args, response));
233            self
234        }
235    }
236    impl CommandRunner for ScriptedRunner {
237        fn run(&self, exe: &str, args: &[String]) -> Result<CommandOutput> {
238            let mut r = self.responses.lock().unwrap();
239            if r.is_empty() {
240                return Err(DodotError::Other(format!(
241                    "ScriptedRunner: unexpected `{exe} {args:?}`"
242                )));
243            }
244            let (e, a, out) = r.remove(0);
245            assert_eq!(exe, e);
246            assert_eq!(args, a.as_slice());
247            out.map_err(DodotError::Other)
248        }
249    }
250    fn ok(stdout: &str) -> std::result::Result<CommandOutput, String> {
251        Ok(CommandOutput {
252            exit_code: 0,
253            stdout: stdout.into(),
254            stderr: String::new(),
255        })
256    }
257    fn err_out(exit: i32, stderr: &str) -> std::result::Result<CommandOutput, String> {
258        Ok(CommandOutput {
259            exit_code: exit,
260            stdout: String::new(),
261            stderr: stderr.into(),
262        })
263    }
264
265    /// A scripted runner whose `run_bytes` returns canned raw-byte
266    /// responses (so binary tests can pin verbatim round-trip).
267    /// `run` is unimplemented — the only call site we exercise is
268    /// `run_bytes`.
269    type ScriptedBytesResponse = (
270        String,
271        Vec<String>,
272        std::result::Result<crate::datastore::CommandOutputBytes, String>,
273    );
274    struct ScriptedBytesRunner {
275        responses: Mutex<Vec<ScriptedBytesResponse>>,
276    }
277    impl ScriptedBytesRunner {
278        fn new() -> Self {
279            Self {
280                responses: Mutex::new(Vec::new()),
281            }
282        }
283        fn expect(
284            self,
285            exe: impl Into<String>,
286            args: Vec<String>,
287            response: std::result::Result<crate::datastore::CommandOutputBytes, String>,
288        ) -> Self {
289            self.responses
290                .lock()
291                .unwrap()
292                .push((exe.into(), args, response));
293            self
294        }
295    }
296    impl CommandRunner for ScriptedBytesRunner {
297        fn run(&self, _exe: &str, _args: &[String]) -> Result<CommandOutput> {
298            unreachable!("ScriptedBytesRunner only supports run_bytes")
299        }
300        fn run_bytes(
301            &self,
302            exe: &str,
303            args: &[String],
304        ) -> Result<crate::datastore::CommandOutputBytes> {
305            let mut r = self.responses.lock().unwrap();
306            if r.is_empty() {
307                return Err(DodotError::Other(format!(
308                    "ScriptedBytesRunner: unexpected `{exe} {args:?}`"
309                )));
310            }
311            let (e, a, out) = r.remove(0);
312            assert_eq!(exe, e);
313            assert_eq!(args, a.as_slice());
314            out.map_err(DodotError::Other)
315        }
316    }
317    fn ok_bytes(
318        stdout: &[u8],
319    ) -> std::result::Result<crate::datastore::CommandOutputBytes, String> {
320        Ok(crate::datastore::CommandOutputBytes {
321            exit_code: 0,
322            stdout: stdout.to_vec(),
323            stderr: String::new(),
324        })
325    }
326    fn make_pp(runner: Arc<dyn CommandRunner>) -> AgePreprocessor {
327        AgePreprocessor::new(runner, PathBuf::from("/k/id.txt"), vec!["age".into()])
328    }
329    fn null_fs() -> crate::fs::OsFs {
330        crate::fs::OsFs::new()
331    }
332
333    // ── matches / stripped_name ─────────────────────────────────
334
335    #[test]
336    fn matches_extension_only_when_dot_age_is_a_real_suffix() {
337        let p = make_pp(Arc::new(ScriptedRunner::new()));
338        assert!(p.matches_extension("id_ed25519.age"));
339        assert!(!p.matches_extension("foo.age.bak"));
340        // `idage` is not `id.age`.
341        assert!(!p.matches_extension("idage"));
342    }
343
344    #[test]
345    fn stripped_name_drops_age_suffix() {
346        let p = make_pp(Arc::new(ScriptedRunner::new()));
347        assert_eq!(p.stripped_name("id_ed25519.age"), "id_ed25519");
348    }
349
350    // ── expand ──────────────────────────────────────────────────
351
352    #[test]
353    fn expand_invokes_age_with_decrypt_and_identity_args() {
354        let runner = Arc::new(ScriptedRunner::new().expect(
355            "age",
356            vec![
357                "--decrypt".into(),
358                "--identity".into(),
359                "/k/id.txt".into(),
360                "/pack/secret.age".into(),
361            ],
362            ok("PLAINTEXT BYTES\n"),
363        ));
364        let p = make_pp(runner);
365        let out = p.expand(Path::new("/pack/secret.age"), &null_fs()).unwrap();
366        assert_eq!(out.len(), 1);
367        assert_eq!(out[0].relative_path, PathBuf::from("secret"));
368        assert_eq!(out[0].content, b"PLAINTEXT BYTES\n");
369        assert_eq!(out[0].deploy_mode, Some(0o600));
370        // Opaque preprocessors don't produce a tracked render or
371        // context hash — the baseline cache won't try to reverse-
372        // merge against them.
373        assert!(out[0].tracked_render.is_none());
374        assert!(out[0].context_hash.is_none());
375    }
376
377    #[test]
378    fn expand_preserves_binary_plaintext_verbatim_via_run_bytes() {
379        // Non-UTF-8 plaintext (a raw binary key blob) flows
380        // through intact via `run_bytes`. The earlier `run` path
381        // would have decoded stdout via `String::from_utf8_lossy`
382        // and replaced 0xff / 0xfe with U+FFFD, corrupting
383        // round-tripped bytes. Pin that the preprocessor goes
384        // through `run_bytes` and the bytes survive verbatim.
385        let raw = vec![0u8, 1, 2, 0xff, 0xfe, b'\n', 0x80, 0xc0];
386        let runner = Arc::new(ScriptedBytesRunner::new().expect(
387            "age",
388            vec![
389                "--decrypt".into(),
390                "--identity".into(),
391                "/k/id.txt".into(),
392                "/pack/key.age".into(),
393            ],
394            ok_bytes(&raw),
395        ));
396        let p = make_pp(runner);
397        let out = p.expand(Path::new("/pack/key.age"), &null_fs()).unwrap();
398        assert_eq!(out[0].deploy_mode, Some(0o600));
399        assert_eq!(out[0].relative_path, PathBuf::from("key"));
400        assert_eq!(out[0].content, raw, "raw bytes must round-trip verbatim");
401    }
402
403    #[test]
404    fn expand_maps_no_identity_match_to_recipient_diagnostic() {
405        let runner = Arc::new(ScriptedRunner::new().expect(
406            "age",
407            vec![
408                "--decrypt".into(),
409                "--identity".into(),
410                "/k/id.txt".into(),
411                "/pack/x.age".into(),
412            ],
413            err_out(1, "age: error: no identity matched any of the recipients"),
414        ));
415        let p = make_pp(runner);
416        let e = p
417            .expand(Path::new("/pack/x.age"), &null_fs())
418            .unwrap_err()
419            .to_string();
420        assert!(e.contains("no identity matched"));
421        assert!(e.contains("Re-encrypt"));
422        assert!(e.contains("/k/id.txt"));
423    }
424
425    #[test]
426    fn expand_maps_missing_identity_file_to_generate_hint() {
427        let runner = Arc::new(ScriptedRunner::new().expect(
428            "age",
429            vec![
430                "--decrypt".into(),
431                "--identity".into(),
432                "/k/id.txt".into(),
433                "/pack/x.age".into(),
434            ],
435            err_out(1, "age: error: identity file does not exist: /k/id.txt"),
436        ));
437        let p = make_pp(runner);
438        let e = p
439            .expand(Path::new("/pack/x.age"), &null_fs())
440            .unwrap_err()
441            .to_string();
442        assert!(e.contains("identity file"));
443        assert!(e.contains("not found"));
444        assert!(e.contains("age-keygen"));
445    }
446
447    #[test]
448    fn expand_passes_unrecognized_stderr_through_with_command_context() {
449        let runner = Arc::new(ScriptedRunner::new().expect(
450            "age",
451            vec![
452                "--decrypt".into(),
453                "--identity".into(),
454                "/k/id.txt".into(),
455                "/pack/x.age".into(),
456            ],
457            err_out(1, "age: error: weird internal failure"),
458        ));
459        let p = make_pp(runner);
460        let e = p
461            .expand(Path::new("/pack/x.age"), &null_fs())
462            .unwrap_err()
463            .to_string();
464        assert!(e.contains("weird internal failure"));
465        assert!(e.contains("age decryption"));
466        assert!(e.contains("exit 1"));
467    }
468
469    #[test]
470    fn expand_handles_empty_stderr_failure() {
471        let runner = Arc::new(ScriptedRunner::new().expect(
472            "age",
473            vec![
474                "--decrypt".into(),
475                "--identity".into(),
476                "/k/id.txt".into(),
477                "/pack/x.age".into(),
478            ],
479            err_out(2, ""),
480        ));
481        let p = make_pp(runner);
482        let e = p
483            .expand(Path::new("/pack/x.age"), &null_fs())
484            .unwrap_err()
485            .to_string();
486        assert!(e.contains("exited 2"));
487        assert!(e.contains("no diagnostic output"));
488    }
489
490    #[test]
491    fn expand_propagates_runner_error_when_subprocess_fails_to_spawn() {
492        let runner = Arc::new(ScriptedRunner::new().expect(
493            "age",
494            vec![
495                "--decrypt".into(),
496                "--identity".into(),
497                "/k/id.txt".into(),
498                "/pack/x.age".into(),
499            ],
500            Err("command not found: age".into()),
501        ));
502        let p = make_pp(runner);
503        let e = p
504            .expand(Path::new("/pack/x.age"), &null_fs())
505            .unwrap_err()
506            .to_string();
507        // The CommandRunner-level error surfaces; users get told
508        // age isn't installed without the preprocessor having to
509        // probe at construction.
510        assert!(e.contains("command not found: age"));
511    }
512}