registry_v7.rs (26298B)
1 //! Frozen food-availability semantics for event-contract registry v7. 2 3 #[cfg(not(feature = "std"))] 4 use alloc::{boxed::Box, string::String, string::ToString, vec::Vec}; 5 6 use core::fmt; 7 #[cfg(feature = "json")] 8 use radroots_blossom::BlobUrl; 9 use radroots_blossom::Sha256; 10 #[cfg(feature = "json")] 11 use radroots_event::wire::DEFAULT_RAW_JSON_MAX_BYTES; 12 use radroots_event::{ 13 envelope::EventTags, 14 envelope::kind::KIND_CLASSIFIED_LISTING, 15 food::availability::{ 16 FoodAvailabilityError, FoodAvailabilityStatus, FoodContent, FoodCurrency, FoodIdentifier, 17 FoodImageDimensions, FoodPrice, FoodPublishedAt, FoodQuantity, FoodText, FoodUnit, 18 RADROOTS_FOOD_DECIMAL_MAX_DIGITS, RADROOTS_FOOD_IMAGE_MAX_COUNT, food_media_blossom_digest, 19 food_media_http_url_is_valid, 20 }, 21 listing::classified::ClassifiedListingPartition, 22 }; 23 24 use crate::verification::v1::RadrootsSignatureVerifiedEvent; 25 26 #[cfg(feature = "json")] 27 const FOOD_SIGNED_EVENT_FIXED_BYTES: usize = "{\"id\":\"".len() 28 + 64 29 + "\",\"pubkey\":\"".len() 30 + 64 31 + "\",\"created_at\":".len() 32 + ",\"kind\":30402,\"tags\":".len() 33 + ",\"content\":".len() 34 + ",\"sig\":\"".len() 35 + 128 36 + "\"}".len(); 37 38 const PROHIBITED_FOOD_CAPABILITY_TAGS: &[&str] = &[ 39 "buyer", 40 "checkout", 41 "delivery", 42 "exception", 43 "group", 44 "invite", 45 "order", 46 "payment", 47 "pickup", 48 "proof", 49 "provenance", 50 "receipt", 51 "route", 52 "route_stop", 53 "task", 54 ]; 55 56 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 57 pub enum RadrootsFoodAvailabilityImageDiagnostic { 58 ShapeInvalid, 59 UrlInvalid, 60 DimensionsMissing, 61 DimensionsInvalid, 62 DuplicateUrl, 63 DuplicateDigest, 64 CountExceeded, 65 } 66 67 impl RadrootsFoodAvailabilityImageDiagnostic { 68 pub const fn code(self) -> &'static str { 69 match self { 70 Self::ShapeInvalid => "food_image_shape_invalid", 71 Self::UrlInvalid => "food_image_url_invalid", 72 Self::DimensionsMissing => "food_image_dimensions_missing", 73 Self::DimensionsInvalid => "food_image_dimensions_invalid", 74 Self::DuplicateUrl => "food_image_duplicate_url", 75 Self::DuplicateDigest => "food_image_duplicate_digest", 76 Self::CountExceeded => "food_image_count_exceeded", 77 } 78 } 79 } 80 81 impl fmt::Display for RadrootsFoodAvailabilityImageDiagnostic { 82 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 83 formatter.write_str(self.code()) 84 } 85 } 86 87 #[derive(Clone, Debug, PartialEq, Eq)] 88 pub struct RadrootsInboundFoodAvailabilityImage { 89 raw_tag: Vec<String>, 90 url: Option<String>, 91 dimensions: Option<FoodImageDimensions>, 92 diagnostics: Vec<RadrootsFoodAvailabilityImageDiagnostic>, 93 } 94 95 impl RadrootsInboundFoodAvailabilityImage { 96 pub fn raw_tag(&self) -> &[String] { 97 &self.raw_tag 98 } 99 100 pub fn url(&self) -> Option<&str> { 101 self.url.as_deref() 102 } 103 104 pub const fn dimensions(&self) -> Option<FoodImageDimensions> { 105 self.dimensions 106 } 107 108 pub fn diagnostics(&self) -> &[RadrootsFoodAvailabilityImageDiagnostic] { 109 &self.diagnostics 110 } 111 112 pub fn qualifies(&self) -> bool { 113 self.diagnostics.is_empty() 114 } 115 } 116 117 #[derive(Clone, Debug, PartialEq, Eq)] 118 pub struct RadrootsInboundFoodAvailabilityProjection { 119 content: FoodContent, 120 identifier: FoodIdentifier, 121 title: FoodText, 122 summary: FoodText, 123 published_at: FoodPublishedAt, 124 location: FoodText, 125 price: FoodPrice, 126 quantity: Option<FoodQuantity>, 127 status: FoodAvailabilityStatus, 128 images: Vec<RadrootsInboundFoodAvailabilityImage>, 129 diagnostics: Vec<RadrootsFoodAvailabilityImageDiagnostic>, 130 } 131 132 impl RadrootsInboundFoodAvailabilityProjection { 133 pub fn content(&self) -> &FoodContent { 134 &self.content 135 } 136 137 pub fn identifier(&self) -> &FoodIdentifier { 138 &self.identifier 139 } 140 141 pub fn title(&self) -> &FoodText { 142 &self.title 143 } 144 145 pub fn summary(&self) -> &FoodText { 146 &self.summary 147 } 148 149 pub const fn published_at(&self) -> FoodPublishedAt { 150 self.published_at 151 } 152 153 pub fn location(&self) -> &FoodText { 154 &self.location 155 } 156 157 pub fn price(&self) -> &FoodPrice { 158 &self.price 159 } 160 161 pub fn quantity(&self) -> Option<&FoodQuantity> { 162 self.quantity.as_ref() 163 } 164 165 pub const fn status(&self) -> FoodAvailabilityStatus { 166 self.status 167 } 168 169 pub fn images(&self) -> &[RadrootsInboundFoodAvailabilityImage] { 170 &self.images 171 } 172 173 pub fn diagnostics(&self) -> &[RadrootsFoodAvailabilityImageDiagnostic] { 174 &self.diagnostics 175 } 176 } 177 178 #[non_exhaustive] 179 #[derive(Clone, Debug, PartialEq, Eq)] 180 pub enum RadrootsFoodAvailabilityProjectionOutcome { 181 Focused(Box<RadrootsInboundFoodAvailabilityProjection>), 182 Excluded(ClassifiedListingPartition), 183 } 184 185 #[non_exhaustive] 186 #[derive(Clone, Debug, PartialEq, Eq)] 187 pub enum RadrootsFoodAvailabilityProjectionError { 188 InvalidKind { expected: u32, actual: u32 }, 189 ProfileAmbiguous, 190 ProhibitedCapability { tag: String }, 191 TagInvalid, 192 PriceFrequencyForbidden, 193 PriceUnitMissing, 194 EventWireTooLarge { max: usize, actual: usize }, 195 Domain(FoodAvailabilityError), 196 } 197 198 impl RadrootsFoodAvailabilityProjectionError { 199 pub const fn code(&self) -> &'static str { 200 match self { 201 Self::InvalidKind { .. } => "invalid_kind", 202 Self::ProfileAmbiguous => "food_profile_ambiguous", 203 Self::ProhibitedCapability { .. } => "prohibited_capability", 204 Self::TagInvalid => "food_tag_invalid", 205 Self::PriceFrequencyForbidden => "price_frequency_forbidden", 206 Self::PriceUnitMissing => "price_unit_missing", 207 Self::EventWireTooLarge { .. } => "food_event_wire_too_large", 208 Self::Domain(error) => error.code(), 209 } 210 } 211 } 212 213 impl fmt::Display for RadrootsFoodAvailabilityProjectionError { 214 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 215 match self { 216 Self::InvalidKind { expected, actual } => write!( 217 formatter, 218 "FoodAvailability event kind must be {expected}, got {actual}" 219 ), 220 Self::ProfileAmbiguous => { 221 formatter.write_str("classified listing mixes focused and operational markers") 222 } 223 Self::ProhibitedCapability { tag } => { 224 write!( 225 formatter, 226 "FoodAvailability tag `{tag}` is a prohibited capability" 227 ) 228 } 229 Self::TagInvalid => formatter.write_str("FoodAvailability core tag shape is invalid"), 230 Self::PriceFrequencyForbidden => { 231 formatter.write_str("FoodAvailability price frequency is forbidden") 232 } 233 Self::PriceUnitMissing => formatter.write_str("FoodAvailability price unit is missing"), 234 Self::EventWireTooLarge { max, actual } => write!( 235 formatter, 236 "FoodAvailability canonical signed event is {actual} bytes; max is {max}" 237 ), 238 Self::Domain(error) => write!(formatter, "{error}"), 239 } 240 } 241 } 242 243 #[cfg(feature = "std")] 244 impl std::error::Error for RadrootsFoodAvailabilityProjectionError { 245 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { 246 match self { 247 Self::Domain(error) => Some(error), 248 _ => None, 249 } 250 } 251 } 252 253 impl From<FoodAvailabilityError> for RadrootsFoodAvailabilityProjectionError { 254 fn from(value: FoodAvailabilityError) -> Self { 255 Self::Domain(value) 256 } 257 } 258 259 /// Projects a signature-and-id verified kind-30402 event at the focused Food boundary. 260 /// 261 /// Raw marker partitioning always precedes tag-shape and prohibited-capability 262 /// validation. Operational and generic NIP-99 inputs are valid exclusions. 263 pub fn project_verified_food_availability_event( 264 verified_event: &RadrootsSignatureVerifiedEvent, 265 ) -> Result<RadrootsFoodAvailabilityProjectionOutcome, RadrootsFoodAvailabilityProjectionError> { 266 project_verified_food_availability_event_registry_v7(verified_event) 267 } 268 269 /// Projects a verified listing with contract-registry-v7 semantics. 270 pub fn project_verified_food_availability_event_registry_v7( 271 verified_event: &RadrootsSignatureVerifiedEvent, 272 ) -> Result<RadrootsFoodAvailabilityProjectionOutcome, RadrootsFoodAvailabilityProjectionError> { 273 let event = verified_event.event(); 274 project_inbound_food_availability_parts( 275 event.kind_u32(), 276 event.created_at_u64(), 277 event.tags(), 278 event.content(), 279 ) 280 } 281 282 pub(crate) fn project_inbound_food_availability_parts( 283 kind: u32, 284 created_at: u64, 285 tags: &EventTags, 286 content: &str, 287 ) -> Result<RadrootsFoodAvailabilityProjectionOutcome, RadrootsFoodAvailabilityProjectionError> { 288 if kind != KIND_CLASSIFIED_LISTING { 289 return Err(RadrootsFoodAvailabilityProjectionError::InvalidKind { 290 expected: KIND_CLASSIFIED_LISTING, 291 actual: kind, 292 }); 293 } 294 295 match classify_classified_listing_tags_registry_v7(tags) { 296 ClassifiedListingPartition::Ambiguous => { 297 return Err(RadrootsFoodAvailabilityProjectionError::ProfileAmbiguous); 298 } 299 partition @ (ClassifiedListingPartition::OperationalListing 300 | ClassifiedListingPartition::GenericNip99) => { 301 return Ok(RadrootsFoodAvailabilityProjectionOutcome::Excluded( 302 partition, 303 )); 304 } 305 ClassifiedListingPartition::FocusedFoodAvailability => {} 306 } 307 308 let tags = tags.to_vec(); 309 if let Some(tag) = tags 310 .iter() 311 .filter_map(|tag| tag.first()) 312 .find(|name| PROHIBITED_FOOD_CAPABILITY_TAGS.contains(&name.as_str())) 313 { 314 return Err( 315 RadrootsFoodAvailabilityProjectionError::ProhibitedCapability { tag: tag.clone() }, 316 ); 317 } 318 319 let identifier_value = singleton_value(&tags, "d")?; 320 let title_value = singleton_value(&tags, "title")?; 321 let summary_value = singleton_value(&tags, "summary")?; 322 let published_at_value = singleton_value(&tags, "published_at")?; 323 let location_value = singleton_value(&tags, "location")?; 324 let status_value = singleton_value(&tags, "status")?; 325 326 let content = FoodContent::new(content.to_string())?; 327 let identifier = FoodIdentifier::parse(identifier_value)?; 328 let title = FoodText::new(title_value.to_string())?; 329 let summary = FoodText::new(summary_value.to_string())?; 330 let published_at = FoodPublishedAt::parse(published_at_value)?; 331 published_at.validate_created_at(created_at)?; 332 let location = FoodText::new(location_value.to_string())?; 333 334 let price_tags = matching_tags(&tags, "price"); 335 let price_tag = match price_tags.as_slice() { 336 [tag] => *tag, 337 _ => return Err(FoodAvailabilityError::PriceInvalid.into()), 338 }; 339 if price_tag.len() > 3 { 340 return Err(RadrootsFoodAvailabilityProjectionError::PriceFrequencyForbidden); 341 } 342 if price_tag.len() != 3 { 343 return Err(FoodAvailabilityError::PriceInvalid.into()); 344 } 345 let amount = normalize_decimal(&price_tag[1], FoodAvailabilityError::PriceInvalid)?; 346 let currency = normalize_currency(&price_tag[2])?; 347 348 let price_unit_tags = matching_tags(&tags, "radroots:price_unit"); 349 if price_unit_tags.is_empty() { 350 return Err(RadrootsFoodAvailabilityProjectionError::PriceUnitMissing); 351 } 352 let unit_value = match price_unit_tags.as_slice() { 353 [tag] if tag.len() == 2 => tag[1].as_str(), 354 _ => return Err(FoodAvailabilityError::PriceUnitInvalid.into()), 355 }; 356 let unit = FoodUnit::parse(unit_value)?; 357 let price = FoodPrice::new(amount, currency, unit)?; 358 359 let quantity_tags = matching_tags(&tags, "radroots:quantity"); 360 let quantity = if quantity_tags.is_empty() { 361 None 362 } else { 363 let tag = match quantity_tags.as_slice() { 364 [tag] if tag.len() == 3 => *tag, 365 _ => { 366 return Err(FoodAvailabilityError::QuantityInvalid.into()); 367 } 368 }; 369 let amount = normalize_decimal(&tag[1], FoodAvailabilityError::QuantityInvalid)?; 370 let quantity_unit = 371 FoodUnit::parse(&tag[2]).map_err(|_| FoodAvailabilityError::QuantityInvalid)?; 372 if quantity_unit != unit { 373 return Err(FoodAvailabilityError::QuantityInvalid.into()); 374 } 375 Some(FoodQuantity::new(amount, quantity_unit)?) 376 }; 377 378 let status = FoodAvailabilityStatus::parse(status_value)?; 379 let image_tags = matching_tags(&tags, "image"); 380 let (images, diagnostics) = project_images(&image_tags); 381 382 Ok(RadrootsFoodAvailabilityProjectionOutcome::Focused( 383 Box::new(RadrootsInboundFoodAvailabilityProjection { 384 content, 385 identifier, 386 title, 387 summary, 388 published_at, 389 location, 390 price, 391 quantity, 392 status, 393 images, 394 diagnostics, 395 }), 396 )) 397 } 398 399 fn classify_classified_listing_tags_registry_v7(tags: &EventTags) -> ClassifiedListingPartition { 400 let mut has_focused_marker = false; 401 let mut has_operational_marker = false; 402 403 for name in tags 404 .as_slice() 405 .iter() 406 .filter_map(|tag| tag.as_slice().first().map(String::as_str)) 407 { 408 match name { 409 "radroots:price_unit" | "radroots:quantity" => has_focused_marker = true, 410 "radroots:primary_bin" | "radroots:bin" | "radroots:price" => { 411 has_operational_marker = true; 412 } 413 _ => {} 414 } 415 416 if has_focused_marker && has_operational_marker { 417 return ClassifiedListingPartition::Ambiguous; 418 } 419 } 420 421 match (has_focused_marker, has_operational_marker) { 422 (true, false) => ClassifiedListingPartition::FocusedFoodAvailability, 423 (false, true) => ClassifiedListingPartition::OperationalListing, 424 (false, false) => ClassifiedListingPartition::GenericNip99, 425 (true, true) => ClassifiedListingPartition::Ambiguous, 426 } 427 } 428 429 fn matching_tags<'a>(tags: &'a [Vec<String>], name: &str) -> Vec<&'a Vec<String>> { 430 tags.iter() 431 .filter(|tag| tag.first().is_some_and(|key| key == name)) 432 .collect() 433 } 434 435 fn singleton_value<'a>( 436 tags: &'a [Vec<String>], 437 name: &str, 438 ) -> Result<&'a str, RadrootsFoodAvailabilityProjectionError> { 439 exact_single_value(matching_tags(tags, name)) 440 } 441 442 fn exact_single_value( 443 matches: Vec<&Vec<String>>, 444 ) -> Result<&str, RadrootsFoodAvailabilityProjectionError> { 445 let tag = exact_single_tag_from_matches(matches)?; 446 if tag.len() != 2 { 447 return Err(RadrootsFoodAvailabilityProjectionError::TagInvalid); 448 } 449 Ok(tag[1].as_str()) 450 } 451 452 fn exact_single_tag_from_matches( 453 matches: Vec<&Vec<String>>, 454 ) -> Result<&Vec<String>, RadrootsFoodAvailabilityProjectionError> { 455 match matches.as_slice() { 456 [tag] => Ok(*tag), 457 _ => Err(RadrootsFoodAvailabilityProjectionError::TagInvalid), 458 } 459 } 460 461 fn normalize_decimal( 462 value: &str, 463 error: FoodAvailabilityError, 464 ) -> Result<String, RadrootsFoodAvailabilityProjectionError> { 465 let mut digits = 0usize; 466 let mut dot = None; 467 for (index, byte) in value.bytes().enumerate() { 468 match byte { 469 b'0'..=b'9' => digits += 1, 470 b'.' if dot.is_none() => dot = Some(index), 471 _ => return Err(error.into()), 472 } 473 } 474 if digits == 0 475 || digits > RADROOTS_FOOD_DECIMAL_MAX_DIGITS 476 || dot.is_some_and(|index| index == 0 || index + 1 == value.len()) 477 { 478 return Err(error.into()); 479 } 480 481 let (integer, fraction) = dot.map_or((value, None), |index| { 482 (&value[..index], Some(&value[index + 1..])) 483 }); 484 let integer = integer.trim_start_matches('0'); 485 let integer = if integer.is_empty() { "0" } else { integer }; 486 let fraction = fraction 487 .map(|value| value.trim_end_matches('0')) 488 .filter(|value| !value.is_empty()); 489 let mut normalized = String::with_capacity(value.len()); 490 normalized.push_str(integer); 491 if let Some(fraction) = fraction { 492 normalized.push('.'); 493 normalized.push_str(fraction); 494 } 495 Ok(normalized) 496 } 497 498 #[derive(Clone, Debug, PartialEq, Eq)] 499 #[cfg(feature = "json")] 500 pub(crate) struct RadrootsStrictFoodAvailabilityProjection { 501 identifier: FoodIdentifier, 502 published_at: FoodPublishedAt, 503 } 504 505 #[cfg(feature = "json")] 506 impl RadrootsStrictFoodAvailabilityProjection { 507 pub(crate) fn identifier(&self) -> &FoodIdentifier { 508 &self.identifier 509 } 510 511 pub(crate) const fn published_at(&self) -> FoodPublishedAt { 512 self.published_at 513 } 514 } 515 516 #[cfg(feature = "json")] 517 fn canonical_food_signed_event_size(tags: &[Vec<String>], content: &str, created_at: u64) -> usize { 518 let mut tags_bytes = 2usize; 519 for (tag_index, tag) in tags.iter().enumerate() { 520 if tag_index > 0 { 521 tags_bytes = tags_bytes.saturating_add(1); 522 } 523 tags_bytes = tags_bytes.saturating_add(2); 524 for (element_index, element) in tag.iter().enumerate() { 525 if element_index > 0 { 526 tags_bytes = tags_bytes.saturating_add(1); 527 } 528 tags_bytes = tags_bytes.saturating_add(canonical_json_string_bytes(element)); 529 } 530 } 531 FOOD_SIGNED_EVENT_FIXED_BYTES 532 .saturating_add(decimal_u64_bytes(created_at)) 533 .saturating_add(tags_bytes) 534 .saturating_add(canonical_json_string_bytes(content)) 535 } 536 537 #[cfg(feature = "json")] 538 fn decimal_u64_bytes(mut value: u64) -> usize { 539 let mut bytes = 1usize; 540 while value >= 10 { 541 value /= 10; 542 bytes += 1; 543 } 544 bytes 545 } 546 547 #[cfg(feature = "json")] 548 fn canonical_json_string_bytes(value: &str) -> usize { 549 value.chars().fold(2usize, |total, character| { 550 total.saturating_add(match character { 551 '"' | '\\' | '\u{0008}' | '\t' | '\n' | '\u{000c}' | '\r' => 2, 552 '\u{0000}'..='\u{001f}' => 6, 553 _ => character.len_utf8(), 554 }) 555 }) 556 } 557 558 /// Validates a verified event against the exact authored Food wire profile. 559 /// 560 /// This does not construct signable parts or elevate inbound media to verified 561 /// media typestate. It exists solely for comparing already signed revisions. 562 #[cfg(feature = "json")] 563 pub(crate) fn project_strict_verified_food_availability_event( 564 verified_event: &RadrootsSignatureVerifiedEvent, 565 ) -> Result<RadrootsStrictFoodAvailabilityProjection, RadrootsFoodAvailabilityProjectionError> { 566 let event = verified_event.event(); 567 if event.kind_u32() != KIND_CLASSIFIED_LISTING { 568 return Err(RadrootsFoodAvailabilityProjectionError::InvalidKind { 569 expected: KIND_CLASSIFIED_LISTING, 570 actual: event.kind_u32(), 571 }); 572 } 573 if classify_classified_listing_tags_registry_v7(event.tags()) 574 != ClassifiedListingPartition::FocusedFoodAvailability 575 { 576 return Err(RadrootsFoodAvailabilityProjectionError::ProfileAmbiguous); 577 } 578 579 let tags = event.tags_as_vec(); 580 let actual_wire_bytes = 581 canonical_food_signed_event_size(&tags, event.content(), event.created_at_u64()); 582 if actual_wire_bytes > DEFAULT_RAW_JSON_MAX_BYTES { 583 return Err(RadrootsFoodAvailabilityProjectionError::EventWireTooLarge { 584 max: DEFAULT_RAW_JSON_MAX_BYTES, 585 actual: actual_wire_bytes, 586 }); 587 } 588 let observed_names = tags 589 .iter() 590 .map(|tag| tag.first().map(String::as_str)) 591 .collect::<Vec<_>>(); 592 let has_quantity = observed_names.contains(&Some("radroots:quantity")); 593 let image_count = observed_names 594 .iter() 595 .filter(|name| **name == Some("image")) 596 .count(); 597 let mut expected_names = Vec::with_capacity(8 + usize::from(has_quantity) + image_count); 598 expected_names.extend([ 599 "d", 600 "title", 601 "summary", 602 "published_at", 603 "location", 604 "price", 605 "radroots:price_unit", 606 ]); 607 if has_quantity { 608 expected_names.push("radroots:quantity"); 609 } 610 expected_names.push("status"); 611 expected_names.extend(core::iter::repeat_n("image", image_count)); 612 if observed_names 613 .iter() 614 .copied() 615 .ne(expected_names.iter().copied().map(Some)) 616 { 617 return Err(RadrootsFoodAvailabilityProjectionError::TagInvalid); 618 } 619 620 let price_tags = matching_tags(&tags, "price"); 621 let price = match price_tags.as_slice() { 622 [tag] if tag.len() == 3 => *tag, 623 [tag] if tag.len() > 3 => { 624 return Err(RadrootsFoodAvailabilityProjectionError::PriceFrequencyForbidden); 625 } 626 _ => return Err(FoodAvailabilityError::PriceInvalid.into()), 627 }; 628 let normalized_price = normalize_decimal(&price[1], FoodAvailabilityError::PriceInvalid)?; 629 if normalized_price != price[1] { 630 return Err(FoodAvailabilityError::PriceInvalid.into()); 631 } 632 if normalize_currency(&price[2])?.as_str() != price[2] { 633 return Err(FoodAvailabilityError::PriceCurrencyInvalid.into()); 634 } 635 636 if let Some(quantity) = matching_tags(&tags, "radroots:quantity").first() { 637 if quantity.len() != 3 { 638 return Err(FoodAvailabilityError::QuantityInvalid.into()); 639 } 640 let normalized = normalize_decimal(&quantity[1], FoodAvailabilityError::QuantityInvalid)?; 641 if normalized != quantity[1] { 642 return Err(FoodAvailabilityError::QuantityInvalid.into()); 643 } 644 } 645 646 if image_count > RADROOTS_FOOD_IMAGE_MAX_COUNT { 647 return Err(FoodAvailabilityError::ImageCountExceeded { 648 max: RADROOTS_FOOD_IMAGE_MAX_COUNT, 649 actual: image_count, 650 } 651 .into()); 652 } 653 let mut seen_urls = Vec::<String>::new(); 654 let mut seen_digests = Vec::<Sha256>::new(); 655 for tag in matching_tags(&tags, "image") { 656 if tag.len() != 3 { 657 return Err(RadrootsFoodAvailabilityProjectionError::TagInvalid); 658 } 659 let url = BlobUrl::parse(&tag[1]) 660 .and_then(BlobUrl::approve) 661 .map_err(|_| RadrootsFoodAvailabilityProjectionError::TagInvalid)?; 662 FoodImageDimensions::parse(&tag[2])?; 663 if seen_urls.iter().any(|seen| seen == &tag[1]) { 664 return Err(FoodAvailabilityError::ImageDuplicateUrl.into()); 665 } 666 let digest = url.as_blob_url().hash_path().hash(); 667 if seen_digests.contains(&digest) { 668 return Err(FoodAvailabilityError::ImageDuplicateDigest.into()); 669 } 670 seen_urls.push(tag[1].clone()); 671 seen_digests.push(digest); 672 } 673 674 match project_inbound_food_availability_parts( 675 event.kind_u32(), 676 event.created_at_u64(), 677 event.tags(), 678 event.content(), 679 )? { 680 RadrootsFoodAvailabilityProjectionOutcome::Focused(projection) 681 if projection.diagnostics().is_empty() => 682 { 683 Ok(RadrootsStrictFoodAvailabilityProjection { 684 identifier: projection.identifier().clone(), 685 published_at: projection.published_at(), 686 }) 687 } 688 _ => Err(RadrootsFoodAvailabilityProjectionError::TagInvalid), 689 } 690 } 691 692 fn normalize_currency( 693 value: &str, 694 ) -> Result<FoodCurrency, RadrootsFoodAvailabilityProjectionError> { 695 if value.len() != 3 || !value.bytes().all(|byte| byte.is_ascii_alphabetic()) { 696 return Err(FoodAvailabilityError::PriceCurrencyInvalid.into()); 697 } 698 Ok(FoodCurrency::parse(value.to_ascii_uppercase())?) 699 } 700 701 fn project_images( 702 image_tags: &[&Vec<String>], 703 ) -> ( 704 Vec<RadrootsInboundFoodAvailabilityImage>, 705 Vec<RadrootsFoodAvailabilityImageDiagnostic>, 706 ) { 707 let mut diagnostics = Vec::new(); 708 if image_tags.len() > RADROOTS_FOOD_IMAGE_MAX_COUNT { 709 diagnostics.push(RadrootsFoodAvailabilityImageDiagnostic::CountExceeded); 710 } 711 let bounded = &image_tags[..image_tags.len().min(RADROOTS_FOOD_IMAGE_MAX_COUNT)]; 712 let mut images = Vec::with_capacity(bounded.len()); 713 let mut seen_urls = Vec::<String>::new(); 714 let mut seen_digests = Vec::<Sha256>::new(); 715 716 for tag in bounded { 717 let raw_url = tag.get(1).cloned(); 718 let raw_dimensions = tag.get(2).cloned(); 719 let mut image_diagnostics = Vec::new(); 720 if tag.len() != 3 { 721 image_diagnostics.push(RadrootsFoodAvailabilityImageDiagnostic::ShapeInvalid); 722 } 723 724 let url = match raw_url.as_deref() { 725 Some(value) if food_media_http_url_is_valid(value) => Some(value.to_string()), 726 Some(_) | None => { 727 image_diagnostics.push(RadrootsFoodAvailabilityImageDiagnostic::UrlInvalid); 728 None 729 } 730 }; 731 let dimensions = match raw_dimensions.as_deref() { 732 None => { 733 image_diagnostics.push(RadrootsFoodAvailabilityImageDiagnostic::DimensionsMissing); 734 None 735 } 736 Some(value) => match FoodImageDimensions::parse(value) { 737 Ok(dimensions) => Some(dimensions), 738 Err(_) => { 739 image_diagnostics 740 .push(RadrootsFoodAvailabilityImageDiagnostic::DimensionsInvalid); 741 None 742 } 743 }, 744 }; 745 746 if let Some(raw_url) = raw_url { 747 if seen_urls.iter().any(|seen| seen == &raw_url) { 748 image_diagnostics.push(RadrootsFoodAvailabilityImageDiagnostic::DuplicateUrl); 749 } 750 seen_urls.push(raw_url.clone()); 751 if let Some(digest) = food_media_blossom_digest(&raw_url) { 752 if seen_digests.contains(&digest) { 753 image_diagnostics 754 .push(RadrootsFoodAvailabilityImageDiagnostic::DuplicateDigest); 755 } 756 seen_digests.push(digest); 757 } 758 } 759 760 diagnostics.extend(image_diagnostics.iter().copied()); 761 images.push(RadrootsInboundFoodAvailabilityImage { 762 raw_tag: (*tag).clone(), 763 url, 764 dimensions, 765 diagnostics: image_diagnostics, 766 }); 767 } 768 769 (images, diagnostics) 770 } 771 772 #[cfg(test)] 773 mod tests;