authored.rs (6224B)
1 #[cfg(not(feature = "std"))] 2 use alloc::vec::Vec; 3 use core::fmt; 4 5 use radroots_event::{ 6 envelope::kind::KIND_PROFILE, 7 profile::{AuthoredProfile, RADROOTS_PROFILE_METADATA_MAX_CONTENT_BYTES}, 8 wire::Nip01EventWireParts, 9 }; 10 use serde::Serialize; 11 12 #[non_exhaustive] 13 #[derive(Clone, Debug, PartialEq, Eq)] 14 pub enum RadrootsAuthoredProfileEncodeError { 15 ContentTooLarge { max: usize, actual: usize }, 16 } 17 18 impl RadrootsAuthoredProfileEncodeError { 19 pub const fn code(&self) -> &'static str { 20 match self { 21 Self::ContentTooLarge { .. } => "content_too_large", 22 } 23 } 24 } 25 26 impl fmt::Display for RadrootsAuthoredProfileEncodeError { 27 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 28 match self { 29 Self::ContentTooLarge { max, actual } => { 30 write!( 31 f, 32 "authored Profile metadata is {actual} bytes; max is {max}" 33 ) 34 } 35 } 36 } 37 } 38 39 #[cfg(feature = "std")] 40 impl std::error::Error for RadrootsAuthoredProfileEncodeError {} 41 42 #[derive(Serialize)] 43 struct AuthoredProfileMetadata<'a> { 44 name: &'a str, 45 #[serde(skip_serializing_if = "Option::is_none")] 46 display_name: Option<&'a str>, 47 #[serde(skip_serializing_if = "Option::is_none")] 48 about: Option<&'a str>, 49 #[serde(skip_serializing_if = "Option::is_none")] 50 picture: Option<&'a str>, 51 #[serde(skip_serializing_if = "Option::is_none")] 52 banner: Option<&'a str>, 53 #[serde(skip_serializing_if = "Option::is_none")] 54 nip05: Option<&'a str>, 55 #[serde(skip_serializing_if = "Option::is_none")] 56 bot: Option<bool>, 57 } 58 59 /// Builds deterministic unsigned kind-0 wire parts from strict authored metadata. 60 /// 61 /// The output is a complete replacement snapshot, not a merge or patch. The 62 /// caller must not use it for an existing Profile edit unless omitted fields 63 /// are intentionally removed. Retaining existing media requires the caller to 64 /// re-establish its byte-verified descriptor state. 65 /// 66 /// The caller must separately prove successful BUD-02 upload completion before 67 /// passing media-bearing output to a signing boundary. 68 pub fn authored_profile_to_wire_parts( 69 profile: &AuthoredProfile, 70 ) -> Result<Nip01EventWireParts, RadrootsAuthoredProfileEncodeError> { 71 let metadata = AuthoredProfileMetadata { 72 name: profile.name(), 73 display_name: profile.display_name(), 74 about: profile.about(), 75 picture: profile 76 .picture() 77 .map(|image| image.descriptor().url().as_str()), 78 banner: profile 79 .banner() 80 .map(|image| image.descriptor().url().as_str()), 81 nip05: profile.nip05().map(|identifier| identifier.as_str()), 82 bot: profile.bot(), 83 }; 84 let expected_len = authored_profile_metadata_len(&metadata); 85 if expected_len > RADROOTS_PROFILE_METADATA_MAX_CONTENT_BYTES { 86 return Err(RadrootsAuthoredProfileEncodeError::ContentTooLarge { 87 max: RADROOTS_PROFILE_METADATA_MAX_CONTENT_BYTES, 88 actual: expected_len, 89 }); 90 } 91 let content = serde_json::to_string(&metadata) 92 .expect("authored Profile metadata contains only infallible JSON scalar types"); 93 Ok(Nip01EventWireParts { 94 kind: KIND_PROFILE, 95 content, 96 tags: Vec::new(), 97 }) 98 } 99 100 fn authored_profile_metadata_len(metadata: &AuthoredProfileMetadata<'_>) -> usize { 101 let mut len = 2usize; 102 let mut fields = 0usize; 103 add_string_field_len(&mut len, &mut fields, "name", metadata.name); 104 if let Some(value) = metadata.display_name { 105 add_string_field_len(&mut len, &mut fields, "display_name", value); 106 } 107 if let Some(value) = metadata.about { 108 add_string_field_len(&mut len, &mut fields, "about", value); 109 } 110 if let Some(value) = metadata.picture { 111 add_string_field_len(&mut len, &mut fields, "picture", value); 112 } 113 if let Some(value) = metadata.banner { 114 add_string_field_len(&mut len, &mut fields, "banner", value); 115 } 116 if let Some(value) = metadata.nip05 { 117 add_string_field_len(&mut len, &mut fields, "nip05", value); 118 } 119 if let Some(value) = metadata.bot { 120 add_field_prefix_len(&mut len, &mut fields, "bot"); 121 len = len.saturating_add(if value { 4 } else { 5 }); 122 } 123 len 124 } 125 126 fn add_string_field_len(len: &mut usize, fields: &mut usize, key: &str, value: &str) { 127 add_field_prefix_len(len, fields, key); 128 *len = len.saturating_add(json_string_encoded_len(value)); 129 } 130 131 fn add_field_prefix_len(len: &mut usize, fields: &mut usize, key: &str) { 132 if *fields > 0 { 133 *len = len.saturating_add(1); 134 } 135 *fields = fields.saturating_add(1); 136 *len = len.saturating_add(key.len().saturating_add(3)); 137 } 138 139 fn json_string_encoded_len(value: &str) -> usize { 140 value.bytes().fold(2usize, |len, byte| { 141 let encoded = match byte { 142 b'"' | b'\\' | b'\x08' | b'\t' | b'\n' | b'\x0c' | b'\r' => 2, 143 0x00..=0x1f => 6, 144 _ => 1, 145 }; 146 len.saturating_add(encoded) 147 }) 148 } 149 150 #[cfg(test)] 151 mod tests { 152 use super::*; 153 use radroots_event::profile::Nip05Identifier; 154 155 #[test] 156 fn preflight_length_matches_compact_json_escaping() { 157 let escaped = "\0\u{8}\t\n\u{c}\r\"\\e\u{301}"; 158 assert_eq!( 159 json_string_encoded_len(escaped), 160 serde_json::to_string(escaped).unwrap().len() 161 ); 162 163 let profile = AuthoredProfile::new("farm \\\"one\\\"") 164 .unwrap() 165 .with_display_name(escaped) 166 .with_about("Victoria \u{e9}") 167 .with_nip05(Nip05Identifier::parse("farm@example.com").unwrap()) 168 .with_bot(true); 169 let wire = authored_profile_to_wire_parts(&profile).unwrap(); 170 let metadata = AuthoredProfileMetadata { 171 name: profile.name(), 172 display_name: profile.display_name(), 173 about: profile.about(), 174 picture: None, 175 banner: None, 176 nip05: profile.nip05().map(|identifier| identifier.as_str()), 177 bot: profile.bot(), 178 }; 179 assert_eq!(authored_profile_metadata_len(&metadata), wire.content.len()); 180 } 181 }