use std::io::Write as _;
use std::path::Path;
use std::str::FromStr as _;
use age::x25519;
use crate::error::{Error, Result};
pub fn parse(text: &str) -> Result<Vec<x25519::Recipient>> {
let mut out = Vec::new();
for (line_no, raw) in text.lines().enumerate() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let recipient = x25519::Recipient::from_str(line).map_err(|e| {
Error::InvalidRecipient(format!("line {}: {e}", line_no.saturating_add(1)))
})?;
out.push(recipient);
}
Ok(out)
}
pub fn load(path: &Path) -> Result<Vec<x25519::Recipient>> {
if !path.exists() {
return Err(Error::NoRecipients(path.to_path_buf()));
}
let text = std::fs::read_to_string(path)?;
let recipients = parse(&text)?;
if recipients.is_empty() {
return Err(Error::NoRecipients(path.to_path_buf()));
}
Ok(recipients)
}
pub fn save(path: &Path, recipients: &[x25519::Recipient]) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp = with_extension(path, "tmp");
{
let mut file = std::fs::File::create(&tmp)?;
writeln!(
file,
"# ks recipients — public keys allowed to decrypt this store."
)?;
writeln!(
file,
"# Add a recipient with `ks recipients add <age1...>`."
)?;
for r in recipients {
writeln!(file, "{r}")?;
}
file.sync_all()?;
}
std::fs::rename(&tmp, path)?;
Ok(())
}
#[must_use]
pub fn contains(list: &[x25519::Recipient], target: &x25519::Recipient) -> bool {
let needle = target.to_string();
list.iter().any(|r| r.to_string() == needle)
}
fn with_extension(path: &Path, ext: &str) -> std::path::PathBuf {
let mut p = path.to_path_buf();
let mut name = p
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("recipients")
.to_owned();
name.push('.');
name.push_str(ext);
p.set_file_name(name);
p
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn skips_comments_and_blanks() {
let id = x25519::Identity::generate();
let pub_str = id.to_public().to_string();
let body = format!("# comment line\n\n{pub_str}\n# trailing\n");
let parsed = parse(&body).expect("parse");
assert_eq!(parsed.len(), 1);
assert_eq!(parsed.first().expect("non-empty").to_string(), pub_str);
}
#[test]
fn rejects_invalid() {
assert!(parse("not-a-pubkey").is_err());
}
#[test]
fn save_then_load_roundtrip() {
let tmp = tempdir();
let path = tmp.join(".recipients");
let id = x25519::Identity::generate();
let r = id.to_public();
save(&path, std::slice::from_ref(&r)).expect("save");
let loaded = load(&path).expect("load");
assert_eq!(loaded.len(), 1);
assert_eq!(
loaded.first().expect("non-empty").to_string(),
r.to_string()
);
}
fn tempdir() -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("ks-test-{}", rand::random::<u64>()));
std::fs::create_dir_all(&dir).expect("create temp dir");
dir
}
}