1pub mod blast_radius;
12pub mod diff;
13pub mod version;
14
15#[cfg(feature = "watch")]
16pub mod watcher;
17
18pub use version::PolicyVersion;
19
20use agentguard_core::Result;
21
22use serde::{Deserialize, Serialize};
23use std::path::Path;
24use std::time::SystemTime;
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct PolicyBundle {
29 pub version: PolicyVersion,
30 pub tenant_id: String,
31 pub schema_hash: [u8; 32],
32 pub policies_hash: [u8; 32],
33 pub created_at: i64,
34 pub created_by: String,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub signature: Option<Vec<u8>>,
37 pub schema_source: String,
38 pub policies: Vec<NamedPolicy>,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct NamedPolicy {
44 pub id: String,
45 pub source: String,
46}
47
48impl PolicyBundle {
49 pub fn content_hash(&self) -> [u8; 32] {
51 use sha2::{Digest, Sha256};
52 let mut hasher = Sha256::new();
53 hasher.update(self.schema_hash);
54 hasher.update(self.policies_hash);
55 hasher.finalize().into()
56 }
57}
58
59#[derive(Debug, Default)]
61pub struct BundleRegistry {
62 by_tenant: parking_lot::RwLock<
64 std::collections::HashMap<String, std::collections::BTreeMap<u64, PolicyBundle>>,
65 >,
66}
67
68impl BundleRegistry {
69 pub fn new() -> Self {
70 Self::default()
71 }
72
73 pub fn register(&self, bundle: PolicyBundle) -> Result<()> {
76 let mut all = self.by_tenant.write();
77 let entry = all.entry(bundle.tenant_id.clone()).or_default();
78 entry.insert(bundle.version.as_u64(), bundle);
79 Ok(())
80 }
81
82 pub fn latest(&self, tenant_id: &str) -> Option<PolicyBundle> {
84 let all = self.by_tenant.read();
85 all.get(tenant_id)
86 .and_then(|m| m.values().next_back().cloned())
87 }
88
89 pub fn at(&self, tenant_id: &str, version: PolicyVersion) -> Option<PolicyBundle> {
91 let all = self.by_tenant.read();
92 all.get(tenant_id)
93 .and_then(|m| m.get(&version.as_u64()).cloned())
94 }
95
96 pub fn list(&self, tenant_id: &str) -> Vec<PolicyBundle> {
98 let all = self.by_tenant.read();
99 all.get(tenant_id)
100 .map(|m| m.values().cloned().collect())
101 .unwrap_or_default()
102 }
103
104 pub fn total(&self) -> usize {
106 let all = self.by_tenant.read();
107 all.values().map(|m| m.len()).sum()
108 }
109}
110
111pub fn sha256(bytes: &[u8]) -> [u8; 32] {
113 use sha2::{Digest, Sha256};
114 let mut h = Sha256::new();
115 h.update(bytes);
116 h.finalize().into()
117}
118
119pub fn now_unix() -> i64 {
121 SystemTime::now()
122 .duration_since(SystemTime::UNIX_EPOCH)
123 .map(|d| d.as_secs() as i64)
124 .unwrap_or(0)
125}
126
127pub fn write_bundle_to_path(bundle: &PolicyBundle, path: impl AsRef<Path>) -> std::io::Result<()> {
129 if let Some(parent) = path.as_ref().parent() {
130 std::fs::create_dir_all(parent)?;
131 }
132 let text = serde_json::to_string_pretty(bundle).map_err(std::io::Error::other)?;
133 std::fs::write(path, text)?;
134 Ok(())
135}
136
137pub fn read_bundle_from_path(path: impl AsRef<Path>) -> std::io::Result<PolicyBundle> {
139 let text = std::fs::read_to_string(path)?;
140 serde_json::from_str(&text).map_err(std::io::Error::other)
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146
147 fn bundle(version: u64, tenant: &str) -> PolicyBundle {
148 PolicyBundle {
149 version: PolicyVersion::new(version),
150 tenant_id: tenant.to_string(),
151 schema_hash: [0u8; 32],
152 policies_hash: [0u8; 32],
153 created_at: now_unix(),
154 created_by: "test".into(),
155 signature: None,
156 schema_source: "entity User;".into(),
157 policies: vec![NamedPolicy {
158 id: "p0".into(),
159 source: "permit (principal, action, resource);".into(),
160 }],
161 }
162 }
163
164 #[test]
165 fn registry_keeps_latest() {
166 let reg = BundleRegistry::new();
167 reg.register(bundle(1, "acme")).unwrap();
168 reg.register(bundle(2, "acme")).unwrap();
169 reg.register(bundle(3, "acme")).unwrap();
170 let latest = reg.latest("acme").unwrap();
171 assert_eq!(latest.version.as_u64(), 3);
172 assert_eq!(reg.list("acme").len(), 3);
173 }
174
175 #[test]
176 fn at_returns_specific_version() {
177 let reg = BundleRegistry::new();
178 reg.register(bundle(1, "acme")).unwrap();
179 reg.register(bundle(5, "acme")).unwrap();
180 let v = reg.at("acme", PolicyVersion::new(5)).unwrap();
181 assert_eq!(v.version.as_u64(), 5);
182 assert!(reg.at("acme", PolicyVersion::new(99)).is_none());
183 }
184
185 #[test]
186 fn tenants_are_isolated() {
187 let reg = BundleRegistry::new();
188 reg.register(bundle(1, "acme")).unwrap();
189 reg.register(bundle(2, "globex")).unwrap();
190 assert_eq!(reg.latest("acme").unwrap().version.as_u64(), 1);
191 assert_eq!(reg.latest("globex").unwrap().version.as_u64(), 2);
192 }
193
194 #[test]
195 fn content_hash_is_stable() {
196 let a = bundle(1, "t");
197 let b = bundle(1, "t");
198 assert_eq!(a.content_hash(), b.content_hash());
199 }
200
201 #[test]
202 fn round_trip_via_disk() {
203 let dir = tempfile::tempdir().unwrap();
204 let path = dir.path().join("bundle.json");
205 let b = bundle(7, "tenant-x");
206 write_bundle_to_path(&b, &path).unwrap();
207 let loaded = read_bundle_from_path(&path).unwrap();
208 assert_eq!(loaded.version.as_u64(), 7);
209 assert_eq!(loaded.tenant_id, "tenant-x");
210 assert_eq!(loaded.policies.len(), 1);
211 }
212}