use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::datastore::CommandRunner;
use crate::fs::Fs;
use crate::preprocessing::{ExpandedFile, Preprocessor, TransformType};
use crate::{DodotError, Result};
pub struct AgePreprocessor {
runner: Arc<dyn CommandRunner>,
identity: PathBuf,
extensions: Vec<String>,
}
impl AgePreprocessor {
pub fn new(runner: Arc<dyn CommandRunner>, identity: PathBuf, extensions: Vec<String>) -> Self {
let extensions: Vec<String> = extensions
.into_iter()
.map(|e| e.trim_start_matches('.').to_string())
.collect();
Self {
runner,
identity,
extensions,
}
}
pub fn from_env(runner: Arc<dyn CommandRunner>) -> Self {
let identity = std::env::var("AGE_IDENTITY")
.map(PathBuf::from)
.ok()
.or_else(|| {
std::env::var("HOME").ok().map(|h| {
let mut p = PathBuf::from(h);
p.push(".config/age/identity.txt");
p
})
})
.unwrap_or_else(|| PathBuf::from("identity.txt"));
Self::new(runner, identity, vec!["age".to_string()])
}
}
impl Preprocessor for AgePreprocessor {
fn name(&self) -> &str {
"age"
}
fn transform_type(&self) -> TransformType {
TransformType::Opaque
}
fn matches_extension(&self, filename: &str) -> bool {
self.extensions.iter().any(|ext| {
filename
.strip_suffix(ext.as_str())
.is_some_and(|prefix| prefix.ends_with('.'))
})
}
fn stripped_name(&self, filename: &str) -> String {
self.extensions
.iter()
.filter_map(|ext| {
filename
.strip_suffix(ext.as_str())
.and_then(|prefix| prefix.strip_suffix('.'))
.map(|stripped| (ext.len(), stripped))
})
.max_by_key(|(len, _)| *len)
.map(|(_, stripped)| stripped.to_string())
.unwrap_or_else(|| filename.to_string())
}
fn expand(&self, source: &Path, _fs: &dyn Fs) -> Result<Vec<ExpandedFile>> {
let out = self.runner.run_bytes(
"age",
&[
"--decrypt".into(),
"--identity".into(),
self.identity.to_string_lossy().to_string(),
source.to_string_lossy().to_string(),
],
)?;
if out.exit_code != 0 {
let stderr = out.stderr.trim();
let msg = if stderr.contains("no identity matched") {
format!(
"age: no identity matched any of the recipients for `{}`. \
The decryption key in `{}` doesn't match the recipient \
this file was encrypted to. Re-encrypt the file with the \
correct recipient (`age -r <pubkey> -e ...`) or point \
`[preprocessor.age] identity` at the right key file.",
source.display(),
self.identity.display()
)
} else if stderr.contains("no such file")
|| stderr.contains("identity") && stderr.contains("does not exist")
{
format!(
"age: identity file `{}` not found. \
Generate one with `age-keygen -o {}`, or set \
`[preprocessor.age] identity` to point at an existing key.",
self.identity.display(),
self.identity.display()
)
} else if stderr.is_empty() {
format!(
"age decryption of `{}` exited {} (no diagnostic output)",
source.display(),
out.exit_code
)
} else {
format!(
"age decryption of `{}` failed (exit {}): {stderr}",
source.display(),
out.exit_code
)
};
return Err(DodotError::PreprocessorError {
preprocessor: "age".into(),
source_file: source.to_path_buf(),
message: msg,
});
}
let filename = source
.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned();
let stripped = self.stripped_name(&filename);
Ok(vec![ExpandedFile {
relative_path: PathBuf::from(stripped),
content: out.stdout,
is_dir: false,
tracked_render: None,
context_hash: None,
secret_line_ranges: Vec::new(),
deploy_mode: Some(0o600),
}])
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::datastore::CommandOutput;
use std::sync::Mutex;
type ScriptedResponse = (
String,
Vec<String>,
std::result::Result<CommandOutput, String>,
);
struct ScriptedRunner {
responses: Mutex<Vec<ScriptedResponse>>,
}
impl ScriptedRunner {
fn new() -> Self {
Self {
responses: Mutex::new(Vec::new()),
}
}
fn expect(
self,
exe: impl Into<String>,
args: Vec<String>,
response: std::result::Result<CommandOutput, String>,
) -> Self {
self.responses
.lock()
.unwrap()
.push((exe.into(), args, response));
self
}
}
impl CommandRunner for ScriptedRunner {
fn run(&self, exe: &str, args: &[String]) -> Result<CommandOutput> {
let mut r = self.responses.lock().unwrap();
if r.is_empty() {
return Err(DodotError::Other(format!(
"ScriptedRunner: unexpected `{exe} {args:?}`"
)));
}
let (e, a, out) = r.remove(0);
assert_eq!(exe, e);
assert_eq!(args, a.as_slice());
out.map_err(DodotError::Other)
}
}
fn ok(stdout: &str) -> std::result::Result<CommandOutput, String> {
Ok(CommandOutput {
exit_code: 0,
stdout: stdout.into(),
stderr: String::new(),
})
}
fn err_out(exit: i32, stderr: &str) -> std::result::Result<CommandOutput, String> {
Ok(CommandOutput {
exit_code: exit,
stdout: String::new(),
stderr: stderr.into(),
})
}
type ScriptedBytesResponse = (
String,
Vec<String>,
std::result::Result<crate::datastore::CommandOutputBytes, String>,
);
struct ScriptedBytesRunner {
responses: Mutex<Vec<ScriptedBytesResponse>>,
}
impl ScriptedBytesRunner {
fn new() -> Self {
Self {
responses: Mutex::new(Vec::new()),
}
}
fn expect(
self,
exe: impl Into<String>,
args: Vec<String>,
response: std::result::Result<crate::datastore::CommandOutputBytes, String>,
) -> Self {
self.responses
.lock()
.unwrap()
.push((exe.into(), args, response));
self
}
}
impl CommandRunner for ScriptedBytesRunner {
fn run(&self, _exe: &str, _args: &[String]) -> Result<CommandOutput> {
unreachable!("ScriptedBytesRunner only supports run_bytes")
}
fn run_bytes(
&self,
exe: &str,
args: &[String],
) -> Result<crate::datastore::CommandOutputBytes> {
let mut r = self.responses.lock().unwrap();
if r.is_empty() {
return Err(DodotError::Other(format!(
"ScriptedBytesRunner: unexpected `{exe} {args:?}`"
)));
}
let (e, a, out) = r.remove(0);
assert_eq!(exe, e);
assert_eq!(args, a.as_slice());
out.map_err(DodotError::Other)
}
}
fn ok_bytes(
stdout: &[u8],
) -> std::result::Result<crate::datastore::CommandOutputBytes, String> {
Ok(crate::datastore::CommandOutputBytes {
exit_code: 0,
stdout: stdout.to_vec(),
stderr: String::new(),
})
}
fn make_pp(runner: Arc<dyn CommandRunner>) -> AgePreprocessor {
AgePreprocessor::new(runner, PathBuf::from("/k/id.txt"), vec!["age".into()])
}
fn null_fs() -> crate::fs::OsFs {
crate::fs::OsFs::new()
}
#[test]
fn matches_extension_only_when_dot_age_is_a_real_suffix() {
let p = make_pp(Arc::new(ScriptedRunner::new()));
assert!(p.matches_extension("id_ed25519.age"));
assert!(!p.matches_extension("foo.age.bak"));
assert!(!p.matches_extension("idage"));
}
#[test]
fn stripped_name_drops_age_suffix() {
let p = make_pp(Arc::new(ScriptedRunner::new()));
assert_eq!(p.stripped_name("id_ed25519.age"), "id_ed25519");
}
#[test]
fn expand_invokes_age_with_decrypt_and_identity_args() {
let runner = Arc::new(ScriptedRunner::new().expect(
"age",
vec![
"--decrypt".into(),
"--identity".into(),
"/k/id.txt".into(),
"/pack/secret.age".into(),
],
ok("PLAINTEXT BYTES\n"),
));
let p = make_pp(runner);
let out = p.expand(Path::new("/pack/secret.age"), &null_fs()).unwrap();
assert_eq!(out.len(), 1);
assert_eq!(out[0].relative_path, PathBuf::from("secret"));
assert_eq!(out[0].content, b"PLAINTEXT BYTES\n");
assert_eq!(out[0].deploy_mode, Some(0o600));
assert!(out[0].tracked_render.is_none());
assert!(out[0].context_hash.is_none());
}
#[test]
fn expand_preserves_binary_plaintext_verbatim_via_run_bytes() {
let raw = vec![0u8, 1, 2, 0xff, 0xfe, b'\n', 0x80, 0xc0];
let runner = Arc::new(ScriptedBytesRunner::new().expect(
"age",
vec![
"--decrypt".into(),
"--identity".into(),
"/k/id.txt".into(),
"/pack/key.age".into(),
],
ok_bytes(&raw),
));
let p = make_pp(runner);
let out = p.expand(Path::new("/pack/key.age"), &null_fs()).unwrap();
assert_eq!(out[0].deploy_mode, Some(0o600));
assert_eq!(out[0].relative_path, PathBuf::from("key"));
assert_eq!(out[0].content, raw, "raw bytes must round-trip verbatim");
}
#[test]
fn expand_maps_no_identity_match_to_recipient_diagnostic() {
let runner = Arc::new(ScriptedRunner::new().expect(
"age",
vec![
"--decrypt".into(),
"--identity".into(),
"/k/id.txt".into(),
"/pack/x.age".into(),
],
err_out(1, "age: error: no identity matched any of the recipients"),
));
let p = make_pp(runner);
let e = p
.expand(Path::new("/pack/x.age"), &null_fs())
.unwrap_err()
.to_string();
assert!(e.contains("no identity matched"));
assert!(e.contains("Re-encrypt"));
assert!(e.contains("/k/id.txt"));
}
#[test]
fn expand_maps_missing_identity_file_to_generate_hint() {
let runner = Arc::new(ScriptedRunner::new().expect(
"age",
vec![
"--decrypt".into(),
"--identity".into(),
"/k/id.txt".into(),
"/pack/x.age".into(),
],
err_out(1, "age: error: identity file does not exist: /k/id.txt"),
));
let p = make_pp(runner);
let e = p
.expand(Path::new("/pack/x.age"), &null_fs())
.unwrap_err()
.to_string();
assert!(e.contains("identity file"));
assert!(e.contains("not found"));
assert!(e.contains("age-keygen"));
}
#[test]
fn expand_passes_unrecognized_stderr_through_with_command_context() {
let runner = Arc::new(ScriptedRunner::new().expect(
"age",
vec![
"--decrypt".into(),
"--identity".into(),
"/k/id.txt".into(),
"/pack/x.age".into(),
],
err_out(1, "age: error: weird internal failure"),
));
let p = make_pp(runner);
let e = p
.expand(Path::new("/pack/x.age"), &null_fs())
.unwrap_err()
.to_string();
assert!(e.contains("weird internal failure"));
assert!(e.contains("age decryption"));
assert!(e.contains("exit 1"));
}
#[test]
fn expand_handles_empty_stderr_failure() {
let runner = Arc::new(ScriptedRunner::new().expect(
"age",
vec![
"--decrypt".into(),
"--identity".into(),
"/k/id.txt".into(),
"/pack/x.age".into(),
],
err_out(2, ""),
));
let p = make_pp(runner);
let e = p
.expand(Path::new("/pack/x.age"), &null_fs())
.unwrap_err()
.to_string();
assert!(e.contains("exited 2"));
assert!(e.contains("no diagnostic output"));
}
#[test]
fn expand_propagates_runner_error_when_subprocess_fails_to_spawn() {
let runner = Arc::new(ScriptedRunner::new().expect(
"age",
vec![
"--decrypt".into(),
"--identity".into(),
"/k/id.txt".into(),
"/pack/x.age".into(),
],
Err("command not found: age".into()),
));
let p = make_pp(runner);
let e = p
.expand(Path::new("/pack/x.age"), &null_fs())
.unwrap_err()
.to_string();
assert!(e.contains("command not found: age"));
}
}