use std::io::{Read, Write};
use std::path::Path;
use std::str::FromStr;
use age::x25519::{Identity, Recipient};
use crate::error::SnapshotError;
pub fn parse_recipient(s: &str) -> Result<Recipient, SnapshotError> {
Recipient::from_str(s).map_err(|e| SnapshotError::Encryption(format!("recipient parse: {e}")))
}
pub fn load_identities(path: &Path) -> Result<Vec<Identity>, SnapshotError> {
let body = std::fs::read_to_string(path)?;
let mut out = Vec::new();
for raw in body.lines() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let id = Identity::from_str(line)
.map_err(|e| SnapshotError::Encryption(format!("identity parse failed: {e}")))?;
out.push(id);
}
if out.is_empty() {
return Err(SnapshotError::Encryption(format!(
"no identities in {}",
path.display()
)));
}
Ok(out)
}
pub fn encrypt_writer<W: Write>(
writer: W,
recipients: Vec<Recipient>,
) -> Result<EncryptingWriter<W>, SnapshotError> {
if recipients.is_empty() {
return Err(SnapshotError::Encryption("no recipients".into()));
}
let dyn_recipients: Vec<Box<dyn age::Recipient + Send>> = recipients
.into_iter()
.map(|r| Box::new(r) as Box<dyn age::Recipient + Send>)
.collect();
let encryptor = age::Encryptor::with_recipients(dyn_recipients)
.ok_or_else(|| SnapshotError::Encryption("encryptor returned None".into()))?;
let inner = encryptor
.wrap_output(writer)
.map_err(|e| SnapshotError::Encryption(format!("wrap_output: {e}")))?;
Ok(EncryptingWriter { inner: Some(inner) })
}
pub struct EncryptingWriter<W: Write> {
inner: Option<age::stream::StreamWriter<W>>,
}
impl<W: Write> EncryptingWriter<W> {
pub fn finish(mut self) -> Result<W, SnapshotError> {
let sw = self
.inner
.take()
.expect("stream writer already taken — finish called twice?");
sw.finish()
.map_err(|e| SnapshotError::Encryption(format!("finish: {e}")))
}
}
impl<W: Write> Write for EncryptingWriter<W> {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.inner
.as_mut()
.expect("stream writer already taken")
.write(buf)
}
fn flush(&mut self) -> std::io::Result<()> {
self.inner
.as_mut()
.expect("stream writer already taken")
.flush()
}
}
pub fn decrypt_reader<R: Read + 'static>(
reader: R,
identities: &[Identity],
) -> Result<Box<dyn Read>, SnapshotError> {
let dec = age::Decryptor::new(reader)
.map_err(|e| SnapshotError::Encryption(format!("decryptor: {e}")))?;
let recipients_dec = match dec {
age::Decryptor::Recipients(d) => d,
age::Decryptor::Passphrase(_) => {
return Err(SnapshotError::Encryption(
"passphrase-protected bundles not supported; use age recipients".into(),
));
}
};
let id_refs: Vec<&dyn age::Identity> =
identities.iter().map(|i| i as &dyn age::Identity).collect();
let inner = recipients_dec
.decrypt(id_refs.into_iter())
.map_err(|e| SnapshotError::Encryption(format!("decrypt: {e}")))?;
Ok(Box::new(inner))
}
pub fn fingerprint(recipient: &Recipient) -> String {
let s = recipient.to_string();
let chars: Vec<char> = s.chars().collect();
let n = chars.len();
let start = n.saturating_sub(8);
chars[start..].iter().collect()
}
#[cfg(test)]
mod tests {
use super::*;
use age::secrecy::ExposeSecret;
use std::io::{Cursor, Read, Write};
fn fresh_identity() -> Identity {
Identity::generate()
}
#[test]
fn parse_recipient_round_trip() {
let id = fresh_identity();
let rec = id.to_public();
let s = rec.to_string();
let parsed = parse_recipient(&s).unwrap();
assert_eq!(parsed.to_string(), s);
}
#[test]
fn fingerprint_is_eight_chars() {
let id = fresh_identity();
let rec = id.to_public();
let fp = fingerprint(&rec);
assert_eq!(fp.chars().count(), 8);
}
fn encrypt_to_vec(body: &[u8], rec: Recipient) -> Vec<u8> {
let mut sink: Vec<u8> = Vec::new();
let mut w = encrypt_writer(&mut sink, vec![rec]).unwrap();
w.write_all(body).unwrap();
w.finish().unwrap();
sink
}
#[test]
fn encrypt_then_decrypt_round_trips_payload() {
let id = fresh_identity();
let rec = id.to_public();
let body = b"sensitive bundle bytes payload\n";
let ciphertext = encrypt_to_vec(body, rec);
assert!(!ciphertext.is_empty());
assert_ne!(ciphertext, body);
let mut r = decrypt_reader(Cursor::new(ciphertext), &[id]).unwrap();
let mut out = Vec::new();
r.read_to_end(&mut out).unwrap();
assert_eq!(out, body);
}
#[test]
fn decrypt_with_wrong_identity_fails() {
let id_owner = fresh_identity();
let id_other = fresh_identity();
let rec = id_owner.to_public();
let ciphertext = encrypt_to_vec(b"x", rec);
let res = decrypt_reader(Cursor::new(ciphertext), &[id_other]);
assert!(res.is_err());
}
#[test]
fn load_identities_skips_comments_and_blanks() {
let id = fresh_identity();
let body = format!(
"# comment\n\n{}\n# trailing comment\n",
id.to_string().expose_secret()
);
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("identity.txt");
std::fs::write(&path, body).unwrap();
let ids = load_identities(&path).unwrap();
assert_eq!(ids.len(), 1);
}
#[test]
fn load_identities_rejects_empty_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("identity.txt");
std::fs::write(&path, "# nothing here\n\n").unwrap();
assert!(load_identities(&path).is_err());
}
#[test]
fn encrypt_with_no_recipients_errors() {
let mut sink: Vec<u8> = Vec::new();
match encrypt_writer(&mut sink, Vec::new()) {
Ok(_) => panic!("expected error from empty recipient set"),
Err(e) => assert!(format!("{e}").contains("no recipients")),
}
}
}