use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
pub const CONFIG_FILE_NAME: &str = ".sopsy.yml";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Recipient {
pub name: String,
pub public_key: String,
#[serde(default)]
pub break_glass: bool,
}
impl Recipient {
pub fn new(name: impl Into<String>, public_key: impl Into<String>) -> Self {
Self {
name: name.into(),
public_key: public_key.into(),
break_glass: false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub recipients: Vec<Recipient>,
#[serde(default = "default_encrypted_globs")]
pub encrypted_globs: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sops_version: Option<String>,
}
impl Default for Config {
fn default() -> Self {
Self {
recipients: Vec::new(),
encrypted_globs: default_encrypted_globs(),
sops_version: None,
}
}
}
fn default_encrypted_globs() -> Vec<String> {
vec![
"*.encrypted".to_string(),
".env.encrypted".to_string(),
"config/*.encrypted.yaml".to_string(),
]
}
impl Config {
pub fn break_glass_recipient(&self) -> Option<&Recipient> {
self.recipients.iter().find(|r| r.break_glass)
}
pub fn recipient(&self, name: &str) -> Option<&Recipient> {
self.recipients.iter().find(|r| r.name == name)
}
pub fn load(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref();
if !path.exists() {
return Err(Error::FileNotFound(path.to_path_buf()));
}
let raw = std::fs::read_to_string(path)?;
serde_yaml_ng::from_str(&raw).map_err(|source| Error::Parse {
path: path.to_path_buf(),
source,
})
}
pub fn load_from_dir(dir: impl AsRef<Path>) -> Result<Self> {
Self::load(dir.as_ref().join(CONFIG_FILE_NAME))
}
pub fn save(&self, path: impl AsRef<Path>) -> Result<()> {
let yaml = serde_yaml_ng::to_string(self)?;
std::fs::write(path.as_ref(), yaml)?;
Ok(())
}
pub fn save_to_dir(&self, dir: impl AsRef<Path>) -> Result<PathBuf> {
let path = dir.as_ref().join(CONFIG_FILE_NAME);
self.save(&path)?;
Ok(path)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_have_encrypted_globs() {
let cfg = Config::default();
assert!(cfg.encrypted_globs.iter().any(|g| g == "*.encrypted"));
assert!(cfg.break_glass_recipient().is_none());
}
#[test]
fn round_trips_through_yaml() {
let mut cfg = Config::default();
cfg.recipients.push(Recipient::new("alice", "age1alice"));
cfg.recipients.push(Recipient {
name: "break-glass".into(),
public_key: "age1emergency".into(),
break_glass: true,
});
cfg.sops_version = Some("3.9.0".into());
let dir = assert_fs::TempDir::new().unwrap();
let path = cfg.save_to_dir(dir.path()).unwrap();
let loaded = Config::load(&path).unwrap();
assert_eq!(cfg, loaded);
assert_eq!(loaded.break_glass_recipient().unwrap().name, "break-glass");
assert_eq!(loaded.recipient("alice").unwrap().public_key, "age1alice");
}
#[test]
fn missing_file_is_reported() {
let err = Config::load("/nonexistent/.sopsy.yml").unwrap_err();
assert!(matches!(err, Error::FileNotFound(_)));
}
#[test]
fn load_from_dir_reads_conventional_file() {
let dir = assert_fs::TempDir::new().unwrap();
let mut cfg = Config::default();
cfg.recipients.push(Recipient::new("alice", "age1alice"));
cfg.save_to_dir(dir.path()).unwrap();
let loaded = Config::load_from_dir(dir.path()).unwrap();
assert_eq!(loaded.recipient("alice").unwrap().public_key, "age1alice");
}
#[test]
fn load_from_dir_missing_file_is_reported() {
let dir = assert_fs::TempDir::new().unwrap();
let err = Config::load_from_dir(dir.path()).unwrap_err();
assert!(matches!(err, Error::FileNotFound(_)));
}
#[test]
fn malformed_yaml_is_a_parse_error() {
let dir = assert_fs::TempDir::new().unwrap();
let path = dir.path().join(CONFIG_FILE_NAME);
std::fs::write(&path, "recipients: not-a-list\n").unwrap();
let err = Config::load(&path).unwrap_err();
assert!(matches!(err, Error::Parse { .. }));
}
#[test]
fn recipient_lookup_misses_return_none() {
let mut cfg = Config::default();
cfg.recipients.push(Recipient::new("alice", "age1alice"));
assert!(cfg.recipient("nobody").is_none());
assert!(cfg.break_glass_recipient().is_none());
}
}