feat(common): Policy load_from_dir + save_local with last-wins merge

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.
This commit is contained in:
michael
2026-04-27 06:23:49 -07:00
parent dc2c4b02cb
commit 9be8e4d0b3
2 changed files with 200 additions and 0 deletions

View File

@@ -10,3 +10,6 @@ serde_json = { workspace = true }
toml = { workspace = true } toml = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
zvariant = { workspace = true } zvariant = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }

View File

@@ -3,6 +3,21 @@
use crate::types::{Method, Mode}; use crate::types::{Method, Mode};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::BTreeMap; 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, zvariant::Type, Default)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
@@ -45,6 +60,188 @@ pub struct Policy {
pub firstrun: Firstrun, 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<Self, PolicyError> {
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 `<dir>/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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;