reply.rs (15626B)
1 #[cfg(not(feature = "std"))] 2 use alloc::string::String; 3 use core::fmt; 4 5 use crate::{ 6 id::{EventId, ParseError, parse_public_key}, 7 post::{ 8 RADROOTS_POST_CONTENT_MAX_BYTES, RADROOTS_POST_EVENT_WIRE_MAX_BYTES, 9 RADROOTS_POST_TAG_ELEMENT_MAX_BYTES, RADROOTS_POST_TAG_TOTAL_MAX_BYTES, 10 }, 11 tag::relay_hint::NostrRelayHint, 12 }; 13 use radroots_identity::PublicKey; 14 15 const RADROOTS_NIP10_REPLY_SIGNED_EVENT_FIXED_MAX_BYTES: usize = "{\"id\":\"".len() 16 + 64 17 + "\",\"pubkey\":\"".len() 18 + 64 19 + "\",\"created_at\":".len() 20 + 20 21 + ",\"kind\":1,\"tags\":".len() 22 + ",\"content\":".len() 23 + ",\"sig\":\"".len() 24 + 128 25 + "\"}".len(); 26 27 #[non_exhaustive] 28 #[derive(Clone, Debug, PartialEq, Eq)] 29 pub enum Nip10ReplyError { 30 ContentMissing, 31 ContentTooLarge { max: usize, actual: usize }, 32 EventIdInvalid(ParseError), 33 AuthorInvalid(ParseError), 34 RelayInvalid(ParseError), 35 NestedParentMatchesRoot, 36 TagElementTooLarge { max: usize, actual: usize }, 37 TagBytesExceeded { max: usize, actual: usize }, 38 EventWireTooLarge { max: usize, actual: usize }, 39 } 40 41 impl Nip10ReplyError { 42 pub const fn code(&self) -> &'static str { 43 match self { 44 Self::ContentMissing => "reply_content_missing", 45 Self::ContentTooLarge { .. } => "reply_content_too_large", 46 Self::EventIdInvalid(_) => "reply_event_id_invalid", 47 Self::AuthorInvalid(_) => "reply_author_invalid", 48 Self::RelayInvalid(_) => "reply_relay_invalid", 49 Self::NestedParentMatchesRoot => "reply_reference_ambiguous", 50 Self::TagElementTooLarge { .. } => "reply_tag_element_too_large", 51 Self::TagBytesExceeded { .. } => "reply_tag_bytes_exceeded", 52 Self::EventWireTooLarge { .. } => "reply_event_wire_too_large", 53 } 54 } 55 } 56 57 impl fmt::Display for Nip10ReplyError { 58 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 59 match self { 60 Self::ContentMissing => { 61 formatter.write_str("authored NIP-10 reply content must be non-whitespace") 62 } 63 Self::ContentTooLarge { max, actual } => { 64 write!( 65 formatter, 66 "authored NIP-10 reply content is {actual} bytes; max is {max}" 67 ) 68 } 69 Self::EventIdInvalid(error) => { 70 write!(formatter, "NIP-10 reply event id is invalid: {error}") 71 } 72 Self::AuthorInvalid(error) => { 73 write!(formatter, "NIP-10 reply author is invalid: {error}") 74 } 75 Self::RelayInvalid(error) => { 76 write!(formatter, "NIP-10 reply relay hint is invalid: {error}") 77 } 78 Self::NestedParentMatchesRoot => { 79 formatter.write_str("nested NIP-10 reply parent must differ from the thread root") 80 } 81 Self::TagElementTooLarge { max, actual } => { 82 write!( 83 formatter, 84 "authored NIP-10 reply tag element is {actual} bytes; max is {max}" 85 ) 86 } 87 Self::TagBytesExceeded { max, actual } => { 88 write!( 89 formatter, 90 "authored NIP-10 reply tag bytes are {actual}; max is {max}" 91 ) 92 } 93 Self::EventWireTooLarge { max, actual } => write!( 94 formatter, 95 "authored NIP-10 reply canonical signed event is at most {actual} bytes; max is {max}" 96 ), 97 } 98 } 99 } 100 101 #[cfg(feature = "std")] 102 impl std::error::Error for Nip10ReplyError { 103 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { 104 match self { 105 Self::EventIdInvalid(error) 106 | Self::AuthorInvalid(error) 107 | Self::RelayInvalid(error) => Some(error), 108 _ => None, 109 } 110 } 111 } 112 113 /// One syntactically validated reference used by strict NIP-10 authoring. 114 /// 115 /// The caller asserts that the target is a kind-1 event. This value does not 116 /// retrieve the target or prove its existence, kind, signature, or author. 117 #[derive(Clone, Debug, PartialEq, Eq)] 118 pub struct Nip10ReplyReference { 119 event_id: EventId, 120 author: PublicKey, 121 relay: Option<NostrRelayHint>, 122 } 123 124 impl Nip10ReplyReference { 125 pub fn new( 126 event_id: EventId, 127 author: PublicKey, 128 relay: Option<NostrRelayHint>, 129 ) -> Result<Self, Nip10ReplyError> { 130 if let Some(relay) = &relay { 131 validate_tag_element(relay.as_str())?; 132 } 133 Ok(Self { 134 event_id, 135 author, 136 relay, 137 }) 138 } 139 140 pub fn parse( 141 event_id: impl AsRef<str>, 142 author: impl AsRef<str>, 143 relay: Option<&str>, 144 ) -> Result<Self, Nip10ReplyError> { 145 let event_id = EventId::parse(event_id).map_err(Nip10ReplyError::EventIdInvalid)?; 146 let author = parse_public_key(author).map_err(Nip10ReplyError::AuthorInvalid)?; 147 let relay = match relay { 148 None | Some("") => None, 149 Some(relay) => { 150 Some(NostrRelayHint::parse(relay).map_err(Nip10ReplyError::RelayInvalid)?) 151 } 152 }; 153 Self::new(event_id, author, relay) 154 } 155 156 pub const fn event_id(&self) -> &EventId { 157 &self.event_id 158 } 159 160 pub const fn author(&self) -> &PublicKey { 161 &self.author 162 } 163 164 pub const fn relay(&self) -> Option<&NostrRelayHint> { 165 self.relay.as_ref() 166 } 167 168 pub fn relay_or_empty(&self) -> &str { 169 self.relay.as_ref().map_or("", NostrRelayHint::as_str) 170 } 171 } 172 173 /// Strict authored marked NIP-10 reply. 174 /// 175 /// Direct replies contain one `root` reference. Nested replies contain one 176 /// `root` and one distinct `reply` reference. The type is intentionally opaque 177 /// and has no Serde construction path. 178 /// 179 /// ```compile_fail 180 /// let _: radroots_event::post::reply::AuthoredNip10Reply = 181 /// serde_json::from_str(r#"{"content":"reply"}"#).unwrap(); 182 /// ``` 183 #[derive(Clone, Debug, PartialEq, Eq)] 184 pub struct AuthoredNip10Reply { 185 content: String, 186 root: Nip10ReplyReference, 187 parent: Option<Nip10ReplyReference>, 188 } 189 190 impl AuthoredNip10Reply { 191 pub fn direct( 192 content: impl Into<String>, 193 root: Nip10ReplyReference, 194 ) -> Result<Self, Nip10ReplyError> { 195 Self::new(content.into(), root, None) 196 } 197 198 pub fn nested( 199 content: impl Into<String>, 200 root: Nip10ReplyReference, 201 parent: Nip10ReplyReference, 202 ) -> Result<Self, Nip10ReplyError> { 203 if root.event_id == parent.event_id { 204 return Err(Nip10ReplyError::NestedParentMatchesRoot); 205 } 206 Self::new(content.into(), root, Some(parent)) 207 } 208 209 fn new( 210 content: String, 211 root: Nip10ReplyReference, 212 parent: Option<Nip10ReplyReference>, 213 ) -> Result<Self, Nip10ReplyError> { 214 validate_content(&content)?; 215 validate_authored_reply_wire_size(&content, &root, parent.as_ref())?; 216 Ok(Self { 217 content, 218 root, 219 parent, 220 }) 221 } 222 223 pub fn content(&self) -> &str { 224 &self.content 225 } 226 227 pub const fn root(&self) -> &Nip10ReplyReference { 228 &self.root 229 } 230 231 pub const fn parent(&self) -> Option<&Nip10ReplyReference> { 232 self.parent.as_ref() 233 } 234 235 pub const fn is_direct(&self) -> bool { 236 self.parent.is_none() 237 } 238 } 239 240 fn validate_content(content: &str) -> Result<(), Nip10ReplyError> { 241 if content.trim().is_empty() { 242 return Err(Nip10ReplyError::ContentMissing); 243 } 244 if content.len() > RADROOTS_POST_CONTENT_MAX_BYTES { 245 return Err(Nip10ReplyError::ContentTooLarge { 246 max: RADROOTS_POST_CONTENT_MAX_BYTES, 247 actual: content.len(), 248 }); 249 } 250 Ok(()) 251 } 252 253 fn validate_tag_element(element: &str) -> Result<(), Nip10ReplyError> { 254 if element.len() > RADROOTS_POST_TAG_ELEMENT_MAX_BYTES { 255 return Err(Nip10ReplyError::TagElementTooLarge { 256 max: RADROOTS_POST_TAG_ELEMENT_MAX_BYTES, 257 actual: element.len(), 258 }); 259 } 260 Ok(()) 261 } 262 263 fn validate_authored_reply_wire_size( 264 content: &str, 265 root: &Nip10ReplyReference, 266 parent: Option<&Nip10ReplyReference>, 267 ) -> Result<(), Nip10ReplyError> { 268 let mut tag_bytes = 0usize; 269 let mut tags_json_bytes = 2usize; 270 let mut tag_count = 0usize; 271 let root_event_id = root.event_id.to_hex(); 272 273 add_tag( 274 &mut tag_bytes, 275 &mut tags_json_bytes, 276 &mut tag_count, 277 &["e", root_event_id.as_str(), root.relay_or_empty(), "root"], 278 ); 279 if let Some(parent) = parent { 280 let parent_event_id = parent.event_id.to_hex(); 281 add_tag( 282 &mut tag_bytes, 283 &mut tags_json_bytes, 284 &mut tag_count, 285 &[ 286 "e", 287 parent_event_id.as_str(), 288 parent.relay_or_empty(), 289 "reply", 290 ], 291 ); 292 } 293 add_tag( 294 &mut tag_bytes, 295 &mut tags_json_bytes, 296 &mut tag_count, 297 &["p", root.author.to_hex().as_str()], 298 ); 299 if let Some(parent) = parent.filter(|parent| parent.author != root.author) { 300 add_tag( 301 &mut tag_bytes, 302 &mut tags_json_bytes, 303 &mut tag_count, 304 &["p", parent.author.to_hex().as_str()], 305 ); 306 } 307 308 if tag_bytes > RADROOTS_POST_TAG_TOTAL_MAX_BYTES { 309 return Err(Nip10ReplyError::TagBytesExceeded { 310 max: RADROOTS_POST_TAG_TOTAL_MAX_BYTES, 311 actual: tag_bytes, 312 }); 313 } 314 let actual = RADROOTS_NIP10_REPLY_SIGNED_EVENT_FIXED_MAX_BYTES 315 .saturating_add(tags_json_bytes) 316 .saturating_add(canonical_json_string_bytes(content)); 317 if actual > RADROOTS_POST_EVENT_WIRE_MAX_BYTES { 318 return Err(Nip10ReplyError::EventWireTooLarge { 319 max: RADROOTS_POST_EVENT_WIRE_MAX_BYTES, 320 actual, 321 }); 322 } 323 Ok(()) 324 } 325 326 fn add_tag( 327 tag_bytes: &mut usize, 328 tags_json_bytes: &mut usize, 329 tag_count: &mut usize, 330 elements: &[&str], 331 ) { 332 if *tag_count > 0 { 333 *tags_json_bytes = tags_json_bytes.saturating_add(1); 334 } 335 *tags_json_bytes = tags_json_bytes.saturating_add(2); 336 for (index, element) in elements.iter().enumerate() { 337 if index > 0 { 338 *tags_json_bytes = tags_json_bytes.saturating_add(1); 339 } 340 *tags_json_bytes = tags_json_bytes.saturating_add(canonical_json_string_bytes(element)); 341 *tag_bytes = tag_bytes.saturating_add(element.len()); 342 } 343 *tag_count = tag_count.saturating_add(1); 344 } 345 346 fn canonical_json_string_bytes(value: &str) -> usize { 347 value.chars().fold(2usize, |total, character| { 348 total.saturating_add(match character { 349 '"' | '\\' | '\u{0008}' | '\t' | '\n' | '\u{000c}' | '\r' => 2, 350 '\u{0000}'..='\u{001f}' => 6, 351 _ => character.len_utf8(), 352 }) 353 }) 354 } 355 356 #[cfg(test)] 357 #[cfg_attr(coverage_nightly, coverage(off))] 358 mod tests { 359 use super::*; 360 361 fn reference(event: char, author: char) -> Nip10ReplyReference { 362 Nip10ReplyReference::parse( 363 event.to_string().repeat(64), 364 crate::test_valid_hex_64(author), 365 Some("wss://relay.example"), 366 ) 367 .expect("reference") 368 } 369 370 #[test] 371 fn builds_direct_and_nested_replies_with_distinct_coordinates() { 372 let direct = AuthoredNip10Reply::direct("Direct", reference('a', 'b')).expect("direct"); 373 assert!(direct.is_direct()); 374 assert!(direct.parent().is_none()); 375 376 let nested = AuthoredNip10Reply::nested("Nested", reference('a', 'b'), reference('c', 'd')) 377 .expect("nested"); 378 assert!(!nested.is_direct()); 379 assert_eq!( 380 nested.parent().expect("parent").event_id().to_hex(), 381 "c".repeat(64) 382 ); 383 } 384 385 #[test] 386 fn rejects_blank_content_and_ambiguous_nested_parent() { 387 assert_eq!( 388 AuthoredNip10Reply::direct("\t", reference('a', 'b')).unwrap_err(), 389 Nip10ReplyError::ContentMissing 390 ); 391 392 let root = reference('a', 'b'); 393 let parent = reference('a', 'c'); 394 assert_eq!( 395 AuthoredNip10Reply::nested("Nested", root, parent).unwrap_err(), 396 Nip10ReplyError::NestedParentMatchesRoot 397 ); 398 } 399 400 #[test] 401 fn parses_and_canonicalizes_reference_identifiers() { 402 let reference = Nip10ReplyReference::parse( 403 "A".repeat(64), 404 crate::test_valid_hex_64('B'), 405 Some("wss://relay.example"), 406 ) 407 .expect("reference"); 408 assert_eq!(reference.event_id().to_hex(), "a".repeat(64)); 409 assert_eq!(reference.author().to_hex(), crate::test_valid_hex_64('b')); 410 assert_eq!( 411 reference.relay().expect("relay").as_str(), 412 "wss://relay.example" 413 ); 414 415 let error = Nip10ReplyReference::parse("not-an-id", crate::test_valid_hex_64('b'), None) 416 .unwrap_err(); 417 assert_eq!(error.code(), "reply_event_id_invalid"); 418 } 419 420 #[test] 421 fn enforces_content_and_relay_element_boundaries() { 422 let exact_content = "x".repeat(RADROOTS_POST_CONTENT_MAX_BYTES); 423 AuthoredNip10Reply::direct(exact_content, reference('a', 'b')) 424 .expect("exact decoded content limit"); 425 assert!(matches!( 426 AuthoredNip10Reply::direct( 427 "x".repeat(RADROOTS_POST_CONTENT_MAX_BYTES + 1), 428 reference('a', 'b'), 429 ), 430 Err(Nip10ReplyError::ContentTooLarge { 431 max: RADROOTS_POST_CONTENT_MAX_BYTES, 432 actual, 433 }) if actual == RADROOTS_POST_CONTENT_MAX_BYTES + 1 434 )); 435 436 let prefix = "wss://relay.example/"; 437 let exact_relay = format!( 438 "{prefix}{}", 439 "a".repeat(RADROOTS_POST_TAG_ELEMENT_MAX_BYTES - prefix.len()) 440 ); 441 Nip10ReplyReference::parse( 442 "a".repeat(64), 443 crate::test_valid_hex_64('b'), 444 Some(&exact_relay), 445 ) 446 .expect("exact tag-element limit"); 447 let overflow_relay = format!("{exact_relay}a"); 448 assert!(matches!( 449 Nip10ReplyReference::parse( 450 "a".repeat(64), 451 crate::test_valid_hex_64('b'), 452 Some(&overflow_relay), 453 ), 454 Err(Nip10ReplyError::TagElementTooLarge { 455 max: RADROOTS_POST_TAG_ELEMENT_MAX_BYTES, 456 actual, 457 }) if actual == RADROOTS_POST_TAG_ELEMENT_MAX_BYTES + 1 458 )); 459 } 460 461 #[test] 462 fn escaped_content_cannot_cross_compact_signed_wire_limit() { 463 let mut lower = 1usize; 464 let mut upper = RADROOTS_POST_CONTENT_MAX_BYTES; 465 while lower < upper { 466 let candidate = lower + (upper - lower).div_ceil(2); 467 if AuthoredNip10Reply::direct("\u{0001}".repeat(candidate), reference('a', 'b')).is_ok() 468 { 469 lower = candidate; 470 } else { 471 upper = candidate - 1; 472 } 473 } 474 475 AuthoredNip10Reply::direct("\u{0001}".repeat(lower), reference('a', 'b')) 476 .expect("largest escaped content fitting the wire budget"); 477 assert!(matches!( 478 AuthoredNip10Reply::direct("\u{0001}".repeat(lower + 1), reference('a', 'b'),), 479 Err(Nip10ReplyError::EventWireTooLarge { 480 max: RADROOTS_POST_EVENT_WIRE_MAX_BYTES, 481 .. 482 }) 483 )); 484 } 485 }