1use 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
38pub struct AgePreprocessor {
47 runner: Arc<dyn CommandRunner>,
48 identity: PathBuf,
49 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 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 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 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 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 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 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 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 #[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 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 #[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 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 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 assert!(e.contains("command not found: age"));
511 }
512}