use std::path::{Path, PathBuf};
use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
pub const CONFIG_FILE_NAME: &str = ".sopsy.yml";
pub const CHECKSUM_FILE_NAME: &str = ".sopsy.sha";
const DEFAULT_REQUEST_TTL: Duration = Duration::from_secs(72 * 3600);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MemberState {
Pending,
#[default]
Active,
}
fn is_active(state: &MemberState) -> bool {
matches!(state, MemberState::Active)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Recipient {
pub name: String,
pub public_key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub username: Option<String>,
#[serde(default, skip_serializing_if = "is_active")]
pub state: MemberState,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub requested_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub approved_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub approved_by: Option<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(),
username: None,
state: MemberState::Active,
requested_at: None,
approved_at: None,
approved_by: None,
break_glass: false,
}
}
pub fn with_username(
name: impl Into<String>,
public_key: impl Into<String>,
username: impl Into<String>,
) -> Self {
Self {
username: Some(username.into()),
..Self::new(name, public_key)
}
}
pub fn pending(
name: impl Into<String>,
public_key: impl Into<String>,
requested_at: impl Into<String>,
) -> Self {
Self {
state: MemberState::Pending,
requested_at: Some(requested_at.into()),
..Self::new(name, public_key)
}
}
pub fn is_pending(&self) -> bool {
matches!(self.state, MemberState::Pending)
}
}
#[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>,
#[serde(
default = "default_request_ttl",
skip_serializing_if = "Option::is_none"
)]
pub join_request_ttl: Option<String>,
}
impl Default for Config {
fn default() -> Self {
Self {
recipients: Vec::new(),
encrypted_globs: default_encrypted_globs(),
sops_version: None,
join_request_ttl: default_request_ttl(),
}
}
}
fn default_request_ttl() -> Option<String> {
Some("72h".to_string())
}
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 resolved_request_ttl(&self) -> Duration {
self.join_request_ttl
.as_deref()
.and_then(|s| humantime::parse_duration(s).ok())
.unwrap_or(DEFAULT_REQUEST_TTL)
}
pub fn admin_public_key(&self) -> Option<&str> {
self.recipients.first().map(|r| r.public_key.as_str())
}
pub fn compute_checksum(&self, raw: &str) -> String {
use sha2::{Digest, Sha256};
let admin_key = self.admin_public_key().unwrap_or("");
let digest = Sha256::digest(format!("{raw}\n{admin_key}").as_bytes());
digest.iter().map(|b| format!("{b:02x}")).collect()
}
pub fn checksum_path(config_path: &Path) -> PathBuf {
config_path.with_file_name(CHECKSUM_FILE_NAME)
}
pub fn load(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref();
let (config, raw) = Self::load_unverified(path)?;
if let Ok(stored) = std::fs::read_to_string(Self::checksum_path(path))
&& stored.trim() != config.compute_checksum(&raw)
{
return Err(Error::Validation(format!(
"{} failed its integrity check against {CHECKSUM_FILE_NAME} — \
it was edited outside sopsy. If the changes are intentional, \
run `sopsy doctor` to repair the checksum; otherwise inspect \
`git diff {CONFIG_FILE_NAME}`",
path.display()
)));
}
Ok(config)
}
pub fn load_unverified(path: impl AsRef<Path>) -> Result<(Self, String)> {
let path = path.as_ref();
if !path.exists() {
return Err(Error::FileNotFound(path.to_path_buf()));
}
let raw = std::fs::read_to_string(path)?;
let config = serde_yaml_ng::from_str(&raw).map_err(|source| Error::Parse {
path: path.to_path_buf(),
source,
})?;
Ok((config, raw))
}
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 path = path.as_ref();
let yaml = serde_yaml_ng::to_string(self)?;
std::fs::write(path, &yaml)?;
std::fs::write(
Self::checksum_path(path),
format!("{}\n", self.compute_checksum(&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 {
break_glass: true,
..Recipient::new("break-glass", "age1emergency")
});
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 username_round_trips_and_is_omitted_when_absent() {
let mut cfg = Config::default();
cfg.recipients
.push(Recipient::with_username("alice", "age1alice", "kig"));
cfg.recipients.push(Recipient::new("bob", "age1bob"));
let dir = assert_fs::TempDir::new().unwrap();
let path = cfg.save_to_dir(dir.path()).unwrap();
let raw = std::fs::read_to_string(&path).unwrap();
assert!(raw.contains("username: kig"));
assert_eq!(raw.matches("username:").count(), 1);
let loaded = Config::load(&path).unwrap();
assert_eq!(cfg, loaded);
assert_eq!(
loaded.recipient("alice").unwrap().username.as_deref(),
Some("kig")
);
assert!(loaded.recipient("bob").unwrap().username.is_none());
}
#[test]
fn pending_member_round_trips_and_state_defaults_to_active() {
let mut cfg = Config::default();
cfg.recipients.push(Recipient::new("alice", "age1alice"));
cfg.recipients
.push(Recipient::pending("bob", "age1bob", "2026-06-27T00:00:00Z"));
let dir = assert_fs::TempDir::new().unwrap();
let path = cfg.save_to_dir(dir.path()).unwrap();
let raw = std::fs::read_to_string(&path).unwrap();
assert!(raw.contains("state: pending"));
assert!(raw.contains("requested_at: 2026-06-27T00:00:00Z"));
assert_eq!(
raw.matches("state:").count(),
1,
"active state must be omitted"
);
let loaded = Config::load(&path).unwrap();
assert_eq!(cfg, loaded);
assert!(!loaded.recipient("alice").unwrap().is_pending());
assert!(loaded.recipient("bob").unwrap().is_pending());
}
#[test]
fn approval_provenance_round_trips_and_is_omitted_when_absent() {
let mut cfg = Config::default();
cfg.recipients.push(Recipient {
requested_at: Some("2026-06-27T00:00:00Z".into()),
approved_at: Some("2026-06-28T10:00:00Z".into()),
approved_by: Some("Konstantin Gredeskoul (kig)".into()),
..Recipient::new("annie", "age1annie")
});
cfg.recipients.push(Recipient::new("bob", "age1bob"));
let dir = assert_fs::TempDir::new().unwrap();
let path = cfg.save_to_dir(dir.path()).unwrap();
let raw = std::fs::read_to_string(&path).unwrap();
assert!(raw.contains("approved_at: 2026-06-28T10:00:00Z"));
assert!(raw.contains("approved_by: Konstantin Gredeskoul (kig)"));
assert!(raw.contains("requested_at: 2026-06-27T00:00:00Z"));
assert_eq!(raw.matches("approved_by:").count(), 1);
let loaded = Config::load(&path).unwrap();
assert_eq!(cfg, loaded);
assert!(loaded.recipient("bob").unwrap().approved_by.is_none());
}
#[test]
fn resolved_request_ttl_parses_and_falls_back() {
let mut cfg = Config {
join_request_ttl: Some("2h".into()),
..Config::default()
};
assert_eq!(cfg.resolved_request_ttl(), Duration::from_secs(7200));
cfg.join_request_ttl = Some("not-a-duration".into());
assert_eq!(cfg.resolved_request_ttl(), Duration::from_secs(72 * 3600));
cfg.join_request_ttl = None;
assert_eq!(cfg.resolved_request_ttl(), Duration::from_secs(72 * 3600));
}
#[test]
fn save_writes_checksum_sidecar_that_load_verifies() {
let mut cfg = Config::default();
cfg.recipients.push(Recipient::new("admin", "age1admin"));
let dir = assert_fs::TempDir::new().unwrap();
let path = cfg.save_to_dir(dir.path()).unwrap();
let sha_path = Config::checksum_path(&path);
let stored = std::fs::read_to_string(&sha_path).unwrap();
let raw = std::fs::read_to_string(&path).unwrap();
assert_eq!(stored.trim(), cfg.compute_checksum(&raw));
assert_eq!(Config::load(&path).unwrap(), cfg);
}
#[test]
fn tampered_config_fails_integrity_check() {
let mut cfg = Config::default();
cfg.recipients.push(Recipient::new("admin", "age1admin"));
let dir = assert_fs::TempDir::new().unwrap();
let path = cfg.save_to_dir(dir.path()).unwrap();
let raw = std::fs::read_to_string(&path).unwrap();
std::fs::write(&path, raw.replace("age1admin", "age1attacker")).unwrap();
let err = Config::load(&path).unwrap_err();
assert!(
matches!(&err, Error::Validation(m) if m.contains("integrity")),
"tampering must fail the integrity check: {err}"
);
let (unverified, _raw) = Config::load_unverified(&path).unwrap();
assert_eq!(
unverified.recipient("admin").unwrap().public_key,
"age1attacker"
);
}
#[test]
fn missing_checksum_sidecar_is_accepted_as_legacy() {
let mut cfg = Config::default();
cfg.recipients.push(Recipient::new("admin", "age1admin"));
let dir = assert_fs::TempDir::new().unwrap();
let path = cfg.save_to_dir(dir.path()).unwrap();
std::fs::remove_file(Config::checksum_path(&path)).unwrap();
assert_eq!(Config::load(&path).unwrap(), cfg);
}
#[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());
}
}