1use std::collections::{BTreeMap, HashSet};
4use std::path::Path;
5
6use chrono::Utc;
7use serde::{Deserialize, Serialize};
8use serde_json::{Map, Value};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct PolicyBundle {
13 pub bundle_id: String,
14 pub version: u32,
15 pub created_at: String,
16 pub rules: Vec<PolicyBundleRule>,
17 pub content_hash: String,
18 #[serde(default, skip_serializing_if = "Option::is_none")]
19 pub signature: Option<String>,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct PolicyBundleRule {
25 pub rule_id: String,
26 pub level: String,
27 pub effect: String,
28 pub conditions: Value,
29 pub priority: u32,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum BundleVerification {
35 Valid,
36 InvalidHash { expected: String, actual: String },
37 MissingSignature,
38 Unsigned,
39}
40
41pub fn compute_content_hash(rules: &[PolicyBundleRule]) -> String {
43 let rules = Value::Array(
44 rules
45 .iter()
46 .map(|rule| {
47 let mut value = Map::new();
48 value.insert("rule_id".to_string(), Value::String(rule.rule_id.clone()));
49 value.insert("level".to_string(), Value::String(rule.level.clone()));
50 value.insert("effect".to_string(), Value::String(rule.effect.clone()));
51 value.insert(
52 "conditions".to_string(),
53 canonicalize_json(&rule.conditions),
54 );
55 value.insert("priority".to_string(), Value::from(rule.priority));
56 canonicalize_json(&Value::Object(value))
57 })
58 .collect(),
59 );
60 let canonical_json =
61 serde_json::to_vec(&rules).expect("policy bundle rules always serialize to JSON");
62 blake3::hash(&canonical_json).to_hex().to_string()
63}
64
65pub fn create_bundle(bundle_id: &str, rules: Vec<PolicyBundleRule>) -> PolicyBundle {
67 let content_hash = compute_content_hash(&rules);
68 PolicyBundle {
69 bundle_id: bundle_id.to_string(),
70 version: 1,
71 created_at: Utc::now().to_rfc3339(),
72 rules,
73 content_hash,
74 signature: None,
75 }
76}
77
78pub fn verify_bundle(bundle: &PolicyBundle) -> BundleVerification {
80 let actual = compute_content_hash(&bundle.rules);
81 if bundle.content_hash != actual {
82 return BundleVerification::InvalidHash {
83 expected: bundle.content_hash.clone(),
84 actual,
85 };
86 }
87
88 match bundle.signature.as_deref() {
89 None => BundleVerification::Unsigned,
90 Some(signature) if signature.trim().is_empty() => BundleVerification::MissingSignature,
91 Some(_) => BundleVerification::Valid,
92 }
93}
94
95pub fn load_bundle(path: &Path) -> Result<PolicyBundle, String> {
97 let contents = std::fs::read_to_string(path)
98 .map_err(|error| format!("read policy bundle {}: {error}", path.display()))?;
99 let bundle: PolicyBundle = serde_json::from_str(&contents)
100 .map_err(|error| format!("parse policy bundle {}: {error}", path.display()))?;
101
102 if let BundleVerification::InvalidHash { expected, actual } = verify_bundle(&bundle) {
103 return Err(format!(
104 "policy bundle {} has an invalid content hash: expected {expected}, got {actual}",
105 path.display()
106 ));
107 }
108
109 Ok(bundle)
110}
111
112pub fn save_bundle(bundle: &PolicyBundle, path: &Path) -> Result<(), String> {
114 let json = serde_json::to_string_pretty(bundle)
115 .map_err(|error| format!("serialize policy bundle: {error}"))?;
116 std::fs::write(path, format!("{json}\n"))
117 .map_err(|error| format!("write policy bundle {}: {error}", path.display()))
118}
119
120pub fn merge_bundles(base: &PolicyBundle, overlay: &PolicyBundle) -> PolicyBundle {
122 let base_rule_ids: HashSet<&str> = base
123 .rules
124 .iter()
125 .map(|rule| rule.rule_id.as_str())
126 .collect();
127 let overlay_rules: BTreeMap<&str, &PolicyBundleRule> = overlay
128 .rules
129 .iter()
130 .map(|rule| (rule.rule_id.as_str(), rule))
131 .collect();
132 let mut rules = Vec::with_capacity(base.rules.len() + overlay.rules.len());
133
134 for rule in &base.rules {
135 if let Some(overlay_rule) = overlay_rules.get(rule.rule_id.as_str()) {
136 rules.push((*overlay_rule).clone());
137 } else {
138 rules.push(rule.clone());
139 }
140 }
141 for rule in &overlay.rules {
142 if !base_rule_ids.contains(rule.rule_id.as_str()) {
143 rules.push(rule.clone());
144 }
145 }
146
147 let mut merged = if overlay.version >= base.version {
148 overlay.clone()
149 } else {
150 base.clone()
151 };
152 merged.rules = rules;
153 merged.content_hash = compute_content_hash(&merged.rules);
154 merged
155}
156
157fn canonicalize_json(value: &Value) -> Value {
158 match value {
159 Value::Array(values) => Value::Array(values.iter().map(canonicalize_json).collect()),
160 Value::Object(values) => {
161 let sorted: BTreeMap<_, _> = values.iter().collect();
162 let mut canonical = Map::new();
163 for (key, value) in sorted {
164 canonical.insert(key.clone(), canonicalize_json(value));
165 }
166 Value::Object(canonical)
167 }
168 _ => value.clone(),
169 }
170}
171
172#[cfg(test)]
173mod tests {
174 use serde_json::json;
175
176 use super::{
177 BundleVerification, PolicyBundleRule, compute_content_hash, create_bundle, load_bundle,
178 merge_bundles, save_bundle, verify_bundle,
179 };
180
181 fn rule(rule_id: &str, effect: &str) -> PolicyBundleRule {
182 PolicyBundleRule {
183 rule_id: rule_id.to_string(),
184 level: "standard".to_string(),
185 effect: effect.to_string(),
186 conditions: json!({"source": "local"}),
187 priority: 10,
188 }
189 }
190
191 #[test]
192 fn created_bundle_verifies_as_unsigned() {
193 let bundle = create_bundle("default", vec![rule("allow-local", "allow")]);
194
195 assert_eq!(verify_bundle(&bundle), BundleVerification::Unsigned);
196 }
197
198 #[test]
199 fn tampered_rule_has_an_invalid_hash() {
200 let mut bundle = create_bundle("default", vec![rule("allow-local", "allow")]);
201 bundle.rules[0].effect = "deny".to_string();
202
203 assert!(matches!(
204 verify_bundle(&bundle),
205 BundleVerification::InvalidHash { .. }
206 ));
207 }
208
209 #[test]
210 fn bundle_with_a_signature_verifies_as_valid() {
211 let mut bundle = create_bundle("default", vec![rule("allow-local", "allow")]);
212 bundle.signature = Some("signature".to_string());
213
214 assert_eq!(verify_bundle(&bundle), BundleVerification::Valid);
215 }
216
217 #[test]
218 fn save_and_load_round_trip() {
219 let directory = tempfile::tempdir().unwrap();
220 let path = directory.path().join("policy-bundle.json");
221 let bundle = create_bundle("default", vec![rule("allow-local", "allow")]);
222
223 save_bundle(&bundle, &path).unwrap();
224
225 let loaded = load_bundle(&path).unwrap();
226 assert_eq!(loaded.bundle_id, bundle.bundle_id);
227 assert_eq!(loaded.rules[0].rule_id, bundle.rules[0].rule_id);
228 assert_eq!(loaded.content_hash, bundle.content_hash);
229 }
230
231 #[test]
232 fn merge_replaces_matching_rules_and_appends_new_ones() {
233 let base = create_bundle(
234 "base",
235 vec![rule("allow-local", "allow"), rule("deny-remote", "deny")],
236 );
237 let mut overlay = create_bundle(
238 "overlay",
239 vec![rule("allow-local", "deny"), rule("audit", "audit")],
240 );
241 overlay.version = 2;
242
243 let merged = merge_bundles(&base, &overlay);
244
245 assert_eq!(merged.bundle_id, "overlay");
246 assert_eq!(merged.version, 2);
247 assert_eq!(merged.rules.len(), 3);
248 assert_eq!(merged.rules[0].effect, "deny");
249 assert_eq!(merged.rules[1].rule_id, "deny-remote");
250 assert_eq!(merged.rules[2].rule_id, "audit");
251 assert_eq!(merged.content_hash, compute_content_hash(&merged.rules));
252 }
253
254 #[test]
255 fn empty_rules_have_a_stable_valid_hash() {
256 let mut bundle = create_bundle("empty", Vec::new());
257 bundle.signature = Some("signature".to_string());
258
259 assert_eq!(bundle.content_hash, compute_content_hash(&[]));
260 assert_eq!(verify_bundle(&bundle), BundleVerification::Valid);
261 }
262
263 #[test]
264 fn object_key_order_does_not_change_content_hash() {
265 let first = rule("ordered", "allow");
266 let mut second = first.clone();
267 second.conditions = json!({"source": "local", "region": "eu"});
268 let mut reordered = second.clone();
269 reordered.conditions = json!({"region": "eu", "source": "local"});
270
271 assert_eq!(
272 compute_content_hash(&[second]),
273 compute_content_hash(&[reordered])
274 );
275 }
276}