use std::fs;
#[cfg(unix)]
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use age::secrecy::ExposeSecret;
use age::x25519::{Identity, Recipient};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct Entry {
pub name: String,
pub sealed: String, pub issued: u64, }
pub fn pub_path(dir: &Path) -> PathBuf {
dir.join("inbox.pub")
}
pub fn enc_path(dir: &Path) -> PathBuf {
dir.join("inbox.enc")
}
pub fn now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub fn seal(recipient: &Recipient, value: &str) -> Result<String, String> {
age::encrypt_and_armor(recipient, value.as_bytes()).map_err(|e| format!("seal failed: {e}"))
}
pub fn open(identity: &Identity, sealed: &str) -> Result<String, String> {
let bytes = age::decrypt(identity, sealed.as_bytes()).map_err(|e| format!("open failed: {e}"))?;
String::from_utf8(bytes).map_err(|_| "decrypted value is not valid UTF-8".to_string())
}
pub fn ensure_recipient(dir: &Path) -> Result<Recipient, String> {
let pubp = pub_path(dir);
if pubp.exists() && crate::keychain::inbox_identity_exists() {
let s = fs::read_to_string(&pubp).map_err(|e| format!("reading {}: {e}", pubp.display()))?;
return s
.trim()
.parse::<Recipient>()
.map_err(|e| format!("invalid inbox.pub: {e}"));
}
let identity = Identity::generate();
let recipient = identity.to_public();
crate::keychain::store_inbox_identity(identity.to_string().expose_secret())?;
fs::create_dir_all(dir).ok();
write_public(&pubp, &recipient.to_string())?;
Ok(recipient)
}
#[cfg(unix)]
fn write_public(path: &Path, recipient: &str) -> Result<(), String> {
use std::os::unix::fs::OpenOptionsExt;
let mut f = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o644) .open(path)
.map_err(|e| format!("writing {}: {e}", path.display()))?;
f.write_all(recipient.as_bytes())
.map_err(|e| format!("writing {}: {e}", path.display()))?;
Ok(())
}
#[cfg(not(unix))]
fn write_public(path: &Path, recipient: &str) -> Result<(), String> {
fs::write(path, recipient.as_bytes()).map_err(|e| format!("writing {}: {e}", path.display()))
}
pub fn append(dir: &Path, name: &str, value: &str) -> Result<usize, String> {
let recipient = ensure_recipient(dir)?;
let sealed = seal(&recipient, value)?;
let entry = Entry { name: name.to_string(), sealed, issued: now() };
let line = serde_json::to_string(&entry).map_err(|e| format!("encoding entry: {e}"))?;
let path = enc_path(dir);
fs::create_dir_all(dir).ok();
append_line(&path, &line)?;
Ok(read_entries(dir).map(|e| e.len()).unwrap_or(0))
}
#[cfg(unix)]
fn append_line(path: &Path, line: &str) -> Result<(), String> {
use std::os::unix::fs::OpenOptionsExt;
let mut f = fs::OpenOptions::new()
.append(true)
.create(true)
.mode(0o600)
.open(path)
.map_err(|e| format!("writing {}: {e}", path.display()))?;
writeln!(f, "{line}").map_err(|e| format!("writing {}: {e}", path.display()))
}
#[cfg(not(unix))]
fn append_line(path: &Path, line: &str) -> Result<(), String> {
use std::io::Write as _;
let mut f = fs::OpenOptions::new()
.append(true)
.create(true)
.open(path)
.map_err(|e| format!("writing {}: {e}", path.display()))?;
writeln!(f, "{line}").map_err(|e| format!("writing {}: {e}", path.display()))
}
pub fn read_entries(dir: &Path) -> Result<Vec<Entry>, String> {
let path = enc_path(dir);
let content = match fs::read_to_string(&path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(format!("reading {}: {e}", path.display())),
};
Ok(content
.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|l| serde_json::from_str::<Entry>(l).ok())
.collect())
}
pub fn drop_entry(dir: &Path, name: &str) -> Result<bool, String> {
let entries = read_entries(dir)?;
let kept: Vec<&Entry> = entries.iter().filter(|e| e.name != name).collect();
if kept.len() == entries.len() {
return Ok(false);
}
rewrite(dir, &kept)?;
Ok(true)
}
pub fn rewrite(dir: &Path, entries: &[&Entry]) -> Result<(), String> {
let path = enc_path(dir);
if entries.is_empty() {
let _ = fs::remove_file(&path);
return Ok(());
}
let body: String = entries
.iter()
.filter_map(|e| serde_json::to_string(e).ok())
.collect::<Vec<_>>()
.join("\n");
write_private(&path, &body)
}
#[cfg(unix)]
fn write_private(path: &Path, body: &str) -> Result<(), String> {
use std::os::unix::fs::OpenOptionsExt;
let mut f = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)
.map_err(|e| format!("writing {}: {e}", path.display()))?;
writeln!(f, "{body}").map_err(|e| format!("writing {}: {e}", path.display()))
}
#[cfg(not(unix))]
fn write_private(path: &Path, body: &str) -> Result<(), String> {
fs::write(path, body.as_bytes()).map_err(|e| format!("writing {}: {e}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn seal_open_round_trip() {
let identity = Identity::generate();
let recipient = identity.to_public();
let sealed = seal(&recipient, "sk-cloudflare-secret-123").unwrap();
assert!(sealed.starts_with("-----BEGIN AGE ENCRYPTED FILE-----"));
let opened = open(&identity, &sealed).unwrap();
assert_eq!(opened, "sk-cloudflare-secret-123");
}
#[test]
fn wrong_identity_cannot_open() {
let recipient = Identity::generate().to_public();
let attacker = Identity::generate(); let sealed = seal(&recipient, "secret").unwrap();
assert!(open(&attacker, &sealed).is_err(), "write-only: only the inbox identity opens");
}
#[test]
fn entries_round_trip_json() {
let e = Entry { name: "CLOUDFLARE_TOKEN".into(), sealed: "x".into(), issued: 1750000000 };
let line = serde_json::to_string(&e).unwrap();
let back: Entry = serde_json::from_str(&line).unwrap();
assert_eq!(back.name, "CLOUDFLARE_TOKEN");
assert_eq!(back.issued, 1750000000);
}
}