focused_availability_lifecycle.rs (24085B)
1 use nostr::{Event, EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp}; 2 use radroots_event::envelope::event_head::{ 3 CurrentEventHead, EventHeadCandidate, EventHeadCandidateResult, EventHeadCoordinate, 4 EventHeadDecision, event_head_candidate_for_nip01_event, select_event_head, 5 }; 6 use radroots_event::food::availability::FoodAvailabilityError; 7 use radroots_event::listing::classified::ClassifiedListingPartition; 8 use radroots_event::wire::Nip01EventWire; 9 use radroots_event_codec::admission::deletion::{ 10 RadrootsAdmittedNip09DeletionRequestEvent, RadrootsNip09DeletionAdmissionError, 11 RadrootsNip09SuppressionOutcome, RadrootsNip09SuppressionReason, evaluate_nip09_suppression, 12 verify_and_admit_nip09_deletion_request_event, 13 }; 14 use radroots_event_codec::admission::food_availability::{ 15 RadrootsFoodAvailabilityAdmissionError, RadrootsFoodAvailabilityAdmissionOutcome, 16 admit_verified_food_availability_event, 17 }; 18 use radroots_event_codec::decode::food_availability::RadrootsFoodAvailabilityProjectionError; 19 use radroots_event_codec::verify::{ 20 RadrootsNip01VerificationError, RadrootsSignatureVerifiedEvent, verify_nip01_event, 21 }; 22 23 #[path = "fixtures/availability_lifecycle_v1.rs"] 24 mod corpus; 25 26 const MAX_EVENT_BYTES: usize = 4 * 1024; 27 const MAX_CORPUS_BYTES: usize = 64 * 1024; 28 const MAX_RETAINED_SOURCE_BYTES: usize = 16 * 1024; 29 const RETAINED_SOURCE: &str = include_str!("fixtures/availability_lifecycle_v1.rs"); 30 31 struct PublicEvent { 32 name: &'static str, 33 json: String, 34 } 35 36 struct PublicCorpus { 37 author: String, 38 other_author: String, 39 events: Vec<PublicEvent>, 40 } 41 42 fn sign_event( 43 keys: &Keys, 44 kind: u16, 45 created_at: u64, 46 content: &str, 47 tags: Vec<Vec<String>>, 48 ) -> Event { 49 assert!(tags.len() <= 16); 50 assert!( 51 content.len() + tags.iter().flatten().map(String::len).sum::<usize>() <= MAX_EVENT_BYTES 52 ); 53 let tags = tags 54 .into_iter() 55 .map(|tag| Tag::parse(tag).expect("bounded public raw tag")); 56 EventBuilder::new(Kind::from(kind), content) 57 .tags(tags) 58 .custom_created_at(Timestamp::from(created_at)) 59 .sign_with_keys(keys) 60 .expect("isolated public lifecycle signing") 61 } 62 63 /// Both signers are dropped before any retained public JSON leaves the helper. 64 fn public_corpus() -> PublicCorpus { 65 let owner = Keys::generate(); 66 let other = Keys::generate(); 67 let author = owner.public_key().to_hex(); 68 let other_author = other.public_key().to_hex(); 69 assert_ne!(author, other_author); 70 let mut signed = Vec::<(&'static str, Event)>::new(); 71 for version in &corpus::VERSIONS { 72 let keys = if version.other_author { &other } else { &owner }; 73 signed.push(( 74 version.name, 75 sign_event( 76 keys, 77 30402, 78 version.created_at, 79 version.content, 80 corpus::listing_tags(version), 81 ), 82 )); 83 } 84 let original = &corpus::VERSIONS[0]; 85 assert_eq!(original.name, "old_active"); 86 signed.push(( 87 "other_kind", 88 sign_event( 89 &owner, 90 corpus::OTHER_ADDRESSABLE_KIND, 91 original.created_at, 92 original.content, 93 corpus::listing_tags(original), 94 ), 95 )); 96 let address = format!("30402:{author}:{}", corpus::IDENTIFIER); 97 for deletion in &corpus::DELETIONS { 98 let mut tags = Vec::new(); 99 match deletion.target { 100 corpus::Target::Event(name) | corpus::Target::EventAndAddress(name) => { 101 let target = signed 102 .iter() 103 .find(|(target_name, _)| *target_name == name) 104 .expect("public target recipe exists"); 105 tags.push(vec!["e".into(), target.1.id.to_hex()]); 106 } 107 corpus::Target::Address => {} 108 } 109 if matches!( 110 deletion.target, 111 corpus::Target::Address | corpus::Target::EventAndAddress(_) 112 ) { 113 tags.push(vec!["a".into(), address.clone()]); 114 } 115 tags.push(vec!["k".into(), "30402".into()]); 116 let keys = if deletion.other_author { 117 &other 118 } else { 119 &owner 120 }; 121 signed.push(( 122 deletion.name, 123 sign_event( 124 keys, 125 5, 126 deletion.created_at, 127 "Public deletion conformance.", 128 tags, 129 ), 130 )); 131 } 132 drop(owner); 133 drop(other); 134 let events = signed 135 .into_iter() 136 .map(|(name, event)| { 137 let json = event.as_json(); 138 assert!(json.len() <= MAX_EVENT_BYTES); 139 PublicEvent { name, json } 140 }) 141 .collect::<Vec<_>>(); 142 assert_eq!(events.len(), 18); 143 assert!(events.iter().map(|event| event.json.len()).sum::<usize>() <= MAX_CORPUS_BYTES); 144 PublicCorpus { 145 author, 146 other_author, 147 events, 148 } 149 } 150 151 fn public_json<'a>(corpus: &'a PublicCorpus, name: &str) -> &'a str { 152 &corpus 153 .events 154 .iter() 155 .find(|event| event.name == name) 156 .expect("named public event") 157 .json 158 } 159 160 fn verified(corpus: &PublicCorpus, name: &str) -> RadrootsSignatureVerifiedEvent { 161 let wire = Nip01EventWire::parse_json(public_json(corpus, name)) 162 .expect("bounded public ID-verified wire"); 163 assert!(wire.extra.is_empty()); 164 verify_nip01_event(wire.into_envelope().expect("ID-verified envelope")) 165 .expect("shared ID and signature verification") 166 } 167 168 fn deletion(corpus: &PublicCorpus, name: &str) -> RadrootsAdmittedNip09DeletionRequestEvent { 169 let wire = Nip01EventWire::parse_json(public_json(corpus, name)).expect("public deletion wire"); 170 verify_and_admit_nip09_deletion_request_event(wire.into_envelope().expect("deletion envelope")) 171 .expect("shared NIP-09 request admission without a target lookup") 172 } 173 174 fn candidate(event: &RadrootsSignatureVerifiedEvent) -> EventHeadCandidate { 175 match event_head_candidate_for_nip01_event(event.event()) { 176 EventHeadCandidateResult::Candidate(candidate) => candidate, 177 _ => panic!("verified addressable fixture must produce a raw protocol head candidate"), 178 } 179 } 180 181 /// Every supplied verified version enters shared ordering before product projection or suppression. 182 fn select_all_versions(versions: &[&RadrootsSignatureVerifiedEvent]) -> CurrentEventHead { 183 assert!(!versions.is_empty()); 184 assert!(versions.len() <= 3); 185 let mut head = None; 186 for version in versions { 187 match select_event_head(candidate(version), head.as_ref()) { 188 EventHeadDecision::Applied(next) => head = Some(next), 189 EventHeadDecision::SkippedDuplicate 190 | EventHeadDecision::SkippedOlder 191 | EventHeadDecision::SkippedSameTimestampHigherEventId => {} 192 EventHeadDecision::CoordinateMismatch => { 193 panic!("single-coordinate permutation fixture") 194 } 195 } 196 } 197 head.expect("shared selected head") 198 } 199 200 fn assert_focused_status(event: &RadrootsSignatureVerifiedEvent, status: &str) { 201 match admit_verified_food_availability_event(event.clone()).expect("focused head admission") { 202 RadrootsFoodAvailabilityAdmissionOutcome::Admitted(admitted) => { 203 assert_eq!(admitted.projection().status().as_str(), status); 204 } 205 _ => panic!("selected focused version must be admitted"), 206 } 207 } 208 209 #[test] 210 fn raw_head_selection_preserves_reverse_arrival_and_exact_duplicates() { 211 let corpus = public_corpus(); 212 let old = verified(&corpus, "old_active"); 213 let new = verified(&corpus, "newer_sold"); 214 let mut cases = 0; 215 for order in [[&old, &new, &new], [&new, &old, &new], [&new, &new, &old]] { 216 let head = select_all_versions(&order); 217 assert_eq!(head.event_id, *new.event().id()); 218 assert_eq!(head.created_at, 1_800_000_200); 219 assert_eq!( 220 select_event_head(candidate(&new), Some(&head)), 221 EventHeadDecision::SkippedDuplicate 222 ); 223 assert_eq!( 224 select_event_head(candidate(&old), Some(&head)), 225 EventHeadDecision::SkippedOlder 226 ); 227 assert_focused_status(&new, "sold"); 228 cases += 1; 229 } 230 assert_eq!(cases, 3); 231 } 232 233 #[test] 234 fn equal_timestamp_heads_select_lowest_event_id_in_every_order() { 235 let corpus = public_corpus(); 236 let left = verified(&corpus, "tie_left"); 237 let right = verified(&corpus, "tie_right"); 238 assert_ne!(left.event().id(), right.event().id()); 239 assert_eq!( 240 left.event().created_at_u64(), 241 right.event().created_at_u64() 242 ); 243 let (lowest, higher) = if left.event().id() < right.event().id() { 244 (&left, &right) 245 } else { 246 (&right, &left) 247 }; 248 let mut cases = 0; 249 for order in [[&left, &right, &left], [&right, &left, &right]] { 250 let head = select_all_versions(&order); 251 assert_eq!(head.event_id, *lowest.event().id()); 252 assert_eq!(head.created_at, 1_800_000_300); 253 assert_eq!( 254 select_event_head(candidate(higher), Some(&head)), 255 EventHeadDecision::SkippedSameTimestampHigherEventId 256 ); 257 assert_eq!( 258 select_event_head(candidate(lowest), Some(&head)), 259 EventHeadDecision::SkippedDuplicate 260 ); 261 cases += 1; 262 } 263 assert_eq!(cases, 2); 264 } 265 266 #[test] 267 fn shared_coordinates_keep_distinct_authors_kinds_and_identifiers_separate() { 268 let corpus = public_corpus(); 269 let original = verified(&corpus, "old_active"); 270 let head = select_all_versions(&[&original]); 271 match &head.coordinate { 272 EventHeadCoordinate::Addressable { 273 kind, 274 pubkey, 275 d_tag, 276 } => { 277 assert_eq!(*kind, 30402); 278 assert_eq!(pubkey.to_hex(), corpus.author); 279 assert_eq!(d_tag, "hcav-lifecycle-v1"); 280 } 281 _ => panic!("kind 30402 has an addressable coordinate"), 282 } 283 for name in ["other_identifier", "other_author", "other_kind"] { 284 let distinct = verified(&corpus, name); 285 assert_eq!(distinct.event().content(), original.event().content()); 286 assert_eq!( 287 select_event_head(candidate(&distinct), Some(&head)), 288 EventHeadDecision::CoordinateMismatch 289 ); 290 assert_ne!(candidate(&distinct).coordinate, head.coordinate); 291 if name == "other_kind" { 292 assert_eq!(distinct.event().author(), original.event().author()); 293 match &candidate(&distinct).coordinate { 294 EventHeadCoordinate::Addressable { 295 kind, 296 pubkey, 297 d_tag, 298 } => { 299 assert_eq!(*kind, u32::from(corpus::OTHER_ADDRESSABLE_KIND)); 300 assert_ne!(*kind, original.event().kind_u32()); 301 assert_eq!(*pubkey, *original.event().author()); 302 assert_eq!(d_tag, corpus::IDENTIFIER); 303 } 304 _ => panic!("control must have an addressable coordinate"), 305 } 306 } 307 assert_eq!( 308 select_all_versions(&[&distinct]).event_id, 309 *distinct.event().id() 310 ); 311 } 312 assert_eq!( 313 verified(&corpus, "other_author").event().author().to_hex(), 314 corpus.other_author 315 ); 316 } 317 318 #[test] 319 fn exact_deletion_before_target_and_deleted_head_never_resurrect_older_active() { 320 let corpus = public_corpus(); 321 // Admission first retains the request without consulting any target event. 322 let request = deletion(&corpus, "exact_newer"); 323 assert_eq!(request.event().created_at_u64(), 1_800_000_000); 324 let old = verified(&corpus, "old_active"); 325 let new = verified(&corpus, "newer_sold"); 326 assert!(request.event().created_at_u64() < new.event().created_at_u64()); 327 let mut cases = 0; 328 for order in corpus::ALL_THREE_PERMUTATIONS { 329 let mut versions = Vec::new(); 330 let mut requests = Vec::new(); 331 for index in order { 332 match index { 333 0 => versions.push(&old), 334 1 => versions.push(&new), 335 2 => requests.push(request.clone()), 336 _ => unreachable!("three-item permutation"), 337 } 338 if !versions.is_empty() { 339 let head = select_all_versions(&versions); 340 let winner = if head.event_id == *new.event().id() { 341 &new 342 } else { 343 &old 344 }; 345 let decision = evaluate_nip09_suppression(winner, &requests); 346 if head.event_id == *new.event().id() && !requests.is_empty() { 347 assert_eq!( 348 decision.outcome(), 349 RadrootsNip09SuppressionOutcome::Suppressed 350 ); 351 assert_eq!( 352 decision.reason(), 353 RadrootsNip09SuppressionReason::EventIdReference 354 ); 355 assert_eq!( 356 decision 357 .event_reference() 358 .expect("exact reference evidence") 359 .request_id(), 360 request.event().id() 361 ); 362 } else { 363 assert_eq!(decision.outcome(), RadrootsNip09SuppressionOutcome::Visible); 364 assert_eq!( 365 decision.reason(), 366 RadrootsNip09SuppressionReason::NoAuthorizedReference 367 ); 368 } 369 } 370 } 371 let head = select_all_versions(&versions); 372 assert_eq!(head.event_id, *new.event().id()); 373 assert_eq!( 374 evaluate_nip09_suppression(&new, &requests).outcome(), 375 RadrootsNip09SuppressionOutcome::Suppressed 376 ); 377 assert_eq!( 378 evaluate_nip09_suppression(&old, &requests).outcome(), 379 RadrootsNip09SuppressionOutcome::Visible 380 ); 381 assert_eq!( 382 select_event_head(candidate(&old), Some(&head)), 383 EventHeadDecision::SkippedOlder 384 ); 385 assert_focused_status(&old, "active"); 386 assert_ne!(head.event_id, *old.event().id()); 387 cases += 1; 388 } 389 assert_eq!(cases, 6); 390 } 391 392 #[test] 393 fn address_deletion_cutoff_is_inclusive_and_arrival_order_independent() { 394 let corpus = public_corpus(); 395 let request = deletion(&corpus, "address_cutoff"); 396 assert_eq!(request.event().created_at_u64(), 1_800_000_100); 397 let mut cases = 0; 398 for (name, outcome, reason) in [ 399 ( 400 "before_cutoff", 401 RadrootsNip09SuppressionOutcome::Suppressed, 402 RadrootsNip09SuppressionReason::AddressReferenceAtOrBeforeCutoff, 403 ), 404 ( 405 "at_cutoff", 406 RadrootsNip09SuppressionOutcome::Suppressed, 407 RadrootsNip09SuppressionReason::AddressReferenceAtOrBeforeCutoff, 408 ), 409 ( 410 "after_cutoff", 411 RadrootsNip09SuppressionOutcome::Visible, 412 RadrootsNip09SuppressionReason::AddressCutoffPrecedesTarget, 413 ), 414 ] { 415 for order in [[0, 1], [1, 0]] { 416 let mut target = None; 417 let mut retained = Vec::new(); 418 for arrival in order { 419 match arrival { 420 0 => target = Some(verified(&corpus, name)), 421 1 => retained.push(deletion(&corpus, "address_cutoff")), 422 _ => unreachable!("two-item arrival permutation"), 423 } 424 if let Some(target) = target.as_ref() { 425 let head = select_all_versions(&[target]); 426 assert_eq!(head.event_id, *target.event().id()); 427 let decision = evaluate_nip09_suppression(target, &retained); 428 if retained.is_empty() { 429 assert_eq!(decision.outcome(), RadrootsNip09SuppressionOutcome::Visible); 430 assert_eq!( 431 decision.reason(), 432 RadrootsNip09SuppressionReason::NoAuthorizedReference 433 ); 434 } else { 435 assert_eq!(decision.outcome(), outcome); 436 assert_eq!(decision.reason(), reason); 437 } 438 } else { 439 assert_eq!( 440 retained.len(), 441 1, 442 "deletion retained before any target observation" 443 ); 444 } 445 } 446 let target = target.expect("target observed in each permutation"); 447 let head = select_all_versions(&[&target]); 448 let decision = evaluate_nip09_suppression(&target, &retained); 449 assert_eq!(head.event_id, *target.event().id()); 450 assert_eq!(decision.outcome(), outcome); 451 assert_eq!(decision.reason(), reason); 452 assert!(decision.event_reference().is_none()); 453 let evidence = decision 454 .address_reference() 455 .expect("retained matching address evidence"); 456 assert_eq!(evidence.inclusive_cutoff(), corpus::CUTOFF); 457 assert_eq!(evidence.request_id(), request.event().id()); 458 assert_eq!(evidence.coordinate().kind(), 30402); 459 assert_eq!(evidence.coordinate().pubkey().to_hex(), corpus.author); 460 assert_eq!(evidence.coordinate().identifier(), "hcav-lifecycle-v1"); 461 cases += 1; 462 } 463 } 464 assert_eq!(cases, 6); 465 let combined = deletion(&corpus, "combined_cutoff"); 466 let target = verified(&corpus, "at_cutoff"); 467 let decision = evaluate_nip09_suppression(&target, std::slice::from_ref(&combined)); 468 assert_eq!( 469 decision.outcome(), 470 RadrootsNip09SuppressionOutcome::Suppressed 471 ); 472 assert_eq!( 473 decision.reason(), 474 RadrootsNip09SuppressionReason::EventIdAndAddressReference 475 ); 476 assert_eq!( 477 decision 478 .event_reference() 479 .expect("exact evidence") 480 .request_id(), 481 combined.event().id() 482 ); 483 assert_eq!( 484 decision 485 .address_reference() 486 .expect("address evidence") 487 .request_id(), 488 combined.event().id() 489 ); 490 } 491 492 #[test] 493 fn forged_deletions_cannot_suppress_other_author_and_invalid_signatures_are_rejected() { 494 let corpus = public_corpus(); 495 let target = verified(&corpus, "newer_sold"); 496 let head = select_all_versions(&[&target]); 497 for name in ["forged_exact", "forged_address"] { 498 let forged = deletion(&corpus, name); 499 assert_eq!(forged.event().author().to_hex(), corpus.other_author); 500 assert_ne!(forged.event().author(), target.event().author()); 501 let decision = evaluate_nip09_suppression(&target, std::slice::from_ref(&forged)); 502 assert_eq!(decision.outcome(), RadrootsNip09SuppressionOutcome::Visible); 503 assert_eq!( 504 decision.reason(), 505 RadrootsNip09SuppressionReason::RequestAuthorMismatch 506 ); 507 assert!(decision.event_reference().is_none()); 508 assert!(decision.address_reference().is_none()); 509 assert_eq!(head.event_id, *target.event().id()); 510 } 511 let unrelated = deletion(&corpus, "unrelated_exact"); 512 let decision = evaluate_nip09_suppression(&target, std::slice::from_ref(&unrelated)); 513 assert_eq!(decision.outcome(), RadrootsNip09SuppressionOutcome::Visible); 514 assert_eq!( 515 decision.reason(), 516 RadrootsNip09SuppressionReason::NoAuthorizedReference 517 ); 518 519 let json = public_json(&corpus, "exact_newer"); 520 let wire = Nip01EventWire::parse_json_unverified(json).expect("public deletion wire"); 521 let signature_field = format!("\"sig\":\"{}\"", wire.sig); 522 assert_eq!(json.matches(&signature_field).count(), 1); 523 let tampered = json.replacen( 524 &signature_field, 525 &format!("\"sig\":\"{}\"", "0".repeat(128)), 526 1, 527 ); 528 let envelope = Nip01EventWire::parse_json_unverified(&tampered) 529 .expect("bounded invalid-signature wire") 530 .into_unverified_envelope() 531 .expect("untrusted deletion envelope"); 532 assert_eq!( 533 verify_and_admit_nip09_deletion_request_event(envelope) 534 .expect_err("invalid deletion has no admitted request authority"), 535 RadrootsNip09DeletionAdmissionError::Nip01Verification( 536 RadrootsNip01VerificationError::SignatureInvalid 537 ) 538 ); 539 } 540 541 #[test] 542 fn newer_verified_unsupported_heads_never_promote_older_active_versions() { 543 let corpus = public_corpus(); 544 let old = verified(&corpus, "old_active"); 545 let mut cases = 0; 546 for name in ["generic_head", "malformed_head"] { 547 let unsupported = verified(&corpus, name); 548 for order in [ 549 [&old, &unsupported, &old], 550 [&unsupported, &old, &unsupported], 551 ] { 552 // The unsupported version participates before focused admission is attempted. 553 let head = select_all_versions(&order); 554 assert_eq!(head.event_id, *unsupported.event().id()); 555 assert_eq!(head.created_at, 1_800_000_200); 556 let result = admit_verified_food_availability_event(unsupported.clone()); 557 if name == "generic_head" { 558 match result.expect("verified generic exclusion") { 559 RadrootsFoodAvailabilityAdmissionOutcome::Excluded(candidate) => { 560 assert_eq!( 561 candidate.partition(), 562 ClassifiedListingPartition::GenericNip99 563 ); 564 assert_eq!(candidate.event().id(), &head.event_id); 565 } 566 _ => panic!("generic winning head remains unsupported"), 567 } 568 } else { 569 assert_eq!( 570 result.expect_err("malformed focused winning head remains known"), 571 RadrootsFoodAvailabilityAdmissionError::Projection( 572 RadrootsFoodAvailabilityProjectionError::Domain( 573 FoodAvailabilityError::StatusInvalid 574 ) 575 ) 576 ); 577 } 578 assert_eq!( 579 evaluate_nip09_suppression(&unsupported, &[]).outcome(), 580 RadrootsNip09SuppressionOutcome::Visible 581 ); 582 assert_eq!( 583 select_event_head(candidate(&old), Some(&head)), 584 EventHeadDecision::SkippedOlder 585 ); 586 assert_focused_status(&old, "active"); 587 assert_ne!(head.event_id, *old.event().id()); 588 cases += 1; 589 } 590 } 591 assert_eq!(cases, 4); 592 } 593 594 #[test] 595 fn lifecycle_corpus_is_bounded_and_retains_only_verified_public_material() { 596 assert_eq!(corpus::VERSIONS.len(), 11); 597 assert_eq!(corpus::DELETIONS.len(), 6); 598 assert_eq!(corpus::ALL_THREE_PERMUTATIONS.len(), 6); 599 assert!(RETAINED_SOURCE.len() <= MAX_RETAINED_SOURCE_BYTES); 600 for forbidden in [ 601 "nsec1", 602 "SecretKey", 603 "secret_key", 604 "private_key", 605 "Keys::", 606 "credential", 607 ] { 608 assert!( 609 !RETAINED_SOURCE.contains(forbidden), 610 "public retained raw recipes" 611 ); 612 } 613 let corpus = public_corpus(); 614 assert_eq!(corpus.events.len(), 18); 615 for event in &corpus.events { 616 for forbidden in ["nsec1", "secret_key", "private_key", "credential"] { 617 assert!( 618 !event.json.contains(forbidden), 619 "public signed lifecycle artifact" 620 ); 621 } 622 let verified = verified(&corpus, event.name); 623 assert!(matches!(verified.event().kind_u32(), 5 | 30402 | 30023)); 624 let author = verified.event().author().to_hex(); 625 assert!(author == corpus.author || author == corpus.other_author); 626 } 627 }