calendar.rs (121733B)
1 #[cfg(not(feature = "std"))] 2 use alloc::{ 3 format, 4 string::{String, ToString}, 5 vec, 6 vec::Vec, 7 }; 8 use core::{fmt, ops::RangeInclusive, str::FromStr}; 9 10 use crate::id::{ 11 AddressableCoordinate, AddressableCoordinateParts, DTag, EventId, RelayUrl, parse_public_key, 12 }; 13 use crate::media::AuthoredImage; 14 use crate::wire::v1::{ 15 DEFAULT_CONTENT_MAX_BYTES, DEFAULT_TAG_ELEMENT_MAX_BYTES, DEFAULT_TAG_MAX_COUNT, 16 DEFAULT_TAG_TOTAL_MAX_BYTES, 17 }; 18 use radroots_blossom::BlobUrl; 19 use radroots_identity::PublicKey; 20 use url_nostd::Url; 21 22 pub const RADROOTS_CALENDAR_SECONDS_PER_DAY: u64 = 86_400; 23 pub const RADROOTS_CALENDAR_MAX_COVERED_UTC_DAYS: u64 = 366; 24 pub const RADROOTS_CALENDAR_MAX_PARTICIPANTS: usize = DEFAULT_TAG_MAX_COUNT - 16; 25 26 #[cfg_attr( 27 any(feature = "serde", test), 28 derive(serde::Serialize, serde::Deserialize) 29 )] 30 #[derive(Clone, Debug, Default, PartialEq, Eq)] 31 pub struct CalendarParticipant { 32 pub pubkey: String, 33 pub relay: Option<String>, 34 pub role: Option<String>, 35 } 36 37 #[cfg_attr( 38 any(feature = "serde", test), 39 derive(serde::Serialize, serde::Deserialize) 40 )] 41 #[cfg_attr(any(feature = "serde", test), serde(rename_all = "snake_case"))] 42 #[derive(Clone, Debug, PartialEq, Eq)] 43 pub enum CalendarEventRsvpStatus { 44 Accepted, 45 Declined, 46 Tentative, 47 } 48 49 #[cfg_attr( 50 any(feature = "serde", test), 51 derive(serde::Serialize, serde::Deserialize) 52 )] 53 #[cfg_attr(any(feature = "serde", test), serde(rename_all = "snake_case"))] 54 #[derive(Clone, Debug, PartialEq, Eq)] 55 pub enum CalendarEventFreeBusy { 56 Free, 57 Busy, 58 } 59 60 #[non_exhaustive] 61 #[derive(Clone, Debug, PartialEq, Eq)] 62 pub enum CalendarEventError { 63 InvalidIdentifier, 64 InvalidCalendarUid, 65 InvalidEventReference, 66 InvalidRevisionReference, 67 InvalidAuthorReference, 68 DuplicateEventReference, 69 AuthorHintMismatch, 70 DeclinedFreeBusyForbidden, 71 InvalidTitle, 72 InvalidText(&'static str), 73 InvalidUrl(&'static str), 74 InvalidGeohash, 75 InvalidTimeZone, 76 InvalidParticipant { 77 index: usize, 78 }, 79 TooManyParticipants { 80 max: usize, 81 actual: usize, 82 }, 83 ContentTooLarge { 84 max: usize, 85 actual: usize, 86 }, 87 TagElementTooLarge { 88 field: &'static str, 89 max: usize, 90 actual: usize, 91 }, 92 TagCountExceeded { 93 max: usize, 94 actual: usize, 95 }, 96 TagBytesExceeded { 97 max: usize, 98 actual: usize, 99 }, 100 InvalidDate, 101 InvalidRange, 102 CoveredDayLimitExceeded { 103 max: u64, 104 actual: u64, 105 }, 106 } 107 108 impl CalendarEventError { 109 pub const fn code(&self) -> &'static str { 110 match self { 111 Self::InvalidIdentifier => "invalid_identifier", 112 Self::InvalidCalendarUid => "invalid_calendar_uid", 113 Self::InvalidEventReference => "invalid_event_reference", 114 Self::InvalidRevisionReference => "invalid_revision_reference", 115 Self::InvalidAuthorReference => "invalid_author_reference", 116 Self::DuplicateEventReference => "duplicate_event_reference", 117 Self::AuthorHintMismatch => "author_hint_mismatch", 118 Self::DeclinedFreeBusyForbidden => "declined_free_busy_forbidden", 119 Self::InvalidTitle => "invalid_title", 120 Self::InvalidText(_) => "invalid_text", 121 Self::InvalidUrl(_) => "invalid_url", 122 Self::InvalidGeohash => "invalid_geohash", 123 Self::InvalidTimeZone => "invalid_time_zone", 124 Self::InvalidParticipant { .. } => "invalid_participant", 125 Self::TooManyParticipants { .. } => "too_many_participants", 126 Self::ContentTooLarge { .. } => "content_too_large", 127 Self::TagElementTooLarge { .. } => "tag_element_too_large", 128 Self::TagCountExceeded { .. } => "tag_count_exceeded", 129 Self::TagBytesExceeded { .. } => "tag_bytes_exceeded", 130 Self::InvalidDate => "invalid_date", 131 Self::InvalidRange => "invalid_range", 132 Self::CoveredDayLimitExceeded { .. } => "covered_day_limit_exceeded", 133 } 134 } 135 } 136 137 impl fmt::Display for CalendarEventError { 138 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 139 match self { 140 Self::InvalidIdentifier => f.write_str("calendar identifier is invalid"), 141 Self::InvalidCalendarUid => f.write_str( 142 "calendar UID must be an unpadded 22-character base64url encoding of 128 bits", 143 ), 144 Self::InvalidEventReference => f.write_str("calendar event reference is invalid"), 145 Self::InvalidRevisionReference => { 146 f.write_str("calendar event revision reference is invalid") 147 } 148 Self::InvalidAuthorReference => { 149 f.write_str("calendar event author reference is invalid") 150 } 151 Self::DuplicateEventReference => { 152 f.write_str("calendar event references must have unique coordinates") 153 } 154 Self::AuthorHintMismatch => { 155 f.write_str("calendar event author hint does not match the event coordinate") 156 } 157 Self::DeclinedFreeBusyForbidden => { 158 f.write_str("an authored declined RSVP cannot carry free/busy state") 159 } 160 Self::InvalidTitle => f.write_str("calendar title must be canonical visible text"), 161 Self::InvalidText(field) => write!(f, "calendar {field} must be visible text"), 162 Self::InvalidUrl(field) => write!(f, "calendar {field} is not a valid URI"), 163 Self::InvalidGeohash => f.write_str("calendar geohash is invalid"), 164 Self::InvalidTimeZone => { 165 f.write_str("calendar time zone is not a canonical IANA identifier") 166 } 167 Self::InvalidParticipant { index } => { 168 write!(f, "calendar participant at index {index} is invalid") 169 } 170 Self::TooManyParticipants { max, actual } => write!( 171 f, 172 "calendar participant count {actual} exceeds maximum {max}" 173 ), 174 Self::ContentTooLarge { max, actual } => { 175 write!(f, "calendar content size {actual} exceeds maximum {max}") 176 } 177 Self::TagElementTooLarge { field, max, actual } => write!( 178 f, 179 "calendar {field} size {actual} exceeds tag element maximum {max}" 180 ), 181 Self::TagCountExceeded { max, actual } => { 182 write!(f, "calendar tag count {actual} exceeds maximum {max}") 183 } 184 Self::TagBytesExceeded { max, actual } => { 185 write!(f, "calendar tag bytes {actual} exceed maximum {max}") 186 } 187 Self::InvalidDate => f.write_str("calendar date is not a valid Gregorian date"), 188 Self::InvalidRange => f.write_str("calendar end must be greater than start"), 189 Self::CoveredDayLimitExceeded { max, actual } => write!( 190 f, 191 "calendar interval covers {actual} UTC days, exceeding the {max}-day limit" 192 ), 193 } 194 } 195 } 196 197 #[cfg(feature = "std")] 198 impl std::error::Error for CalendarEventError {} 199 200 /// A syntax-valid 128-bit calendar identifier encoded as unpadded base64url. 201 /// 202 /// This type validates the identifier's entropy shape, not uniqueness. The 203 /// authoring runtime must still generate a fresh value for every calendar or 204 /// RSVP identity it creates. 205 #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] 206 pub struct CalendarUid(String); 207 208 impl CalendarUid { 209 pub fn parse(value: impl AsRef<str>) -> Result<Self, CalendarEventError> { 210 let value = value.as_ref(); 211 let valid_alphabet = value 212 .bytes() 213 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')); 214 let valid_final_quantum = value 215 .as_bytes() 216 .last() 217 .is_some_and(|byte| matches!(byte, b'A' | b'Q' | b'g' | b'w')); 218 if [value.len() == 22, valid_alphabet, valid_final_quantum] != [true; 3] { 219 return Err(CalendarEventError::InvalidCalendarUid); 220 } 221 Ok(Self(value.to_string())) 222 } 223 224 pub fn as_str(&self) -> &str { 225 &self.0 226 } 227 228 pub fn into_string(self) -> String { 229 self.0 230 } 231 } 232 233 impl fmt::Display for CalendarUid { 234 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 235 f.write_str(self.as_str()) 236 } 237 } 238 239 impl AsRef<str> for CalendarUid { 240 fn as_ref(&self) -> &str { 241 self.as_str() 242 } 243 } 244 245 impl FromStr for CalendarUid { 246 type Err = CalendarEventError; 247 248 fn from_str(value: &str) -> Result<Self, Self::Err> { 249 Self::parse(value) 250 } 251 } 252 253 impl TryFrom<&str> for CalendarUid { 254 type Error = CalendarEventError; 255 256 fn try_from(value: &str) -> Result<Self, Self::Error> { 257 Self::parse(value) 258 } 259 } 260 261 impl TryFrom<String> for CalendarUid { 262 type Error = CalendarEventError; 263 264 fn try_from(value: String) -> Result<Self, Self::Error> { 265 Self::parse(value) 266 } 267 } 268 269 #[cfg(any(feature = "serde", test))] 270 impl serde::Serialize for CalendarUid { 271 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> 272 where 273 S: serde::Serializer, 274 { 275 serializer.serialize_str(self.as_str()) 276 } 277 } 278 279 /// A raw-preserving NIP-52 address reference to a kind-31922 or kind-31923 event. 280 #[derive(Clone, Debug, PartialEq, Eq)] 281 pub struct CalendarEventReference { 282 coordinate: AddressableCoordinate, 283 kind: u32, 284 author: PublicKey, 285 d_tag: DTag, 286 relay: Option<String>, 287 } 288 289 impl CalendarEventReference { 290 pub fn parse( 291 coordinate: impl AsRef<str>, 292 relay: Option<&str>, 293 ) -> Result<Self, CalendarEventError> { 294 let coordinate = AddressableCoordinate::parse(coordinate.as_ref()) 295 .map_err(|_| CalendarEventError::InvalidEventReference)?; 296 let parts = AddressableCoordinateParts::parse(coordinate.as_str()) 297 .map_err(|_| CalendarEventError::InvalidEventReference)?; 298 if !matches!( 299 parts.kind, 300 crate::envelope::kind::KIND_CALENDAR_DATE_EVENT 301 | crate::envelope::kind::KIND_CALENDAR_TIME_EVENT 302 ) { 303 return Err(CalendarEventError::InvalidEventReference); 304 } 305 let relay = 306 parse_calendar_reference_relay(relay, CalendarEventError::InvalidEventReference)?; 307 Ok(Self { 308 coordinate, 309 kind: parts.kind, 310 author: parts.pubkey, 311 d_tag: parts.d_tag, 312 relay, 313 }) 314 } 315 316 pub fn coordinate(&self) -> &AddressableCoordinate { 317 &self.coordinate 318 } 319 320 pub const fn kind(&self) -> u32 { 321 self.kind 322 } 323 324 pub fn author(&self) -> &PublicKey { 325 &self.author 326 } 327 328 pub fn d_tag(&self) -> &DTag { 329 &self.d_tag 330 } 331 332 pub fn relay(&self) -> Option<&str> { 333 self.relay.as_deref() 334 } 335 336 pub fn is_canonical(&self) -> bool { 337 // Nostr does not define relay URL normalization. Strict admission validates the 338 // lowercase ws/wss syntax while preserving the caller's host, port, path, and query. 339 [ 340 self.coordinate.as_str() == format!("{}:{}:{}", self.kind, self.author, self.d_tag), 341 self.relay() 342 .is_none_or(|relay| RelayUrl::parse(relay).is_ok()), 343 ] == [true; 2] 344 } 345 346 fn has_same_coordinate(&self, other: &Self) -> bool { 347 [ 348 self.kind == other.kind, 349 self.author == other.author, 350 self.d_tag == other.d_tag, 351 ] == [true; 3] 352 } 353 } 354 355 /// A raw-preserving event-id reference to a specific NIP-52 event revision. 356 #[derive(Clone, Debug, PartialEq, Eq)] 357 pub struct CalendarEventRevisionReference { 358 raw_event_id: String, 359 event_id: EventId, 360 relay: Option<String>, 361 } 362 363 impl CalendarEventRevisionReference { 364 pub fn parse( 365 event_id: impl AsRef<str>, 366 relay: Option<&str>, 367 ) -> Result<Self, CalendarEventError> { 368 let raw_event_id = event_id.as_ref(); 369 let event_id = EventId::parse(raw_event_id) 370 .map_err(|_| CalendarEventError::InvalidRevisionReference)?; 371 let relay = 372 parse_calendar_reference_relay(relay, CalendarEventError::InvalidRevisionReference)?; 373 Ok(Self { 374 raw_event_id: raw_event_id.to_string(), 375 event_id, 376 relay, 377 }) 378 } 379 380 pub fn raw_event_id(&self) -> &str { 381 &self.raw_event_id 382 } 383 384 pub fn event_id(&self) -> &EventId { 385 &self.event_id 386 } 387 388 pub fn relay(&self) -> Option<&str> { 389 self.relay.as_deref() 390 } 391 392 pub fn is_canonical(&self) -> bool { 393 // Relay hints use strict Radroots syntax; their raw URL spelling is not normalized. 394 self.raw_event_id == self.event_id.to_hex() 395 && self 396 .relay() 397 .is_none_or(|relay| RelayUrl::parse(relay).is_ok()) 398 } 399 } 400 401 /// A raw-preserving optional RSVP author hint. This reference has no role field. 402 #[derive(Clone, Debug, PartialEq, Eq)] 403 pub struct CalendarEventAuthorReference { 404 raw_pubkey: String, 405 pubkey: PublicKey, 406 relay: Option<String>, 407 } 408 409 impl CalendarEventAuthorReference { 410 pub fn parse(pubkey: impl AsRef<str>, relay: Option<&str>) -> Result<Self, CalendarEventError> { 411 let raw_pubkey = pubkey.as_ref(); 412 let pubkey = 413 parse_public_key(raw_pubkey).map_err(|_| CalendarEventError::InvalidAuthorReference)?; 414 let relay = 415 parse_calendar_reference_relay(relay, CalendarEventError::InvalidAuthorReference)?; 416 Ok(Self { 417 raw_pubkey: raw_pubkey.to_string(), 418 pubkey, 419 relay, 420 }) 421 } 422 423 pub fn raw_pubkey(&self) -> &str { 424 &self.raw_pubkey 425 } 426 427 pub fn pubkey(&self) -> &PublicKey { 428 &self.pubkey 429 } 430 431 pub fn relay(&self) -> Option<&str> { 432 self.relay.as_deref() 433 } 434 435 pub fn is_canonical(&self) -> bool { 436 // Relay hints use strict Radroots syntax; their raw URL spelling is not normalized. 437 [ 438 self.raw_pubkey == self.pubkey.to_hex(), 439 self.relay() 440 .is_none_or(|relay| RelayUrl::parse(relay).is_ok()), 441 ] == [true; 2] 442 } 443 } 444 445 /// A canonical IANA Time Zone Database identifier. 446 #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] 447 pub struct IanaTimeZoneId(String); 448 449 impl IanaTimeZoneId { 450 pub fn parse(value: impl AsRef<str>) -> Result<Self, CalendarEventError> { 451 let value = value.as_ref(); 452 let Some((canonical, _)) = jiff_tzdb::get(value) else { 453 return Err(CalendarEventError::InvalidTimeZone); 454 }; 455 if [ 456 canonical == value, 457 canonical_calendar_tag_text_is_valid(value), 458 ] != [true; 2] 459 { 460 return Err(CalendarEventError::InvalidTimeZone); 461 } 462 Ok(Self(value.into())) 463 } 464 465 pub fn as_str(&self) -> &str { 466 &self.0 467 } 468 } 469 470 impl fmt::Display for IanaTimeZoneId { 471 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 472 f.write_str(self.as_str()) 473 } 474 } 475 476 impl AsRef<str> for IanaTimeZoneId { 477 fn as_ref(&self) -> &str { 478 self.as_str() 479 } 480 } 481 482 impl FromStr for IanaTimeZoneId { 483 type Err = CalendarEventError; 484 485 fn from_str(value: &str) -> Result<Self, Self::Err> { 486 Self::parse(value) 487 } 488 } 489 490 #[cfg(any(feature = "serde", test))] 491 impl serde::Serialize for IanaTimeZoneId { 492 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> 493 where 494 S: serde::Serializer, 495 { 496 serializer.serialize_str(self.as_str()) 497 } 498 } 499 500 #[cfg(any(feature = "serde", test))] 501 impl<'de> serde::Deserialize<'de> for IanaTimeZoneId { 502 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 503 where 504 D: serde::Deserializer<'de>, 505 { 506 let value = <String as serde::Deserialize>::deserialize(deserializer)?; 507 Self::parse(value).map_err(serde::de::Error::custom) 508 } 509 } 510 511 /// A structurally valid absolute URI preserved exactly as it appeared on wire. 512 #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] 513 pub struct CalendarUri(String); 514 515 impl CalendarUri { 516 pub fn parse(value: impl AsRef<str>) -> Result<Self, CalendarEventError> { 517 let value = value.as_ref(); 518 let valid = [ 519 value.trim() == value, 520 !value.chars().any(|character| { 521 [character.is_whitespace(), character.is_control()].contains(&true) 522 }), 523 value.len() <= DEFAULT_TAG_ELEMENT_MAX_BYTES, 524 Url::parse(value).is_ok(), 525 ]; 526 if valid != [true; 4] { 527 return Err(CalendarEventError::InvalidUrl("URI")); 528 } 529 Ok(Self(value.into())) 530 } 531 532 pub fn as_str(&self) -> &str { 533 &self.0 534 } 535 } 536 537 impl AsRef<str> for CalendarUri { 538 fn as_ref(&self) -> &str { 539 self.as_str() 540 } 541 } 542 543 impl FromStr for CalendarUri { 544 type Err = CalendarEventError; 545 546 fn from_str(value: &str) -> Result<Self, Self::Err> { 547 Self::parse(value) 548 } 549 } 550 551 impl fmt::Display for CalendarUri { 552 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 553 f.write_str(self.as_str()) 554 } 555 } 556 557 #[cfg(any(feature = "serde", test))] 558 impl serde::Serialize for CalendarUri { 559 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> 560 where 561 S: serde::Serializer, 562 { 563 serializer.serialize_str(self.as_str()) 564 } 565 } 566 567 #[cfg(any(feature = "serde", test))] 568 impl<'de> serde::Deserialize<'de> for CalendarUri { 569 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 570 where 571 D: serde::Deserializer<'de>, 572 { 573 let value = <String as serde::Deserialize>::deserialize(deserializer)?; 574 Self::parse(value).map_err(serde::de::Error::custom) 575 } 576 } 577 578 /// Strict authored representation of a NIP-52 kind-31924 calendar collection. 579 #[derive(Clone, Debug, PartialEq, Eq)] 580 pub struct AuthoredCalendar { 581 uid: CalendarUid, 582 title: String, 583 content: String, 584 event_references: Vec<CalendarEventReference>, 585 list_description: Option<String>, 586 image: Option<AuthoredImage>, 587 } 588 589 impl AuthoredCalendar { 590 pub fn new( 591 uid: CalendarUid, 592 title: impl Into<String>, 593 content: impl Into<String>, 594 event_references: Vec<CalendarEventReference>, 595 ) -> Result<Self, CalendarEventError> { 596 let title = validated_title(title.into())?; 597 let content = content.into(); 598 validate_calendar_content(&content)?; 599 validate_strict_calendar_event_references(&event_references)?; 600 let authored = Self { 601 uid, 602 title, 603 content, 604 event_references, 605 list_description: None, 606 image: None, 607 }; 608 authored.validate_budget()?; 609 Ok(authored) 610 } 611 612 pub fn with_list_description( 613 mut self, 614 value: impl Into<String>, 615 ) -> Result<Self, CalendarEventError> { 616 let value = value.into(); 617 validate_canonical_calendar_tag_text(&value, "description")?; 618 self.list_description = Some(value); 619 self.validate_budget()?; 620 Ok(self) 621 } 622 623 pub fn with_image(mut self, image: AuthoredImage) -> Result<Self, CalendarEventError> { 624 self.image = Some(image); 625 self.validate_budget()?; 626 Ok(self) 627 } 628 629 pub fn uid(&self) -> &CalendarUid { 630 &self.uid 631 } 632 633 pub fn title(&self) -> &str { 634 &self.title 635 } 636 637 pub fn content(&self) -> &str { 638 &self.content 639 } 640 641 pub fn event_references(&self) -> &[CalendarEventReference] { 642 &self.event_references 643 } 644 645 pub fn list_description(&self) -> Option<&str> { 646 self.list_description.as_deref() 647 } 648 649 pub fn image(&self) -> Option<&AuthoredImage> { 650 self.image.as_ref() 651 } 652 653 fn validate_budget(&self) -> Result<(), CalendarEventError> { 654 validate_calendar_collection_budget( 655 self.uid.as_str(), 656 &self.title, 657 &self.content, 658 &self.event_references, 659 self.list_description.as_deref(), 660 self.image 661 .as_ref() 662 .map(|image| image.descriptor().url().as_str()), 663 ) 664 } 665 } 666 667 /// Constructor input for the tolerant, raw-preserving kind-31924 parse layer. 668 #[derive(Clone, Debug, PartialEq, Eq)] 669 pub struct ParsedNip52CalendarParts { 670 pub d_tag: String, 671 pub title: String, 672 pub content: String, 673 pub event_references: Vec<CalendarEventReference>, 674 pub list_description: Option<String>, 675 pub image: Option<CalendarUri>, 676 } 677 678 /// Structurally valid kind-31924 data. Canonical admission is a separate step. 679 #[derive(Clone, Debug, PartialEq, Eq)] 680 pub struct ParsedNip52Calendar { 681 d_tag: String, 682 title: String, 683 content: String, 684 event_references: Vec<CalendarEventReference>, 685 list_description: Option<String>, 686 image: Option<CalendarUri>, 687 } 688 689 impl ParsedNip52Calendar { 690 pub fn try_new(parts: ParsedNip52CalendarParts) -> Result<Self, CalendarEventError> { 691 validate_calendar_tag_text(&parts.d_tag, "d")?; 692 validate_calendar_tag_text(&parts.title, "title")?; 693 validate_calendar_content(&parts.content)?; 694 if let Some(description) = parts.list_description.as_deref() { 695 validate_calendar_tag_text(description, "description")?; 696 } 697 validate_calendar_collection_budget( 698 &parts.d_tag, 699 &parts.title, 700 &parts.content, 701 &parts.event_references, 702 parts.list_description.as_deref(), 703 parts.image.as_ref().map(CalendarUri::as_str), 704 )?; 705 Ok(Self { 706 d_tag: parts.d_tag, 707 title: parts.title, 708 content: parts.content, 709 event_references: parts.event_references, 710 list_description: parts.list_description, 711 image: parts.image, 712 }) 713 } 714 715 pub fn d_tag(&self) -> &str { 716 &self.d_tag 717 } 718 719 pub fn title(&self) -> &str { 720 &self.title 721 } 722 723 pub fn content(&self) -> &str { 724 &self.content 725 } 726 727 pub fn event_references(&self) -> &[CalendarEventReference] { 728 &self.event_references 729 } 730 731 pub fn list_description(&self) -> Option<&str> { 732 self.list_description.as_deref() 733 } 734 735 pub fn image(&self) -> Option<&CalendarUri> { 736 self.image.as_ref() 737 } 738 } 739 740 /// Canonical Radroots admission of a parsed kind-31924 calendar collection. 741 #[derive(Clone, Debug, PartialEq, Eq)] 742 pub struct AdmittedCalendar { 743 parsed: ParsedNip52Calendar, 744 uid: CalendarUid, 745 blossom_image: Option<BlobUrl>, 746 } 747 748 impl AdmittedCalendar { 749 pub fn try_from_parsed(parsed: ParsedNip52Calendar) -> Result<Self, CalendarAdmissionError> { 750 let uid = CalendarUid::parse(parsed.d_tag()) 751 .map_err(|_| CalendarAdmissionError::NonCanonicalField("d"))?; 752 if !canonical_calendar_tag_text_is_valid(parsed.title()) { 753 return Err(CalendarAdmissionError::NonCanonicalField("title")); 754 } 755 if parsed 756 .list_description() 757 .is_some_and(|description| !canonical_calendar_tag_text_is_valid(description)) 758 { 759 return Err(CalendarAdmissionError::NonCanonicalField("description")); 760 } 761 if parsed 762 .event_references() 763 .iter() 764 .any(|reference| !reference.is_canonical()) 765 { 766 return Err(CalendarAdmissionError::NonCanonicalField("event_reference")); 767 } 768 if calendar_event_references_have_duplicates(parsed.event_references()) { 769 return Err(CalendarAdmissionError::DuplicateEventReference); 770 } 771 let blossom_image = parsed 772 .image() 773 .map(|image| { 774 BlobUrl::parse(image.as_str()).map_err(|_| CalendarAdmissionError::NonBlossomImage) 775 }) 776 .transpose()?; 777 Ok(Self { 778 parsed, 779 uid, 780 blossom_image, 781 }) 782 } 783 784 pub fn parsed(&self) -> &ParsedNip52Calendar { 785 &self.parsed 786 } 787 788 pub fn uid(&self) -> &CalendarUid { 789 &self.uid 790 } 791 792 pub fn title(&self) -> &str { 793 self.parsed.title() 794 } 795 796 pub fn content(&self) -> &str { 797 self.parsed.content() 798 } 799 800 pub fn event_references(&self) -> &[CalendarEventReference] { 801 self.parsed.event_references() 802 } 803 804 pub fn list_description(&self) -> Option<&str> { 805 self.parsed.list_description() 806 } 807 808 pub fn blossom_image(&self) -> Option<&BlobUrl> { 809 self.blossom_image.as_ref() 810 } 811 } 812 813 #[cfg_attr(any(feature = "serde", test), derive(serde::Serialize))] 814 #[derive(Clone, Debug, PartialEq, Eq)] 815 pub struct CalendarRequest { 816 calendar: AddressableCoordinate, 817 relay: Option<String>, 818 } 819 820 impl CalendarRequest { 821 pub fn new(calendar: impl AsRef<str>, relay: Option<&str>) -> Result<Self, CalendarEventError> { 822 let calendar = AddressableCoordinate::parse(calendar.as_ref()) 823 .map_err(|_| CalendarEventError::InvalidUrl("calendar request"))?; 824 let parts = crate::id::AddressableCoordinateParts::parse(calendar.as_str()) 825 .map_err(|_| CalendarEventError::InvalidUrl("calendar request"))?; 826 if parts.kind != crate::envelope::kind::KIND_CALENDAR { 827 return Err(CalendarEventError::InvalidUrl("calendar request")); 828 } 829 let relay = relay 830 .map(|value| { 831 if calendar_relay_url_is_valid(value) { 832 Ok(value.to_string()) 833 } else { 834 Err(CalendarEventError::InvalidUrl("calendar request relay")) 835 } 836 }) 837 .transpose()?; 838 Ok(Self { calendar, relay }) 839 } 840 841 pub fn calendar(&self) -> &AddressableCoordinate { 842 &self.calendar 843 } 844 845 pub fn relay(&self) -> Option<&str> { 846 self.relay.as_deref() 847 } 848 849 pub fn is_canonical(&self) -> bool { 850 let Ok(parts) = crate::id::AddressableCoordinateParts::parse(self.calendar.as_str()) else { 851 return false; 852 }; 853 [ 854 self.calendar.as_str() == format!("{}:{}:{}", parts.kind, parts.pubkey, parts.d_tag), 855 self.relay 856 .as_deref() 857 .is_none_or(|relay| RelayUrl::parse(relay).is_ok()), 858 ] == [true; 2] 859 } 860 } 861 862 /// A canonical Gregorian calendar date in `YYYY-MM-DD` form. 863 #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] 864 pub struct CalendarDate(String); 865 866 impl CalendarDate { 867 pub fn parse(value: &str) -> Result<Self, CalendarEventError> { 868 let bytes = value.as_bytes(); 869 if bytes.len() != 10 || bytes[4] != b'-' || bytes[7] != b'-' { 870 return Err(CalendarEventError::InvalidDate); 871 } 872 let year = parse_date_digits(&bytes[0..4])?; 873 let month = parse_date_digits(&bytes[5..7])?; 874 let day = parse_date_digits(&bytes[8..10])?; 875 if year == 0 || month == 0 || month > 12 || day == 0 || day > days_in_month(year, month) { 876 return Err(CalendarEventError::InvalidDate); 877 } 878 Ok(Self(value.into())) 879 } 880 881 pub fn as_str(&self) -> &str { 882 &self.0 883 } 884 } 885 886 impl fmt::Display for CalendarDate { 887 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 888 f.write_str(self.as_str()) 889 } 890 } 891 892 impl AsRef<str> for CalendarDate { 893 fn as_ref(&self) -> &str { 894 self.as_str() 895 } 896 } 897 898 impl FromStr for CalendarDate { 899 type Err = CalendarEventError; 900 901 fn from_str(value: &str) -> Result<Self, Self::Err> { 902 Self::parse(value) 903 } 904 } 905 906 #[cfg(any(feature = "serde", test))] 907 impl serde::Serialize for CalendarDate { 908 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> 909 where 910 S: serde::Serializer, 911 { 912 serializer.serialize_str(self.as_str()) 913 } 914 } 915 916 #[cfg(any(feature = "serde", test))] 917 impl<'de> serde::Deserialize<'de> for CalendarDate { 918 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 919 where 920 D: serde::Deserializer<'de>, 921 { 922 let value = <String as serde::Deserialize>::deserialize(deserializer)?; 923 Self::parse(&value).map_err(serde::de::Error::custom) 924 } 925 } 926 927 /// Strict authored NIP-52 kind-31922 event. 928 /// 929 /// Authored images can only enter through a byte-verified image descriptor. 930 /// The model cannot represent the obsolete uppercase-`D` date list. 931 #[derive(Clone, Debug, PartialEq, Eq)] 932 pub struct AuthoredCalendarDateEvent { 933 d_tag: DTag, 934 title: String, 935 start: CalendarDate, 936 description: Option<String>, 937 end: Option<CalendarDate>, 938 locations: Vec<String>, 939 geohash: Option<String>, 940 summary: Option<String>, 941 image: Option<AuthoredImage>, 942 participants: Option<Vec<CalendarParticipant>>, 943 categories: Vec<String>, 944 references: Vec<CalendarUri>, 945 calendar_requests: Vec<CalendarRequest>, 946 } 947 948 impl AuthoredCalendarDateEvent { 949 pub fn new( 950 d_tag: impl AsRef<str>, 951 title: impl Into<String>, 952 start: CalendarDate, 953 ) -> Result<Self, CalendarEventError> { 954 let d_tag = 955 DTag::parse(d_tag.as_ref()).map_err(|_| CalendarEventError::InvalidIdentifier)?; 956 let title = validated_title(title.into())?; 957 Ok(Self { 958 d_tag, 959 title, 960 start, 961 description: None, 962 end: None, 963 locations: Vec::new(), 964 geohash: None, 965 summary: None, 966 image: None, 967 participants: None, 968 categories: Vec::new(), 969 references: Vec::new(), 970 calendar_requests: Vec::new(), 971 }) 972 } 973 974 pub fn with_end(mut self, end: CalendarDate) -> Result<Self, CalendarEventError> { 975 if end <= self.start { 976 return Err(CalendarEventError::InvalidRange); 977 } 978 self.end = Some(end); 979 self.validate_budget()?; 980 Ok(self) 981 } 982 983 pub fn with_description( 984 mut self, 985 value: impl Into<String>, 986 ) -> Result<Self, CalendarEventError> { 987 let value = value.into(); 988 validate_calendar_content(&value)?; 989 self.description = Some(value); 990 self.validate_budget()?; 991 Ok(self) 992 } 993 994 pub fn with_locations(mut self, value: Vec<String>) -> Result<Self, CalendarEventError> { 995 validate_authored_calendar_locations(&value)?; 996 self.locations = value; 997 self.validate_budget()?; 998 Ok(self) 999 } 1000 1001 pub fn with_geohash(mut self, value: impl Into<String>) -> Result<Self, CalendarEventError> { 1002 let value = value.into(); 1003 if !canonical_calendar_geohash_is_valid(&value) { 1004 return Err(CalendarEventError::InvalidGeohash); 1005 } 1006 self.geohash = Some(value); 1007 self.validate_budget()?; 1008 Ok(self) 1009 } 1010 1011 pub fn with_summary(mut self, value: impl Into<String>) -> Result<Self, CalendarEventError> { 1012 let value = value.into(); 1013 validate_canonical_calendar_tag_text(&value, "summary")?; 1014 self.summary = Some(value); 1015 self.validate_budget()?; 1016 Ok(self) 1017 } 1018 1019 pub fn with_image(mut self, value: AuthoredImage) -> Result<Self, CalendarEventError> { 1020 self.image = Some(value); 1021 self.validate_budget()?; 1022 Ok(self) 1023 } 1024 1025 pub fn with_participants( 1026 mut self, 1027 value: Vec<CalendarParticipant>, 1028 ) -> Result<Self, CalendarEventError> { 1029 validate_authored_calendar_participants(&value)?; 1030 self.participants = Some(value); 1031 self.validate_budget()?; 1032 Ok(self) 1033 } 1034 1035 pub fn with_categories(mut self, value: Vec<String>) -> Result<Self, CalendarEventError> { 1036 validate_authored_calendar_categories(&value)?; 1037 self.categories = value; 1038 self.validate_budget()?; 1039 Ok(self) 1040 } 1041 1042 pub fn with_references(mut self, value: Vec<CalendarUri>) -> Result<Self, CalendarEventError> { 1043 self.references = value; 1044 self.validate_budget()?; 1045 Ok(self) 1046 } 1047 1048 pub fn with_calendar_requests( 1049 mut self, 1050 value: Vec<CalendarRequest>, 1051 ) -> Result<Self, CalendarEventError> { 1052 validate_authored_calendar_requests(&value)?; 1053 self.calendar_requests = value; 1054 self.validate_budget()?; 1055 Ok(self) 1056 } 1057 1058 pub fn d_tag(&self) -> &DTag { 1059 &self.d_tag 1060 } 1061 1062 pub fn title(&self) -> &str { 1063 &self.title 1064 } 1065 1066 pub fn start(&self) -> &CalendarDate { 1067 &self.start 1068 } 1069 1070 pub fn description(&self) -> Option<&str> { 1071 self.description.as_deref() 1072 } 1073 1074 pub fn end(&self) -> Option<&CalendarDate> { 1075 self.end.as_ref() 1076 } 1077 1078 pub fn locations(&self) -> &[String] { 1079 &self.locations 1080 } 1081 1082 pub fn geohash(&self) -> Option<&str> { 1083 self.geohash.as_deref() 1084 } 1085 1086 pub fn summary(&self) -> Option<&str> { 1087 self.summary.as_deref() 1088 } 1089 1090 pub fn image(&self) -> Option<&AuthoredImage> { 1091 self.image.as_ref() 1092 } 1093 1094 pub fn participants(&self) -> Option<&Vec<CalendarParticipant>> { 1095 self.participants.as_ref() 1096 } 1097 1098 pub fn categories(&self) -> &[String] { 1099 &self.categories 1100 } 1101 1102 pub fn references(&self) -> &[CalendarUri] { 1103 &self.references 1104 } 1105 1106 pub fn calendar_requests(&self) -> &[CalendarRequest] { 1107 &self.calendar_requests 1108 } 1109 1110 fn validate_budget(&self) -> Result<(), CalendarEventError> { 1111 validate_authored_calendar_budget(self.description(), authored_date_tags_for_budget(self)) 1112 } 1113 } 1114 1115 /// Strict authored NIP-52 kind-31923 event. 1116 /// 1117 /// Covered uppercase-`D` values are derived from the timestamp range and are 1118 /// never accepted from callers. 1119 #[derive(Clone, Debug, PartialEq, Eq)] 1120 pub struct AuthoredCalendarTimeEvent { 1121 d_tag: DTag, 1122 title: String, 1123 start: u64, 1124 description: Option<String>, 1125 end: Option<u64>, 1126 start_tzid: Option<IanaTimeZoneId>, 1127 end_tzid: Option<IanaTimeZoneId>, 1128 locations: Vec<String>, 1129 geohash: Option<String>, 1130 summary: Option<String>, 1131 image: Option<AuthoredImage>, 1132 participants: Option<Vec<CalendarParticipant>>, 1133 categories: Vec<String>, 1134 references: Vec<CalendarUri>, 1135 calendar_requests: Vec<CalendarRequest>, 1136 } 1137 1138 impl AuthoredCalendarTimeEvent { 1139 pub fn new( 1140 d_tag: impl AsRef<str>, 1141 title: impl Into<String>, 1142 start: u64, 1143 ) -> Result<Self, CalendarEventError> { 1144 let d_tag = 1145 DTag::parse(d_tag.as_ref()).map_err(|_| CalendarEventError::InvalidIdentifier)?; 1146 let title = validated_title(title.into())?; 1147 covered_utc_days(start, None)?; 1148 Ok(Self { 1149 d_tag, 1150 title, 1151 start, 1152 description: None, 1153 end: None, 1154 start_tzid: None, 1155 end_tzid: None, 1156 locations: Vec::new(), 1157 geohash: None, 1158 summary: None, 1159 image: None, 1160 participants: None, 1161 categories: Vec::new(), 1162 references: Vec::new(), 1163 calendar_requests: Vec::new(), 1164 }) 1165 } 1166 1167 pub fn with_end(mut self, end: u64) -> Result<Self, CalendarEventError> { 1168 covered_utc_days(self.start, Some(end))?; 1169 self.end = Some(end); 1170 self.validate_budget()?; 1171 Ok(self) 1172 } 1173 1174 pub fn with_description( 1175 mut self, 1176 value: impl Into<String>, 1177 ) -> Result<Self, CalendarEventError> { 1178 let value = value.into(); 1179 validate_calendar_content(&value)?; 1180 self.description = Some(value); 1181 self.validate_budget()?; 1182 Ok(self) 1183 } 1184 1185 pub fn with_start_tzid(mut self, value: impl AsRef<str>) -> Result<Self, CalendarEventError> { 1186 self.start_tzid = Some(IanaTimeZoneId::parse(value)?); 1187 self.validate_budget()?; 1188 Ok(self) 1189 } 1190 1191 pub fn with_end_tzid(mut self, value: impl AsRef<str>) -> Result<Self, CalendarEventError> { 1192 self.end_tzid = Some(IanaTimeZoneId::parse(value)?); 1193 self.validate_budget()?; 1194 Ok(self) 1195 } 1196 1197 pub fn with_locations(mut self, value: Vec<String>) -> Result<Self, CalendarEventError> { 1198 validate_authored_calendar_locations(&value)?; 1199 self.locations = value; 1200 self.validate_budget()?; 1201 Ok(self) 1202 } 1203 1204 pub fn with_geohash(mut self, value: impl Into<String>) -> Result<Self, CalendarEventError> { 1205 let value = value.into(); 1206 if !canonical_calendar_geohash_is_valid(&value) { 1207 return Err(CalendarEventError::InvalidGeohash); 1208 } 1209 self.geohash = Some(value); 1210 self.validate_budget()?; 1211 Ok(self) 1212 } 1213 1214 pub fn with_summary(mut self, value: impl Into<String>) -> Result<Self, CalendarEventError> { 1215 let value = value.into(); 1216 validate_canonical_calendar_tag_text(&value, "summary")?; 1217 self.summary = Some(value); 1218 self.validate_budget()?; 1219 Ok(self) 1220 } 1221 1222 pub fn with_image(mut self, value: AuthoredImage) -> Result<Self, CalendarEventError> { 1223 self.image = Some(value); 1224 self.validate_budget()?; 1225 Ok(self) 1226 } 1227 1228 pub fn with_participants( 1229 mut self, 1230 value: Vec<CalendarParticipant>, 1231 ) -> Result<Self, CalendarEventError> { 1232 validate_authored_calendar_participants(&value)?; 1233 self.participants = Some(value); 1234 self.validate_budget()?; 1235 Ok(self) 1236 } 1237 1238 pub fn with_categories(mut self, value: Vec<String>) -> Result<Self, CalendarEventError> { 1239 validate_authored_calendar_categories(&value)?; 1240 self.categories = value; 1241 self.validate_budget()?; 1242 Ok(self) 1243 } 1244 1245 pub fn with_references(mut self, value: Vec<CalendarUri>) -> Result<Self, CalendarEventError> { 1246 self.references = value; 1247 self.validate_budget()?; 1248 Ok(self) 1249 } 1250 1251 pub fn with_calendar_requests( 1252 mut self, 1253 value: Vec<CalendarRequest>, 1254 ) -> Result<Self, CalendarEventError> { 1255 validate_authored_calendar_requests(&value)?; 1256 self.calendar_requests = value; 1257 self.validate_budget()?; 1258 Ok(self) 1259 } 1260 1261 pub fn d_tag(&self) -> &DTag { 1262 &self.d_tag 1263 } 1264 1265 pub fn title(&self) -> &str { 1266 &self.title 1267 } 1268 1269 pub const fn start(&self) -> u64 { 1270 self.start 1271 } 1272 1273 pub fn description(&self) -> Option<&str> { 1274 self.description.as_deref() 1275 } 1276 1277 pub const fn end(&self) -> Option<u64> { 1278 self.end 1279 } 1280 1281 pub fn start_tzid(&self) -> Option<&IanaTimeZoneId> { 1282 self.start_tzid.as_ref() 1283 } 1284 1285 pub fn end_tzid(&self) -> Option<&IanaTimeZoneId> { 1286 self.end_tzid.as_ref() 1287 } 1288 1289 pub fn effective_end_tzid(&self) -> Option<&IanaTimeZoneId> { 1290 self.end_tzid.as_ref().or(self.start_tzid.as_ref()) 1291 } 1292 1293 pub fn locations(&self) -> &[String] { 1294 &self.locations 1295 } 1296 1297 pub fn geohash(&self) -> Option<&str> { 1298 self.geohash.as_deref() 1299 } 1300 1301 pub fn summary(&self) -> Option<&str> { 1302 self.summary.as_deref() 1303 } 1304 1305 pub fn image(&self) -> Option<&AuthoredImage> { 1306 self.image.as_ref() 1307 } 1308 1309 pub fn participants(&self) -> Option<&Vec<CalendarParticipant>> { 1310 self.participants.as_ref() 1311 } 1312 1313 pub fn categories(&self) -> &[String] { 1314 &self.categories 1315 } 1316 1317 pub fn references(&self) -> &[CalendarUri] { 1318 &self.references 1319 } 1320 1321 pub fn calendar_requests(&self) -> &[CalendarRequest] { 1322 &self.calendar_requests 1323 } 1324 1325 fn validate_budget(&self) -> Result<(), CalendarEventError> { 1326 validate_authored_calendar_budget(self.description(), authored_time_tags_for_budget(self)?) 1327 } 1328 } 1329 1330 #[cfg_attr(any(feature = "serde", test), derive(serde::Serialize))] 1331 #[derive(Clone, Debug, PartialEq, Eq)] 1332 pub struct ParsedNip52CalendarCommon { 1333 d_tag: String, 1334 title: String, 1335 description: Option<String>, 1336 locations: Vec<String>, 1337 geohash: Option<String>, 1338 summary: Option<String>, 1339 image: Option<CalendarUri>, 1340 participants: Vec<CalendarParticipant>, 1341 categories: Vec<String>, 1342 references: Vec<CalendarUri>, 1343 calendar_requests: Vec<CalendarRequest>, 1344 legacy_name: Option<String>, 1345 } 1346 1347 #[derive(Clone, Debug, PartialEq, Eq)] 1348 pub struct ParsedNip52CalendarCommonParts { 1349 pub d_tag: String, 1350 pub title: String, 1351 pub description: Option<String>, 1352 pub locations: Vec<String>, 1353 pub geohash: Option<String>, 1354 pub summary: Option<String>, 1355 pub image: Option<CalendarUri>, 1356 pub participants: Vec<CalendarParticipant>, 1357 pub categories: Vec<String>, 1358 pub references: Vec<CalendarUri>, 1359 pub calendar_requests: Vec<CalendarRequest>, 1360 pub legacy_name: Option<String>, 1361 } 1362 1363 impl ParsedNip52CalendarCommon { 1364 pub fn try_new(parts: ParsedNip52CalendarCommonParts) -> Result<Self, CalendarEventError> { 1365 validate_calendar_tag_text(&parts.d_tag, "d")?; 1366 validate_calendar_tag_text(&parts.title, "title")?; 1367 if let Some(description) = parts.description.as_deref() { 1368 validate_calendar_content(description)?; 1369 } 1370 for location in &parts.locations { 1371 validate_calendar_tag_text(location, "location")?; 1372 } 1373 if let Some(geohash) = parts.geohash.as_deref() 1374 && !calendar_geohash_is_valid(geohash) 1375 { 1376 return Err(CalendarEventError::InvalidGeohash); 1377 } 1378 if let Some(summary) = parts.summary.as_deref() { 1379 validate_calendar_tag_text(summary, "summary")?; 1380 } 1381 validate_inbound_calendar_participants(&parts.participants)?; 1382 for category in &parts.categories { 1383 validate_calendar_tag_text(category, "category")?; 1384 } 1385 if let Some(legacy_name) = parts.legacy_name.as_deref() { 1386 validate_calendar_tag_text(legacy_name, "legacy name")?; 1387 } 1388 Ok(Self { 1389 d_tag: parts.d_tag, 1390 title: parts.title, 1391 description: parts.description, 1392 locations: parts.locations, 1393 geohash: parts.geohash, 1394 summary: parts.summary, 1395 image: parts.image, 1396 participants: parts.participants, 1397 categories: parts.categories, 1398 references: parts.references, 1399 calendar_requests: parts.calendar_requests, 1400 legacy_name: parts.legacy_name, 1401 }) 1402 } 1403 1404 pub fn d_tag(&self) -> &str { 1405 &self.d_tag 1406 } 1407 1408 pub fn title(&self) -> &str { 1409 &self.title 1410 } 1411 1412 pub fn description(&self) -> Option<&str> { 1413 self.description.as_deref() 1414 } 1415 1416 pub fn locations(&self) -> &[String] { 1417 &self.locations 1418 } 1419 1420 pub fn geohash(&self) -> Option<&str> { 1421 self.geohash.as_deref() 1422 } 1423 1424 pub fn summary(&self) -> Option<&str> { 1425 self.summary.as_deref() 1426 } 1427 1428 pub fn image(&self) -> Option<&CalendarUri> { 1429 self.image.as_ref() 1430 } 1431 1432 pub fn participants(&self) -> &[CalendarParticipant] { 1433 &self.participants 1434 } 1435 1436 pub fn categories(&self) -> &[String] { 1437 &self.categories 1438 } 1439 1440 pub fn references(&self) -> &[CalendarUri] { 1441 &self.references 1442 } 1443 1444 pub fn calendar_requests(&self) -> &[CalendarRequest] { 1445 &self.calendar_requests 1446 } 1447 1448 pub fn legacy_name(&self) -> Option<&str> { 1449 self.legacy_name.as_deref() 1450 } 1451 } 1452 1453 #[cfg_attr(any(feature = "serde", test), derive(serde::Serialize))] 1454 #[derive(Clone, Debug, PartialEq, Eq)] 1455 pub struct ParsedNip52CalendarDateEvent { 1456 common: ParsedNip52CalendarCommon, 1457 start: CalendarDate, 1458 end: Option<CalendarDate>, 1459 extension_day_tags: Vec<Vec<String>>, 1460 } 1461 1462 impl ParsedNip52CalendarDateEvent { 1463 pub fn try_new( 1464 common: ParsedNip52CalendarCommon, 1465 start: CalendarDate, 1466 end: Option<CalendarDate>, 1467 extension_day_tags: Vec<Vec<String>>, 1468 ) -> Result<Self, CalendarEventError> { 1469 if end.as_ref().is_some_and(|end| end <= &start) { 1470 return Err(CalendarEventError::InvalidRange); 1471 } 1472 Ok(Self { 1473 common, 1474 start, 1475 end, 1476 extension_day_tags, 1477 }) 1478 } 1479 1480 pub fn common(&self) -> &ParsedNip52CalendarCommon { 1481 &self.common 1482 } 1483 1484 pub fn start(&self) -> &CalendarDate { 1485 &self.start 1486 } 1487 1488 pub fn end(&self) -> Option<&CalendarDate> { 1489 self.end.as_ref() 1490 } 1491 1492 pub fn extension_day_tags(&self) -> &[Vec<String>] { 1493 &self.extension_day_tags 1494 } 1495 } 1496 1497 #[cfg_attr(any(feature = "serde", test), derive(serde::Serialize))] 1498 #[derive(Clone, Debug, PartialEq, Eq)] 1499 pub struct ObservedUtcDay { 1500 wire_value: String, 1501 index: u64, 1502 } 1503 1504 impl ObservedUtcDay { 1505 pub fn parse(value: impl Into<String>) -> Result<Self, CalendarEventError> { 1506 let wire_value = value.into(); 1507 if wire_value.is_empty() || !wire_value.bytes().all(|byte| byte.is_ascii_digit()) { 1508 return Err(CalendarEventError::InvalidText("D")); 1509 } 1510 let index = wire_value 1511 .parse() 1512 .map_err(|_| CalendarEventError::InvalidText("D"))?; 1513 Ok(Self { wire_value, index }) 1514 } 1515 1516 pub fn wire_value(&self) -> &str { 1517 &self.wire_value 1518 } 1519 1520 pub const fn index(&self) -> u64 { 1521 self.index 1522 } 1523 1524 pub fn is_canonical(&self) -> bool { 1525 self.wire_value == self.index.to_string() 1526 } 1527 } 1528 1529 #[cfg_attr(any(feature = "serde", test), derive(serde::Serialize))] 1530 #[derive(Clone, Debug, PartialEq, Eq)] 1531 pub struct ParsedNip52CalendarTimeEvent { 1532 common: ParsedNip52CalendarCommon, 1533 start_wire: String, 1534 start: u64, 1535 end_wire: Option<String>, 1536 end: Option<u64>, 1537 observed_day_indices: Vec<ObservedUtcDay>, 1538 start_tzid: Option<IanaTimeZoneId>, 1539 end_tzid: Option<IanaTimeZoneId>, 1540 } 1541 1542 impl ParsedNip52CalendarTimeEvent { 1543 #[allow(clippy::too_many_arguments)] 1544 pub fn try_new( 1545 common: ParsedNip52CalendarCommon, 1546 start_wire: String, 1547 start: u64, 1548 end_wire: Option<String>, 1549 end: Option<u64>, 1550 observed_day_indices: Vec<ObservedUtcDay>, 1551 start_tzid: Option<IanaTimeZoneId>, 1552 end_tzid: Option<IanaTimeZoneId>, 1553 ) -> Result<Self, CalendarEventError> { 1554 if parse_calendar_decimal(&start_wire) != Some(start) 1555 || end_wire 1556 .as_deref() 1557 .zip(end) 1558 .is_some_and(|(wire, end)| parse_calendar_decimal(wire) != Some(end)) 1559 || end_wire.is_some() != end.is_some() 1560 { 1561 return Err(CalendarEventError::InvalidRange); 1562 } 1563 if end.is_some_and(|end| end <= start) { 1564 return Err(CalendarEventError::InvalidRange); 1565 } 1566 let first = start / RADROOTS_CALENDAR_SECONDS_PER_DAY; 1567 let last = end 1568 .map(|end| (end - 1) / RADROOTS_CALENDAR_SECONDS_PER_DAY) 1569 .unwrap_or(first); 1570 if observed_day_indices.is_empty() 1571 || observed_day_indices 1572 .iter() 1573 .any(|day| !(first..=last).contains(&day.index)) 1574 { 1575 return Err(CalendarEventError::InvalidRange); 1576 } 1577 Ok(Self { 1578 common, 1579 start_wire, 1580 start, 1581 end_wire, 1582 end, 1583 observed_day_indices, 1584 start_tzid, 1585 end_tzid, 1586 }) 1587 } 1588 1589 pub fn common(&self) -> &ParsedNip52CalendarCommon { 1590 &self.common 1591 } 1592 1593 pub fn start_wire(&self) -> &str { 1594 &self.start_wire 1595 } 1596 1597 pub const fn start(&self) -> u64 { 1598 self.start 1599 } 1600 1601 pub fn end_wire(&self) -> Option<&str> { 1602 self.end_wire.as_deref() 1603 } 1604 1605 pub const fn end(&self) -> Option<u64> { 1606 self.end 1607 } 1608 1609 pub fn observed_day_indices(&self) -> &[ObservedUtcDay] { 1610 &self.observed_day_indices 1611 } 1612 1613 pub fn start_tzid(&self) -> Option<&IanaTimeZoneId> { 1614 self.start_tzid.as_ref() 1615 } 1616 1617 pub fn end_tzid(&self) -> Option<&IanaTimeZoneId> { 1618 self.end_tzid.as_ref() 1619 } 1620 1621 pub fn effective_end_tzid(&self) -> Option<&IanaTimeZoneId> { 1622 self.end_tzid.as_ref().or(self.start_tzid.as_ref()) 1623 } 1624 } 1625 1626 #[non_exhaustive] 1627 #[derive(Clone, Debug, PartialEq, Eq)] 1628 pub enum CalendarAdmissionError { 1629 NonCanonicalField(&'static str), 1630 DuplicateEventReference, 1631 AuthorHintMismatch, 1632 ForbiddenDateDayIndex, 1633 IncompleteDayCoverage, 1634 CoveredDayLimitExceeded { max: u64, actual: u64 }, 1635 NonBlossomImage, 1636 } 1637 1638 impl CalendarAdmissionError { 1639 pub const fn code(&self) -> &'static str { 1640 match self { 1641 Self::NonCanonicalField(_) => "non_canonical_field", 1642 Self::DuplicateEventReference => "duplicate_event_reference", 1643 Self::AuthorHintMismatch => "author_hint_mismatch", 1644 Self::ForbiddenDateDayIndex => "forbidden_date_day_index", 1645 Self::IncompleteDayCoverage => "incomplete_day_coverage", 1646 Self::CoveredDayLimitExceeded { .. } => "covered_day_limit_exceeded", 1647 Self::NonBlossomImage => "non_blossom_image", 1648 } 1649 } 1650 } 1651 1652 impl fmt::Display for CalendarAdmissionError { 1653 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 1654 match self { 1655 Self::NonCanonicalField(field) => write!(f, "calendar {field} is not canonical"), 1656 Self::DuplicateEventReference => { 1657 f.write_str("calendar event references must have unique coordinates") 1658 } 1659 Self::AuthorHintMismatch => { 1660 f.write_str("calendar event author hint does not match the event coordinate") 1661 } 1662 Self::ForbiddenDateDayIndex => { 1663 f.write_str("calendar date event carries a forbidden uppercase-D extension") 1664 } 1665 Self::IncompleteDayCoverage => { 1666 f.write_str("calendar time event lacks exact ordered UTC-day coverage") 1667 } 1668 Self::CoveredDayLimitExceeded { max, actual } => write!( 1669 f, 1670 "calendar interval covers {actual} UTC days, exceeding the {max}-day admission limit" 1671 ), 1672 Self::NonBlossomImage => { 1673 f.write_str("calendar image is not a structural Blossom hash-path URL") 1674 } 1675 } 1676 } 1677 } 1678 1679 #[cfg(feature = "std")] 1680 impl std::error::Error for CalendarAdmissionError {} 1681 1682 #[derive(Clone, Debug, PartialEq, Eq)] 1683 pub struct AdmittedCalendarDateEvent { 1684 parsed: ParsedNip52CalendarDateEvent, 1685 d_tag: DTag, 1686 blossom_image: Option<BlobUrl>, 1687 } 1688 1689 impl AdmittedCalendarDateEvent { 1690 pub fn try_from_parsed( 1691 parsed: ParsedNip52CalendarDateEvent, 1692 ) -> Result<Self, CalendarAdmissionError> { 1693 if !parsed.extension_day_tags.is_empty() { 1694 return Err(CalendarAdmissionError::ForbiddenDateDayIndex); 1695 } 1696 validate_admitted_calendar_common(&parsed.common)?; 1697 let d_tag = admitted_d_tag(&parsed.common)?; 1698 let blossom_image = admitted_blossom_image(&parsed.common)?; 1699 Ok(Self { 1700 parsed, 1701 d_tag, 1702 blossom_image, 1703 }) 1704 } 1705 1706 pub fn parsed(&self) -> &ParsedNip52CalendarDateEvent { 1707 &self.parsed 1708 } 1709 1710 pub fn d_tag(&self) -> &DTag { 1711 &self.d_tag 1712 } 1713 1714 pub fn blossom_image(&self) -> Option<&BlobUrl> { 1715 self.blossom_image.as_ref() 1716 } 1717 } 1718 1719 #[derive(Clone, Debug, PartialEq, Eq)] 1720 pub struct AdmittedCalendarTimeEvent { 1721 parsed: ParsedNip52CalendarTimeEvent, 1722 d_tag: DTag, 1723 covered_utc_days: Vec<u64>, 1724 blossom_image: Option<BlobUrl>, 1725 } 1726 1727 impl AdmittedCalendarTimeEvent { 1728 pub fn try_from_parsed( 1729 parsed: ParsedNip52CalendarTimeEvent, 1730 ) -> Result<Self, CalendarAdmissionError> { 1731 validate_admitted_calendar_common(&parsed.common)?; 1732 let d_tag = admitted_d_tag(&parsed.common)?; 1733 if [ 1734 parsed.start_wire == parsed.start.to_string(), 1735 !parsed 1736 .end_wire 1737 .as_deref() 1738 .zip(parsed.end) 1739 .is_some_and(|(wire, end)| wire != end.to_string()), 1740 ] != [true; 2] 1741 { 1742 return Err(CalendarAdmissionError::NonCanonicalField("timestamp")); 1743 } 1744 let expected = covered_utc_days(parsed.start, parsed.end).map_err(|error| match error { 1745 CalendarEventError::CoveredDayLimitExceeded { max, actual } => { 1746 CalendarAdmissionError::CoveredDayLimitExceeded { max, actual } 1747 } 1748 _ => CalendarAdmissionError::IncompleteDayCoverage, 1749 })?; 1750 let covered_utc_days = expected.clone().collect::<Vec<_>>(); 1751 if parsed.observed_day_indices.len() != covered_utc_days.len() 1752 || parsed 1753 .observed_day_indices 1754 .iter() 1755 .zip(&covered_utc_days) 1756 .any(|(observed, expected)| !observed.is_canonical() || observed.index != *expected) 1757 { 1758 return Err(CalendarAdmissionError::IncompleteDayCoverage); 1759 } 1760 let blossom_image = admitted_blossom_image(&parsed.common)?; 1761 Ok(Self { 1762 parsed, 1763 d_tag, 1764 covered_utc_days, 1765 blossom_image, 1766 }) 1767 } 1768 1769 pub fn parsed(&self) -> &ParsedNip52CalendarTimeEvent { 1770 &self.parsed 1771 } 1772 1773 pub fn d_tag(&self) -> &DTag { 1774 &self.d_tag 1775 } 1776 1777 pub fn covered_utc_days(&self) -> &[u64] { 1778 &self.covered_utc_days 1779 } 1780 1781 pub fn blossom_image(&self) -> Option<&BlobUrl> { 1782 self.blossom_image.as_ref() 1783 } 1784 } 1785 1786 pub fn covered_utc_days( 1787 start: u64, 1788 end: Option<u64>, 1789 ) -> Result<RangeInclusive<u64>, CalendarEventError> { 1790 if end.is_some_and(|end| end <= start) { 1791 return Err(CalendarEventError::InvalidRange); 1792 } 1793 let first = start / RADROOTS_CALENDAR_SECONDS_PER_DAY; 1794 let last = end 1795 .map(|end| (end - 1) / RADROOTS_CALENDAR_SECONDS_PER_DAY) 1796 .unwrap_or(first); 1797 let actual = last - first + 1; 1798 if actual > RADROOTS_CALENDAR_MAX_COVERED_UTC_DAYS { 1799 return Err(CalendarEventError::CoveredDayLimitExceeded { 1800 max: RADROOTS_CALENDAR_MAX_COVERED_UTC_DAYS, 1801 actual, 1802 }); 1803 } 1804 Ok(first..=last) 1805 } 1806 1807 fn validated_title(value: String) -> Result<String, CalendarEventError> { 1808 if !canonical_calendar_tag_text_is_valid(&value) { 1809 return Err(CalendarEventError::InvalidTitle); 1810 } 1811 Ok(value) 1812 } 1813 1814 fn parse_calendar_decimal(value: &str) -> Option<u64> { 1815 if [ 1816 !value.is_empty(), 1817 value.bytes().all(|byte| byte.is_ascii_digit()), 1818 ] != [true; 2] 1819 { 1820 return None; 1821 } 1822 value.parse().ok() 1823 } 1824 1825 pub fn calendar_tag_text_is_valid(value: &str) -> bool { 1826 [ 1827 !value.trim().is_empty(), 1828 !value.chars().any(char::is_control), 1829 value.len() <= DEFAULT_TAG_ELEMENT_MAX_BYTES, 1830 ] == [true; 3] 1831 } 1832 1833 pub fn canonical_calendar_tag_text_is_valid(value: &str) -> bool { 1834 [calendar_tag_text_is_valid(value), value.trim() == value] == [true; 2] 1835 } 1836 1837 pub fn calendar_geohash_is_valid(value: &str) -> bool { 1838 [ 1839 !value.is_empty(), 1840 value.len() <= 12, 1841 value.bytes().all(|byte| { 1842 matches!( 1843 byte.to_ascii_lowercase(), 1844 b'0'..=b'9' 1845 | b'b' 1846 | b'c' 1847 | b'd' 1848 | b'e' 1849 | b'f' 1850 | b'g' 1851 | b'h' 1852 | b'j' 1853 | b'k' 1854 | b'm' 1855 | b'n' 1856 | b'p' 1857 | b'q' 1858 | b'r' 1859 | b's' 1860 | b't' 1861 | b'u' 1862 | b'v' 1863 | b'w' 1864 | b'x' 1865 | b'y' 1866 | b'z' 1867 ) 1868 }), 1869 ] == [true; 3] 1870 } 1871 1872 pub fn canonical_calendar_geohash_is_valid(value: &str) -> bool { 1873 [ 1874 calendar_geohash_is_valid(value), 1875 value.bytes().all(|byte| !byte.is_ascii_uppercase()), 1876 ] == [true; 2] 1877 } 1878 1879 pub fn calendar_relay_url_is_valid(value: &str) -> bool { 1880 if [ 1881 !value.is_empty(), 1882 !value 1883 .chars() 1884 .any(|character| [character.is_control(), character.is_whitespace()].contains(&true)), 1885 ] != [true; 2] 1886 { 1887 return false; 1888 } 1889 let Some((scheme, remainder)) = value.split_once("://") else { 1890 return false; 1891 }; 1892 if ![ 1893 scheme.eq_ignore_ascii_case("ws"), 1894 scheme.eq_ignore_ascii_case("wss"), 1895 ] 1896 .contains(&true) 1897 { 1898 return false; 1899 } 1900 let Ok(parsed) = Url::parse(value) else { 1901 return false; 1902 }; 1903 let authority = remainder.split(['/', '?', '#']).next().unwrap_or(remainder); 1904 [ 1905 !authority.is_empty(), 1906 matches!(parsed.scheme(), "ws" | "wss"), 1907 parsed.host_str().is_some_and(|host| !host.is_empty()), 1908 parsed.username().is_empty(), 1909 parsed.password().is_none(), 1910 parsed.fragment().is_none(), 1911 parsed.port() != Some(0), 1912 !authority.contains('@'), 1913 ] == [true; 8] 1914 } 1915 1916 fn parse_calendar_reference_relay( 1917 relay: Option<&str>, 1918 error: CalendarEventError, 1919 ) -> Result<Option<String>, CalendarEventError> { 1920 relay 1921 .map(|relay| { 1922 if calendar_relay_url_is_valid(relay) { 1923 Ok(relay.to_string()) 1924 } else { 1925 Err(error.clone()) 1926 } 1927 }) 1928 .transpose() 1929 } 1930 1931 fn calendar_event_references_have_duplicates(references: &[CalendarEventReference]) -> bool { 1932 references.iter().enumerate().any(|(index, reference)| { 1933 references 1934 .iter() 1935 .skip(index + 1) 1936 .any(|candidate| reference.has_same_coordinate(candidate)) 1937 }) 1938 } 1939 1940 fn validate_strict_calendar_event_references( 1941 references: &[CalendarEventReference], 1942 ) -> Result<(), CalendarEventError> { 1943 if references.iter().any(|reference| !reference.is_canonical()) { 1944 return Err(CalendarEventError::InvalidEventReference); 1945 } 1946 if calendar_event_references_have_duplicates(references) { 1947 return Err(CalendarEventError::DuplicateEventReference); 1948 } 1949 Ok(()) 1950 } 1951 1952 fn validate_strict_calendar_author_hint( 1953 event_reference: &CalendarEventReference, 1954 author_hint: &CalendarEventAuthorReference, 1955 ) -> Result<(), CalendarEventError> { 1956 if !author_hint.is_canonical() { 1957 return Err(CalendarEventError::InvalidAuthorReference); 1958 } 1959 if author_hint.pubkey() != event_reference.author() { 1960 return Err(CalendarEventError::AuthorHintMismatch); 1961 } 1962 Ok(()) 1963 } 1964 1965 fn validate_calendar_tag_text(value: &str, field: &'static str) -> Result<(), CalendarEventError> { 1966 if !calendar_tag_text_is_valid(value) { 1967 return Err(if value.len() > DEFAULT_TAG_ELEMENT_MAX_BYTES { 1968 CalendarEventError::TagElementTooLarge { 1969 field, 1970 max: DEFAULT_TAG_ELEMENT_MAX_BYTES, 1971 actual: value.len(), 1972 } 1973 } else { 1974 CalendarEventError::InvalidText(field) 1975 }); 1976 } 1977 Ok(()) 1978 } 1979 1980 fn validate_canonical_calendar_tag_text( 1981 value: &str, 1982 field: &'static str, 1983 ) -> Result<(), CalendarEventError> { 1984 validate_calendar_tag_text(value, field)?; 1985 if value.trim() != value { 1986 return Err(CalendarEventError::InvalidText(field)); 1987 } 1988 Ok(()) 1989 } 1990 1991 fn validate_calendar_content(value: &str) -> Result<(), CalendarEventError> { 1992 if value.len() > DEFAULT_CONTENT_MAX_BYTES { 1993 return Err(CalendarEventError::ContentTooLarge { 1994 max: DEFAULT_CONTENT_MAX_BYTES, 1995 actual: value.len(), 1996 }); 1997 } 1998 Ok(()) 1999 } 2000 2001 fn validate_authored_calendar_locations(locations: &[String]) -> Result<(), CalendarEventError> { 2002 for location in locations { 2003 validate_canonical_calendar_tag_text(location, "location")?; 2004 } 2005 Ok(()) 2006 } 2007 2008 fn validate_authored_calendar_requests( 2009 requests: &[CalendarRequest], 2010 ) -> Result<(), CalendarEventError> { 2011 if requests.iter().any(|request| !request.is_canonical()) { 2012 return Err(CalendarEventError::InvalidUrl("calendar request")); 2013 } 2014 Ok(()) 2015 } 2016 2017 fn validate_inbound_calendar_participants( 2018 participants: &[CalendarParticipant], 2019 ) -> Result<(), CalendarEventError> { 2020 for (index, participant) in participants.iter().enumerate() { 2021 if parse_public_key(&participant.pubkey).is_err() 2022 || participant 2023 .relay 2024 .as_deref() 2025 .is_some_and(|relay| !calendar_relay_url_is_valid(relay)) 2026 || participant 2027 .role 2028 .as_deref() 2029 .is_some_and(|role| !calendar_tag_text_is_valid(role)) 2030 { 2031 return Err(CalendarEventError::InvalidParticipant { index }); 2032 } 2033 } 2034 Ok(()) 2035 } 2036 2037 fn validate_authored_calendar_participants( 2038 participants: &[CalendarParticipant], 2039 ) -> Result<(), CalendarEventError> { 2040 if participants.len() > RADROOTS_CALENDAR_MAX_PARTICIPANTS { 2041 return Err(CalendarEventError::TooManyParticipants { 2042 max: RADROOTS_CALENDAR_MAX_PARTICIPANTS, 2043 actual: participants.len(), 2044 }); 2045 } 2046 validate_inbound_calendar_participants(participants)?; 2047 for (index, participant) in participants.iter().enumerate() { 2048 let pubkey = parse_public_key(&participant.pubkey) 2049 .map_err(|_| CalendarEventError::InvalidParticipant { index })?; 2050 if pubkey.to_hex() != participant.pubkey 2051 || participant 2052 .relay 2053 .as_deref() 2054 .is_some_and(|relay| RelayUrl::parse(relay).is_err()) 2055 || participant 2056 .role 2057 .as_deref() 2058 .is_some_and(|role| !canonical_calendar_tag_text_is_valid(role)) 2059 { 2060 return Err(CalendarEventError::InvalidParticipant { index }); 2061 } 2062 } 2063 Ok(()) 2064 } 2065 2066 fn validate_authored_calendar_categories(categories: &[String]) -> Result<(), CalendarEventError> { 2067 for category in categories { 2068 validate_canonical_calendar_tag_text(category, "category")?; 2069 } 2070 Ok(()) 2071 } 2072 2073 fn validate_admitted_calendar_common( 2074 common: &ParsedNip52CalendarCommon, 2075 ) -> Result<(), CalendarAdmissionError> { 2076 let canonical = canonical_calendar_tag_text_is_valid(&common.title) 2077 && common 2078 .locations 2079 .iter() 2080 .all(|value| canonical_calendar_tag_text_is_valid(value)) 2081 && common 2082 .summary 2083 .as_deref() 2084 .is_none_or(canonical_calendar_tag_text_is_valid) 2085 && common 2086 .geohash 2087 .as_deref() 2088 .is_none_or(canonical_calendar_geohash_is_valid) 2089 && common 2090 .categories 2091 .iter() 2092 .all(|value| canonical_calendar_tag_text_is_valid(value)) 2093 && common 2094 .legacy_name 2095 .as_deref() 2096 .is_none_or(canonical_calendar_tag_text_is_valid) 2097 && common 2098 .calendar_requests 2099 .iter() 2100 .all(CalendarRequest::is_canonical); 2101 if !canonical { 2102 return Err(CalendarAdmissionError::NonCanonicalField("metadata")); 2103 } 2104 validate_authored_calendar_participants(&common.participants) 2105 .map_err(|_| CalendarAdmissionError::NonCanonicalField("participant"))?; 2106 Ok(()) 2107 } 2108 2109 fn admitted_blossom_image( 2110 common: &ParsedNip52CalendarCommon, 2111 ) -> Result<Option<BlobUrl>, CalendarAdmissionError> { 2112 common 2113 .image 2114 .as_ref() 2115 .map(|image| { 2116 BlobUrl::parse(image.as_str()).map_err(|_| CalendarAdmissionError::NonBlossomImage) 2117 }) 2118 .transpose() 2119 } 2120 2121 fn admitted_d_tag(common: &ParsedNip52CalendarCommon) -> Result<DTag, CalendarAdmissionError> { 2122 let d_tag = 2123 DTag::parse(&common.d_tag).map_err(|_| CalendarAdmissionError::NonCanonicalField("d"))?; 2124 if d_tag.as_str() != common.d_tag { 2125 return Err(CalendarAdmissionError::NonCanonicalField("d")); 2126 } 2127 Ok(d_tag) 2128 } 2129 2130 fn authored_date_tags_for_budget(event: &AuthoredCalendarDateEvent) -> Vec<Vec<String>> { 2131 let mut tags = vec![ 2132 budget_tag("d", event.d_tag().as_str()), 2133 budget_tag("title", event.title()), 2134 budget_tag("start", event.start().as_str()), 2135 ]; 2136 if let Some(end) = event.end() { 2137 tags.push(budget_tag("end", end.as_str())); 2138 } 2139 append_authored_common_tags_for_budget( 2140 &mut tags, 2141 event.locations(), 2142 event.geohash(), 2143 event.summary(), 2144 event.image(), 2145 event.participants(), 2146 event.categories(), 2147 event.references(), 2148 event.calendar_requests(), 2149 ); 2150 tags 2151 } 2152 2153 fn authored_time_tags_for_budget( 2154 event: &AuthoredCalendarTimeEvent, 2155 ) -> Result<Vec<Vec<String>>, CalendarEventError> { 2156 let mut tags = vec![ 2157 budget_tag("d", event.d_tag().as_str()), 2158 budget_tag("title", event.title()), 2159 budget_tag("start", &event.start().to_string()), 2160 ]; 2161 if let Some(end) = event.end() { 2162 tags.push(budget_tag("end", &end.to_string())); 2163 } 2164 for day in covered_utc_days(event.start(), event.end())? { 2165 tags.push(budget_tag("D", &day.to_string())); 2166 } 2167 if let Some(tzid) = event.start_tzid() { 2168 tags.push(budget_tag("start_tzid", tzid.as_str())); 2169 } 2170 if let Some(tzid) = event.end_tzid() { 2171 tags.push(budget_tag("end_tzid", tzid.as_str())); 2172 } 2173 append_authored_common_tags_for_budget( 2174 &mut tags, 2175 event.locations(), 2176 event.geohash(), 2177 event.summary(), 2178 event.image(), 2179 event.participants(), 2180 event.categories(), 2181 event.references(), 2182 event.calendar_requests(), 2183 ); 2184 Ok(tags) 2185 } 2186 2187 #[allow(clippy::too_many_arguments)] 2188 fn append_authored_common_tags_for_budget( 2189 tags: &mut Vec<Vec<String>>, 2190 locations: &[String], 2191 geohash: Option<&str>, 2192 summary: Option<&str>, 2193 image: Option<&AuthoredImage>, 2194 participants: Option<&Vec<CalendarParticipant>>, 2195 categories: &[String], 2196 references: &[CalendarUri], 2197 calendar_requests: &[CalendarRequest], 2198 ) { 2199 tags.extend( 2200 locations 2201 .iter() 2202 .map(|location| budget_tag("location", location)), 2203 ); 2204 if let Some(geohash) = geohash { 2205 tags.push(budget_tag("g", geohash)); 2206 } 2207 if let Some(summary) = summary { 2208 tags.push(budget_tag("summary", summary)); 2209 } 2210 if let Some(image) = image { 2211 tags.push(budget_tag("image", image.descriptor().url().as_str())); 2212 } 2213 if let Some(participants) = participants { 2214 for participant in participants { 2215 let mut tag = vec!["p".to_string(), participant.pubkey.clone()]; 2216 if let Some(role) = participant.role.as_ref() { 2217 tag.push(participant.relay.clone().unwrap_or_default()); 2218 tag.push(role.clone()); 2219 } else if let Some(relay) = participant.relay.as_ref() { 2220 tag.push(relay.clone()); 2221 } 2222 tags.push(tag); 2223 } 2224 } 2225 tags.extend(categories.iter().map(|category| budget_tag("t", category))); 2226 tags.extend( 2227 references 2228 .iter() 2229 .map(|reference| budget_tag("r", reference.as_str())), 2230 ); 2231 for request in calendar_requests { 2232 let mut tag = budget_tag("a", request.calendar().as_str()); 2233 if let Some(relay) = request.relay() { 2234 tag.push(relay.to_string()); 2235 } 2236 tags.push(tag); 2237 } 2238 } 2239 2240 fn budget_tag(key: &str, value: &str) -> Vec<String> { 2241 vec![key.to_string(), value.to_string()] 2242 } 2243 2244 fn validate_calendar_collection_budget( 2245 d_tag: &str, 2246 title: &str, 2247 content: &str, 2248 event_references: &[CalendarEventReference], 2249 list_description: Option<&str>, 2250 image: Option<&str>, 2251 ) -> Result<(), CalendarEventError> { 2252 let mut tags = vec![budget_tag("d", d_tag), budget_tag("title", title)]; 2253 for reference in event_references { 2254 let mut tag = budget_tag("a", reference.coordinate().as_str()); 2255 if let Some(relay) = reference.relay() { 2256 tag.push(relay.to_string()); 2257 } 2258 tags.push(tag); 2259 } 2260 if let Some(description) = list_description { 2261 tags.push(budget_tag("description", description)); 2262 } 2263 if let Some(image) = image { 2264 tags.push(budget_tag("image", image)); 2265 } 2266 validate_authored_calendar_budget(Some(content), tags) 2267 } 2268 2269 #[allow(clippy::too_many_arguments)] 2270 fn validate_calendar_rsvp_budget( 2271 d_tag: &str, 2272 event_reference: &CalendarEventReference, 2273 revision_reference: Option<&CalendarEventRevisionReference>, 2274 status: &CalendarEventRsvpStatus, 2275 free_busy: Option<&CalendarEventFreeBusy>, 2276 author_hint: Option<&CalendarEventAuthorReference>, 2277 note: Option<&str>, 2278 ) -> Result<(), CalendarEventError> { 2279 let mut event_tag = budget_tag("a", event_reference.coordinate().as_str()); 2280 if let Some(relay) = event_reference.relay() { 2281 event_tag.push(relay.to_string()); 2282 } 2283 let mut tags = vec![ 2284 budget_tag("d", d_tag), 2285 event_tag, 2286 budget_tag("status", calendar_rsvp_status_wire_value(status)), 2287 ]; 2288 if let Some(reference) = revision_reference { 2289 let mut tag = budget_tag("e", reference.raw_event_id()); 2290 if let Some(relay) = reference.relay() { 2291 tag.push(relay.to_string()); 2292 } 2293 tags.push(tag); 2294 } 2295 if let Some(free_busy) = free_busy { 2296 tags.push(budget_tag("fb", calendar_free_busy_wire_value(free_busy))); 2297 } 2298 if let Some(author_hint) = author_hint { 2299 let mut tag = budget_tag("p", author_hint.raw_pubkey()); 2300 if let Some(relay) = author_hint.relay() { 2301 tag.push(relay.to_string()); 2302 } 2303 tags.push(tag); 2304 } 2305 validate_authored_calendar_budget(note, tags) 2306 } 2307 2308 fn calendar_rsvp_status_wire_value(status: &CalendarEventRsvpStatus) -> &'static str { 2309 match status { 2310 CalendarEventRsvpStatus::Accepted => "accepted", 2311 CalendarEventRsvpStatus::Declined => "declined", 2312 CalendarEventRsvpStatus::Tentative => "tentative", 2313 } 2314 } 2315 2316 fn calendar_free_busy_wire_value(free_busy: &CalendarEventFreeBusy) -> &'static str { 2317 match free_busy { 2318 CalendarEventFreeBusy::Free => "free", 2319 CalendarEventFreeBusy::Busy => "busy", 2320 } 2321 } 2322 2323 fn validate_authored_calendar_budget( 2324 description: Option<&str>, 2325 tags: Vec<Vec<String>>, 2326 ) -> Result<(), CalendarEventError> { 2327 if let Some(description) = description { 2328 validate_calendar_content(description)?; 2329 } 2330 if tags.len() > DEFAULT_TAG_MAX_COUNT { 2331 return Err(CalendarEventError::TagCountExceeded { 2332 max: DEFAULT_TAG_MAX_COUNT, 2333 actual: tags.len(), 2334 }); 2335 } 2336 let mut total = 0usize; 2337 for tag in &tags { 2338 for value in tag { 2339 if value.len() > DEFAULT_TAG_ELEMENT_MAX_BYTES { 2340 return Err(CalendarEventError::TagElementTooLarge { 2341 field: "tag", 2342 max: DEFAULT_TAG_ELEMENT_MAX_BYTES, 2343 actual: value.len(), 2344 }); 2345 } 2346 total = total.saturating_add(value.len()); 2347 } 2348 } 2349 if total > DEFAULT_TAG_TOTAL_MAX_BYTES { 2350 return Err(CalendarEventError::TagBytesExceeded { 2351 max: DEFAULT_TAG_TOTAL_MAX_BYTES, 2352 actual: total, 2353 }); 2354 } 2355 Ok(()) 2356 } 2357 2358 fn parse_date_digits(bytes: &[u8]) -> Result<u16, CalendarEventError> { 2359 bytes.iter().try_fold(0_u16, |value, byte| { 2360 if byte.is_ascii_digit() { 2361 Ok(value * 10 + u16::from(byte - b'0')) 2362 } else { 2363 Err(CalendarEventError::InvalidDate) 2364 } 2365 }) 2366 } 2367 2368 const fn days_in_month(year: u16, month: u16) -> u16 { 2369 match month { 2370 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, 2371 4 | 6 | 9 | 11 => 30, 2372 2 if is_leap_year(year) => 29, 2373 2 => 28, 2374 _ => 0, 2375 } 2376 } 2377 2378 const fn is_leap_year(year: u16) -> bool { 2379 year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400)) 2380 } 2381 2382 /// Strict authored representation of a NIP-52 kind-31925 RSVP. 2383 #[derive(Clone, Debug, PartialEq, Eq)] 2384 pub struct AuthoredCalendarEventRsvp { 2385 uid: CalendarUid, 2386 event_reference: CalendarEventReference, 2387 revision_reference: Option<CalendarEventRevisionReference>, 2388 status: CalendarEventRsvpStatus, 2389 free_busy: Option<CalendarEventFreeBusy>, 2390 author_hint: Option<CalendarEventAuthorReference>, 2391 note: Option<String>, 2392 } 2393 2394 impl AuthoredCalendarEventRsvp { 2395 pub fn new( 2396 uid: CalendarUid, 2397 event_reference: CalendarEventReference, 2398 status: CalendarEventRsvpStatus, 2399 ) -> Result<Self, CalendarEventError> { 2400 if !event_reference.is_canonical() { 2401 return Err(CalendarEventError::InvalidEventReference); 2402 } 2403 let authored = Self { 2404 uid, 2405 event_reference, 2406 revision_reference: None, 2407 status, 2408 free_busy: None, 2409 author_hint: None, 2410 note: None, 2411 }; 2412 authored.validate_budget()?; 2413 Ok(authored) 2414 } 2415 2416 pub fn with_revision_reference( 2417 mut self, 2418 revision_reference: CalendarEventRevisionReference, 2419 ) -> Result<Self, CalendarEventError> { 2420 if !revision_reference.is_canonical() { 2421 return Err(CalendarEventError::InvalidRevisionReference); 2422 } 2423 self.revision_reference = Some(revision_reference); 2424 self.validate_budget()?; 2425 Ok(self) 2426 } 2427 2428 pub fn with_free_busy( 2429 mut self, 2430 free_busy: CalendarEventFreeBusy, 2431 ) -> Result<Self, CalendarEventError> { 2432 if self.status == CalendarEventRsvpStatus::Declined { 2433 return Err(CalendarEventError::DeclinedFreeBusyForbidden); 2434 } 2435 self.free_busy = Some(free_busy); 2436 self.validate_budget()?; 2437 Ok(self) 2438 } 2439 2440 pub fn with_author_hint( 2441 mut self, 2442 author_hint: CalendarEventAuthorReference, 2443 ) -> Result<Self, CalendarEventError> { 2444 validate_strict_calendar_author_hint(&self.event_reference, &author_hint)?; 2445 self.author_hint = Some(author_hint); 2446 self.validate_budget()?; 2447 Ok(self) 2448 } 2449 2450 pub fn with_note(mut self, note: impl Into<String>) -> Result<Self, CalendarEventError> { 2451 let note = note.into(); 2452 validate_calendar_content(¬e)?; 2453 self.note = (!note.is_empty()).then_some(note); 2454 self.validate_budget()?; 2455 Ok(self) 2456 } 2457 2458 pub fn uid(&self) -> &CalendarUid { 2459 &self.uid 2460 } 2461 2462 pub fn event_reference(&self) -> &CalendarEventReference { 2463 &self.event_reference 2464 } 2465 2466 pub fn revision_reference(&self) -> Option<&CalendarEventRevisionReference> { 2467 self.revision_reference.as_ref() 2468 } 2469 2470 pub fn status(&self) -> &CalendarEventRsvpStatus { 2471 &self.status 2472 } 2473 2474 pub fn observed_free_busy(&self) -> Option<&CalendarEventFreeBusy> { 2475 self.free_busy.as_ref() 2476 } 2477 2478 pub fn effective_free_busy(&self) -> Option<&CalendarEventFreeBusy> { 2479 self.free_busy.as_ref() 2480 } 2481 2482 pub fn author_hint(&self) -> Option<&CalendarEventAuthorReference> { 2483 self.author_hint.as_ref() 2484 } 2485 2486 pub fn note(&self) -> Option<&str> { 2487 self.note.as_deref() 2488 } 2489 2490 fn validate_budget(&self) -> Result<(), CalendarEventError> { 2491 validate_calendar_rsvp_budget( 2492 self.uid.as_str(), 2493 &self.event_reference, 2494 self.revision_reference.as_ref(), 2495 &self.status, 2496 self.free_busy.as_ref(), 2497 self.author_hint.as_ref(), 2498 self.note.as_deref(), 2499 ) 2500 } 2501 } 2502 2503 /// Constructor input for the tolerant, raw-preserving kind-31925 parse layer. 2504 #[derive(Clone, Debug, PartialEq, Eq)] 2505 pub struct ParsedNip52CalendarEventRsvpParts { 2506 pub d_tag: String, 2507 pub event_reference: CalendarEventReference, 2508 pub revision_reference: Option<CalendarEventRevisionReference>, 2509 pub status: CalendarEventRsvpStatus, 2510 pub observed_free_busy: Option<CalendarEventFreeBusy>, 2511 pub author_hint: Option<CalendarEventAuthorReference>, 2512 pub note: Option<String>, 2513 } 2514 2515 /// Structurally valid kind-31925 data. Canonical admission is a separate step. 2516 #[derive(Clone, Debug, PartialEq, Eq)] 2517 pub struct ParsedNip52CalendarEventRsvp { 2518 d_tag: String, 2519 event_reference: CalendarEventReference, 2520 revision_reference: Option<CalendarEventRevisionReference>, 2521 status: CalendarEventRsvpStatus, 2522 observed_free_busy: Option<CalendarEventFreeBusy>, 2523 author_hint: Option<CalendarEventAuthorReference>, 2524 note: Option<String>, 2525 } 2526 2527 impl ParsedNip52CalendarEventRsvp { 2528 pub fn try_new(parts: ParsedNip52CalendarEventRsvpParts) -> Result<Self, CalendarEventError> { 2529 validate_calendar_tag_text(&parts.d_tag, "d")?; 2530 if let Some(note) = parts.note.as_deref() { 2531 validate_calendar_content(note)?; 2532 } 2533 validate_calendar_rsvp_budget( 2534 &parts.d_tag, 2535 &parts.event_reference, 2536 parts.revision_reference.as_ref(), 2537 &parts.status, 2538 parts.observed_free_busy.as_ref(), 2539 parts.author_hint.as_ref(), 2540 parts.note.as_deref(), 2541 )?; 2542 Ok(Self { 2543 d_tag: parts.d_tag, 2544 event_reference: parts.event_reference, 2545 revision_reference: parts.revision_reference, 2546 status: parts.status, 2547 observed_free_busy: parts.observed_free_busy, 2548 author_hint: parts.author_hint, 2549 note: parts.note, 2550 }) 2551 } 2552 2553 pub fn d_tag(&self) -> &str { 2554 &self.d_tag 2555 } 2556 2557 pub fn event_reference(&self) -> &CalendarEventReference { 2558 &self.event_reference 2559 } 2560 2561 pub fn revision_reference(&self) -> Option<&CalendarEventRevisionReference> { 2562 self.revision_reference.as_ref() 2563 } 2564 2565 pub fn status(&self) -> &CalendarEventRsvpStatus { 2566 &self.status 2567 } 2568 2569 pub fn observed_free_busy(&self) -> Option<&CalendarEventFreeBusy> { 2570 self.observed_free_busy.as_ref() 2571 } 2572 2573 pub fn effective_free_busy(&self) -> Option<&CalendarEventFreeBusy> { 2574 if self.status == CalendarEventRsvpStatus::Declined { 2575 None 2576 } else { 2577 self.observed_free_busy.as_ref() 2578 } 2579 } 2580 2581 pub fn author_hint(&self) -> Option<&CalendarEventAuthorReference> { 2582 self.author_hint.as_ref() 2583 } 2584 2585 pub fn note(&self) -> Option<&str> { 2586 self.note.as_deref() 2587 } 2588 } 2589 2590 /// Canonical Radroots admission of a parsed kind-31925 RSVP. 2591 #[derive(Clone, Debug, PartialEq, Eq)] 2592 pub struct AdmittedCalendarEventRsvp { 2593 parsed: ParsedNip52CalendarEventRsvp, 2594 uid: CalendarUid, 2595 } 2596 2597 impl AdmittedCalendarEventRsvp { 2598 pub fn try_from_parsed( 2599 parsed: ParsedNip52CalendarEventRsvp, 2600 ) -> Result<Self, CalendarAdmissionError> { 2601 let uid = CalendarUid::parse(parsed.d_tag()) 2602 .map_err(|_| CalendarAdmissionError::NonCanonicalField("d"))?; 2603 if !parsed.event_reference().is_canonical() { 2604 return Err(CalendarAdmissionError::NonCanonicalField("event_reference")); 2605 } 2606 if parsed 2607 .revision_reference() 2608 .is_some_and(|reference| !reference.is_canonical()) 2609 { 2610 return Err(CalendarAdmissionError::NonCanonicalField( 2611 "revision_reference", 2612 )); 2613 } 2614 if let Some(author_hint) = parsed.author_hint() { 2615 if !author_hint.is_canonical() { 2616 return Err(CalendarAdmissionError::NonCanonicalField("author_hint")); 2617 } 2618 if author_hint.pubkey() != parsed.event_reference().author() { 2619 return Err(CalendarAdmissionError::AuthorHintMismatch); 2620 } 2621 } 2622 Ok(Self { parsed, uid }) 2623 } 2624 2625 pub fn parsed(&self) -> &ParsedNip52CalendarEventRsvp { 2626 &self.parsed 2627 } 2628 2629 pub fn uid(&self) -> &CalendarUid { 2630 &self.uid 2631 } 2632 2633 pub fn event_reference(&self) -> &CalendarEventReference { 2634 self.parsed.event_reference() 2635 } 2636 2637 pub fn revision_reference(&self) -> Option<&CalendarEventRevisionReference> { 2638 self.parsed.revision_reference() 2639 } 2640 2641 pub fn status(&self) -> &CalendarEventRsvpStatus { 2642 self.parsed.status() 2643 } 2644 2645 pub fn observed_free_busy(&self) -> Option<&CalendarEventFreeBusy> { 2646 self.parsed.observed_free_busy() 2647 } 2648 2649 pub fn effective_free_busy(&self) -> Option<&CalendarEventFreeBusy> { 2650 self.parsed.effective_free_busy() 2651 } 2652 2653 pub fn author_hint(&self) -> Option<&CalendarEventAuthorReference> { 2654 self.parsed.author_hint() 2655 } 2656 2657 pub fn note(&self) -> Option<&str> { 2658 self.parsed.note() 2659 } 2660 } 2661 2662 #[cfg(test)] 2663 #[cfg_attr(coverage_nightly, coverage(off))] 2664 mod tests { 2665 use super::*; 2666 use radroots_blossom::{BlobDescriptor, BlobUrl, MediaType, Sha256}; 2667 2668 #[test] 2669 fn authored_date_event_validates_each_optional_field_at_construction() { 2670 let event = AuthoredCalendarDateEvent::new( 2671 "market-day", 2672 "Market day", 2673 CalendarDate::parse("2026-06-20").unwrap(), 2674 ) 2675 .unwrap() 2676 .with_description("Farm stand pickup window.") 2677 .unwrap() 2678 .with_locations(vec!["Farm stand".to_string()]) 2679 .unwrap() 2680 .with_geohash("c23nb62w20st") 2681 .unwrap() 2682 .with_summary("Weekly pickup") 2683 .unwrap() 2684 .with_categories(vec!["farmers-market".to_string(), "vegetables".to_string()]) 2685 .unwrap(); 2686 2687 assert_eq!(event.d_tag().as_str(), "market-day"); 2688 assert_eq!(event.start().as_str(), "2026-06-20"); 2689 assert_eq!(event.summary(), Some("Weekly pickup")); 2690 assert_eq!(event.categories(), ["farmers-market", "vegetables"]); 2691 2692 let error = AuthoredCalendarDateEvent::new( 2693 "market-day", 2694 " Market day ", 2695 CalendarDate::parse("2026-06-20").unwrap(), 2696 ) 2697 .unwrap_err(); 2698 assert_eq!(error, CalendarEventError::InvalidTitle); 2699 2700 let error = AuthoredCalendarDateEvent::new( 2701 "market-day", 2702 "Market day", 2703 CalendarDate::parse("2026-06-20").unwrap(), 2704 ) 2705 .unwrap() 2706 .with_geohash("C23NB62W20ST") 2707 .unwrap_err(); 2708 assert_eq!(error, CalendarEventError::InvalidGeohash); 2709 } 2710 2711 #[test] 2712 fn authored_time_event_uses_exact_iana_ids_and_end_timezone_fallback() { 2713 assert_eq!(jiff_tzdb::VERSION, Some("2026c")); 2714 2715 let event = AuthoredCalendarTimeEvent::new("wash-pack", "Wash pack shift", 1_781_895_600) 2716 .unwrap() 2717 .with_end(1_781_899_200) 2718 .unwrap() 2719 .with_description("Pack CSA shares before pickup.") 2720 .unwrap() 2721 .with_start_tzid("America/Vancouver") 2722 .unwrap() 2723 .with_participants(vec![CalendarParticipant { 2724 pubkey: "a".repeat(64), 2725 relay: None, 2726 role: Some("host".to_string()), 2727 }]) 2728 .unwrap(); 2729 2730 assert_eq!(event.start(), 1_781_895_600); 2731 assert_eq!(event.end(), Some(1_781_899_200)); 2732 assert_eq!( 2733 event.start_tzid().map(IanaTimeZoneId::as_str), 2734 Some("America/Vancouver") 2735 ); 2736 assert_eq!(event.end_tzid(), None); 2737 assert_eq!( 2738 event.effective_end_tzid().map(IanaTimeZoneId::as_str), 2739 Some("America/Vancouver") 2740 ); 2741 assert_eq!(event.participants().map(Vec::len), Some(1)); 2742 2743 for invalid in ["america/vancouver", "America/Imaginary", " UTC ", ""] { 2744 assert_eq!( 2745 IanaTimeZoneId::parse(invalid), 2746 Err(CalendarEventError::InvalidTimeZone), 2747 "{invalid}" 2748 ); 2749 } 2750 } 2751 2752 #[test] 2753 fn calendar_dates_validate_gregorian_semantics_and_strict_ranges() { 2754 for valid in ["0001-01-01", "2000-02-29", "2024-02-29", "9999-12-31"] { 2755 assert_eq!(CalendarDate::parse(valid).unwrap().as_str(), valid); 2756 } 2757 for invalid in [ 2758 "0000-01-01", 2759 "1900-02-29", 2760 "2026-02-29", 2761 "2026-02-30", 2762 "2026-13-01", 2763 "2026-00-01", 2764 "2026-06-00", 2765 "2026-6-20", 2766 "+2026-06-20", 2767 "2026/06-20", 2768 "2026-06/20", 2769 ] { 2770 assert_eq!( 2771 CalendarDate::parse(invalid), 2772 Err(CalendarEventError::InvalidDate), 2773 "{invalid}" 2774 ); 2775 } 2776 2777 let start = CalendarDate::parse("2026-06-20").unwrap(); 2778 let event = AuthoredCalendarDateEvent::new("market-day", "Market", start.clone()).unwrap(); 2779 assert_eq!( 2780 event 2781 .clone() 2782 .with_end(CalendarDate::parse("2026-06-20").unwrap()), 2783 Err(CalendarEventError::InvalidRange) 2784 ); 2785 assert_eq!( 2786 event.with_end(CalendarDate::parse("2026-06-19").unwrap()), 2787 Err(CalendarEventError::InvalidRange) 2788 ); 2789 } 2790 2791 #[test] 2792 fn covered_utc_days_are_exclusive_end_and_bounded() { 2793 assert_eq!( 2794 covered_utc_days(86_399, None).unwrap().collect::<Vec<_>>(), 2795 vec![0] 2796 ); 2797 assert_eq!( 2798 covered_utc_days(86_399, Some(86_401)) 2799 .unwrap() 2800 .collect::<Vec<_>>(), 2801 vec![0, 1] 2802 ); 2803 assert_eq!( 2804 covered_utc_days(0, Some(86_400)) 2805 .unwrap() 2806 .collect::<Vec<_>>(), 2807 vec![0] 2808 ); 2809 assert_eq!( 2810 covered_utc_days(10, Some(10)), 2811 Err(CalendarEventError::InvalidRange) 2812 ); 2813 assert_eq!( 2814 covered_utc_days(10, Some(9)), 2815 Err(CalendarEventError::InvalidRange) 2816 ); 2817 assert_eq!( 2818 covered_utc_days(0, Some(367 * RADROOTS_CALENDAR_SECONDS_PER_DAY)), 2819 Err(CalendarEventError::CoveredDayLimitExceeded { 2820 max: RADROOTS_CALENDAR_MAX_COVERED_UTC_DAYS, 2821 actual: 367, 2822 }) 2823 ); 2824 } 2825 2826 #[test] 2827 fn baseline_wire_types_preserve_noncanonical_but_structural_values() { 2828 let uri = CalendarUri::parse("ipfs://bafybeigdyrzt/calendar.webp").unwrap(); 2829 assert_eq!(uri.as_str(), "ipfs://bafybeigdyrzt/calendar.webp"); 2830 2831 let observed = ObservedUtcDay::parse("00042").unwrap(); 2832 assert_eq!(observed.wire_value(), "00042"); 2833 assert_eq!(observed.index(), 42); 2834 assert!(!observed.is_canonical()); 2835 2836 assert_eq!( 2837 CalendarUri::parse("not an absolute URI"), 2838 Err(CalendarEventError::InvalidUrl("URI")) 2839 ); 2840 assert_eq!( 2841 ObservedUtcDay::parse("+42"), 2842 Err(CalendarEventError::InvalidText("D")) 2843 ); 2844 } 2845 2846 #[test] 2847 fn authored_models_enforce_content_and_participant_limits() { 2848 let event = AuthoredCalendarDateEvent::new( 2849 "market-day", 2850 "Market day", 2851 CalendarDate::parse("2026-06-20").unwrap(), 2852 ) 2853 .unwrap(); 2854 let oversized = "x".repeat(DEFAULT_CONTENT_MAX_BYTES + 1); 2855 assert_eq!( 2856 event.clone().with_description(oversized), 2857 Err(CalendarEventError::ContentTooLarge { 2858 max: DEFAULT_CONTENT_MAX_BYTES, 2859 actual: DEFAULT_CONTENT_MAX_BYTES + 1, 2860 }) 2861 ); 2862 2863 let participant = CalendarParticipant { 2864 pubkey: "a".repeat(64), 2865 relay: None, 2866 role: None, 2867 }; 2868 assert_eq!( 2869 event.with_participants(vec![participant; RADROOTS_CALENDAR_MAX_PARTICIPANTS + 1]), 2870 Err(CalendarEventError::TooManyParticipants { 2871 max: RADROOTS_CALENDAR_MAX_PARTICIPANTS, 2872 actual: RADROOTS_CALENDAR_MAX_PARTICIPANTS + 1, 2873 }) 2874 ); 2875 } 2876 2877 #[test] 2878 fn calendar_uid_requires_exact_unpadded_128_bit_base64url_shape() { 2879 for valid in [ 2880 "AAAAAAAAAAAAAAAAAAAAAA", 2881 "AAAAAAAAAAAAAAAAAAAAAQ", 2882 "_____________________w", 2883 ] { 2884 assert_eq!(CalendarUid::parse(valid).unwrap().as_str(), valid); 2885 } 2886 for invalid in [ 2887 "AAAAAAAAAAAAAAAAAAAAA", 2888 "AAAAAAAAAAAAAAAAAAAAAAA", 2889 "AAAAAAAAAAAAAAAAAAAAA=", 2890 "AAAAAAAAAAAAAAAAAAAAA+", 2891 "AAAAAAAAAAAAAAAAAAAAAB", 2892 ] { 2893 assert_eq!( 2894 CalendarUid::parse(invalid), 2895 Err(CalendarEventError::InvalidCalendarUid), 2896 "{invalid}" 2897 ); 2898 } 2899 } 2900 2901 #[test] 2902 fn calendar_references_preserve_structural_wire_values_and_expose_canonicality() { 2903 let uppercase_author = "A".repeat(64); 2904 let event = CalendarEventReference::parse( 2905 format!("031923:{uppercase_author}:wash-pack"), 2906 Some("WSS://Relay.Example/events"), 2907 ) 2908 .unwrap(); 2909 assert_eq!( 2910 event.coordinate().as_str(), 2911 format!("031923:{uppercase_author}:wash-pack") 2912 ); 2913 assert_eq!(event.author().to_hex(), "a".repeat(64)); 2914 assert_eq!(event.relay(), Some("WSS://Relay.Example/events")); 2915 assert!(!event.is_canonical()); 2916 2917 let revision = 2918 CalendarEventRevisionReference::parse("B".repeat(64), Some("WSS://revision.example")) 2919 .unwrap(); 2920 assert_eq!(revision.raw_event_id(), "B".repeat(64)); 2921 assert_eq!(revision.event_id().to_hex(), "b".repeat(64)); 2922 assert!(!revision.is_canonical()); 2923 2924 let author = CalendarEventAuthorReference::parse( 2925 uppercase_author.clone(), 2926 Some("WSS://author.example"), 2927 ) 2928 .unwrap(); 2929 assert_eq!(author.raw_pubkey(), uppercase_author); 2930 assert_eq!(author.pubkey().to_hex(), "a".repeat(64)); 2931 assert!(!author.is_canonical()); 2932 2933 assert_eq!( 2934 CalendarEventReference::parse(format!("31924:{}:not-an-event", "a".repeat(64)), None,), 2935 Err(CalendarEventError::InvalidEventReference) 2936 ); 2937 } 2938 2939 #[test] 2940 fn calendar_collection_layers_keep_baseline_tolerance_out_of_strict_states() { 2941 let uid = CalendarUid::parse("AAAAAAAAAAAAAAAAAAAAAA").unwrap(); 2942 let event = canonical_event_reference("wash-pack"); 2943 2944 let empty = AuthoredCalendar::new( 2945 uid.clone(), 2946 "Farm calendar", 2947 "Shared farm operations schedule.", 2948 Vec::new(), 2949 ) 2950 .unwrap(); 2951 assert!(empty.event_references().is_empty()); 2952 2953 assert_eq!( 2954 AuthoredCalendar::new( 2955 uid.clone(), 2956 "Farm calendar", 2957 "", 2958 vec![event.clone(), event.clone()], 2959 ), 2960 Err(CalendarEventError::DuplicateEventReference) 2961 ); 2962 2963 let noncanonical = 2964 CalendarEventReference::parse(format!("031923:{}:wash-pack", "A".repeat(64)), None) 2965 .unwrap(); 2966 assert_eq!( 2967 AuthoredCalendar::new(uid.clone(), "Farm calendar", "", vec![noncanonical],), 2968 Err(CalendarEventError::InvalidEventReference) 2969 ); 2970 2971 let parsed = ParsedNip52Calendar::try_new(ParsedNip52CalendarParts { 2972 d_tag: uid.to_string(), 2973 title: " Farm calendar ".to_string(), 2974 content: "Shared farm operations schedule.".to_string(), 2975 event_references: vec![event.clone(), event], 2976 list_description: Some(" Local operations ".to_string()), 2977 image: None, 2978 }) 2979 .unwrap(); 2980 assert_eq!(parsed.event_references().len(), 2); 2981 assert_eq!( 2982 AdmittedCalendar::try_from_parsed(parsed), 2983 Err(CalendarAdmissionError::NonCanonicalField("title")) 2984 ); 2985 2986 let parsed = ParsedNip52Calendar::try_new(ParsedNip52CalendarParts { 2987 d_tag: uid.to_string(), 2988 title: "Farm calendar".to_string(), 2989 content: String::new(), 2990 event_references: Vec::new(), 2991 list_description: None, 2992 image: None, 2993 }) 2994 .unwrap(); 2995 assert!( 2996 AdmittedCalendar::try_from_parsed(parsed) 2997 .unwrap() 2998 .event_references() 2999 .is_empty() 3000 ); 3001 } 3002 3003 #[test] 3004 fn calendar_admission_requires_unique_refs_and_structural_blossom_images() { 3005 let uid = "AAAAAAAAAAAAAAAAAAAAAA"; 3006 let event = canonical_event_reference("market-day"); 3007 let duplicate = ParsedNip52Calendar::try_new(ParsedNip52CalendarParts { 3008 d_tag: uid.to_string(), 3009 title: "Market calendar".to_string(), 3010 content: String::new(), 3011 event_references: vec![event.clone(), event.clone()], 3012 list_description: None, 3013 image: None, 3014 }) 3015 .unwrap(); 3016 let error = AdmittedCalendar::try_from_parsed(duplicate).unwrap_err(); 3017 assert_eq!(error, CalendarAdmissionError::DuplicateEventReference); 3018 assert_eq!(error.code(), "duplicate_event_reference"); 3019 3020 let ordinary_image = ParsedNip52Calendar::try_new(ParsedNip52CalendarParts { 3021 d_tag: uid.to_string(), 3022 title: "Market calendar".to_string(), 3023 content: String::new(), 3024 event_references: vec![event.clone()], 3025 list_description: None, 3026 image: Some(CalendarUri::parse("https://media.example/market.webp").unwrap()), 3027 }) 3028 .unwrap(); 3029 assert_eq!( 3030 AdmittedCalendar::try_from_parsed(ordinary_image), 3031 Err(CalendarAdmissionError::NonBlossomImage) 3032 ); 3033 3034 let blossom_url = format!("https://media.example/{}.webp", "c".repeat(64)); 3035 let blossom = ParsedNip52Calendar::try_new(ParsedNip52CalendarParts { 3036 d_tag: uid.to_string(), 3037 title: "Market calendar".to_string(), 3038 content: String::new(), 3039 event_references: vec![event], 3040 list_description: Some("Victoria farm markets".to_string()), 3041 image: Some(CalendarUri::parse(&blossom_url).unwrap()), 3042 }) 3043 .unwrap(); 3044 assert_eq!( 3045 AdmittedCalendar::try_from_parsed(blossom) 3046 .unwrap() 3047 .blossom_image() 3048 .unwrap() 3049 .as_str(), 3050 blossom_url 3051 ); 3052 } 3053 3054 #[test] 3055 fn calendar_collection_enforces_wire_tag_budgets() { 3056 let references = (0..DEFAULT_TAG_MAX_COUNT) 3057 .map(|index| canonical_event_reference(&format!("event-{index}"))) 3058 .collect::<Vec<_>>(); 3059 assert_eq!( 3060 AuthoredCalendar::new( 3061 CalendarUid::parse("AAAAAAAAAAAAAAAAAAAAAA").unwrap(), 3062 "Farm calendar", 3063 "", 3064 references, 3065 ), 3066 Err(CalendarEventError::TagCountExceeded { 3067 max: DEFAULT_TAG_MAX_COUNT, 3068 actual: DEFAULT_TAG_MAX_COUNT + 2, 3069 }) 3070 ); 3071 } 3072 3073 #[test] 3074 fn declined_rsvp_retains_observed_fb_only_in_parsed_and_admitted_layers() { 3075 let uid = CalendarUid::parse("AAAAAAAAAAAAAAAAAAAAAQ").unwrap(); 3076 let event = canonical_event_reference("wash-pack"); 3077 assert_eq!( 3078 AuthoredCalendarEventRsvp::new( 3079 uid.clone(), 3080 event.clone(), 3081 CalendarEventRsvpStatus::Declined, 3082 ) 3083 .unwrap() 3084 .with_free_busy(CalendarEventFreeBusy::Busy), 3085 Err(CalendarEventError::DeclinedFreeBusyForbidden) 3086 ); 3087 3088 let parsed = ParsedNip52CalendarEventRsvp::try_new(ParsedNip52CalendarEventRsvpParts { 3089 d_tag: uid.to_string(), 3090 event_reference: event, 3091 revision_reference: Some( 3092 CalendarEventRevisionReference::parse( 3093 "b".repeat(64), 3094 Some("wss://revision.example"), 3095 ) 3096 .unwrap(), 3097 ), 3098 status: CalendarEventRsvpStatus::Declined, 3099 observed_free_busy: Some(CalendarEventFreeBusy::Busy), 3100 author_hint: Some( 3101 CalendarEventAuthorReference::parse("a".repeat(64), Some("wss://author.example")) 3102 .unwrap(), 3103 ), 3104 note: Some("Unable to attend".to_string()), 3105 }) 3106 .unwrap(); 3107 assert_eq!( 3108 parsed.observed_free_busy(), 3109 Some(&CalendarEventFreeBusy::Busy) 3110 ); 3111 assert_eq!(parsed.effective_free_busy(), None); 3112 3113 let admitted = AdmittedCalendarEventRsvp::try_from_parsed(parsed).unwrap(); 3114 assert_eq!( 3115 admitted.observed_free_busy(), 3116 Some(&CalendarEventFreeBusy::Busy) 3117 ); 3118 assert_eq!(admitted.effective_free_busy(), None); 3119 assert_eq!( 3120 admitted.revision_reference().unwrap().relay(), 3121 Some("wss://revision.example") 3122 ); 3123 assert_eq!( 3124 admitted.author_hint().unwrap().relay(), 3125 Some("wss://author.example") 3126 ); 3127 } 3128 3129 #[test] 3130 fn rsvp_author_hint_must_match_event_coordinate_in_strict_layers() { 3131 let uid = CalendarUid::parse("AAAAAAAAAAAAAAAAAAAAAQ").unwrap(); 3132 let event = canonical_event_reference("wash-pack"); 3133 let mismatched = 3134 CalendarEventAuthorReference::parse(crate::test_valid_hex_64('b'), None).unwrap(); 3135 assert_eq!( 3136 AuthoredCalendarEventRsvp::new( 3137 uid.clone(), 3138 event.clone(), 3139 CalendarEventRsvpStatus::Accepted, 3140 ) 3141 .unwrap() 3142 .with_author_hint(mismatched.clone()), 3143 Err(CalendarEventError::AuthorHintMismatch) 3144 ); 3145 3146 let parsed = ParsedNip52CalendarEventRsvp::try_new(ParsedNip52CalendarEventRsvpParts { 3147 d_tag: uid.to_string(), 3148 event_reference: event, 3149 revision_reference: None, 3150 status: CalendarEventRsvpStatus::Accepted, 3151 observed_free_busy: None, 3152 author_hint: Some(mismatched), 3153 note: None, 3154 }) 3155 .unwrap(); 3156 let error = AdmittedCalendarEventRsvp::try_from_parsed(parsed).unwrap_err(); 3157 assert_eq!(error, CalendarAdmissionError::AuthorHintMismatch); 3158 assert_eq!(error.code(), "author_hint_mismatch"); 3159 } 3160 3161 #[test] 3162 fn calendar_value_types_cover_conversion_serialization_and_error_contracts() { 3163 let uid = CalendarUid::parse("AAAAAAAAAAAAAAAAAAAAAQ").unwrap(); 3164 assert_eq!(uid.as_ref(), uid.as_str()); 3165 assert_eq!(uid.to_string(), uid.as_str()); 3166 assert_eq!(CalendarUid::from_str(uid.as_str()).unwrap(), uid); 3167 assert_eq!(CalendarUid::try_from(uid.as_str()).unwrap(), uid); 3168 assert_eq!(CalendarUid::try_from(uid.to_string()).unwrap(), uid); 3169 assert_eq!(serde_json::to_string(&uid).unwrap(), format!("\"{uid}\"")); 3170 3171 let date = CalendarDate::from_str("2028-02-29").unwrap(); 3172 assert_eq!(date.as_ref(), "2028-02-29"); 3173 assert_eq!(date.to_string(), "2028-02-29"); 3174 assert_eq!(serde_json::to_string(&date).unwrap(), "\"2028-02-29\""); 3175 assert_eq!( 3176 serde_json::from_str::<CalendarDate>("\"2028-02-29\"").unwrap(), 3177 date 3178 ); 3179 3180 let zone = IanaTimeZoneId::from_str("UTC").unwrap(); 3181 assert_eq!(zone.as_ref(), "UTC"); 3182 assert_eq!(zone.to_string(), "UTC"); 3183 assert_eq!(serde_json::to_string(&zone).unwrap(), "\"UTC\""); 3184 assert_eq!( 3185 serde_json::from_str::<IanaTimeZoneId>("\"UTC\"").unwrap(), 3186 zone 3187 ); 3188 3189 let uri = CalendarUri::from_str("https://example.com/calendar").unwrap(); 3190 assert_eq!(uri.as_ref(), uri.as_str()); 3191 assert_eq!(uri.to_string(), uri.as_str()); 3192 assert_eq!( 3193 serde_json::from_str::<CalendarUri>(&serde_json::to_string(&uri).unwrap()).unwrap(), 3194 uri 3195 ); 3196 3197 let event_errors = [ 3198 CalendarEventError::InvalidIdentifier, 3199 CalendarEventError::InvalidCalendarUid, 3200 CalendarEventError::InvalidEventReference, 3201 CalendarEventError::InvalidRevisionReference, 3202 CalendarEventError::InvalidAuthorReference, 3203 CalendarEventError::DuplicateEventReference, 3204 CalendarEventError::AuthorHintMismatch, 3205 CalendarEventError::DeclinedFreeBusyForbidden, 3206 CalendarEventError::InvalidTitle, 3207 CalendarEventError::InvalidText("field"), 3208 CalendarEventError::InvalidUrl("field"), 3209 CalendarEventError::InvalidGeohash, 3210 CalendarEventError::InvalidTimeZone, 3211 CalendarEventError::InvalidParticipant { index: 2 }, 3212 CalendarEventError::TooManyParticipants { max: 1, actual: 2 }, 3213 CalendarEventError::ContentTooLarge { max: 1, actual: 2 }, 3214 CalendarEventError::TagElementTooLarge { 3215 field: "x", 3216 max: 1, 3217 actual: 2, 3218 }, 3219 CalendarEventError::TagCountExceeded { max: 1, actual: 2 }, 3220 CalendarEventError::TagBytesExceeded { max: 1, actual: 2 }, 3221 CalendarEventError::InvalidDate, 3222 CalendarEventError::InvalidRange, 3223 CalendarEventError::CoveredDayLimitExceeded { max: 1, actual: 2 }, 3224 ]; 3225 for error in event_errors { 3226 assert!(!error.code().is_empty()); 3227 assert!(!error.to_string().is_empty()); 3228 } 3229 let admission_errors = [ 3230 CalendarAdmissionError::NonCanonicalField("field"), 3231 CalendarAdmissionError::DuplicateEventReference, 3232 CalendarAdmissionError::AuthorHintMismatch, 3233 CalendarAdmissionError::ForbiddenDateDayIndex, 3234 CalendarAdmissionError::IncompleteDayCoverage, 3235 CalendarAdmissionError::CoveredDayLimitExceeded { max: 1, actual: 2 }, 3236 CalendarAdmissionError::NonBlossomImage, 3237 ]; 3238 for error in admission_errors { 3239 assert!(!error.code().is_empty()); 3240 assert!(!error.to_string().is_empty()); 3241 } 3242 } 3243 3244 #[test] 3245 fn calendar_references_and_requests_cover_canonical_accessors_and_rejections() { 3246 let event = canonical_event_reference("market"); 3247 assert_eq!( 3248 event.kind(), 3249 crate::envelope::kind::KIND_CALENDAR_TIME_EVENT 3250 ); 3251 assert_eq!(event.d_tag().as_str(), "market"); 3252 assert!(event.is_canonical()); 3253 assert_eq!(event.relay(), Some("wss://relay.example")); 3254 3255 let revision = CalendarEventRevisionReference::parse("b".repeat(64), None).unwrap(); 3256 assert_eq!(revision.raw_event_id(), revision.event_id().to_hex()); 3257 assert_eq!(revision.relay(), None); 3258 assert!(revision.is_canonical()); 3259 let author = CalendarEventAuthorReference::parse("a".repeat(64), None).unwrap(); 3260 assert_eq!(author.raw_pubkey(), author.pubkey().to_hex()); 3261 assert_eq!(author.relay(), None); 3262 assert!(author.is_canonical()); 3263 3264 let coordinate = format!("31924:{}:calendar", "a".repeat(64)); 3265 let request = CalendarRequest::new(&coordinate, Some("wss://relay.example")).unwrap(); 3266 assert_eq!(request.calendar().as_str(), coordinate); 3267 assert_eq!(request.relay(), Some("wss://relay.example")); 3268 assert!(request.is_canonical()); 3269 assert!(CalendarRequest::new("bad", None).is_err()); 3270 assert!(CalendarRequest::new(format!("31923:{}:event", "a".repeat(64)), None).is_err()); 3271 assert!(CalendarRequest::new(&coordinate, Some("https://relay.example")).is_err()); 3272 assert!(CalendarEventReference::parse("bad", None).is_err()); 3273 assert!(CalendarEventRevisionReference::parse("bad", None).is_err()); 3274 assert!(CalendarEventAuthorReference::parse("bad", None).is_err()); 3275 } 3276 3277 #[test] 3278 fn full_authored_calendar_models_expose_every_validated_field() { 3279 let uid = CalendarUid::parse("AAAAAAAAAAAAAAAAAAAAAQ").unwrap(); 3280 let reference = canonical_event_reference("market"); 3281 let image = calendar_image(); 3282 let calendar = AuthoredCalendar::new( 3283 uid.clone(), 3284 "Farm calendar", 3285 "Fresh food", 3286 vec![reference.clone()], 3287 ) 3288 .unwrap() 3289 .with_list_description("Weekly calendar") 3290 .unwrap() 3291 .with_image(image.clone()) 3292 .unwrap(); 3293 assert_eq!(calendar.uid(), &uid); 3294 assert_eq!(calendar.title(), "Farm calendar"); 3295 assert_eq!(calendar.content(), "Fresh food"); 3296 assert_eq!(calendar.event_references(), &[reference]); 3297 assert_eq!(calendar.list_description(), Some("Weekly calendar")); 3298 assert!(calendar.image().is_some()); 3299 3300 let request = CalendarRequest::new( 3301 format!("31924:{}:calendar", "a".repeat(64)), 3302 Some("wss://relay.example"), 3303 ) 3304 .unwrap(); 3305 let participant = CalendarParticipant { 3306 pubkey: "a".repeat(64), 3307 relay: Some("wss://relay.example".into()), 3308 role: Some("host".into()), 3309 }; 3310 let uri = CalendarUri::parse("https://example.com/details").unwrap(); 3311 let date = AuthoredCalendarDateEvent::new( 3312 "market", 3313 "Market", 3314 CalendarDate::parse("2026-06-20").unwrap(), 3315 ) 3316 .unwrap() 3317 .with_end(CalendarDate::parse("2026-06-21").unwrap()) 3318 .unwrap() 3319 .with_description("description") 3320 .unwrap() 3321 .with_locations(vec!["Barn".into()]) 3322 .unwrap() 3323 .with_geohash("c23nb62w20st") 3324 .unwrap() 3325 .with_summary("summary") 3326 .unwrap() 3327 .with_image(image.clone()) 3328 .unwrap() 3329 .with_participants(vec![participant.clone()]) 3330 .unwrap() 3331 .with_categories(vec!["market".into()]) 3332 .unwrap() 3333 .with_references(vec![uri.clone()]) 3334 .unwrap() 3335 .with_calendar_requests(vec![request.clone()]) 3336 .unwrap(); 3337 assert_eq!(date.d_tag().as_str(), "market"); 3338 assert_eq!(date.title(), "Market"); 3339 assert_eq!(date.start().as_str(), "2026-06-20"); 3340 assert_eq!(date.end().unwrap().as_str(), "2026-06-21"); 3341 assert_eq!(date.description(), Some("description")); 3342 assert_eq!(date.locations(), ["Barn"]); 3343 assert_eq!(date.geohash(), Some("c23nb62w20st")); 3344 assert_eq!(date.summary(), Some("summary")); 3345 assert!(date.image().is_some()); 3346 assert_eq!(date.participants().unwrap(), &vec![participant.clone()]); 3347 assert_eq!(date.categories(), ["market"]); 3348 assert_eq!(date.references(), std::slice::from_ref(&uri)); 3349 assert_eq!(date.calendar_requests(), std::slice::from_ref(&request)); 3350 3351 let time = AuthoredCalendarTimeEvent::new("shift", "Shift", 86_400) 3352 .unwrap() 3353 .with_end(90_000) 3354 .unwrap() 3355 .with_description("description") 3356 .unwrap() 3357 .with_start_tzid("UTC") 3358 .unwrap() 3359 .with_end_tzid("America/Vancouver") 3360 .unwrap() 3361 .with_locations(vec!["Barn".into()]) 3362 .unwrap() 3363 .with_geohash("c23nb62w20st") 3364 .unwrap() 3365 .with_summary("summary") 3366 .unwrap() 3367 .with_image(image) 3368 .unwrap() 3369 .with_participants(vec![participant]) 3370 .unwrap() 3371 .with_categories(vec!["shift".into()]) 3372 .unwrap() 3373 .with_references(vec![uri]) 3374 .unwrap() 3375 .with_calendar_requests(vec![request]) 3376 .unwrap(); 3377 assert_eq!(time.d_tag().as_str(), "shift"); 3378 assert_eq!(time.title(), "Shift"); 3379 assert_eq!(time.start(), 86_400); 3380 assert_eq!(time.end(), Some(90_000)); 3381 assert_eq!(time.description(), Some("description")); 3382 assert_eq!(time.start_tzid().unwrap().as_str(), "UTC"); 3383 assert_eq!(time.end_tzid().unwrap().as_str(), "America/Vancouver"); 3384 assert_eq!(time.effective_end_tzid(), time.end_tzid()); 3385 assert_eq!(time.locations(), ["Barn"]); 3386 assert_eq!(time.geohash(), Some("c23nb62w20st")); 3387 assert_eq!(time.summary(), Some("summary")); 3388 assert!(time.image().is_some()); 3389 assert_eq!(time.participants().unwrap().len(), 1); 3390 assert_eq!(time.categories(), ["shift"]); 3391 assert_eq!(time.references().len(), 1); 3392 assert_eq!(time.calendar_requests().len(), 1); 3393 } 3394 3395 #[test] 3396 fn parsed_and_admitted_event_layers_preserve_complete_canonical_shapes() { 3397 let image_url = format!("https://media.example/{}.webp", "c".repeat(64)); 3398 let common = ParsedNip52CalendarCommon::try_new(ParsedNip52CalendarCommonParts { 3399 d_tag: "event".into(), 3400 title: "Event".into(), 3401 description: Some("Description".into()), 3402 locations: vec!["Barn".into()], 3403 geohash: Some("c23nb62w20st".into()), 3404 summary: Some("Summary".into()), 3405 image: Some(CalendarUri::parse(&image_url).unwrap()), 3406 participants: vec![CalendarParticipant { 3407 pubkey: "a".repeat(64), 3408 relay: None, 3409 role: None, 3410 }], 3411 categories: vec!["market".into()], 3412 references: vec![CalendarUri::parse("https://example.com/details").unwrap()], 3413 calendar_requests: vec![ 3414 CalendarRequest::new(format!("31924:{}:calendar", "a".repeat(64)), None).unwrap(), 3415 ], 3416 legacy_name: Some("Legacy".into()), 3417 }) 3418 .unwrap(); 3419 assert_eq!(common.d_tag(), "event"); 3420 assert_eq!(common.title(), "Event"); 3421 assert_eq!(common.description(), Some("Description")); 3422 assert_eq!(common.locations(), ["Barn"]); 3423 assert_eq!(common.geohash(), Some("c23nb62w20st")); 3424 assert_eq!(common.summary(), Some("Summary")); 3425 assert!(common.image().is_some()); 3426 assert_eq!(common.participants().len(), 1); 3427 assert_eq!(common.categories(), ["market"]); 3428 assert_eq!(common.references().len(), 1); 3429 assert_eq!(common.calendar_requests().len(), 1); 3430 assert_eq!(common.legacy_name(), Some("Legacy")); 3431 3432 let date = ParsedNip52CalendarDateEvent::try_new( 3433 common.clone(), 3434 CalendarDate::parse("2026-06-20").unwrap(), 3435 None, 3436 Vec::new(), 3437 ) 3438 .unwrap(); 3439 assert_eq!(date.common(), &common); 3440 assert_eq!(date.start().as_str(), "2026-06-20"); 3441 assert_eq!(date.end(), None); 3442 assert!(date.extension_day_tags().is_empty()); 3443 let admitted_date = AdmittedCalendarDateEvent::try_from_parsed(date).unwrap(); 3444 assert_eq!(admitted_date.parsed().common().title(), "Event"); 3445 assert_eq!(admitted_date.d_tag().as_str(), "event"); 3446 assert_eq!(admitted_date.blossom_image().unwrap().as_str(), image_url); 3447 3448 let time = ParsedNip52CalendarTimeEvent::try_new( 3449 common, 3450 "86400".into(), 3451 86_400, 3452 Some("90000".into()), 3453 Some(90_000), 3454 vec![ObservedUtcDay::parse("1").unwrap()], 3455 Some(IanaTimeZoneId::parse("UTC").unwrap()), 3456 None, 3457 ) 3458 .unwrap(); 3459 assert_eq!(time.start_wire(), "86400"); 3460 assert_eq!(time.start(), 86_400); 3461 assert_eq!(time.end_wire(), Some("90000")); 3462 assert_eq!(time.end(), Some(90_000)); 3463 assert_eq!(time.observed_day_indices()[0].index(), 1); 3464 assert_eq!(time.start_tzid().unwrap().as_str(), "UTC"); 3465 assert_eq!(time.end_tzid(), None); 3466 assert_eq!(time.effective_end_tzid(), time.start_tzid()); 3467 let admitted_time = AdmittedCalendarTimeEvent::try_from_parsed(time).unwrap(); 3468 assert_eq!(admitted_time.parsed().common().title(), "Event"); 3469 assert_eq!(admitted_time.d_tag().as_str(), "event"); 3470 assert_eq!(admitted_time.covered_utc_days(), [1]); 3471 assert_eq!(admitted_time.blossom_image().unwrap().as_str(), image_url); 3472 } 3473 3474 #[test] 3475 fn calendar_validation_helpers_reject_noncanonical_inputs() { 3476 for valid in ["text", "Märket", "a-b_c.1"] { 3477 assert!(calendar_tag_text_is_valid(valid)); 3478 } 3479 for invalid in ["", " text", "text ", "line\nbreak", "x\u{0000}"] { 3480 assert!(!canonical_calendar_tag_text_is_valid(invalid)); 3481 } 3482 for valid in ["c23nb62w20st", "0", "zzzz"] { 3483 assert!(calendar_geohash_is_valid(valid)); 3484 } 3485 for invalid in ["", "C23", "a", "i234"] { 3486 assert!(!canonical_calendar_geohash_is_valid(invalid)); 3487 } 3488 for valid in ["ws://localhost", "wss://relay.example/path?x=1"] { 3489 assert!(calendar_relay_url_is_valid(valid)); 3490 } 3491 for invalid in [ 3492 "", 3493 " wss://relay.example", 3494 "wss://relay.example/line\nbreak", 3495 "relay.example", 3496 "ftp://relay.example", 3497 "wss:///path", 3498 "https://relay.example", 3499 "wss://user@relay.example", 3500 "wss://relay.example/#fragment", 3501 ] { 3502 assert!(!calendar_relay_url_is_valid(invalid)); 3503 } 3504 assert!(ObservedUtcDay::parse("").is_err()); 3505 assert!(ObservedUtcDay::parse("x").is_err()); 3506 assert!(ObservedUtcDay::parse("18446744073709551616").is_err()); 3507 } 3508 3509 #[test] 3510 fn parsed_calendar_layers_fail_closed_across_optional_and_range_branches() { 3511 for invalid in [ 3512 " https://example.com/path", 3513 "https://example.com/line\nbreak", 3514 "not-a-uri", 3515 ] { 3516 assert!(CalendarUri::parse(invalid).is_err(), "{invalid}"); 3517 } 3518 assert!( 3519 CalendarUri::parse(format!( 3520 "https://example.com/{}", 3521 "x".repeat(DEFAULT_TAG_ELEMENT_MAX_BYTES) 3522 )) 3523 .is_err() 3524 ); 3525 3526 let minimal_common = ParsedNip52CalendarCommon::try_new(ParsedNip52CalendarCommonParts { 3527 d_tag: "event".into(), 3528 title: "Event".into(), 3529 description: None, 3530 locations: Vec::new(), 3531 geohash: None, 3532 summary: None, 3533 image: None, 3534 participants: Vec::new(), 3535 categories: Vec::new(), 3536 references: Vec::new(), 3537 calendar_requests: Vec::new(), 3538 legacy_name: None, 3539 }) 3540 .unwrap(); 3541 assert_eq!(minimal_common.description(), None); 3542 assert_eq!(minimal_common.geohash(), None); 3543 assert_eq!(minimal_common.summary(), None); 3544 assert_eq!(minimal_common.legacy_name(), None); 3545 let invalid_common = ParsedNip52CalendarCommon::try_new(ParsedNip52CalendarCommonParts { 3546 d_tag: "event".into(), 3547 title: "Event".into(), 3548 description: None, 3549 locations: Vec::new(), 3550 geohash: Some("INVALID".into()), 3551 summary: None, 3552 image: None, 3553 participants: Vec::new(), 3554 categories: Vec::new(), 3555 references: Vec::new(), 3556 calendar_requests: Vec::new(), 3557 legacy_name: None, 3558 }); 3559 assert_eq!(invalid_common, Err(CalendarEventError::InvalidGeohash)); 3560 3561 let start_date = CalendarDate::parse("2026-06-20").unwrap(); 3562 assert_eq!( 3563 ParsedNip52CalendarDateEvent::try_new( 3564 minimal_common.clone(), 3565 start_date.clone(), 3566 Some(start_date.clone()), 3567 Vec::new(), 3568 ), 3569 Err(CalendarEventError::InvalidRange) 3570 ); 3571 let extension_date = ParsedNip52CalendarDateEvent::try_new( 3572 minimal_common.clone(), 3573 start_date, 3574 None, 3575 vec![vec!["D".into(), "1".into()]], 3576 ) 3577 .unwrap(); 3578 assert_eq!( 3579 AdmittedCalendarDateEvent::try_from_parsed(extension_date), 3580 Err(CalendarAdmissionError::ForbiddenDateDayIndex) 3581 ); 3582 3583 let make_time = |start_wire: &str, 3584 start: u64, 3585 end_wire: Option<&str>, 3586 end: Option<u64>, 3587 days: Vec<&str>| { 3588 ParsedNip52CalendarTimeEvent::try_new( 3589 minimal_common.clone(), 3590 start_wire.into(), 3591 start, 3592 end_wire.map(str::to_owned), 3593 end, 3594 days.into_iter() 3595 .map(|day| ObservedUtcDay::parse(day).unwrap()) 3596 .collect(), 3597 None, 3598 None, 3599 ) 3600 }; 3601 assert_eq!( 3602 make_time("2", 1, None, None, vec!["0"]), 3603 Err(CalendarEventError::InvalidRange) 3604 ); 3605 assert_eq!( 3606 make_time("1", 1, Some("3"), Some(2), vec!["0"]), 3607 Err(CalendarEventError::InvalidRange) 3608 ); 3609 assert_eq!( 3610 make_time("1", 1, Some("2"), None, vec!["0"]), 3611 Err(CalendarEventError::InvalidRange) 3612 ); 3613 assert_eq!( 3614 make_time("1", 1, Some("1"), Some(1), vec!["0"]), 3615 Err(CalendarEventError::InvalidRange) 3616 ); 3617 assert_eq!( 3618 make_time("1", 1, None, None, Vec::new()), 3619 Err(CalendarEventError::InvalidRange) 3620 ); 3621 assert_eq!( 3622 make_time("1", 1, None, None, vec!["1"]), 3623 Err(CalendarEventError::InvalidRange) 3624 ); 3625 3626 let noncanonical = make_time("01", 1, None, None, vec!["0"]).unwrap(); 3627 assert_eq!( 3628 AdmittedCalendarTimeEvent::try_from_parsed(noncanonical), 3629 Err(CalendarAdmissionError::NonCanonicalField("timestamp")) 3630 ); 3631 let incomplete = make_time("1", 1, Some("86401"), Some(86_401), vec!["0"]).unwrap(); 3632 assert_eq!( 3633 AdmittedCalendarTimeEvent::try_from_parsed(incomplete), 3634 Err(CalendarAdmissionError::IncompleteDayCoverage) 3635 ); 3636 let noncanonical_day = make_time("1", 1, None, None, vec!["00"]).unwrap(); 3637 assert_eq!( 3638 AdmittedCalendarTimeEvent::try_from_parsed(noncanonical_day), 3639 Err(CalendarAdmissionError::IncompleteDayCoverage) 3640 ); 3641 } 3642 3643 #[test] 3644 fn authored_and_admitted_rsvp_layers_cover_optional_transitions() { 3645 let uid = CalendarUid::parse("AAAAAAAAAAAAAAAAAAAAAQ").unwrap(); 3646 let event = canonical_event_reference("shift"); 3647 let revision = CalendarEventRevisionReference::parse("b".repeat(64), None).unwrap(); 3648 let author = CalendarEventAuthorReference::parse("a".repeat(64), None).unwrap(); 3649 let authored = AuthoredCalendarEventRsvp::new( 3650 uid.clone(), 3651 event.clone(), 3652 CalendarEventRsvpStatus::Accepted, 3653 ) 3654 .unwrap() 3655 .with_revision_reference(revision.clone()) 3656 .unwrap() 3657 .with_free_busy(CalendarEventFreeBusy::Free) 3658 .unwrap() 3659 .with_author_hint(author.clone()) 3660 .unwrap() 3661 .with_note("Attending") 3662 .unwrap(); 3663 assert_eq!(authored.uid(), &uid); 3664 assert_eq!(authored.event_reference(), &event); 3665 assert_eq!(authored.revision_reference(), Some(&revision)); 3666 assert_eq!(authored.status(), &CalendarEventRsvpStatus::Accepted); 3667 assert_eq!( 3668 authored.observed_free_busy(), 3669 Some(&CalendarEventFreeBusy::Free) 3670 ); 3671 assert_eq!( 3672 authored.effective_free_busy(), 3673 Some(&CalendarEventFreeBusy::Free) 3674 ); 3675 assert_eq!(authored.author_hint(), Some(&author)); 3676 assert_eq!(authored.note(), Some("Attending")); 3677 3678 let parsed = ParsedNip52CalendarEventRsvp::try_new(ParsedNip52CalendarEventRsvpParts { 3679 d_tag: uid.to_string(), 3680 event_reference: event, 3681 revision_reference: Some(revision), 3682 status: CalendarEventRsvpStatus::Tentative, 3683 observed_free_busy: Some(CalendarEventFreeBusy::Busy), 3684 author_hint: Some(author), 3685 note: Some("Maybe".into()), 3686 }) 3687 .unwrap(); 3688 assert_eq!(parsed.d_tag(), uid.as_str()); 3689 assert_eq!(parsed.note(), Some("Maybe")); 3690 assert_eq!( 3691 parsed.effective_free_busy(), 3692 Some(&CalendarEventFreeBusy::Busy) 3693 ); 3694 let admitted = AdmittedCalendarEventRsvp::try_from_parsed(parsed).unwrap(); 3695 assert_eq!(admitted.parsed().d_tag(), uid.as_str()); 3696 assert_eq!(admitted.uid(), &uid); 3697 assert_eq!(admitted.status(), &CalendarEventRsvpStatus::Tentative); 3698 assert_eq!(admitted.note(), Some("Maybe")); 3699 } 3700 3701 fn calendar_image() -> AuthoredImage { 3702 let bytes = b"calendar-image"; 3703 let hash = Sha256::digest(bytes); 3704 let media_type = MediaType::parse("image/webp").unwrap(); 3705 let descriptor = BlobDescriptor::new( 3706 BlobUrl::parse(&format!("https://media.example/{hash}.webp")).unwrap(), 3707 hash, 3708 bytes.len() as u64, 3709 media_type.clone(), 3710 1_784_347_200, 3711 ) 3712 .unwrap() 3713 .approve_reference() 3714 .unwrap() 3715 .verify_bytes(bytes, &media_type) 3716 .unwrap(); 3717 AuthoredImage::try_from(descriptor).unwrap() 3718 } 3719 3720 fn canonical_event_reference(d_tag: &str) -> CalendarEventReference { 3721 CalendarEventReference::parse( 3722 format!("31923:{}:{d_tag}", "a".repeat(64)), 3723 Some("wss://relay.example"), 3724 ) 3725 .unwrap() 3726 } 3727 }