use std::collections::BTreeSet;
use std::path::Path;
use serde::Deserialize;
pub const FED_INVENTORY_PATH_ENV: &str = "AI_MEMORY_FED_INVENTORY_PATH";
pub const MIN_QUORUM_WIDTH: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub enum AttestorMethod {
MtlsCert,
NodePlugin,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NodeSpec {
pub id: String,
pub attestor: AttestorMethod,
pub cred_ttl: String,
pub renew_before: String,
#[serde(default)]
pub roles: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RegionSpec {
pub name: String,
#[serde(default)]
pub intermediate_ca: Option<String>,
#[serde(default)]
pub nodes: Vec<NodeSpec>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct QuorumSpec {
pub width: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EnforcementSpec {
#[serde(default)]
pub require_sig: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FederationInventory {
pub trust_domain: String,
#[serde(default)]
pub root_ca: Option<String>,
#[serde(default)]
pub regions: Vec<RegionSpec>,
pub quorum: QuorumSpec,
#[serde(default)]
pub enforcement: EnforcementSpec,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InventoryError {
Parse(String),
Io(String),
EmptyTrustDomain,
EmptyRegionName,
InvalidNodeId { id: String, reason: String },
InvalidDuration {
node: String,
field: &'static str,
value: String,
},
RenewBeforeNotShorterThanTtl { node: String },
DuplicateNodeId { id: String },
InvalidQuorumWidth { width: u32 },
}
impl InventoryError {
#[must_use]
pub fn tag(&self) -> &'static str {
match self {
Self::Parse(_) => "inventory_parse_error",
Self::Io(_) => "inventory_io_error",
Self::EmptyTrustDomain => "inventory_empty_trust_domain",
Self::EmptyRegionName => "inventory_empty_region_name",
Self::InvalidNodeId { .. } => "inventory_invalid_node_id",
Self::InvalidDuration { .. } => "inventory_invalid_duration",
Self::RenewBeforeNotShorterThanTtl { .. } => "inventory_renew_before_not_shorter",
Self::DuplicateNodeId { .. } => "inventory_duplicate_node_id",
Self::InvalidQuorumWidth { .. } => "inventory_invalid_quorum_width",
}
}
}
impl std::fmt::Display for InventoryError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Parse(msg) | Self::Io(msg) => write!(f, "{} ({msg})", self.tag()),
Self::InvalidNodeId { id, reason } => {
write!(f, "{} (id={id}: {reason})", self.tag())
}
Self::InvalidDuration { node, field, value } => {
write!(f, "{} (node={node} {field}={value})", self.tag())
}
Self::RenewBeforeNotShorterThanTtl { node } => {
write!(f, "{} (node={node})", self.tag())
}
Self::DuplicateNodeId { id } => write!(f, "{} (id={id})", self.tag()),
Self::InvalidQuorumWidth { width } => write!(f, "{} (width={width})", self.tag()),
_ => f.write_str(self.tag()),
}
}
}
impl std::error::Error for InventoryError {}
impl FederationInventory {
pub fn from_yaml_str(yaml: &str) -> Result<Self, InventoryError> {
let inventory: Self =
serde_yaml::from_str(yaml).map_err(|e| InventoryError::Parse(e.to_string()))?;
inventory.validate()?;
Ok(inventory)
}
pub fn load_from_path(path: &Path) -> Result<Self, InventoryError> {
let raw = std::fs::read_to_string(path).map_err(|e| InventoryError::Io(e.to_string()))?;
Self::from_yaml_str(&raw)
}
pub fn load_from_env() -> Result<Option<Self>, InventoryError> {
match std::env::var(FED_INVENTORY_PATH_ENV) {
Ok(path) => Self::load_from_path(Path::new(&path)).map(Some),
Err(_) => Ok(None),
}
}
pub fn nodes(&self) -> impl Iterator<Item = &NodeSpec> {
self.regions.iter().flat_map(|r| r.nodes.iter())
}
pub fn validate(&self) -> Result<(), InventoryError> {
if self.trust_domain.trim().is_empty() {
return Err(InventoryError::EmptyTrustDomain);
}
if self.quorum.width < MIN_QUORUM_WIDTH {
return Err(InventoryError::InvalidQuorumWidth {
width: self.quorum.width,
});
}
let mut seen_ids: BTreeSet<&str> = BTreeSet::new();
for region in &self.regions {
if region.name.trim().is_empty() {
return Err(InventoryError::EmptyRegionName);
}
for node in ®ion.nodes {
node.validate()?;
if !seen_ids.insert(node.id.as_str()) {
return Err(InventoryError::DuplicateNodeId {
id: node.id.clone(),
});
}
}
}
Ok(())
}
}
impl NodeSpec {
#[must_use]
pub fn cred_ttl_duration(&self) -> Option<chrono::Duration> {
crate::config::parse_duration_string(&self.cred_ttl)
}
#[must_use]
pub fn renew_before_duration(&self) -> Option<chrono::Duration> {
crate::config::parse_duration_string(&self.renew_before)
}
fn validate(&self) -> Result<(), InventoryError> {
crate::validate::validate_agent_id_shape(&self.id).map_err(|e| {
InventoryError::InvalidNodeId {
id: self.id.clone(),
reason: e.to_string(),
}
})?;
let ttl = positive_duration(self.cred_ttl_duration()).ok_or_else(|| {
InventoryError::InvalidDuration {
node: self.id.clone(),
field: "cred_ttl",
value: self.cred_ttl.clone(),
}
})?;
let renew = positive_duration(self.renew_before_duration()).ok_or_else(|| {
InventoryError::InvalidDuration {
node: self.id.clone(),
field: "renew_before",
value: self.renew_before.clone(),
}
})?;
if renew >= ttl {
return Err(InventoryError::RenewBeforeNotShorterThanTtl {
node: self.id.clone(),
});
}
Ok(())
}
}
fn positive_duration(d: Option<chrono::Duration>) -> Option<chrono::Duration> {
d.filter(|d| *d > chrono::Duration::zero())
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = "\
trust_domain: fleet.example
root_ca: root.pub
regions:
- name: nyc
intermediate_ca: nyc-int.pub
nodes:
- id: region/nyc/node-1
attestor: mtls-cert
cred_ttl: 1h
renew_before: 15m
roles: [writer, reader]
- id: region/nyc/node-2
attestor: node-plugin
cred_ttl: 2h
renew_before: 30m
- name: sfo
nodes:
- id: region/sfo/node-1
attestor: mtls-cert
cred_ttl: 1h
renew_before: 10m
quorum:
width: 2
enforcement:
require_sig: true
";
#[test]
fn parses_and_validates_a_full_inventory() {
let inv = FederationInventory::from_yaml_str(SAMPLE).expect("valid");
assert_eq!(inv.trust_domain, "fleet.example");
assert_eq!(inv.root_ca.as_deref(), Some("root.pub"));
assert_eq!(inv.regions.len(), 2);
assert_eq!(inv.quorum.width, 2);
assert!(inv.enforcement.require_sig);
assert_eq!(inv.nodes().count(), 3);
let first = inv.nodes().next().expect("a node");
assert_eq!(first.id, "region/nyc/node-1");
assert_eq!(first.attestor, AttestorMethod::MtlsCert);
assert_eq!(first.roles, vec!["writer", "reader"]);
assert_eq!(first.cred_ttl_duration(), Some(chrono::Duration::hours(1)));
assert_eq!(
first.renew_before_duration(),
Some(chrono::Duration::minutes(15))
);
}
#[test]
fn enforcement_defaults_to_permissive_when_omitted() {
let yaml = "\
trust_domain: d
quorum:
width: 1
";
let inv = FederationInventory::from_yaml_str(yaml).expect("valid");
assert!(!inv.enforcement.require_sig);
assert_eq!(inv.nodes().count(), 0);
}
#[test]
fn unknown_field_is_a_hard_parse_error() {
let yaml = "\
trust_domain: d
quorum:
width: 1
enforcement:
requir_sig: true
";
let err = FederationInventory::from_yaml_str(yaml).expect_err("typo must fail");
assert_eq!(err.tag(), "inventory_parse_error");
}
#[test]
fn empty_trust_domain_is_rejected() {
let yaml = "\
trust_domain: ' '
quorum:
width: 1
";
let err = FederationInventory::from_yaml_str(yaml).expect_err("empty domain");
assert_eq!(err, InventoryError::EmptyTrustDomain);
}
#[test]
fn zero_quorum_width_is_rejected() {
let yaml = "\
trust_domain: d
quorum:
width: 0
";
let err = FederationInventory::from_yaml_str(yaml).expect_err("zero width");
assert_eq!(err, InventoryError::InvalidQuorumWidth { width: 0 });
}
#[test]
fn path_traversal_node_id_is_rejected() {
let yaml = "\
trust_domain: d
regions:
- name: r
nodes:
- id: ../../etc/secret
attestor: mtls-cert
cred_ttl: 1h
renew_before: 5m
quorum:
width: 1
";
let err = FederationInventory::from_yaml_str(yaml).expect_err("traversal");
assert_eq!(err.tag(), "inventory_invalid_node_id");
}
#[test]
fn unparsable_duration_is_rejected() {
let yaml = "\
trust_domain: d
regions:
- name: r
nodes:
- id: node-1
attestor: mtls-cert
cred_ttl: soon
renew_before: 5m
quorum:
width: 1
";
let err = FederationInventory::from_yaml_str(yaml).expect_err("bad ttl");
assert_eq!(
err,
InventoryError::InvalidDuration {
node: "node-1".to_string(),
field: "cred_ttl",
value: "soon".to_string(),
}
);
}
#[test]
fn renew_before_not_shorter_than_ttl_is_rejected() {
let yaml = "\
trust_domain: d
regions:
- name: r
nodes:
- id: node-1
attestor: mtls-cert
cred_ttl: 1h
renew_before: 1h
quorum:
width: 1
";
let err = FederationInventory::from_yaml_str(yaml).expect_err("renew>=ttl");
assert_eq!(
err,
InventoryError::RenewBeforeNotShorterThanTtl {
node: "node-1".to_string()
}
);
}
#[test]
fn duplicate_node_id_across_regions_is_rejected() {
let yaml = "\
trust_domain: d
regions:
- name: r1
nodes:
- id: dup
attestor: mtls-cert
cred_ttl: 1h
renew_before: 5m
- name: r2
nodes:
- id: dup
attestor: mtls-cert
cred_ttl: 1h
renew_before: 5m
quorum:
width: 1
";
let err = FederationInventory::from_yaml_str(yaml).expect_err("dup id");
assert_eq!(
err,
InventoryError::DuplicateNodeId {
id: "dup".to_string()
}
);
}
#[test]
fn empty_region_name_is_rejected() {
let yaml = "\
trust_domain: d
regions:
- name: ' '
nodes: []
quorum:
width: 1
";
let err = FederationInventory::from_yaml_str(yaml).expect_err("empty region");
assert_eq!(err, InventoryError::EmptyRegionName);
}
#[test]
fn load_from_env_unset_is_none() {
unsafe { std::env::remove_var(FED_INVENTORY_PATH_ENV) };
assert_eq!(FederationInventory::load_from_env().expect("ok"), None);
}
}