registry_v7.rs (15985B)
1 //! Frozen post inbound semantics for event-contract registry v7. 2 3 #[cfg(not(feature = "std"))] 4 use alloc::{ 5 collections::{BTreeMap, BTreeSet}, 6 string::{String, ToString}, 7 vec, 8 vec::Vec, 9 }; 10 use core::fmt; 11 #[cfg(feature = "std")] 12 use std::collections::{BTreeMap, BTreeSet}; 13 14 use radroots_event::{ 15 envelope::kind::KIND_POST, 16 post::{ 17 PostImageDimensions, RADROOTS_ASK_MARKER_TAG_VALUE, RADROOTS_POST_ALT_MAX_BYTES, 18 RADROOTS_POST_CONTENT_MAX_BYTES, RADROOTS_POST_IMETA_MAX_COUNT, 19 post_image_media_type_is_valid, post_media_http_url_is_valid, 20 }, 21 }; 22 23 use crate::verification::v1::RadrootsSignatureVerifiedEvent; 24 25 const REQUIRED_IMETA_FIELDS: [&str; 6] = ["url", "x", "m", "dim", "size", "alt"]; 26 27 #[non_exhaustive] 28 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 29 pub enum RadrootsPostDiagnostic { 30 AskMarkerShape, 31 ImetaCountExceeded, 32 ImetaFieldInvalid, 33 ImetaUrlMissing, 34 ImetaMetadataMissing, 35 ImetaSingletonDuplicate, 36 ImetaUrlMissingFromContent, 37 DuplicateImetaUrl, 38 ImetaUrlInvalid, 39 ImetaHashInvalid, 40 ImetaMimeInvalid, 41 ImetaDimensionsInvalid, 42 ImetaSizeInvalid, 43 ImetaAltInvalid, 44 ImetaAltTooLarge, 45 ImetaFallbackUrlInvalid, 46 } 47 48 impl RadrootsPostDiagnostic { 49 pub const fn code(self) -> &'static str { 50 match self { 51 Self::AskMarkerShape => "ask_marker_shape", 52 Self::ImetaCountExceeded => "imeta_count_exceeded", 53 Self::ImetaFieldInvalid => "imeta_field_invalid", 54 Self::ImetaUrlMissing => "imeta_url_missing", 55 Self::ImetaMetadataMissing => "imeta_metadata_missing", 56 Self::ImetaSingletonDuplicate => "imeta_singleton_duplicate", 57 Self::ImetaUrlMissingFromContent => "imeta_url_missing_from_content", 58 Self::DuplicateImetaUrl => "duplicate_imeta_url", 59 Self::ImetaUrlInvalid => "imeta_url_invalid", 60 Self::ImetaHashInvalid => "imeta_hash_invalid", 61 Self::ImetaMimeInvalid => "imeta_mime_invalid", 62 Self::ImetaDimensionsInvalid => "imeta_dimensions_invalid", 63 Self::ImetaSizeInvalid => "imeta_size_invalid", 64 Self::ImetaAltInvalid => "imeta_alt_invalid", 65 Self::ImetaAltTooLarge => "imeta_alt_too_large", 66 Self::ImetaFallbackUrlInvalid => "imeta_fallback_url_invalid", 67 } 68 } 69 } 70 71 impl fmt::Display for RadrootsPostDiagnostic { 72 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 73 formatter.write_str(self.code()) 74 } 75 } 76 77 /// Product projection for a verified kind-1 event. 78 /// 79 /// ThreadExcluded is an exclusion classification only; strict NIP-10 parsing 80 /// remains a separate contract. Update, PhotoUpdate, and Ask are root-card 81 /// profiles. 82 #[non_exhaustive] 83 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 84 pub enum RadrootsPostClassification { 85 ThreadExcluded, 86 Update, 87 PhotoUpdate, 88 Ask, 89 } 90 91 impl RadrootsPostClassification { 92 pub const fn contract_id(self) -> &'static str { 93 match self { 94 Self::ThreadExcluded => "radroots.social.post.v1", 95 Self::Update => "radroots.social.update.v1", 96 Self::PhotoUpdate => "radroots.social.photo_update.v1", 97 Self::Ask => "radroots.social.ask.v1", 98 } 99 } 100 101 pub const fn is_root_card(self) -> bool { 102 !matches!(self, Self::ThreadExcluded) 103 } 104 } 105 106 /// One raw inbound NIP-92 `imeta` projection. 107 /// 108 /// URLs and metadata remain unverified even when the entry qualifies for 109 /// PhotoUpdate classification. Classification is structural and performs no 110 /// network request or blob verification. 111 #[derive(Clone, Debug, PartialEq, Eq)] 112 pub struct RadrootsInboundPostImeta { 113 raw_fields: Vec<String>, 114 url: Option<String>, 115 sha256: Option<String>, 116 media_type: Option<String>, 117 dimensions: Option<PostImageDimensions>, 118 size: Option<u64>, 119 alt: Option<String>, 120 fallbacks: Vec<String>, 121 unknown_fields: Vec<String>, 122 diagnostics: Vec<RadrootsPostDiagnostic>, 123 } 124 125 impl RadrootsInboundPostImeta { 126 pub fn raw_fields(&self) -> &[String] { 127 &self.raw_fields 128 } 129 130 pub fn url(&self) -> Option<&str> { 131 self.url.as_deref() 132 } 133 134 pub fn sha256(&self) -> Option<&str> { 135 self.sha256.as_deref() 136 } 137 138 pub fn media_type(&self) -> Option<&str> { 139 self.media_type.as_deref() 140 } 141 142 pub const fn dimensions(&self) -> Option<PostImageDimensions> { 143 self.dimensions 144 } 145 146 pub const fn size(&self) -> Option<u64> { 147 self.size 148 } 149 150 pub fn alt(&self) -> Option<&str> { 151 self.alt.as_deref() 152 } 153 154 pub fn fallbacks(&self) -> &[String] { 155 &self.fallbacks 156 } 157 158 pub fn unknown_fields(&self) -> &[String] { 159 &self.unknown_fields 160 } 161 162 pub fn diagnostics(&self) -> &[RadrootsPostDiagnostic] { 163 &self.diagnostics 164 } 165 166 pub fn qualifies_photo(&self) -> bool { 167 self.diagnostics.is_empty() 168 } 169 } 170 171 /// Tolerant, ordered product projection of one verified kind-1 event. 172 #[derive(Clone, Debug, PartialEq, Eq)] 173 pub struct RadrootsInboundPostProjection { 174 classification: RadrootsPostClassification, 175 ask_marker: Option<Vec<String>>, 176 imeta: Vec<RadrootsInboundPostImeta>, 177 diagnostics: Vec<RadrootsPostDiagnostic>, 178 } 179 180 impl RadrootsInboundPostProjection { 181 pub const fn classification(&self) -> RadrootsPostClassification { 182 self.classification 183 } 184 185 pub fn ask_marker(&self) -> Option<&[String]> { 186 self.ask_marker.as_deref() 187 } 188 189 pub fn imeta(&self) -> &[RadrootsInboundPostImeta] { 190 &self.imeta 191 } 192 193 pub fn diagnostics(&self) -> &[RadrootsPostDiagnostic] { 194 &self.diagnostics 195 } 196 } 197 198 #[non_exhaustive] 199 #[derive(Clone, Debug, PartialEq, Eq)] 200 pub enum RadrootsPostProjectionError { 201 InvalidKind { expected: u32, actual: u32 }, 202 ContentTooLarge { max: usize, actual: usize }, 203 AskMarkerCount, 204 } 205 206 impl RadrootsPostProjectionError { 207 pub const fn code(&self) -> &'static str { 208 match self { 209 Self::InvalidKind { .. } => "invalid_kind", 210 Self::ContentTooLarge { .. } => "post_content_too_large", 211 Self::AskMarkerCount => "ask_marker_count", 212 } 213 } 214 } 215 216 impl fmt::Display for RadrootsPostProjectionError { 217 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 218 match self { 219 Self::InvalidKind { expected, actual } => { 220 write!( 221 formatter, 222 "post event kind must be {expected}, got {actual}" 223 ) 224 } 225 Self::ContentTooLarge { max, actual } => { 226 write!(formatter, "post content is {actual} bytes; max is {max}") 227 } 228 Self::AskMarkerCount => { 229 formatter.write_str("post event must not contain multiple normalized Ask markers") 230 } 231 } 232 } 233 } 234 235 #[cfg(feature = "std")] 236 impl std::error::Error for RadrootsPostProjectionError {} 237 238 /// Projects a signature-and-id verified kind-1 event without admitting it to a 239 /// relay or claiming media verification. 240 /// 241 /// Any `e` tag excludes the event before Ask or media inspection. This function 242 /// does not claim that the event is a valid NIP-10 reply; that belongs to the 243 /// dedicated reply contract. 244 pub fn project_verified_post_event( 245 verified_event: &RadrootsSignatureVerifiedEvent, 246 ) -> Result<RadrootsInboundPostProjection, RadrootsPostProjectionError> { 247 project_verified_post_event_registry_v7(verified_event) 248 } 249 250 /// Projects a verified kind-1 event with contract-registry-v7 semantics. 251 pub fn project_verified_post_event_registry_v7( 252 verified_event: &RadrootsSignatureVerifiedEvent, 253 ) -> Result<RadrootsInboundPostProjection, RadrootsPostProjectionError> { 254 let event = verified_event.event(); 255 project_inbound_post_parts(event.kind_u32(), &event.tags_as_vec(), event.content()) 256 } 257 258 pub(crate) fn project_inbound_post_parts( 259 kind: u32, 260 tags: &[Vec<String>], 261 content: &str, 262 ) -> Result<RadrootsInboundPostProjection, RadrootsPostProjectionError> { 263 if kind != KIND_POST { 264 return Err(RadrootsPostProjectionError::InvalidKind { 265 expected: KIND_POST, 266 actual: kind, 267 }); 268 } 269 if content.len() > RADROOTS_POST_CONTENT_MAX_BYTES { 270 return Err(RadrootsPostProjectionError::ContentTooLarge { 271 max: RADROOTS_POST_CONTENT_MAX_BYTES, 272 actual: content.len(), 273 }); 274 } 275 if tags 276 .iter() 277 .any(|tag| tag.first().is_some_and(|key| key == "e")) 278 { 279 return Ok(RadrootsInboundPostProjection { 280 classification: RadrootsPostClassification::ThreadExcluded, 281 ask_marker: None, 282 imeta: Vec::new(), 283 diagnostics: Vec::new(), 284 }); 285 } 286 287 let (ask_marker, marker_diagnostics) = project_ask_marker(tags)?; 288 let imeta_tags = tags 289 .iter() 290 .filter(|tag| tag.first().is_some_and(|key| key == "imeta")) 291 .collect::<Vec<_>>(); 292 let mut diagnostics = marker_diagnostics; 293 if imeta_tags.len() > RADROOTS_POST_IMETA_MAX_COUNT { 294 diagnostics.push(RadrootsPostDiagnostic::ImetaCountExceeded); 295 } 296 let imeta = project_imeta(imeta_tags, content); 297 diagnostics.extend( 298 imeta 299 .iter() 300 .flat_map(|item| item.diagnostics.iter().copied()), 301 ); 302 let classification = if ask_marker.is_some() { 303 RadrootsPostClassification::Ask 304 } else if !imeta.is_empty() && diagnostics.is_empty() { 305 RadrootsPostClassification::PhotoUpdate 306 } else { 307 RadrootsPostClassification::Update 308 }; 309 Ok(RadrootsInboundPostProjection { 310 classification, 311 ask_marker, 312 imeta, 313 diagnostics, 314 }) 315 } 316 317 fn project_ask_marker( 318 tags: &[Vec<String>], 319 ) -> Result<(Option<Vec<String>>, Vec<RadrootsPostDiagnostic>), RadrootsPostProjectionError> { 320 let candidates = tags 321 .iter() 322 .filter(|tag| { 323 tag.first().is_some_and(|key| key == "t") 324 && tag.get(1).is_some_and(|value| normalized_ask_marker(value)) 325 }) 326 .collect::<Vec<_>>(); 327 if candidates.len() > 1 { 328 return Err(RadrootsPostProjectionError::AskMarkerCount); 329 } 330 let Some(candidate) = candidates.first() else { 331 return Ok((None, Vec::new())); 332 }; 333 if candidate.len() != 2 { 334 return Ok((None, vec![RadrootsPostDiagnostic::AskMarkerShape])); 335 } 336 Ok((Some((*candidate).clone()), Vec::new())) 337 } 338 339 fn normalized_ask_marker(value: &str) -> bool { 340 value 341 .trim_matches(|character| { 342 matches!( 343 character, 344 ' ' | '\t' | '\n' | '\r' | '\u{000b}' | '\u{000c}' 345 ) 346 }) 347 .eq_ignore_ascii_case(RADROOTS_ASK_MARKER_TAG_VALUE) 348 } 349 350 fn project_imeta(tags: Vec<&Vec<String>>, content: &str) -> Vec<RadrootsInboundPostImeta> { 351 let mut projections = Vec::with_capacity(tags.len()); 352 let mut seen_urls = BTreeSet::new(); 353 for tag in tags { 354 let raw_fields = tag[1..].to_vec(); 355 let mut fields = BTreeMap::new(); 356 let mut fallbacks = Vec::new(); 357 let mut unknown_fields = Vec::new(); 358 let mut diagnostics = Vec::new(); 359 360 for raw_field in &raw_fields { 361 let Some((key, value)) = raw_field.split_once(' ') else { 362 diagnostics.push(RadrootsPostDiagnostic::ImetaFieldInvalid); 363 continue; 364 }; 365 if key.is_empty() || value.is_empty() { 366 diagnostics.push(RadrootsPostDiagnostic::ImetaFieldInvalid); 367 } else if key == "fallback" { 368 fallbacks.push(value.to_string()); 369 } else if imeta_singleton_field(key) { 370 if fields.contains_key(key) { 371 diagnostics.push(RadrootsPostDiagnostic::ImetaSingletonDuplicate); 372 } else { 373 fields.insert(key.to_string(), value.to_string()); 374 } 375 } else { 376 unknown_fields.push(raw_field.clone()); 377 } 378 } 379 380 let url = fields.get("url").cloned(); 381 if url.is_none() { 382 diagnostics.push(RadrootsPostDiagnostic::ImetaUrlMissing); 383 } else if REQUIRED_IMETA_FIELDS 384 .iter() 385 .any(|required| !fields.contains_key(*required)) 386 { 387 diagnostics.push(RadrootsPostDiagnostic::ImetaMetadataMissing); 388 } 389 if let Some(url) = &url { 390 if !content.contains(url) { 391 diagnostics.push(RadrootsPostDiagnostic::ImetaUrlMissingFromContent); 392 } 393 if !seen_urls.insert(url.clone()) { 394 diagnostics.push(RadrootsPostDiagnostic::DuplicateImetaUrl); 395 } 396 if !post_media_http_url_is_valid(url) { 397 diagnostics.push(RadrootsPostDiagnostic::ImetaUrlInvalid); 398 } 399 } 400 401 let sha256 = fields.get("x").cloned(); 402 if sha256.as_deref().is_some_and(|value| !lower_hex_64(value)) { 403 diagnostics.push(RadrootsPostDiagnostic::ImetaHashInvalid); 404 } 405 let media_type = fields.get("m").cloned(); 406 if media_type 407 .as_deref() 408 .is_some_and(|value| !post_image_media_type_is_valid(value)) 409 { 410 diagnostics.push(RadrootsPostDiagnostic::ImetaMimeInvalid); 411 } 412 let dimensions = fields.get("dim").and_then(|value| { 413 parse_dimensions(value).or_else(|| { 414 diagnostics.push(RadrootsPostDiagnostic::ImetaDimensionsInvalid); 415 None 416 }) 417 }); 418 let size = fields.get("size").and_then(|value| { 419 parse_nonzero_u64(value).or_else(|| { 420 diagnostics.push(RadrootsPostDiagnostic::ImetaSizeInvalid); 421 None 422 }) 423 }); 424 let alt = fields.get("alt").cloned(); 425 if let Some(alt) = &alt { 426 if alt.trim().is_empty() { 427 diagnostics.push(RadrootsPostDiagnostic::ImetaAltInvalid); 428 } else if alt.len() > RADROOTS_POST_ALT_MAX_BYTES { 429 diagnostics.push(RadrootsPostDiagnostic::ImetaAltTooLarge); 430 } 431 } 432 for fallback in &fallbacks { 433 if !post_media_http_url_is_valid(fallback) { 434 diagnostics.push(RadrootsPostDiagnostic::ImetaFallbackUrlInvalid); 435 } 436 } 437 438 projections.push(RadrootsInboundPostImeta { 439 raw_fields, 440 url, 441 sha256, 442 media_type, 443 dimensions, 444 size, 445 alt, 446 fallbacks, 447 unknown_fields, 448 diagnostics, 449 }); 450 } 451 projections 452 } 453 454 fn imeta_singleton_field(value: &str) -> bool { 455 matches!( 456 value, 457 "url" 458 | "m" 459 | "x" 460 | "ox" 461 | "size" 462 | "dim" 463 | "magnet" 464 | "i" 465 | "blurhash" 466 | "thumb" 467 | "image" 468 | "summary" 469 | "alt" 470 | "service" 471 ) 472 } 473 474 fn lower_hex_64(value: &str) -> bool { 475 value.len() == 64 476 && value 477 .bytes() 478 .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) 479 } 480 481 fn parse_dimensions(value: &str) -> Option<PostImageDimensions> { 482 let (width, height) = value.split_once('x')?; 483 if !canonical_nonzero_decimal(width) || !canonical_nonzero_decimal(height) { 484 return None; 485 } 486 PostImageDimensions::new(width.parse().ok()?, height.parse().ok()?).ok() 487 } 488 489 fn parse_nonzero_u64(value: &str) -> Option<u64> { 490 canonical_nonzero_decimal(value) 491 .then(|| value.parse().ok()) 492 .flatten() 493 } 494 495 fn canonical_nonzero_decimal(value: &str) -> bool { 496 value 497 .as_bytes() 498 .first() 499 .is_some_and(|byte| matches!(byte, b'1'..=b'9')) 500 && value.bytes().all(|byte| byte.is_ascii_digit()) 501 } 502 503 #[cfg(test)] 504 mod tests;