comment.rs (35063B)
1 #[cfg(not(feature = "std"))] 2 use alloc::{ 3 format, 4 string::{String, ToString}, 5 }; 6 use core::fmt; 7 8 use crate::{ 9 envelope::kind::{ 10 KIND_ARTICLE, KIND_CALENDAR_DATE_EVENT, KIND_CALENDAR_TIME_EVENT, KIND_CLASSIFIED_LISTING, 11 KIND_COMMENT, 12 }, 13 id::{ 14 AddressableCoordinate, AddressableCoordinateParts, EventId, ParseError, parse_public_key, 15 }, 16 tag::relay_hint::NostrRelayHint, 17 wire::{ 18 DEFAULT_CONTENT_MAX_BYTES, DEFAULT_RAW_JSON_MAX_BYTES, DEFAULT_TAG_ELEMENT_MAX_BYTES, 19 DEFAULT_TAG_MAX_COUNT, DEFAULT_TAG_TOTAL_ELEMENT_MAX_COUNT, DEFAULT_TAG_TOTAL_MAX_BYTES, 20 }, 21 }; 22 use radroots_identity::PublicKey; 23 24 pub const RADROOTS_NIP22_COMMENT_CONTENT_MAX_BYTES: usize = DEFAULT_CONTENT_MAX_BYTES; 25 pub const RADROOTS_NIP22_COMMENT_TAG_MAX_COUNT: usize = DEFAULT_TAG_MAX_COUNT; 26 pub const RADROOTS_NIP22_COMMENT_TAG_TOTAL_ELEMENT_MAX_COUNT: usize = 27 DEFAULT_TAG_TOTAL_ELEMENT_MAX_COUNT; 28 pub const RADROOTS_NIP22_COMMENT_TAG_ELEMENT_MAX_BYTES: usize = DEFAULT_TAG_ELEMENT_MAX_BYTES; 29 pub const RADROOTS_NIP22_COMMENT_TAG_TOTAL_MAX_BYTES: usize = DEFAULT_TAG_TOTAL_MAX_BYTES; 30 pub const RADROOTS_NIP22_COMMENT_EVENT_WIRE_MAX_BYTES: usize = DEFAULT_RAW_JSON_MAX_BYTES; 31 32 const RADROOTS_NIP22_COMMENT_SIGNED_EVENT_FIXED_MAX_BYTES: usize = "{\"id\":\"".len() 33 + 64 34 + "\",\"pubkey\":\"".len() 35 + 64 36 + "\",\"created_at\":".len() 37 + 20 38 + ",\"kind\":1111,\"tags\":".len() 39 + ",\"content\":".len() 40 + ",\"sig\":\"".len() 41 + 128 42 + "\"}".len(); 43 44 #[non_exhaustive] 45 #[derive(Clone, Debug, PartialEq, Eq)] 46 pub enum Nip22CommentError { 47 ContentMissing, 48 ContentTooLarge { max: usize, actual: usize }, 49 RootEventIdInvalid(ParseError), 50 RootAuthorInvalid(ParseError), 51 RootCoordinateInvalid(ParseError), 52 ParentEventIdInvalid(ParseError), 53 ParentAuthorInvalid(ParseError), 54 RevisionEventIdInvalid(ParseError), 55 RootKindUnsupported { actual: u32 }, 56 RelayInvalid(ParseError), 57 ParentReferenceMismatch, 58 TagCountExceeded { max: usize, actual: usize }, 59 TagElementCountExceeded { max: usize, actual: usize }, 60 TagElementTooLarge { max: usize, actual: usize }, 61 TagBytesExceeded { max: usize, actual: usize }, 62 EventWireTooLarge { max: usize, actual: usize }, 63 } 64 65 impl Nip22CommentError { 66 pub const fn code(&self) -> &'static str { 67 match self { 68 Self::ContentMissing => "comment_content_missing", 69 Self::ContentTooLarge { .. } => "comment_content_too_large", 70 Self::RootEventIdInvalid(_) => "comment_root_event_id_invalid", 71 Self::RootAuthorInvalid(_) => "comment_root_author_invalid", 72 Self::RootCoordinateInvalid(_) => "comment_root_coordinate_invalid", 73 Self::ParentEventIdInvalid(_) => "comment_parent_event_id_invalid", 74 Self::ParentAuthorInvalid(_) => "comment_parent_author_invalid", 75 Self::RevisionEventIdInvalid(_) => "comment_revision_event_id_invalid", 76 Self::RootKindUnsupported { .. } => "comment_root_kind_unsupported", 77 Self::RelayInvalid(_) => "comment_relay_invalid", 78 Self::ParentReferenceMismatch => "comment_parent_reference_mismatch", 79 Self::TagCountExceeded { .. } => "comment_tag_count_exceeded", 80 Self::TagElementCountExceeded { .. } => "comment_tag_element_count_exceeded", 81 Self::TagElementTooLarge { .. } => "comment_tag_element_too_large", 82 Self::TagBytesExceeded { .. } => "comment_tag_bytes_exceeded", 83 Self::EventWireTooLarge { .. } => "comment_event_wire_too_large", 84 } 85 } 86 } 87 88 impl fmt::Display for Nip22CommentError { 89 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 90 match self { 91 Self::ContentMissing => { 92 formatter.write_str("authored NIP-22 comment content must be non-whitespace") 93 } 94 Self::ContentTooLarge { max, actual } => write!( 95 formatter, 96 "authored NIP-22 comment content is {actual} bytes; max is {max}" 97 ), 98 Self::RootEventIdInvalid(error) => { 99 write!( 100 formatter, 101 "NIP-22 comment root event id is invalid: {error}" 102 ) 103 } 104 Self::RootAuthorInvalid(error) => { 105 write!(formatter, "NIP-22 comment root author is invalid: {error}") 106 } 107 Self::RootCoordinateInvalid(error) => { 108 write!( 109 formatter, 110 "NIP-22 comment root coordinate is invalid: {error}" 111 ) 112 } 113 Self::ParentEventIdInvalid(error) => { 114 write!( 115 formatter, 116 "NIP-22 comment parent event id is invalid: {error}" 117 ) 118 } 119 Self::ParentAuthorInvalid(error) => { 120 write!( 121 formatter, 122 "NIP-22 comment parent author is invalid: {error}" 123 ) 124 } 125 Self::RevisionEventIdInvalid(error) => { 126 write!( 127 formatter, 128 "NIP-22 comment revision event id is invalid: {error}" 129 ) 130 } 131 Self::RootKindUnsupported { actual } => write!( 132 formatter, 133 "NIP-22 comment root kind {actual} is outside the supported profile" 134 ), 135 Self::RelayInvalid(error) => { 136 write!(formatter, "NIP-22 comment relay hint is invalid: {error}") 137 } 138 Self::ParentReferenceMismatch => { 139 formatter.write_str("nested NIP-22 comment parent must differ from its event root") 140 } 141 Self::TagCountExceeded { max, actual } => write!( 142 formatter, 143 "authored NIP-22 comment has {actual} tags; max is {max}" 144 ), 145 Self::TagElementCountExceeded { max, actual } => write!( 146 formatter, 147 "authored NIP-22 comment has {actual} total tag elements; max is {max}" 148 ), 149 Self::TagElementTooLarge { max, actual } => write!( 150 formatter, 151 "authored NIP-22 comment tag element is {actual} bytes; max is {max}" 152 ), 153 Self::TagBytesExceeded { max, actual } => write!( 154 formatter, 155 "authored NIP-22 comment tag bytes are {actual}; max is {max}" 156 ), 157 Self::EventWireTooLarge { max, actual } => write!( 158 formatter, 159 "authored NIP-22 comment maximum canonical signed event size is {actual} bytes; max is {max}" 160 ), 161 } 162 } 163 } 164 165 #[cfg(feature = "std")] 166 impl std::error::Error for Nip22CommentError { 167 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { 168 match self { 169 Self::RootEventIdInvalid(error) 170 | Self::RootAuthorInvalid(error) 171 | Self::RootCoordinateInvalid(error) 172 | Self::ParentEventIdInvalid(error) 173 | Self::ParentAuthorInvalid(error) 174 | Self::RevisionEventIdInvalid(error) 175 | Self::RelayInvalid(error) => Some(error), 176 _ => None, 177 } 178 } 179 } 180 181 /// Root event kinds supported by the strict Radroots NIP-22 profile. 182 #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] 183 pub enum Nip22CommentRootKind { 184 Article, 185 ClassifiedListing, 186 CalendarDateEvent, 187 CalendarTimeEvent, 188 } 189 190 impl Nip22CommentRootKind { 191 pub const fn as_u32(self) -> u32 { 192 match self { 193 Self::Article => KIND_ARTICLE, 194 Self::ClassifiedListing => KIND_CLASSIFIED_LISTING, 195 Self::CalendarDateEvent => KIND_CALENDAR_DATE_EVENT, 196 Self::CalendarTimeEvent => KIND_CALENDAR_TIME_EVENT, 197 } 198 } 199 200 pub const fn parse(kind: u32) -> Result<Self, Nip22CommentError> { 201 match kind { 202 KIND_ARTICLE => Ok(Self::Article), 203 KIND_CLASSIFIED_LISTING => Ok(Self::ClassifiedListing), 204 KIND_CALENDAR_DATE_EVENT => Ok(Self::CalendarDateEvent), 205 KIND_CALENDAR_TIME_EVENT => Ok(Self::CalendarTimeEvent), 206 actual => Err(Nip22CommentError::RootKindUnsupported { actual }), 207 } 208 } 209 } 210 211 impl From<Nip22CommentRootKind> for u32 { 212 fn from(value: Nip22CommentRootKind) -> Self { 213 value.as_u32() 214 } 215 } 216 217 impl TryFrom<u32> for Nip22CommentRootKind { 218 type Error = Nip22CommentError; 219 220 fn try_from(value: u32) -> Result<Self, Self::Error> { 221 Self::parse(value) 222 } 223 } 224 225 /// One revision-specific NIP-22 event root. 226 /// 227 /// The caller asserts the target kind and author. This does not prove event 228 /// existence, signature validity, actual authorship, or relay availability. 229 #[derive(Clone, Debug, PartialEq, Eq)] 230 pub struct Nip22EventRootReference { 231 event_id: EventId, 232 author: PublicKey, 233 kind: Nip22CommentRootKind, 234 relay: Option<NostrRelayHint>, 235 } 236 237 impl Nip22EventRootReference { 238 pub fn new( 239 event_id: EventId, 240 author: PublicKey, 241 kind: Nip22CommentRootKind, 242 relay: Option<NostrRelayHint>, 243 ) -> Result<Self, Nip22CommentError> { 244 validate_optional_relay(&relay)?; 245 Ok(Self { 246 event_id, 247 author, 248 kind, 249 relay, 250 }) 251 } 252 253 pub fn parse( 254 event_id: impl AsRef<str>, 255 author: impl AsRef<str>, 256 kind: u32, 257 relay: Option<&str>, 258 ) -> Result<Self, Nip22CommentError> { 259 Self::new( 260 EventId::parse(event_id).map_err(Nip22CommentError::RootEventIdInvalid)?, 261 parse_public_key(author).map_err(Nip22CommentError::RootAuthorInvalid)?, 262 Nip22CommentRootKind::parse(kind)?, 263 parse_optional_relay(relay)?, 264 ) 265 } 266 267 pub const fn event_id(&self) -> &EventId { 268 &self.event_id 269 } 270 271 pub const fn author(&self) -> &PublicKey { 272 &self.author 273 } 274 275 pub const fn kind(&self) -> Nip22CommentRootKind { 276 self.kind 277 } 278 279 pub const fn relay(&self) -> Option<&NostrRelayHint> { 280 self.relay.as_ref() 281 } 282 283 pub fn relay_or_empty(&self) -> &str { 284 relay_or_empty(self.relay()) 285 } 286 } 287 288 /// One coordinate-stable NIP-22 address root. 289 /// 290 /// This proves coordinate syntax and the focused root-kind allowlist only. It 291 /// does not prove event existence, signature validity, a current revision, or 292 /// relay availability. 293 #[derive(Clone, Debug, PartialEq, Eq)] 294 pub struct Nip22AddressRootReference { 295 coordinate: AddressableCoordinate, 296 author: PublicKey, 297 kind: Nip22CommentRootKind, 298 relay: Option<NostrRelayHint>, 299 } 300 301 impl Nip22AddressRootReference { 302 pub fn new( 303 coordinate: AddressableCoordinate, 304 relay: Option<NostrRelayHint>, 305 ) -> Result<Self, Nip22CommentError> { 306 let parts = AddressableCoordinateParts::parse(coordinate.as_str()) 307 .map_err(Nip22CommentError::RootCoordinateInvalid)?; 308 let kind = Nip22CommentRootKind::parse(parts.kind)?; 309 let coordinate = AddressableCoordinate::parse(format!( 310 "{}:{}:{}", 311 kind.as_u32(), 312 parts.pubkey, 313 parts.d_tag 314 )) 315 .map_err(Nip22CommentError::RootCoordinateInvalid)?; 316 validate_tag_element(coordinate.as_str())?; 317 validate_optional_relay(&relay)?; 318 Ok(Self { 319 coordinate, 320 author: parts.pubkey, 321 kind, 322 relay, 323 }) 324 } 325 326 pub fn parse( 327 coordinate: impl AsRef<str>, 328 relay: Option<&str>, 329 ) -> Result<Self, Nip22CommentError> { 330 Self::new( 331 AddressableCoordinate::parse(coordinate) 332 .map_err(Nip22CommentError::RootCoordinateInvalid)?, 333 parse_optional_relay(relay)?, 334 ) 335 } 336 337 pub const fn coordinate(&self) -> &AddressableCoordinate { 338 &self.coordinate 339 } 340 341 pub const fn author(&self) -> &PublicKey { 342 &self.author 343 } 344 345 pub const fn kind(&self) -> Nip22CommentRootKind { 346 self.kind 347 } 348 349 pub const fn relay(&self) -> Option<&NostrRelayHint> { 350 self.relay.as_ref() 351 } 352 353 pub fn relay_or_empty(&self) -> &str { 354 relay_or_empty(self.relay()) 355 } 356 } 357 358 /// One kind-1111 parent reference for a nested NIP-22 comment. 359 /// 360 /// The caller asserts the target is a Comment by the stated author. This does 361 /// not prove event existence, target kind, signature validity, actual 362 /// authorship, or relay availability. 363 #[derive(Clone, Debug, PartialEq, Eq)] 364 pub struct Nip22CommentParentReference { 365 event_id: EventId, 366 author: PublicKey, 367 relay: Option<NostrRelayHint>, 368 } 369 370 impl Nip22CommentParentReference { 371 pub fn new( 372 event_id: EventId, 373 author: PublicKey, 374 relay: Option<NostrRelayHint>, 375 ) -> Result<Self, Nip22CommentError> { 376 validate_optional_relay(&relay)?; 377 Ok(Self { 378 event_id, 379 author, 380 relay, 381 }) 382 } 383 384 pub fn parse( 385 event_id: impl AsRef<str>, 386 author: impl AsRef<str>, 387 relay: Option<&str>, 388 ) -> Result<Self, Nip22CommentError> { 389 Self::new( 390 EventId::parse(event_id).map_err(Nip22CommentError::ParentEventIdInvalid)?, 391 parse_public_key(author).map_err(Nip22CommentError::ParentAuthorInvalid)?, 392 parse_optional_relay(relay)?, 393 ) 394 } 395 396 pub const fn event_id(&self) -> &EventId { 397 &self.event_id 398 } 399 400 pub const fn author(&self) -> &PublicKey { 401 &self.author 402 } 403 404 pub const fn relay(&self) -> Option<&NostrRelayHint> { 405 self.relay.as_ref() 406 } 407 408 pub fn relay_or_empty(&self) -> &str { 409 relay_or_empty(self.relay()) 410 } 411 } 412 413 #[derive(Clone, Debug, PartialEq, Eq)] 414 pub enum Nip22CommentRoot { 415 Event(Nip22EventRootReference), 416 Address(Nip22AddressRootReference), 417 } 418 419 impl Nip22CommentRoot { 420 pub const fn kind(&self) -> Nip22CommentRootKind { 421 match self { 422 Self::Event(reference) => reference.kind(), 423 Self::Address(reference) => reference.kind(), 424 } 425 } 426 427 pub const fn author(&self) -> &PublicKey { 428 match self { 429 Self::Event(reference) => reference.author(), 430 Self::Address(reference) => reference.author(), 431 } 432 } 433 434 pub const fn relay(&self) -> Option<&NostrRelayHint> { 435 match self { 436 Self::Event(reference) => reference.relay(), 437 Self::Address(reference) => reference.relay(), 438 } 439 } 440 } 441 442 impl From<Nip22EventRootReference> for Nip22CommentRoot { 443 fn from(value: Nip22EventRootReference) -> Self { 444 Self::Event(value) 445 } 446 } 447 448 impl From<Nip22AddressRootReference> for Nip22CommentRoot { 449 fn from(value: Nip22AddressRootReference) -> Self { 450 Self::Address(value) 451 } 452 } 453 454 #[derive(Clone, Debug, PartialEq, Eq)] 455 pub enum Nip22CommentPosition { 456 TopLevelEvent, 457 TopLevelAddress { current_revision: EventId }, 458 Nested { parent: Nip22CommentParentReference }, 459 } 460 461 /// Strict authored kind-1111 NIP-22 comment. 462 /// 463 /// This type is opaque and has no Serde construction path. 464 /// 465 /// ```compile_fail 466 /// let _: radroots_event::post::comment::AuthoredNip22Comment = 467 /// serde_json::from_str(r#"{"content":"comment"}"#).unwrap(); 468 /// ``` 469 #[derive(Clone, Debug, PartialEq, Eq)] 470 pub struct AuthoredNip22Comment { 471 content: String, 472 root: Nip22CommentRoot, 473 position: Nip22CommentPosition, 474 } 475 476 impl AuthoredNip22Comment { 477 pub fn top_level_event( 478 content: impl Into<String>, 479 root: Nip22EventRootReference, 480 ) -> Result<Self, Nip22CommentError> { 481 Self::new( 482 content.into(), 483 Nip22CommentRoot::Event(root), 484 Nip22CommentPosition::TopLevelEvent, 485 ) 486 } 487 488 pub fn top_level_address( 489 content: impl Into<String>, 490 root: Nip22AddressRootReference, 491 current_revision: EventId, 492 ) -> Result<Self, Nip22CommentError> { 493 Self::new( 494 content.into(), 495 Nip22CommentRoot::Address(root), 496 Nip22CommentPosition::TopLevelAddress { current_revision }, 497 ) 498 } 499 500 pub fn parse_top_level_address( 501 content: impl Into<String>, 502 root: Nip22AddressRootReference, 503 current_revision: impl AsRef<str>, 504 ) -> Result<Self, Nip22CommentError> { 505 Self::top_level_address( 506 content, 507 root, 508 EventId::parse(current_revision).map_err(Nip22CommentError::RevisionEventIdInvalid)?, 509 ) 510 } 511 512 pub fn nested( 513 content: impl Into<String>, 514 root: impl Into<Nip22CommentRoot>, 515 parent: Nip22CommentParentReference, 516 ) -> Result<Self, Nip22CommentError> { 517 let root = root.into(); 518 if matches!( 519 &root, 520 Nip22CommentRoot::Event(reference) 521 if reference.event_id() == parent.event_id() 522 ) { 523 return Err(Nip22CommentError::ParentReferenceMismatch); 524 } 525 Self::new( 526 content.into(), 527 root, 528 Nip22CommentPosition::Nested { parent }, 529 ) 530 } 531 532 fn new( 533 content: String, 534 root: Nip22CommentRoot, 535 position: Nip22CommentPosition, 536 ) -> Result<Self, Nip22CommentError> { 537 validate_content(&content)?; 538 validate_authored_comment_wire_size(&content, &root, &position)?; 539 Ok(Self { 540 content, 541 root, 542 position, 543 }) 544 } 545 546 pub fn content(&self) -> &str { 547 &self.content 548 } 549 550 pub const fn root(&self) -> &Nip22CommentRoot { 551 &self.root 552 } 553 554 pub const fn position(&self) -> &Nip22CommentPosition { 555 &self.position 556 } 557 } 558 559 fn parse_optional_relay(relay: Option<&str>) -> Result<Option<NostrRelayHint>, Nip22CommentError> { 560 match relay { 561 None | Some("") => Ok(None), 562 Some(relay) => { 563 validate_tag_element(relay)?; 564 NostrRelayHint::parse(relay) 565 .map(Some) 566 .map_err(Nip22CommentError::RelayInvalid) 567 } 568 } 569 } 570 571 fn validate_optional_relay(relay: &Option<NostrRelayHint>) -> Result<(), Nip22CommentError> { 572 if let Some(relay) = relay { 573 validate_tag_element(relay.as_str())?; 574 } 575 Ok(()) 576 } 577 578 fn relay_or_empty(relay: Option<&NostrRelayHint>) -> &str { 579 relay.map_or("", NostrRelayHint::as_str) 580 } 581 582 fn validate_content(content: &str) -> Result<(), Nip22CommentError> { 583 crate::require_invariant(!content.trim().is_empty(), &|| { 584 Nip22CommentError::ContentMissing 585 })?; 586 crate::require_invariant( 587 content.len() <= RADROOTS_NIP22_COMMENT_CONTENT_MAX_BYTES, 588 &|| Nip22CommentError::ContentTooLarge { 589 max: RADROOTS_NIP22_COMMENT_CONTENT_MAX_BYTES, 590 actual: content.len(), 591 }, 592 ) 593 } 594 595 fn validate_tag_element(element: &str) -> Result<(), Nip22CommentError> { 596 crate::require_invariant( 597 element.len() <= RADROOTS_NIP22_COMMENT_TAG_ELEMENT_MAX_BYTES, 598 &|| Nip22CommentError::TagElementTooLarge { 599 max: RADROOTS_NIP22_COMMENT_TAG_ELEMENT_MAX_BYTES, 600 actual: element.len(), 601 }, 602 ) 603 } 604 605 fn validate_authored_comment_wire_size( 606 content: &str, 607 root: &Nip22CommentRoot, 608 position: &Nip22CommentPosition, 609 ) -> Result<(), Nip22CommentError> { 610 let mut tag_bytes = 0usize; 611 let mut tags_json_bytes = 2usize; 612 let mut tag_count = 0usize; 613 let root_kind = root.kind().as_u32().to_string(); 614 615 let root_tag_element_count = match root { 616 Nip22CommentRoot::Event(reference) => { 617 add_tag( 618 &mut tag_bytes, 619 &mut tags_json_bytes, 620 &mut tag_count, 621 &[ 622 "E", 623 reference.event_id().to_hex().as_str(), 624 reference.relay_or_empty(), 625 reference.author().to_hex().as_str(), 626 ], 627 ); 628 8usize + usize::from(reference.relay().is_some()) 629 } 630 Nip22CommentRoot::Address(reference) => { 631 add_optional_relay_tag( 632 &mut tag_bytes, 633 &mut tags_json_bytes, 634 &mut tag_count, 635 "A", 636 reference.coordinate().as_str(), 637 reference.relay(), 638 ); 639 6usize + 2 * usize::from(reference.relay().is_some()) 640 } 641 }; 642 add_tag( 643 &mut tag_bytes, 644 &mut tags_json_bytes, 645 &mut tag_count, 646 &["K", root_kind.as_str()], 647 ); 648 add_optional_relay_tag( 649 &mut tag_bytes, 650 &mut tags_json_bytes, 651 &mut tag_count, 652 "P", 653 root.author().to_hex().as_str(), 654 root.relay(), 655 ); 656 657 let position_tag_element_count = match (root, position) { 658 (Nip22CommentRoot::Event(reference), Nip22CommentPosition::TopLevelEvent) => { 659 add_tag( 660 &mut tag_bytes, 661 &mut tags_json_bytes, 662 &mut tag_count, 663 &[ 664 "e", 665 reference.event_id().to_hex().as_str(), 666 reference.relay_or_empty(), 667 reference.author().to_hex().as_str(), 668 ], 669 ); 670 add_tag( 671 &mut tag_bytes, 672 &mut tags_json_bytes, 673 &mut tag_count, 674 &["k", root_kind.as_str()], 675 ); 676 add_optional_relay_tag( 677 &mut tag_bytes, 678 &mut tags_json_bytes, 679 &mut tag_count, 680 "p", 681 reference.author().to_hex().as_str(), 682 reference.relay(), 683 ); 684 8usize + usize::from(reference.relay().is_some()) 685 } 686 ( 687 Nip22CommentRoot::Address(reference), 688 Nip22CommentPosition::TopLevelAddress { current_revision }, 689 ) => { 690 add_optional_relay_tag( 691 &mut tag_bytes, 692 &mut tags_json_bytes, 693 &mut tag_count, 694 "a", 695 reference.coordinate().as_str(), 696 reference.relay(), 697 ); 698 add_optional_relay_tag( 699 &mut tag_bytes, 700 &mut tags_json_bytes, 701 &mut tag_count, 702 "e", 703 current_revision.to_hex().as_str(), 704 reference.relay(), 705 ); 706 add_tag( 707 &mut tag_bytes, 708 &mut tags_json_bytes, 709 &mut tag_count, 710 &["k", root_kind.as_str()], 711 ); 712 add_optional_relay_tag( 713 &mut tag_bytes, 714 &mut tags_json_bytes, 715 &mut tag_count, 716 "p", 717 reference.author().to_hex().as_str(), 718 reference.relay(), 719 ); 720 8usize + 3 * usize::from(reference.relay().is_some()) 721 } 722 (_, Nip22CommentPosition::Nested { parent }) => { 723 add_tag( 724 &mut tag_bytes, 725 &mut tags_json_bytes, 726 &mut tag_count, 727 &[ 728 "e", 729 parent.event_id().to_hex().as_str(), 730 parent.relay_or_empty(), 731 parent.author().to_hex().as_str(), 732 ], 733 ); 734 add_tag( 735 &mut tag_bytes, 736 &mut tags_json_bytes, 737 &mut tag_count, 738 &["k", KIND_COMMENT.to_string().as_str()], 739 ); 740 add_optional_relay_tag( 741 &mut tag_bytes, 742 &mut tags_json_bytes, 743 &mut tag_count, 744 "p", 745 parent.author().to_hex().as_str(), 746 parent.relay(), 747 ); 748 8usize + usize::from(parent.relay().is_some()) 749 } 750 _ => unreachable!("constructors preserve root and position compatibility"), 751 }; 752 753 crate::require_invariant(tag_count <= RADROOTS_NIP22_COMMENT_TAG_MAX_COUNT, &|| { 754 Nip22CommentError::TagCountExceeded { 755 max: RADROOTS_NIP22_COMMENT_TAG_MAX_COUNT, 756 actual: tag_count, 757 } 758 })?; 759 let tag_element_count = root_tag_element_count.saturating_add(position_tag_element_count); 760 crate::require_invariant( 761 tag_element_count <= RADROOTS_NIP22_COMMENT_TAG_TOTAL_ELEMENT_MAX_COUNT, 762 &|| Nip22CommentError::TagElementCountExceeded { 763 max: RADROOTS_NIP22_COMMENT_TAG_TOTAL_ELEMENT_MAX_COUNT, 764 actual: tag_element_count, 765 }, 766 )?; 767 768 crate::require_invariant( 769 tag_bytes <= RADROOTS_NIP22_COMMENT_TAG_TOTAL_MAX_BYTES, 770 &|| Nip22CommentError::TagBytesExceeded { 771 max: RADROOTS_NIP22_COMMENT_TAG_TOTAL_MAX_BYTES, 772 actual: tag_bytes, 773 }, 774 )?; 775 let actual = RADROOTS_NIP22_COMMENT_SIGNED_EVENT_FIXED_MAX_BYTES 776 .saturating_add(tags_json_bytes) 777 .saturating_add(canonical_json_string_bytes(content)); 778 crate::require_invariant( 779 actual <= RADROOTS_NIP22_COMMENT_EVENT_WIRE_MAX_BYTES, 780 &|| Nip22CommentError::EventWireTooLarge { 781 max: RADROOTS_NIP22_COMMENT_EVENT_WIRE_MAX_BYTES, 782 actual, 783 }, 784 ) 785 } 786 787 fn add_optional_relay_tag( 788 tag_bytes: &mut usize, 789 tags_json_bytes: &mut usize, 790 tag_count: &mut usize, 791 name: &str, 792 value: &str, 793 relay: Option<&NostrRelayHint>, 794 ) { 795 if let Some(relay) = relay { 796 add_tag( 797 tag_bytes, 798 tags_json_bytes, 799 tag_count, 800 &[name, value, relay.as_str()], 801 ); 802 } else { 803 add_tag(tag_bytes, tags_json_bytes, tag_count, &[name, value]); 804 } 805 } 806 807 fn add_tag( 808 tag_bytes: &mut usize, 809 tags_json_bytes: &mut usize, 810 tag_count: &mut usize, 811 elements: &[&str], 812 ) { 813 if *tag_count > 0 { 814 *tags_json_bytes = tags_json_bytes.saturating_add(1); 815 } 816 *tags_json_bytes = tags_json_bytes.saturating_add(2); 817 for (index, element) in elements.iter().enumerate() { 818 if index > 0 { 819 *tags_json_bytes = tags_json_bytes.saturating_add(1); 820 } 821 *tags_json_bytes = tags_json_bytes.saturating_add(canonical_json_string_bytes(element)); 822 *tag_bytes = tag_bytes.saturating_add(element.len()); 823 } 824 *tag_count = tag_count.saturating_add(1); 825 } 826 827 fn canonical_json_string_bytes(value: &str) -> usize { 828 value.chars().fold(2usize, |total, character| { 829 total.saturating_add(match character { 830 '"' | '\\' | '\u{0008}' | '\t' | '\n' | '\u{000c}' | '\r' => 2, 831 '\u{0000}'..='\u{001f}' => 6, 832 _ => character.len_utf8(), 833 }) 834 }) 835 } 836 837 #[cfg(test)] 838 #[cfg_attr(coverage_nightly, coverage(off))] 839 mod tests { 840 use super::*; 841 842 fn event_root(kind: u32) -> Nip22EventRootReference { 843 Nip22EventRootReference::parse( 844 "a".repeat(64), 845 crate::test_valid_hex_64('b'), 846 kind, 847 Some("wss://relay.example"), 848 ) 849 .expect("event root") 850 } 851 852 fn address_root(kind: u32) -> Nip22AddressRootReference { 853 Nip22AddressRootReference::parse( 854 format!("{kind}:{}:victoria-market", crate::test_valid_hex_64('b')), 855 Some("wss://relay.example"), 856 ) 857 .expect("address root") 858 } 859 860 fn parent() -> Nip22CommentParentReference { 861 Nip22CommentParentReference::parse( 862 "c".repeat(64), 863 "d".repeat(64), 864 Some("wss://comments.example"), 865 ) 866 .expect("parent") 867 } 868 869 #[test] 870 fn supports_each_root_kind_for_event_and_address_roots() { 871 for kind in [ 872 KIND_ARTICLE, 873 KIND_CLASSIFIED_LISTING, 874 KIND_CALENDAR_DATE_EVENT, 875 KIND_CALENDAR_TIME_EVENT, 876 ] { 877 AuthoredNip22Comment::top_level_event("Comment", event_root(kind)) 878 .expect("event comment"); 879 AuthoredNip22Comment::parse_top_level_address( 880 "Comment", 881 address_root(kind), 882 "e".repeat(64), 883 ) 884 .expect("address comment"); 885 AuthoredNip22Comment::nested("Reply", event_root(kind), parent()) 886 .expect("nested event comment"); 887 AuthoredNip22Comment::nested("Reply", address_root(kind), parent()) 888 .expect("nested address comment"); 889 } 890 } 891 892 #[test] 893 fn rejects_other_root_kinds_and_ambiguous_parent() { 894 for kind in [1, KIND_COMMENT, 30_018] { 895 assert!(matches!( 896 Nip22EventRootReference::parse( 897 "a".repeat(64), 898 crate::test_valid_hex_64('b'), 899 kind, 900 None 901 ), 902 Err(Nip22CommentError::RootKindUnsupported { actual }) if actual == kind 903 )); 904 } 905 906 let root = event_root(KIND_CLASSIFIED_LISTING); 907 let parent = 908 Nip22CommentParentReference::parse(root.event_id().to_hex(), "d".repeat(64), None) 909 .expect("parent"); 910 assert_eq!( 911 AuthoredNip22Comment::nested("Reply", root, parent).unwrap_err(), 912 Nip22CommentError::ParentReferenceMismatch 913 ); 914 } 915 916 #[test] 917 fn canonicalizes_address_author_and_requires_current_revision() { 918 let root = Nip22AddressRootReference::parse( 919 format!("30402:{}:listing", crate::test_valid_hex_64('B')), 920 None, 921 ) 922 .expect("address root"); 923 assert_eq!( 924 root.coordinate().as_str(), 925 format!("30402:{}:listing", crate::test_valid_hex_64('b')) 926 ); 927 assert_eq!( 928 root.author().to_hex().as_str(), 929 crate::test_valid_hex_64('b') 930 ); 931 932 let comment = 933 AuthoredNip22Comment::parse_top_level_address("Comment", root, "E".repeat(64)) 934 .expect("top-level address"); 935 assert!(matches!( 936 comment.position(), 937 Nip22CommentPosition::TopLevelAddress { current_revision } 938 if current_revision.to_hex() == "e".repeat(64) 939 )); 940 } 941 942 #[test] 943 fn address_root_rechecks_the_canonical_coordinate_tag_element() { 944 let maximum_d_tag = "x".repeat(512); 945 let root = Nip22AddressRootReference::parse( 946 format!("30402:{}:{maximum_d_tag}", crate::test_valid_hex_64('b')), 947 None, 948 ) 949 .expect("maximum public d tag fits the tag-element budget"); 950 assert!(root.coordinate().as_str().len() <= RADROOTS_NIP22_COMMENT_TAG_ELEMENT_MAX_BYTES); 951 952 let oversized = format!( 953 "30402:{}:{}", 954 crate::test_valid_hex_64('b'), 955 "x".repeat(513) 956 ); 957 assert!(matches!( 958 Nip22AddressRootReference::parse(oversized, None), 959 Err(Nip22CommentError::RootCoordinateInvalid( 960 ParseError::TooLong { 961 max: 512, 962 actual: 513 963 } 964 )) 965 )); 966 } 967 968 #[test] 969 fn relay_element_budget_precedes_relay_syntax() { 970 let oversized_noncanonical_relay = format!("WSS://relay.example/{}", "x".repeat(4096)); 971 assert!(matches!( 972 Nip22EventRootReference::parse( 973 "a".repeat(64), 974 crate::test_valid_hex_64('b'), 975 KIND_CLASSIFIED_LISTING, 976 Some(&oversized_noncanonical_relay), 977 ), 978 Err(Nip22CommentError::TagElementTooLarge { 979 max: RADROOTS_NIP22_COMMENT_TAG_ELEMENT_MAX_BYTES, 980 actual, 981 }) if actual == oversized_noncanonical_relay.len() 982 )); 983 } 984 985 #[test] 986 fn enforces_content_and_relay_element_boundaries() { 987 AuthoredNip22Comment::top_level_event( 988 "x".repeat(RADROOTS_NIP22_COMMENT_CONTENT_MAX_BYTES), 989 event_root(KIND_CLASSIFIED_LISTING), 990 ) 991 .expect("exact content limit"); 992 assert!(matches!( 993 AuthoredNip22Comment::top_level_event( 994 "x".repeat(RADROOTS_NIP22_COMMENT_CONTENT_MAX_BYTES + 1), 995 event_root(KIND_CLASSIFIED_LISTING) 996 ), 997 Err(Nip22CommentError::ContentTooLarge { max, actual }) 998 if max == RADROOTS_NIP22_COMMENT_CONTENT_MAX_BYTES 999 && actual == max + 1 1000 )); 1001 1002 let prefix = "wss://relay.example/"; 1003 let exact_relay = format!( 1004 "{prefix}{}", 1005 "a".repeat(RADROOTS_NIP22_COMMENT_TAG_ELEMENT_MAX_BYTES - prefix.len()) 1006 ); 1007 Nip22EventRootReference::parse( 1008 "a".repeat(64), 1009 crate::test_valid_hex_64('b'), 1010 KIND_CLASSIFIED_LISTING, 1011 Some(&exact_relay), 1012 ) 1013 .expect("exact tag-element limit"); 1014 let overflow = format!("{exact_relay}a"); 1015 assert!(matches!( 1016 Nip22EventRootReference::parse( 1017 "a".repeat(64), 1018 crate::test_valid_hex_64('b'), 1019 KIND_CLASSIFIED_LISTING, 1020 Some(&overflow) 1021 ), 1022 Err(Nip22CommentError::TagElementTooLarge { max, actual }) 1023 if max == RADROOTS_NIP22_COMMENT_TAG_ELEMENT_MAX_BYTES 1024 && actual == max + 1 1025 )); 1026 } 1027 1028 #[test] 1029 fn uses_unicode_white_space_for_blank_content() { 1030 AuthoredNip22Comment::top_level_event("\u{001c}", event_root(KIND_CLASSIFIED_LISTING)) 1031 .expect("U+001C is not Unicode White_Space"); 1032 assert_eq!( 1033 AuthoredNip22Comment::top_level_event("\u{00a0}", event_root(KIND_CLASSIFIED_LISTING)) 1034 .unwrap_err(), 1035 Nip22CommentError::ContentMissing 1036 ); 1037 } 1038 1039 #[test] 1040 fn escaped_content_cannot_cross_compact_signed_wire_limit() { 1041 let mut lower = 1usize; 1042 let mut upper = RADROOTS_NIP22_COMMENT_CONTENT_MAX_BYTES; 1043 while lower < upper { 1044 let candidate = lower + (upper - lower).div_ceil(2); 1045 if AuthoredNip22Comment::top_level_event( 1046 "\u{0001}".repeat(candidate), 1047 event_root(KIND_CLASSIFIED_LISTING), 1048 ) 1049 .is_ok() 1050 { 1051 lower = candidate; 1052 } else { 1053 upper = candidate - 1; 1054 } 1055 } 1056 1057 AuthoredNip22Comment::top_level_event( 1058 "\u{0001}".repeat(lower), 1059 event_root(KIND_CLASSIFIED_LISTING), 1060 ) 1061 .expect("largest escaped content fitting the wire budget"); 1062 assert!(matches!( 1063 AuthoredNip22Comment::top_level_event( 1064 "\u{0001}".repeat(lower + 1), 1065 event_root(KIND_CLASSIFIED_LISTING) 1066 ), 1067 Err(Nip22CommentError::EventWireTooLarge { max, .. }) 1068 if max == RADROOTS_NIP22_COMMENT_EVENT_WIRE_MAX_BYTES 1069 )); 1070 } 1071 }