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:
@@ -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 }
|
||||||
|
|||||||
@@ -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::*;
|
||||||
|
|||||||
Reference in New Issue
Block a user