nip09_suppression_conformance.rs (18592B)
1 #![cfg(feature = "json")] 2 3 mod support; 4 5 use std::{ 6 borrow::Cow, 7 collections::{BTreeMap, BTreeSet}, 8 fs, 9 path::Path, 10 }; 11 12 use radroots_event::{envelope::EventEnvelope, envelope::EventEnvelopeParts}; 13 use radroots_event_codec::{ 14 admission::deletion::{ 15 RadrootsAdmittedNip09DeletionRequestEvent, RadrootsNip09SuppressionDecision, 16 admit_verified_nip09_deletion_request_event, evaluate_nip09_suppression, 17 }, 18 verify::{RadrootsSignatureVerifiedEvent, verify_nip01_event}, 19 }; 20 use serde::{Deserialize, Serialize}; 21 use serde_json::{Value, json}; 22 23 const PACKAGED_VECTORS: &str = include_str!("fixtures/deletion_suppression.v1.json"); 24 const WORKSPACE_VECTOR_PATH: &str = 25 "../../contracts/conformance/vectors/deletion/suppression.v1.json"; 26 const WORKSPACE_CONTRACT_MARKER_PATH: &str = "../../contracts/manifest.toml"; 27 const OPERATION: &str = "social.deletion_request.evaluate_suppression.valid"; 28 29 const VECTOR_EXPECTATIONS: [(&str, &str, &str); 32] = [ 30 ( 31 "nip09_suppress_no_requests_visible", 32 "visible", 33 "deletion_no_authorized_reference", 34 ), 35 ( 36 "nip09_suppress_same_author_event_reference", 37 "suppressed", 38 "deletion_event_id_reference", 39 ), 40 ( 41 "nip09_suppress_same_author_nonmatching_reference", 42 "visible", 43 "deletion_no_authorized_reference", 44 ), 45 ( 46 "nip09_suppress_wrong_author_exact_event_reference", 47 "visible", 48 "deletion_request_author_mismatch", 49 ), 50 ( 51 "nip09_suppress_event_reference_predates_target", 52 "suppressed", 53 "deletion_event_id_reference", 54 ), 55 ( 56 "nip09_suppress_deletion_request_immune_event_reference", 57 "visible", 58 "deletion_request_immune", 59 ), 60 ( 61 "nip09_suppress_deletion_request_immune_mixed_references", 62 "visible", 63 "deletion_request_immune", 64 ), 65 ( 66 "nip09_suppress_address_cutoff_before_target", 67 "visible", 68 "deletion_address_cutoff_precedes_target", 69 ), 70 ( 71 "nip09_suppress_address_cutoff_equal_target", 72 "suppressed", 73 "deletion_address_reference", 74 ), 75 ( 76 "nip09_suppress_address_cutoff_after_target", 77 "suppressed", 78 "deletion_address_reference", 79 ), 80 ( 81 "nip09_suppress_address_wrong_kind", 82 "visible", 83 "deletion_no_authorized_reference", 84 ), 85 ( 86 "nip09_suppress_address_wrong_identifier", 87 "visible", 88 "deletion_no_authorized_reference", 89 ), 90 ( 91 "nip09_suppress_address_wrong_pubkey", 92 "visible", 93 "deletion_no_authorized_reference", 94 ), 95 ( 96 "nip09_suppress_wrong_author_address_reference", 97 "visible", 98 "deletion_request_author_mismatch", 99 ), 100 ( 101 "nip09_suppress_replaceable_kind_0", 102 "suppressed", 103 "deletion_address_reference", 104 ), 105 ( 106 "nip09_suppress_replaceable_kind_3", 107 "suppressed", 108 "deletion_address_reference", 109 ), 110 ( 111 "nip09_suppress_replaceable_kind_10000", 112 "suppressed", 113 "deletion_address_reference", 114 ), 115 ( 116 "nip09_suppress_replaceable_kind_19999", 117 "suppressed", 118 "deletion_address_reference", 119 ), 120 ( 121 "nip09_suppress_addressable_kind_30000_empty_identifier", 122 "suppressed", 123 "deletion_address_reference", 124 ), 125 ( 126 "nip09_suppress_addressable_kind_39999_opaque_identifier", 127 "suppressed", 128 "deletion_address_reference", 129 ), 130 ( 131 "nip09_suppress_combined_event_and_address_references", 132 "suppressed", 133 "deletion_event_id_and_address_reference", 134 ), 135 ( 136 "nip09_suppress_event_reference_with_stale_address", 137 "suppressed", 138 "deletion_event_id_reference", 139 ), 140 ( 141 "nip09_suppress_kind_advisory_diagnostics_ignored", 142 "suppressed", 143 "deletion_event_id_reference", 144 ), 145 ( 146 "nip09_suppress_duplicate_raw_targets_deduplicated", 147 "suppressed", 148 "deletion_event_id_and_address_reference", 149 ), 150 ( 151 "nip09_suppress_max_address_cutoff_forward_order", 152 "suppressed", 153 "deletion_address_reference", 154 ), 155 ( 156 "nip09_suppress_max_address_cutoff_reverse_order", 157 "suppressed", 158 "deletion_address_reference", 159 ), 160 ( 161 "nip09_suppress_later_revision_survives_address_cutoff", 162 "visible", 163 "deletion_address_cutoff_precedes_target", 164 ), 165 ( 166 "nip09_suppress_equal_max_cutoff_uses_lowest_request_id", 167 "suppressed", 168 "deletion_address_reference", 169 ), 170 ( 171 "nip09_suppress_multiple_event_references_use_lowest_request_id", 172 "suppressed", 173 "deletion_event_id_reference", 174 ), 175 ( 176 "nip09_suppress_repeated_request_is_idempotent", 177 "suppressed", 178 "deletion_event_id_reference", 179 ), 180 ( 181 "nip09_suppress_unauthorized_event_plus_stale_authorized_address", 182 "visible", 183 "deletion_address_cutoff_precedes_target", 184 ), 185 ( 186 "nip09_suppress_exact_event_deletes_later_replacement", 187 "suppressed", 188 "deletion_event_id_reference", 189 ), 190 ]; 191 192 #[derive(Debug, Deserialize)] 193 #[serde(deny_unknown_fields)] 194 struct Suite { 195 suite: String, 196 contract_version: String, 197 vectors: Vec<Vector>, 198 } 199 200 #[derive(Debug, Deserialize)] 201 #[serde(deny_unknown_fields)] 202 struct Vector { 203 id: String, 204 kind: String, 205 input: Input, 206 expected: ExpectedDecision, 207 } 208 209 #[derive(Debug, Deserialize)] 210 #[serde(deny_unknown_fields)] 211 struct Input { 212 request_event_jsons: Vec<String>, 213 target_event_json: String, 214 } 215 216 #[derive(Clone, Debug, Deserialize, PartialEq, Eq)] 217 #[serde(deny_unknown_fields)] 218 struct ExpectedDecision { 219 outcome: String, 220 reason: String, 221 event_reference: Option<ExpectedEventReference>, 222 address_reference: Option<ExpectedAddressReference>, 223 } 224 225 #[derive(Clone, Debug, Deserialize, PartialEq, Eq)] 226 #[serde(deny_unknown_fields)] 227 struct ExpectedEventReference { 228 request_id: String, 229 } 230 231 #[derive(Clone, Debug, Deserialize, PartialEq, Eq)] 232 #[serde(deny_unknown_fields)] 233 struct ExpectedAddressReference { 234 coordinate: String, 235 inclusive_cutoff: u64, 236 request_id: String, 237 } 238 239 #[derive(Clone, Debug, Deserialize, Serialize)] 240 #[serde(deny_unknown_fields)] 241 struct RawEvent { 242 id: String, 243 pubkey: String, 244 created_at: u64, 245 kind: u32, 246 tags: Vec<Vec<String>>, 247 content: String, 248 sig: String, 249 } 250 251 #[test] 252 fn suppression_vectors_execute_the_public_evaluator() { 253 let vectors = conformance_vectors(); 254 let raw_suite: Value = 255 serde_json::from_str(&vectors).expect("NIP-09 suppression JSON must parse"); 256 validate_no_forbidden_corpus_metadata(&raw_suite, "$") 257 .unwrap_or_else(|error| panic!("{error}")); 258 assert_exact_nullable_evidence_shape(&raw_suite); 259 260 let suite: Suite = 261 serde_json::from_str(&vectors).expect("NIP-09 suppression vectors must parse"); 262 assert_eq!(suite.suite, "nip09_suppression_evaluator"); 263 assert_eq!(suite.contract_version, "1.0.0"); 264 assert_vector_inventory(&suite.vectors); 265 assert_signed_event_inventory(&suite.vectors); 266 267 for vector in &suite.vectors { 268 execute(vector); 269 } 270 } 271 272 fn assert_vector_inventory(vectors: &[Vector]) { 273 let expected = VECTOR_EXPECTATIONS 274 .into_iter() 275 .map(|(id, outcome, reason)| (id, (OPERATION, outcome, reason))) 276 .collect::<BTreeMap<_, _>>(); 277 let actual = vectors 278 .iter() 279 .map(|vector| { 280 ( 281 vector.id.as_str(), 282 ( 283 vector.kind.as_str(), 284 vector.expected.outcome.as_str(), 285 vector.expected.reason.as_str(), 286 ), 287 ) 288 }) 289 .collect::<BTreeMap<_, _>>(); 290 assert_eq!(vectors.len(), VECTOR_EXPECTATIONS.len()); 291 assert_eq!(actual, expected); 292 assert!(vectors.iter().all(|vector| vector.id.starts_with("nip09_"))); 293 294 let reasons = vectors 295 .iter() 296 .map(|vector| vector.expected.reason.as_str()) 297 .collect::<BTreeSet<_>>(); 298 assert_eq!( 299 reasons, 300 BTreeSet::from([ 301 "deletion_address_cutoff_precedes_target", 302 "deletion_address_reference", 303 "deletion_event_id_and_address_reference", 304 "deletion_event_id_reference", 305 "deletion_no_authorized_reference", 306 "deletion_request_author_mismatch", 307 "deletion_request_immune", 308 ]) 309 ); 310 } 311 312 fn assert_signed_event_inventory(vectors: &[Vector]) { 313 let event_jsons = vectors 314 .iter() 315 .flat_map(|vector| { 316 std::iter::once(vector.input.target_event_json.as_str()) 317 .chain(vector.input.request_event_jsons.iter().map(String::as_str)) 318 }) 319 .collect::<BTreeSet<_>>(); 320 assert_eq!(event_jsons.len(), 38, "signed event inventory drifted"); 321 322 let mut deletion_request_count = 0usize; 323 for event_json in event_jsons { 324 let verified = verified_event(event_json, "signed event inventory"); 325 if verified.event().kind_u32() == 5 { 326 admit_verified_nip09_deletion_request_event(verified) 327 .expect("every fixed kind-5 corpus event must admit"); 328 deletion_request_count += 1; 329 } 330 } 331 assert_eq!(deletion_request_count, 30); 332 } 333 334 fn execute(vector: &Vector) { 335 let target = verified_event(&vector.input.target_event_json, &vector.id); 336 let requests = vector 337 .input 338 .request_event_jsons 339 .iter() 340 .map(|event_json| admitted_request(event_json, &vector.id)) 341 .collect::<Vec<_>>(); 342 let target_before = target.clone(); 343 let requests_before = requests.clone(); 344 345 let decision = evaluate_nip09_suppression(&target, &requests); 346 assert_eq!(actual_decision(&decision), vector.expected, "{}", vector.id); 347 assert_eq!(target, target_before, "{} target changed", vector.id); 348 assert_eq!(requests, requests_before, "{} requests changed", vector.id); 349 350 let repeated = evaluate_nip09_suppression(&target, &requests); 351 assert_eq!( 352 repeated, decision, 353 "{} repeat evaluation drifted", 354 vector.id 355 ); 356 let mut reversed = requests.clone(); 357 reversed.reverse(); 358 assert_eq!( 359 evaluate_nip09_suppression(&target, &reversed), 360 decision, 361 "{} request order changed the decision", 362 vector.id 363 ); 364 } 365 366 fn actual_decision(decision: &RadrootsNip09SuppressionDecision) -> ExpectedDecision { 367 ExpectedDecision { 368 outcome: decision.outcome().code().to_owned(), 369 reason: decision.reason().code().to_owned(), 370 event_reference: decision 371 .event_reference() 372 .map(|evidence| ExpectedEventReference { 373 request_id: evidence.request_id().to_hex(), 374 }), 375 address_reference: decision 376 .address_reference() 377 .map(|evidence| ExpectedAddressReference { 378 coordinate: evidence.coordinate().as_str().to_owned(), 379 inclusive_cutoff: evidence.inclusive_cutoff(), 380 request_id: evidence.request_id().to_hex(), 381 }), 382 } 383 } 384 385 fn admitted_request( 386 event_json: &str, 387 vector_id: &str, 388 ) -> RadrootsAdmittedNip09DeletionRequestEvent { 389 let verified = verified_event(event_json, vector_id); 390 admit_verified_nip09_deletion_request_event(verified) 391 .unwrap_or_else(|error| panic!("{vector_id} request admission failed: {error}")) 392 } 393 394 fn verified_event(event_json: &str, vector_id: &str) -> RadrootsSignatureVerifiedEvent { 395 verify_nip01_event(event_envelope(event_json, vector_id)) 396 .unwrap_or_else(|error| panic!("{vector_id} signature verification failed: {error}")) 397 } 398 399 fn event_envelope(event_json: &str, vector_id: &str) -> EventEnvelope { 400 let raw: RawEvent = serde_json::from_str(event_json) 401 .unwrap_or_else(|error| panic!("{vector_id} signed event JSON is invalid: {error}")); 402 assert_eq!( 403 serde_json::to_string(&raw).expect("raw event serialization"), 404 event_json, 405 "{vector_id} signed event JSON must be canonical and compact" 406 ); 407 EventEnvelope::new(EventEnvelopeParts { 408 id: raw.id, 409 author: raw.pubkey, 410 created_at: raw.created_at, 411 kind: raw.kind, 412 tags: raw.tags, 413 content: raw.content, 414 sig: raw.sig, 415 }) 416 .unwrap_or_else(|error| panic!("{vector_id} event envelope failed: {error}")) 417 } 418 419 fn assert_exact_nullable_evidence_shape(suite: &Value) { 420 let suite_object = suite.as_object().expect("suite object"); 421 assert_eq!( 422 suite_object 423 .keys() 424 .map(String::as_str) 425 .collect::<BTreeSet<_>>(), 426 BTreeSet::from(["contract_version", "suite", "vectors"]) 427 ); 428 for vector in suite["vectors"].as_array().expect("vectors array") { 429 assert_eq!( 430 vector 431 .as_object() 432 .expect("vector object") 433 .keys() 434 .map(String::as_str) 435 .collect::<BTreeSet<_>>(), 436 BTreeSet::from(["expected", "id", "input", "kind"]) 437 ); 438 assert_eq!( 439 vector["input"] 440 .as_object() 441 .expect("input object") 442 .keys() 443 .map(String::as_str) 444 .collect::<BTreeSet<_>>(), 445 BTreeSet::from(["request_event_jsons", "target_event_json"]) 446 ); 447 assert_eq!( 448 vector["expected"] 449 .as_object() 450 .expect("expected object") 451 .keys() 452 .map(String::as_str) 453 .collect::<BTreeSet<_>>(), 454 BTreeSet::from(["address_reference", "event_reference", "outcome", "reason",]) 455 ); 456 } 457 } 458 459 fn conformance_vectors() -> Cow<'static, str> { 460 let workspace_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(WORKSPACE_VECTOR_PATH); 461 match fs::read_to_string(&workspace_path) { 462 Ok(canonical) => { 463 assert_eq!( 464 canonical, 465 PACKAGED_VECTORS, 466 "packaged suppression vectors must match {}", 467 workspace_path.display() 468 ); 469 Cow::Owned(canonical) 470 } 471 Err(error) 472 if error.kind() == std::io::ErrorKind::NotFound 473 && !Path::new(env!("CARGO_MANIFEST_DIR")) 474 .join(WORKSPACE_CONTRACT_MARKER_PATH) 475 .is_file() => 476 { 477 Cow::Borrowed(PACKAGED_VECTORS) 478 } 479 Err(error) => panic!("failed to read {}: {error}", workspace_path.display()), 480 } 481 } 482 483 fn validate_no_forbidden_corpus_metadata(value: &Value, path: &str) -> Result<(), String> { 484 match value { 485 Value::Object(object) => { 486 for (key, child) in object { 487 if is_forbidden_metadata_key(key) { 488 return Err(format!( 489 "frozen NIP-09 suppression corpus contains forbidden key {path}.{key}" 490 )); 491 } 492 validate_no_forbidden_corpus_metadata(child, &format!("{path}.{key}"))?; 493 } 494 } 495 Value::Array(values) => { 496 for (index, child) in values.iter().enumerate() { 497 validate_no_forbidden_corpus_metadata(child, &format!("{path}[{index}]"))?; 498 } 499 } 500 Value::String(string) => { 501 if string.to_ascii_lowercase().contains("nsec1") { 502 return Err(format!( 503 "frozen NIP-09 suppression corpus contains nsec material at {path}" 504 )); 505 } 506 if contains_approved_fixture_secret(string) { 507 return Err(format!( 508 "frozen NIP-09 suppression corpus contains a fixture secret at {path}" 509 )); 510 } 511 } 512 _ => {} 513 } 514 Ok(()) 515 } 516 517 fn is_forbidden_metadata_key(key: &str) -> bool { 518 let normalized = key.to_ascii_lowercase(); 519 matches!(normalized.as_str(), "base" | "mutation") 520 || normalized.contains("source") 521 || normalized.contains("seed") 522 || normalized.contains("generator") 523 || normalized.contains("recipe") 524 || normalized.contains("secret_key") 525 || normalized.contains("private_key") 526 || normalized.contains("signing_key") 527 || normalized.contains("authorization") 528 || normalized.contains("authorized") 529 || normalized.contains("store_mutation") 530 || normalized == "effect" 531 || normalized.ends_with("_effect") 532 || normalized == "effects" 533 } 534 535 fn contains_approved_fixture_secret(value: &str) -> bool { 536 let normalized = value.to_ascii_lowercase(); 537 support::approved_fixture_identities() 538 .iter() 539 .any(|identity| normalized.contains(identity.secret_key_hex)) 540 } 541 542 #[test] 543 fn suppression_corpus_hygiene_rejects_generation_and_effect_authority_metadata() { 544 for key in [ 545 "SOURCE", 546 "SeEd", 547 "GENERATOR", 548 "MuTaTiOn", 549 "AuThOrIzAtIoN", 550 "store_effect", 551 ] { 552 let value = Value::Object([(key.to_owned(), Value::Null)].into_iter().collect()); 553 let error = validate_no_forbidden_corpus_metadata(&value, "$") 554 .expect_err("forbidden corpus metadata must fail"); 555 assert!(error.contains(key), "{error}"); 556 } 557 assert!(validate_no_forbidden_corpus_metadata(&json!("NSEC1FORBIDDEN"), "$").is_err()); 558 for identity in support::approved_fixture_identities() { 559 assert!( 560 validate_no_forbidden_corpus_metadata(&json!(identity.secret_key_hex), "$").is_err() 561 ); 562 } 563 } 564 565 #[test] 566 fn suppression_corpus_shapes_reject_unknown_fields() { 567 let input = json!({ 568 "request_event_jsons": [], 569 "target_event_json": "{}", 570 "unexpected": null 571 }); 572 serde_json::from_value::<Input>(input).expect_err("unknown input fields must fail"); 573 574 let expected = json!({ 575 "outcome": "visible", 576 "reason": "deletion_no_authorized_reference", 577 "event_reference": null, 578 "address_reference": null, 579 "unexpected": null 580 }); 581 serde_json::from_value::<ExpectedDecision>(expected) 582 .expect_err("unknown expected fields must fail"); 583 584 let evidence = json!({"request_id": "a", "unexpected": null}); 585 serde_json::from_value::<ExpectedEventReference>(evidence) 586 .expect_err("unknown evidence fields must fail"); 587 }