//! Policy types and (Phase 2) parsing/merging logic. Wire-typed for D-Bus. use crate::types::{Method, Mode}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::path::Path; use thiserror::Error; #[derive(Debug, Error)] pub enum PolicyError { #[error("reading policy directory: {0}")] Io(#[from] std::io::Error), #[error("parsing policy file {path}: {source}")] Toml { path: String, source: toml::de::Error, }, #[error("serializing policy: {0}")] Serialize(#[from] toml::ser::Error), } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, zvariant::Type, Default)] #[serde(rename_all = "kebab-case")] #[zvariant(signature = "s")] pub enum StorageBackend { #[default] PerUser, Central, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, zvariant::Type)] pub struct StackPolicy { pub mode: Mode, pub methods: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, zvariant::Type, Default)] pub struct Storage { #[serde(default)] pub backend: StorageBackend, #[serde(default)] pub central_path: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, zvariant::Type, Default)] pub struct Firstrun { #[serde(default)] pub default_required_methods: Vec, #[serde(default)] pub deadline_hours: u32, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, zvariant::Type, Default)] pub struct Policy { #[serde(default)] pub stacks: BTreeMap, #[serde(default)] pub storage: Storage, #[serde(default)] pub firstrun: Firstrun, } impl Policy { /// Load and merge all `*.conf` files in `dir`, lex-ascending. Last value /// for any key wins. Missing dir → returns Self::default(). pub fn load_from_dir(dir: &Path) -> Result { if !dir.exists() { return Ok(Self::default()); } let mut entries: Vec<_> = std::fs::read_dir(dir)? .filter_map(Result::ok) .filter(|e| { e.path() .extension() .and_then(|s| s.to_str()) .map(|s| s.eq_ignore_ascii_case("conf")) .unwrap_or(false) }) .collect(); entries.sort_by_key(|e| e.file_name()); let mut acc = Self::default(); for e in entries { let body = std::fs::read_to_string(e.path())?; let next: Policy = toml::from_str(&body).map_err(|source| PolicyError::Toml { path: e.path().display().to_string(), source, })?; acc.merge(next); } Ok(acc) } fn merge(&mut self, other: Policy) { for (k, v) in other.stacks { self.stacks.insert(k, v); } if other.storage != Storage::default() { self.storage = other.storage; } if other.firstrun != Firstrun::default() { self.firstrun = other.firstrun; } } /// Serialize `self` as TOML and write to `/50-local.conf`. Other files /// in `dir` are not read, written, or removed. pub fn save_local(&self, dir: &Path) -> Result<(), PolicyError> { std::fs::create_dir_all(dir)?; let body = toml::to_string_pretty(self)?; std::fs::write(dir.join("50-local.conf"), body)?; Ok(()) } } #[cfg(test)] mod parse_tests { use super::*; use crate::types::Method; use tempfile::tempdir; fn write(dir: &Path, name: &str, body: &str) { std::fs::write(dir.join(name), body).unwrap(); } #[test] fn load_single_file() { let d = tempdir().unwrap(); write( d.path(), "00-base.conf", r#" [stacks.sudo] mode = "required" methods = ["fido2"] "#, ); let p = Policy::load_from_dir(d.path()).unwrap(); assert_eq!(p.stacks.len(), 1); let sudo = p.stacks.get("sudo").unwrap(); assert_eq!(sudo.mode, Mode::Required); assert_eq!(sudo.methods, vec![Method::Fido2]); } #[test] fn last_file_wins_on_overlap() { let d = tempdir().unwrap(); write( d.path(), "00-base.conf", r#"[stacks.sudo] mode = "optional" methods = ["fido2"]"#, ); write( d.path(), "90-fleet.conf", r#"[stacks.sudo] mode = "required" methods = ["fido2","totp"]"#, ); let p = Policy::load_from_dir(d.path()).unwrap(); let sudo = p.stacks.get("sudo").unwrap(); assert_eq!(sudo.mode, Mode::Required); assert_eq!(sudo.methods.len(), 2); } #[test] fn lex_order_not_filesystem_order() { let d = tempdir().unwrap(); write( d.path(), "99-late.conf", r#"[stacks.sudo] mode = "disabled" methods = []"#, ); write( d.path(), "10-early.conf", r#"[stacks.sudo] mode = "required" methods = ["fido2"]"#, ); let p = Policy::load_from_dir(d.path()).unwrap(); assert_eq!(p.stacks.get("sudo").unwrap().mode, Mode::Disabled); } #[test] fn ignores_non_conf_files() { let d = tempdir().unwrap(); write(d.path(), "garbage.txt", "this is not toml"); write( d.path(), "00-base.conf", r#"[stacks.sudo] mode = "required" methods = ["fido2"]"#, ); let p = Policy::load_from_dir(d.path()).unwrap(); assert_eq!(p.stacks.get("sudo").unwrap().mode, Mode::Required); } #[test] fn missing_dir_yields_default() { let d = tempdir().unwrap(); let bogus = d.path().join("nope"); assert_eq!(Policy::load_from_dir(&bogus).unwrap(), Policy::default()); } #[test] fn save_local_preserves_sibling_files() { let d = tempdir().unwrap(); write( d.path(), "90-fleet.conf", r#"[stacks.sudo] mode = "required" methods = ["fido2"]"#, ); let mut local = Policy::default(); local.stacks.insert( "gdm-password".to_string(), StackPolicy { mode: Mode::Optional, methods: vec![Method::Fido2], }, ); local.save_local(d.path()).unwrap(); let fleet_body = std::fs::read_to_string(d.path().join("90-fleet.conf")).unwrap(); assert!(fleet_body.contains("required")); assert!(d.path().join("50-local.conf").exists()); let merged = Policy::load_from_dir(d.path()).unwrap(); assert_eq!(merged.stacks.get("sudo").unwrap().mode, Mode::Required); assert_eq!( merged.stacks.get("gdm-password").unwrap().mode, Mode::Optional ); } } #[cfg(test)] mod tests { use super::*; #[test] fn policy_default_is_all_disabled() { let p = Policy::default(); assert!(p.stacks.is_empty()); assert_eq!(p.storage.backend, StorageBackend::PerUser); assert!(p.firstrun.default_required_methods.is_empty()); assert_eq!(p.firstrun.deadline_hours, 0); } #[test] fn policy_serde_via_json() { let mut p = Policy::default(); p.stacks.insert( "sudo".to_string(), StackPolicy { mode: Mode::Required, methods: vec![Method::Fido2], }, ); let json = serde_json::to_string(&p).unwrap(); let back: Policy = serde_json::from_str(&json).unwrap(); assert_eq!(p, back); } }