lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

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 }