services_hardening_evidence_policy.rs (15540B)
1 #![forbid(unsafe_code)] 2 3 use serde_json::Value; 4 use sha2::{Digest, Sha256}; 5 use std::collections::BTreeSet; 6 7 const POLICY: &str = include_str!("../contracts/services_hardening/evidence_policy.v1.json"); 8 const CONSUMER_ROOT: &str = include_str!("../.radroots-consumer-root"); 9 10 fn policy() -> Value { 11 serde_json::from_str(POLICY).expect("evidence policy decision must be valid JSON") 12 } 13 14 fn sha256_hex(bytes: &[u8]) -> String { 15 Sha256::digest(bytes) 16 .iter() 17 .map(|byte| format!("{byte:02x}")) 18 .collect() 19 } 20 21 fn decision_sections_digest(value: &Value) -> String { 22 let sections = serde_json::json!({ 23 "completion": value["completion"], 24 "configuration": value["configuration"], 25 "configuration_fixture": value["configuration_fixture"], 26 "cursor": value["cursor"], 27 "fixed_vector": value["fixed_vector"], 28 "policy_digest": value["policy_digest"], 29 "publication_relationship": value["publication_relationship"], 30 "required_optional_semantics": value["required_optional_semantics"], 31 "selector": value["selector"], 32 "source_kinds": value["source_kinds"], 33 "configuration_vectors": value["configuration_vectors"], 34 "completion_vectors": value["completion_vectors"], 35 "cursor_vectors": value["cursor_vectors"], 36 "coverage_vectors": value["coverage_vectors"] 37 }); 38 sha256_hex(&serde_json::to_vec(§ions).expect("serialize decision sections")) 39 } 40 41 fn stable_id(value: &str) -> bool { 42 !value.is_empty() 43 && value.len() <= 64 44 && value.as_bytes()[0].is_ascii_lowercase() 45 && value 46 .bytes() 47 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"_-".contains(&byte)) 48 } 49 50 fn mutate_config(base: &Value, mutation: &Value) -> Value { 51 let mut config = base.clone(); 52 let object = config.as_object_mut().unwrap(); 53 match mutation["op"].as_str().unwrap() { 54 "none" => {} 55 "append_source" => object["sources"] 56 .as_array_mut() 57 .unwrap() 58 .push(mutation["source"].clone()), 59 "set_source_field" => { 60 object["sources"][0][mutation["field"].as_str().unwrap()] = mutation["value"].clone(); 61 } 62 "set_source_fields" => { 63 for (field, value) in mutation["fields"].as_object().unwrap() { 64 object["sources"][0][field] = value.clone(); 65 } 66 } 67 "remove_top_field" => { 68 object.remove(mutation["field"].as_str().unwrap()); 69 } 70 "set_top_field" => { 71 object.insert( 72 mutation["field"].as_str().unwrap().to_owned(), 73 mutation["value"].clone(), 74 ); 75 } 76 "add_top_field" => { 77 object.insert( 78 mutation["field"].as_str().unwrap().to_owned(), 79 mutation["value"].clone(), 80 ); 81 } 82 "repeat_sources" => { 83 let template = object["sources"][0].clone(); 84 let count = mutation["count"].as_u64().unwrap(); 85 let sources = (0..count) 86 .map(|index| { 87 let mut source = template.clone(); 88 source["source_id"] = format!("trade-{index}").into(); 89 source["relay_id"] = format!("relay-{index}").into(); 90 source 91 }) 92 .collect(); 93 object.insert("sources".to_owned(), Value::Array(sources)); 94 } 95 "remove_source_field" => { 96 object["sources"][0] 97 .as_object_mut() 98 .unwrap() 99 .remove(mutation["field"].as_str().unwrap()); 100 } 101 "add_source_field" => { 102 object["sources"][0].as_object_mut().unwrap().insert( 103 mutation["field"].as_str().unwrap().to_owned(), 104 mutation["value"].clone(), 105 ); 106 } 107 operation => panic!("unknown configuration mutation {operation}"), 108 } 109 config 110 } 111 112 fn validate_config(config: &Value) -> Result<(), &'static str> { 113 let Some(object) = config.as_object() else { 114 return Err("invalid_config_type"); 115 }; 116 let required_top = BTreeSet::from(["contract", "contract_version", "policy_id", "sources"]); 117 let actual_top = object.keys().map(String::as_str).collect::<BTreeSet<_>>(); 118 if !required_top.is_subset(&actual_top) { 119 return Err("missing_required_field"); 120 } 121 if actual_top != required_top { 122 return Err("unknown_field"); 123 } 124 if config["contract"] != "radroots.rhi.evidence-policy" || config["contract_version"] != 1 { 125 return Err("invalid_contract_identity"); 126 } 127 if !config["policy_id"].as_str().is_some_and(stable_id) { 128 return Err("invalid_policy_id"); 129 } 130 let Some(sources) = config["sources"].as_array() else { 131 return Err("invalid_sources_type"); 132 }; 133 if sources.is_empty() || sources.len() > 16 { 134 return Err("source_count_out_of_range"); 135 } 136 let required_source = BTreeSet::from([ 137 "deadline_ms", 138 "kind", 139 "lookback_seconds", 140 "overlap_seconds", 141 "relay_id", 142 "required", 143 "selector", 144 "source_id", 145 ]); 146 for source in sources { 147 let Some(source) = source.as_object() else { 148 return Err("invalid_source_type"); 149 }; 150 let actual = source.keys().map(String::as_str).collect::<BTreeSet<_>>(); 151 if !required_source.is_subset(&actual) { 152 return Err("missing_required_field"); 153 } 154 if actual != required_source { 155 return Err("unknown_field"); 156 } 157 if source["kind"] != "nostr_relay" { 158 return Err("unsupported_source_kind"); 159 } 160 if source["selector"] != "trade_mutation_lineage_v1" { 161 return Err("unsupported_selector"); 162 } 163 if !source["source_id"].as_str().is_some_and(stable_id) 164 || !source["relay_id"].as_str().is_some_and(stable_id) 165 { 166 return Err("invalid_source_id"); 167 } 168 if !source["required"].is_boolean() { 169 return Err("invalid_required_type"); 170 } 171 let deadline = source["deadline_ms"] 172 .as_u64() 173 .ok_or("invalid_deadline_type")?; 174 if !(100..=30_000).contains(&deadline) { 175 return Err("deadline_out_of_range"); 176 } 177 let lookback = source["lookback_seconds"] 178 .as_u64() 179 .ok_or("invalid_lookback_type")?; 180 if !(60..=2_678_400).contains(&lookback) { 181 return Err("lookback_out_of_range"); 182 } 183 let overlap = source["overlap_seconds"] 184 .as_u64() 185 .ok_or("invalid_overlap_type")?; 186 if !(1..=86_400).contains(&overlap) { 187 return Err("overlap_out_of_range"); 188 } 189 if overlap > lookback { 190 return Err("overlap_exceeds_lookback"); 191 } 192 } 193 let source_ids = sources 194 .iter() 195 .map(|source| source["source_id"].as_str().unwrap()) 196 .collect::<BTreeSet<_>>(); 197 if source_ids.len() != sources.len() { 198 return Err("duplicate_source_id"); 199 } 200 let bindings = sources 201 .iter() 202 .map(|source| { 203 format!( 204 "{}|{}|{}", 205 source["kind"].as_str().unwrap(), 206 source["relay_id"].as_str().unwrap(), 207 source["selector"].as_str().unwrap() 208 ) 209 }) 210 .collect::<BTreeSet<_>>(); 211 if bindings.len() != sources.len() { 212 return Err("duplicate_source_binding"); 213 } 214 if !sources.iter().any(|source| source["required"] == true) { 215 return Err("no_required_source"); 216 } 217 Ok(()) 218 } 219 220 fn completion(input: &Value) -> &'static str { 221 if input["unsupported"] == true { 222 "unsupported" 223 } else if input["resource_limit"] == true { 224 "incomplete_resource_limit" 225 } else if input["disconnect"] == true || input["relay_error"] == true { 226 "incomplete_unavailable" 227 } else if let Some(eose_at) = input["eose_at_ms"].as_u64() { 228 if eose_at < input["deadline_ms"].as_u64().unwrap() { 229 "complete" 230 } else { 231 "incomplete_timeout" 232 } 233 } else if input["deadline_elapsed_without_eose"] == true { 234 "incomplete_timeout" 235 } else { 236 "incomplete_unknown" 237 } 238 } 239 240 fn coverage(vector: &Value) -> (&'static str, Vec<&'static str>) { 241 let sources = vector["sources"].as_array().unwrap(); 242 if sources 243 .iter() 244 .any(|source| source["required"] == true && source["completion"] == "unsupported") 245 { 246 return ("Unsupported", vec!["Indeterminate"]); 247 } 248 let all_required_complete = sources 249 .iter() 250 .filter(|source| source["required"] == true) 251 .all(|source| source["completion"] == "complete"); 252 if all_required_complete && vector["scope_prerequisites_satisfied"] == true { 253 return ("ScopeSatisfied", vec!["Valid", "Invalid", "Indeterminate"]); 254 } 255 if sources.iter().any(|source| { 256 source["completion"] == "complete" || source["admitted_events"].as_u64().unwrap() > 0 257 }) { 258 ("Partial", vec!["Indeterminate"]) 259 } else { 260 ("Missing", vec!["Indeterminate"]) 261 } 262 } 263 264 #[test] 265 fn policy_identity_source_and_selector_are_exact() { 266 assert_eq!(CONSUMER_ROOT, "rhi\n"); 267 let value = policy(); 268 assert_eq!(value["schema"], "radroots.rhi.evidence-policy-decision.v1"); 269 assert_eq!(value["contract_version"], 1); 270 assert_eq!(value["decision_state"], "reserved_preimplementation"); 271 assert_eq!( 272 value["source_kinds"]["qualified_v1"], 273 serde_json::json!(["nostr_relay"]) 274 ); 275 assert_eq!( 276 value["selector"], 277 serde_json::json!({ 278 "id": "trade_mutation_lineage_v1", 279 "trade_event_contract": { 280 "repository": "https://github.com/radrootslabs/lib", 281 "path": "contracts/architecture/decisions/services_hardening_events.v1.json", 282 "schema": "radroots.services-hardening.event-decisions.v1", 283 "contract_family": "radroots.trade.mutation-index.v1" 284 }, 285 "event_kinds": [3470, 3471, 3472, 3473, 3474], 286 "event_kind_meanings": ["proposal", "decision", "revision_proposal", "revision_decision", "cancellation"], 287 "dynamic_filter": { "tag": "#d", "value": "exact_job_trade_id" }, 288 "required_structural_tags": ["contract", "d", "x:mutation", "p:buyer", "p:seller"], 289 "nonproposal_additional_tags": ["x:root", "x:parent"], 290 "author_policy": "validate_against_typed_trade_party_and_mutation_contract", 291 "filter_extra_tags": false, 292 "maximum_events_per_result": 4096, 293 "maximum_result_bytes": 8_388_608 294 }) 295 ); 296 } 297 298 #[test] 299 fn cursor_completion_and_coverage_fail_closed() { 300 let value = policy(); 301 assert_eq!( 302 value["cursor"]["tuple"], 303 serde_json::json!(["created_at_unix_seconds", "event_id_lowercase_hex"]) 304 ); 305 assert_eq!(value["cursor"]["equal_timestamp_safe"], true); 306 assert_eq!( 307 value["cursor"]["rejected_event"], 308 "cannot_advance_cursor_or_completion_or_dirty_generation" 309 ); 310 assert_eq!( 311 value["completion"]["success_evidence"], 312 "nostr_eose_received_before_source_deadline" 313 ); 314 assert_eq!( 315 value["completion"]["response_without_eose"], 316 "incomplete_unknown" 317 ); 318 assert_eq!( 319 value["required_optional_semantics"]["coverage_precedence"], 320 serde_json::json!(["Unsupported", "ScopeSatisfied", "Partial", "Missing"]) 321 ); 322 assert_eq!( 323 value["required_optional_semantics"]["optional_incomplete"], 324 "record_safe_source_result_without_blocking_scope_satisfied" 325 ); 326 let base = &value["configuration_fixture"]; 327 for vector in value["configuration_vectors"].as_array().unwrap() { 328 let candidate = mutate_config(base, &vector["mutation"]); 329 let actual = validate_config(&candidate) 330 .map(|()| "valid") 331 .unwrap_or_else(|error| error); 332 assert_eq!( 333 actual, 334 vector["expected"].as_str().unwrap(), 335 "configuration vector {}", 336 vector["case"] 337 ); 338 } 339 for vector in value["completion_vectors"].as_array().unwrap() { 340 assert_eq!( 341 completion(&vector["input"]), 342 vector["expected"].as_str().unwrap(), 343 "completion vector {}", 344 vector["case"] 345 ); 346 } 347 for vector in value["cursor_vectors"].as_array().unwrap() { 348 let current = &vector["current"]; 349 let candidate = &vector["candidate"]; 350 let next = if candidate["admitted"] == true 351 && vector["source_complete"] == true 352 && vector["generation_fence"] == true 353 && ( 354 candidate["created_at_unix_seconds"].as_u64(), 355 candidate["event_id_lowercase_hex"].as_str(), 356 ) > ( 357 current["created_at_unix_seconds"].as_u64(), 358 current["event_id_lowercase_hex"].as_str(), 359 ) { 360 candidate 361 } else { 362 current 363 }; 364 let projected = serde_json::json!({ 365 "created_at_unix_seconds": next["created_at_unix_seconds"], 366 "event_id_lowercase_hex": next["event_id_lowercase_hex"] 367 }); 368 assert_eq!( 369 projected, vector["expected"], 370 "cursor vector {}", 371 vector["case"] 372 ); 373 } 374 for vector in value["coverage_vectors"].as_array().unwrap() { 375 let (actual_coverage, actual_outcomes) = coverage(vector); 376 assert_eq!(actual_coverage, vector["expected_coverage"]); 377 assert_eq!( 378 actual_outcomes, 379 vector["allowed_outcomes"] 380 .as_array() 381 .unwrap() 382 .iter() 383 .map(|outcome| outcome.as_str().unwrap()) 384 .collect::<Vec<_>>() 385 ); 386 } 387 } 388 389 #[test] 390 fn policy_digest_vector_and_publication_independence_are_exact() { 391 let value = policy(); 392 let vector = &value["fixed_vector"]["normalized_policy"]; 393 let canonical = serde_json::to_vec(vector).expect("canonical vector JSON"); 394 assert_eq!( 395 canonical, 396 value["fixed_vector"]["canonical_policy_utf8"] 397 .as_str() 398 .unwrap() 399 .as_bytes() 400 ); 401 let mut preimage = b"radroots:rhi-evidence-policy:v1\0".to_vec(); 402 preimage.extend_from_slice(&canonical); 403 assert_eq!( 404 preimage, 405 value["fixed_vector"]["preimage_utf8"] 406 .as_str() 407 .unwrap() 408 .as_bytes() 409 ); 410 assert_eq!( 411 sha256_hex(&preimage), 412 value["fixed_vector"]["policy_digest"].as_str().unwrap() 413 ); 414 assert_eq!( 415 value["publication_relationship"], 416 serde_json::json!({ 417 "relationship": "independent", 418 "evidence_sources_are_publication_targets": false, 419 "publication_targets_affect_coverage_or_outcome": false, 420 "publication_failure_changes_report_or_attestation_content": false, 421 "required_publication_failure": "publication_workflow_and_readiness_only", 422 "disabled_publication": "no_target_or_network_work" 423 }) 424 ); 425 assert_eq!( 426 decision_sections_digest(&value), 427 "242642269d9272d2a1bae50bae5a02a1329cab1a2dfb636d363aa362ce475011" 428 ); 429 }