From 9be8e4d0b3331393e62dcc2f8af5e5a82e214e8e Mon Sep 17 00:00:00 2001 From: michael Date: Mon, 27 Apr 2026 06:23:49 -0700 Subject: [PATCH] feat(common): Policy load_from_dir + save_local with last-wins merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lands plan tasks 2.3 (single-file load), 2.4 (multi-file merge cases — last-wins, lex order, non-conf skip, missing-dir default), and 2.5 (save_local preserves sibling files) as one logical unit. - load_from_dir: reads *.conf in lex-ascending order, parses TOML, merges via last-wins on stack key, storage block, and firstrun block. Missing dir yields Policy::default(). Non-.conf entries silently skipped. - save_local: writes 50-local.conf with toml::to_string_pretty; never reads or removes siblings. Created via create_dir_all. - 6 parse tests added: load_single_file, last_file_wins_on_overlap, lex_order_not_filesystem_order, ignores_non_conf_files, missing_dir_yields_default, save_local_preserves_sibling_files. --- common/Cargo.toml | 3 + common/src/policy.rs | 197 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 200 insertions(+) diff --git a/common/Cargo.toml b/common/Cargo.toml index 71f9431..d514a42 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -10,3 +10,6 @@ serde_json = { workspace = true } toml = { workspace = true } thiserror = { workspace = true } zvariant = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/common/src/policy.rs b/common/src/policy.rs index 0b4baa5..d4398a3 100644 --- a/common/src/policy.rs +++ b/common/src/policy.rs @@ -3,6 +3,21 @@ 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")] @@ -45,6 +60,188 @@ pub struct Policy { 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::*;