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, PartialEq, Eq, Serialize, Deserialize)]
12pub enum PolicyCriticality {
13 Critical,
14 High,
15 Medium,
16 Low,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21pub enum ExpiryBehavior {
22 FailClosed,
23 FailOpen,
24 GracePeriod,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum ResolvedFailMode {
30 Allow,
31 Deny,
32 DenyAfterGrace { remaining_seconds: u64 },
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct PolicyBundle {
38 pub bundle_id: String,
39 pub version: u32,
40 pub created_at: String,
41 pub rules: Vec<PolicyBundleRule>,
42 pub content_hash: String,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub signature: Option<String>,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct PolicyBundleRule {
50 pub rule_id: String,
51 pub level: String,
52 pub effect: String,
53 pub conditions: Value,
54 pub priority: u32,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub criticality: Option<PolicyCriticality>,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub expiry_behavior: Option<ExpiryBehavior>,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub grace_period_seconds: Option<u64>,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub fallback_policy_ref: Option<String>,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub last_known_good_allowed: Option<bool>,
65}
66
67pub fn effective_fail_mode(rule: &PolicyBundleRule, seconds_since_expiry: u64) -> ResolvedFailMode {
70 let behavior = rule
71 .expiry_behavior
72 .as_ref()
73 .unwrap_or(&ExpiryBehavior::FailOpen);
74 match behavior {
75 ExpiryBehavior::FailClosed => ResolvedFailMode::Deny,
76 ExpiryBehavior::FailOpen => ResolvedFailMode::Allow,
77 ExpiryBehavior::GracePeriod => {
78 let grace = rule.grace_period_seconds.unwrap_or(300);
79 if seconds_since_expiry < grace {
80 ResolvedFailMode::DenyAfterGrace {
81 remaining_seconds: grace - seconds_since_expiry,
82 }
83 } else {
84 ResolvedFailMode::Deny
85 }
86 }
87 }
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
92pub enum BundleVerification {
93 Valid,
94 InvalidHash { expected: String, actual: String },
95 MissingSignature,
96 Unsigned,
97}
98
99pub fn compute_content_hash(rules: &[PolicyBundleRule]) -> String {
101 let rules = Value::Array(
102 rules
103 .iter()
104 .map(|rule| {
105 let mut value = Map::new();
106 value.insert("rule_id".to_string(), Value::String(rule.rule_id.clone()));
107 value.insert("level".to_string(), Value::String(rule.level.clone()));
108 value.insert("effect".to_string(), Value::String(rule.effect.clone()));
109 value.insert(
110 "conditions".to_string(),
111 canonicalize_json(&rule.conditions),
112 );
113 value.insert("priority".to_string(), Value::from(rule.priority));
114 if let Some(criticality) = &rule.criticality {
115 value.insert(
116 "criticality".to_string(),
117 serde_json::to_value(criticality)
118 .expect("policy criticality always serializes to JSON"),
119 );
120 }
121 if let Some(expiry_behavior) = &rule.expiry_behavior {
122 value.insert(
123 "expiry_behavior".to_string(),
124 serde_json::to_value(expiry_behavior)
125 .expect("expiry behavior always serializes to JSON"),
126 );
127 }
128 if let Some(grace_period_seconds) = rule.grace_period_seconds {
129 value.insert(
130 "grace_period_seconds".to_string(),
131 Value::from(grace_period_seconds),
132 );
133 }
134 if let Some(fallback_policy_ref) = &rule.fallback_policy_ref {
135 value.insert(
136 "fallback_policy_ref".to_string(),
137 Value::String(fallback_policy_ref.clone()),
138 );
139 }
140 if let Some(last_known_good_allowed) = rule.last_known_good_allowed {
141 value.insert(
142 "last_known_good_allowed".to_string(),
143 Value::Bool(last_known_good_allowed),
144 );
145 }
146 canonicalize_json(&Value::Object(value))
147 })
148 .collect(),
149 );
150 let canonical_json =
151 serde_json::to_vec(&rules).expect("policy bundle rules always serialize to JSON");
152 blake3::hash(&canonical_json).to_hex().to_string()
153}
154
155pub fn create_bundle(bundle_id: &str, rules: Vec<PolicyBundleRule>) -> PolicyBundle {
157 let content_hash = compute_content_hash(&rules);
158 PolicyBundle {
159 bundle_id: bundle_id.to_string(),
160 version: 1,
161 created_at: Utc::now().to_rfc3339(),
162 rules,
163 content_hash,
164 signature: None,
165 }
166}
167
168pub fn verify_bundle(bundle: &PolicyBundle) -> BundleVerification {
170 let actual = compute_content_hash(&bundle.rules);
171 if bundle.content_hash != actual {
172 return BundleVerification::InvalidHash {
173 expected: bundle.content_hash.clone(),
174 actual,
175 };
176 }
177
178 match bundle.signature.as_deref() {
179 None => BundleVerification::Unsigned,
180 Some(signature) if signature.trim().is_empty() => BundleVerification::MissingSignature,
181 Some(_) => BundleVerification::Valid,
182 }
183}
184
185pub fn load_bundle(path: &Path) -> Result<PolicyBundle, String> {
187 let contents = std::fs::read_to_string(path)
188 .map_err(|error| format!("read policy bundle {}: {error}", path.display()))?;
189 let bundle: PolicyBundle = serde_json::from_str(&contents)
190 .map_err(|error| format!("parse policy bundle {}: {error}", path.display()))?;
191
192 if let BundleVerification::InvalidHash { expected, actual } = verify_bundle(&bundle) {
193 return Err(format!(
194 "policy bundle {} has an invalid content hash: expected {expected}, got {actual}",
195 path.display()
196 ));
197 }
198
199 Ok(bundle)
200}
201
202pub fn save_bundle(bundle: &PolicyBundle, path: &Path) -> Result<(), String> {
204 let json = serde_json::to_string_pretty(bundle)
205 .map_err(|error| format!("serialize policy bundle: {error}"))?;
206 std::fs::write(path, format!("{json}\n"))
207 .map_err(|error| format!("write policy bundle {}: {error}", path.display()))
208}
209
210pub fn merge_bundles(base: &PolicyBundle, overlay: &PolicyBundle) -> PolicyBundle {
212 let base_rule_ids: HashSet<&str> = base
213 .rules
214 .iter()
215 .map(|rule| rule.rule_id.as_str())
216 .collect();
217 let overlay_rules: BTreeMap<&str, &PolicyBundleRule> = overlay
218 .rules
219 .iter()
220 .map(|rule| (rule.rule_id.as_str(), rule))
221 .collect();
222 let mut rules = Vec::with_capacity(base.rules.len() + overlay.rules.len());
223
224 for rule in &base.rules {
225 if let Some(overlay_rule) = overlay_rules.get(rule.rule_id.as_str()) {
226 rules.push((*overlay_rule).clone());
227 } else {
228 rules.push(rule.clone());
229 }
230 }
231 for rule in &overlay.rules {
232 if !base_rule_ids.contains(rule.rule_id.as_str()) {
233 rules.push(rule.clone());
234 }
235 }
236
237 let mut merged = if overlay.version >= base.version {
238 overlay.clone()
239 } else {
240 base.clone()
241 };
242 merged.rules = rules;
243 merged.content_hash = compute_content_hash(&merged.rules);
244 merged
245}
246
247fn canonicalize_json(value: &Value) -> Value {
248 match value {
249 Value::Array(values) => Value::Array(values.iter().map(canonicalize_json).collect()),
250 Value::Object(values) => {
251 let sorted: BTreeMap<_, _> = values.iter().collect();
252 let mut canonical = Map::new();
253 for (key, value) in sorted {
254 canonical.insert(key.clone(), canonicalize_json(value));
255 }
256 Value::Object(canonical)
257 }
258 _ => value.clone(),
259 }
260}
261
262#[cfg(test)]
263mod tests {
264 use serde_json::{from_value, json, to_value};
265
266 use super::{
267 BundleVerification, ExpiryBehavior, PolicyBundleRule, PolicyCriticality, ResolvedFailMode,
268 compute_content_hash, create_bundle, effective_fail_mode, load_bundle, merge_bundles,
269 save_bundle, verify_bundle,
270 };
271
272 fn rule(rule_id: &str, effect: &str) -> PolicyBundleRule {
273 PolicyBundleRule {
274 rule_id: rule_id.to_string(),
275 level: "standard".to_string(),
276 effect: effect.to_string(),
277 conditions: json!({"source": "local"}),
278 priority: 10,
279 criticality: None,
280 expiry_behavior: None,
281 grace_period_seconds: None,
282 fallback_policy_ref: None,
283 last_known_good_allowed: None,
284 }
285 }
286
287 #[test]
288 fn test_fail_mode_closed() {
289 let mut rule = rule("critical", "deny");
290 rule.criticality = Some(PolicyCriticality::Critical);
291 rule.expiry_behavior = Some(ExpiryBehavior::FailClosed);
292
293 assert_eq!(effective_fail_mode(&rule, 0), ResolvedFailMode::Deny);
294 }
295
296 #[test]
297 fn test_fail_mode_open() {
298 let mut rule = rule("low", "allow");
299 rule.criticality = Some(PolicyCriticality::Low);
300 rule.expiry_behavior = Some(ExpiryBehavior::FailOpen);
301
302 assert_eq!(effective_fail_mode(&rule, 60), ResolvedFailMode::Allow);
303 }
304
305 #[test]
306 fn test_fail_mode_grace_period() {
307 let mut rule = rule("grace", "deny");
308 rule.expiry_behavior = Some(ExpiryBehavior::GracePeriod);
309 rule.grace_period_seconds = Some(120);
310
311 assert_eq!(
312 effective_fail_mode(&rule, 30),
313 ResolvedFailMode::DenyAfterGrace {
314 remaining_seconds: 90,
315 }
316 );
317 assert_eq!(effective_fail_mode(&rule, 120), ResolvedFailMode::Deny);
318 }
319
320 #[test]
321 fn test_backward_compat_no_classification() {
322 let value = json!({
323 "rule_id": "legacy",
324 "level": "standard",
325 "effect": "allow",
326 "conditions": {},
327 "priority": 10
328 });
329 let rule: PolicyBundleRule = from_value(value).unwrap();
330
331 assert_eq!(effective_fail_mode(&rule, 1), ResolvedFailMode::Allow);
332 }
333
334 #[test]
335 fn test_serde_roundtrip_with_classification() {
336 let mut original = rule("classified", "deny");
337 original.criticality = Some(PolicyCriticality::High);
338 original.expiry_behavior = Some(ExpiryBehavior::GracePeriod);
339 original.grace_period_seconds = Some(600);
340 original.fallback_policy_ref = Some("policy://fallback".to_string());
341 original.last_known_good_allowed = Some(true);
342
343 let serialized = to_value(&original).unwrap();
344 let round_tripped: PolicyBundleRule = from_value(serialized).unwrap();
345
346 assert_eq!(round_tripped.rule_id, original.rule_id);
347 assert_eq!(round_tripped.criticality, original.criticality);
348 assert_eq!(round_tripped.expiry_behavior, original.expiry_behavior);
349 assert_eq!(
350 round_tripped.grace_period_seconds,
351 original.grace_period_seconds
352 );
353 assert_eq!(
354 round_tripped.fallback_policy_ref,
355 original.fallback_policy_ref
356 );
357 assert_eq!(
358 round_tripped.last_known_good_allowed,
359 original.last_known_good_allowed
360 );
361 }
362
363 #[test]
364 fn created_bundle_verifies_as_unsigned() {
365 let bundle = create_bundle("default", vec![rule("allow-local", "allow")]);
366
367 assert_eq!(verify_bundle(&bundle), BundleVerification::Unsigned);
368 }
369
370 #[test]
371 fn tampered_rule_has_an_invalid_hash() {
372 let mut bundle = create_bundle("default", vec![rule("allow-local", "allow")]);
373 bundle.rules[0].effect = "deny".to_string();
374
375 assert!(matches!(
376 verify_bundle(&bundle),
377 BundleVerification::InvalidHash { .. }
378 ));
379 }
380
381 #[test]
382 fn bundle_with_a_signature_verifies_as_valid() {
383 let mut bundle = create_bundle("default", vec![rule("allow-local", "allow")]);
384 bundle.signature = Some("signature".to_string());
385
386 assert_eq!(verify_bundle(&bundle), BundleVerification::Valid);
387 }
388
389 #[test]
390 fn save_and_load_round_trip() {
391 let directory = tempfile::tempdir().unwrap();
392 let path = directory.path().join("policy-bundle.json");
393 let bundle = create_bundle("default", vec![rule("allow-local", "allow")]);
394
395 save_bundle(&bundle, &path).unwrap();
396
397 let loaded = load_bundle(&path).unwrap();
398 assert_eq!(loaded.bundle_id, bundle.bundle_id);
399 assert_eq!(loaded.rules[0].rule_id, bundle.rules[0].rule_id);
400 assert_eq!(loaded.content_hash, bundle.content_hash);
401 }
402
403 #[test]
404 fn merge_replaces_matching_rules_and_appends_new_ones() {
405 let base = create_bundle(
406 "base",
407 vec![rule("allow-local", "allow"), rule("deny-remote", "deny")],
408 );
409 let mut overlay = create_bundle(
410 "overlay",
411 vec![rule("allow-local", "deny"), rule("audit", "audit")],
412 );
413 overlay.version = 2;
414
415 let merged = merge_bundles(&base, &overlay);
416
417 assert_eq!(merged.bundle_id, "overlay");
418 assert_eq!(merged.version, 2);
419 assert_eq!(merged.rules.len(), 3);
420 assert_eq!(merged.rules[0].effect, "deny");
421 assert_eq!(merged.rules[1].rule_id, "deny-remote");
422 assert_eq!(merged.rules[2].rule_id, "audit");
423 assert_eq!(merged.content_hash, compute_content_hash(&merged.rules));
424 }
425
426 #[test]
427 fn empty_rules_have_a_stable_valid_hash() {
428 let mut bundle = create_bundle("empty", Vec::new());
429 bundle.signature = Some("signature".to_string());
430
431 assert_eq!(bundle.content_hash, compute_content_hash(&[]));
432 assert_eq!(verify_bundle(&bundle), BundleVerification::Valid);
433 }
434
435 #[test]
436 fn object_key_order_does_not_change_content_hash() {
437 let first = rule("ordered", "allow");
438 let mut second = first.clone();
439 second.conditions = json!({"source": "local", "region": "eu"});
440 let mut reordered = second.clone();
441 reordered.conditions = json!({"region": "eu", "source": "local"});
442
443 assert_eq!(
444 compute_content_hash(&[second]),
445 compute_content_hash(&[reordered])
446 );
447 }
448}