food_availability.rs (45084B)
1 #[cfg(not(feature = "std"))] 2 use alloc::{string::String, vec::Vec}; 3 #[cfg(feature = "std")] 4 use std::{string::String, vec::Vec}; 5 6 use core::{fmt, str::FromStr}; 7 use radroots_blossom::hash::{HashPath, Sha256}; 8 use unicode_general_category::{GeneralCategory, get_general_category}; 9 use url_nostd::{Host, Url}; 10 11 use crate::media::AuthoredImage; 12 13 pub const RADROOTS_FOOD_CONTENT_MAX_BYTES: usize = 128 * 1024; 14 pub const RADROOTS_FOOD_IDENTIFIER_MAX_BYTES: usize = 512; 15 pub const RADROOTS_FOOD_TEXT_MAX_BYTES: usize = 4 * 1024; 16 pub const RADROOTS_FOOD_DECIMAL_MAX_DIGITS: usize = 28; 17 pub const RADROOTS_FOOD_IMAGE_MAX_COUNT: usize = 64; 18 pub const RADROOTS_FOOD_AVAILABILITY_CONTRACT_ID: &str = "radroots.food.availability.v1"; 19 20 /// Errors raised while constructing strict FoodAvailability details. 21 #[non_exhaustive] 22 #[derive(Clone, Debug, PartialEq, Eq)] 23 pub enum FoodAvailabilityError { 24 ContentMissing, 25 ContentTooLarge { max: usize, actual: usize }, 26 IdentifierInvalid, 27 IdentifierTooLarge { max: usize, actual: usize }, 28 TextInvalid, 29 TextTooLarge { max: usize, actual: usize }, 30 PublishedAtInvalid, 31 PublishedAtFuture { published_at: u64, created_at: u64 }, 32 PriceInvalid, 33 PriceCurrencyInvalid, 34 PriceUnitInvalid, 35 QuantityInvalid, 36 QuantityZero, 37 StatusInvalid, 38 ImageDimensionsInvalid, 39 ImageCountExceeded { max: usize, actual: usize }, 40 ImageDuplicateUrl, 41 ImageDuplicateDigest, 42 } 43 44 impl FoodAvailabilityError { 45 pub const fn code(&self) -> &'static str { 46 match self { 47 Self::ContentMissing => "food_content_missing", 48 Self::ContentTooLarge { .. } => "food_content_too_large", 49 Self::IdentifierInvalid | Self::IdentifierTooLarge { .. } => "food_identifier_invalid", 50 Self::TextInvalid | Self::TextTooLarge { .. } => "food_text_invalid", 51 Self::PublishedAtInvalid => "food_published_at_invalid", 52 Self::PublishedAtFuture { .. } => "food_published_at_future", 53 Self::PriceInvalid => "price_invalid", 54 Self::PriceCurrencyInvalid => "price_currency_invalid", 55 Self::PriceUnitInvalid => "price_unit_invalid", 56 Self::QuantityInvalid => "quantity_invalid", 57 Self::QuantityZero => "quantity_zero", 58 Self::StatusInvalid => "food_status_invalid", 59 Self::ImageDimensionsInvalid => "food_image_dimensions_invalid", 60 Self::ImageCountExceeded { .. } => "food_image_count_exceeded", 61 Self::ImageDuplicateUrl => "food_image_duplicate_url", 62 Self::ImageDuplicateDigest => "food_image_duplicate_digest", 63 } 64 } 65 } 66 67 impl fmt::Display for FoodAvailabilityError { 68 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 69 match self { 70 Self::ContentMissing => { 71 formatter.write_str("FoodAvailability content must be non-whitespace") 72 } 73 Self::ContentTooLarge { max, actual } => write!( 74 formatter, 75 "FoodAvailability content is {actual} bytes; max is {max}" 76 ), 77 Self::IdentifierInvalid => formatter.write_str( 78 "FoodAvailability identifier must be nonempty and contain no whitespace, control, or format characters", 79 ), 80 Self::IdentifierTooLarge { max, actual } => write!( 81 formatter, 82 "FoodAvailability identifier is {actual} bytes; max is {max}" 83 ), 84 Self::TextInvalid => formatter.write_str( 85 "FoodAvailability text must be trimmed, nonempty, and contain no control or format characters", 86 ), 87 Self::TextTooLarge { max, actual } => write!( 88 formatter, 89 "FoodAvailability text is {actual} bytes; max is {max}" 90 ), 91 Self::PublishedAtInvalid => formatter.write_str( 92 "FoodAvailability published_at must be a canonical nonzero u64 timestamp", 93 ), 94 Self::PublishedAtFuture { 95 published_at, 96 created_at, 97 } => write!( 98 formatter, 99 "FoodAvailability published_at {published_at} exceeds created_at {created_at}" 100 ), 101 Self::PriceInvalid => formatter.write_str( 102 "FoodAvailability price must be a canonical unsigned decimal with at most 28 digits", 103 ), 104 Self::PriceCurrencyInvalid => formatter.write_str( 105 "FoodAvailability price currency must be three uppercase ASCII letters", 106 ), 107 Self::PriceUnitInvalid => { 108 formatter.write_str("FoodAvailability price unit is not governed") 109 } 110 Self::QuantityInvalid => formatter.write_str( 111 "FoodAvailability quantity must be canonical and use the price unit", 112 ), 113 Self::QuantityZero => { 114 formatter.write_str("FoodAvailability quantity must be positive") 115 } 116 Self::StatusInvalid => { 117 formatter.write_str("FoodAvailability status must be active or sold") 118 } 119 Self::ImageDimensionsInvalid => formatter.write_str( 120 "FoodAvailability image dimensions must be canonical nonzero u32 values", 121 ), 122 Self::ImageCountExceeded { max, actual } => write!( 123 formatter, 124 "FoodAvailability has {actual} images; max is {max}" 125 ), 126 Self::ImageDuplicateUrl => { 127 formatter.write_str("FoodAvailability image URLs must be unique") 128 } 129 Self::ImageDuplicateDigest => { 130 formatter.write_str("FoodAvailability image digests must be unique") 131 } 132 } 133 } 134 } 135 136 #[cfg(feature = "std")] 137 impl std::error::Error for FoodAvailabilityError {} 138 139 #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] 140 pub struct FoodContent(String); 141 142 impl FoodContent { 143 pub fn new(value: impl Into<String>) -> Result<Self, FoodAvailabilityError> { 144 let value = value.into(); 145 crate::require_invariant(!value.chars().all(is_food_contract_whitespace), &|| { 146 FoodAvailabilityError::ContentMissing 147 })?; 148 crate::require_invariant(value.len() <= RADROOTS_FOOD_CONTENT_MAX_BYTES, &|| { 149 FoodAvailabilityError::ContentTooLarge { 150 max: RADROOTS_FOOD_CONTENT_MAX_BYTES, 151 actual: value.len(), 152 } 153 })?; 154 Ok(Self(value)) 155 } 156 157 pub fn as_str(&self) -> &str { 158 self.0.as_str() 159 } 160 161 pub fn into_string(self) -> String { 162 self.0 163 } 164 } 165 166 impl AsRef<str> for FoodContent { 167 fn as_ref(&self) -> &str { 168 self.as_str() 169 } 170 } 171 172 impl fmt::Display for FoodContent { 173 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 174 formatter.write_str(self.as_str()) 175 } 176 } 177 178 #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] 179 pub struct FoodIdentifier(String); 180 181 impl FoodIdentifier { 182 pub fn parse(value: impl AsRef<str>) -> Result<Self, FoodAvailabilityError> { 183 let value = value.as_ref(); 184 crate::require_invariant( 185 [ 186 !value.is_empty(), 187 !value.chars().any(|character| { 188 [character.is_whitespace(), is_control_or_format(character)].contains(&true) 189 }), 190 ] == [true; 2], 191 &|| FoodAvailabilityError::IdentifierInvalid, 192 )?; 193 crate::require_invariant(value.len() <= RADROOTS_FOOD_IDENTIFIER_MAX_BYTES, &|| { 194 FoodAvailabilityError::IdentifierTooLarge { 195 max: RADROOTS_FOOD_IDENTIFIER_MAX_BYTES, 196 actual: value.len(), 197 } 198 })?; 199 Ok(Self(value.into())) 200 } 201 202 pub fn as_str(&self) -> &str { 203 self.0.as_str() 204 } 205 206 pub fn into_string(self) -> String { 207 self.0 208 } 209 } 210 211 impl AsRef<str> for FoodIdentifier { 212 fn as_ref(&self) -> &str { 213 self.as_str() 214 } 215 } 216 217 impl fmt::Display for FoodIdentifier { 218 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 219 formatter.write_str(self.as_str()) 220 } 221 } 222 223 impl FromStr for FoodIdentifier { 224 type Err = FoodAvailabilityError; 225 226 fn from_str(value: &str) -> Result<Self, Self::Err> { 227 Self::parse(value) 228 } 229 } 230 231 #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] 232 pub struct FoodText(String); 233 234 impl FoodText { 235 pub fn new(value: impl Into<String>) -> Result<Self, FoodAvailabilityError> { 236 let value = value.into(); 237 crate::require_invariant( 238 [ 239 !value.is_empty(), 240 value.trim() == value, 241 !value.chars().any(is_control_or_format), 242 ] == [true; 3], 243 &|| FoodAvailabilityError::TextInvalid, 244 )?; 245 crate::require_invariant(value.len() <= RADROOTS_FOOD_TEXT_MAX_BYTES, &|| { 246 FoodAvailabilityError::TextTooLarge { 247 max: RADROOTS_FOOD_TEXT_MAX_BYTES, 248 actual: value.len(), 249 } 250 })?; 251 Ok(Self(value)) 252 } 253 254 pub fn as_str(&self) -> &str { 255 self.0.as_str() 256 } 257 258 pub fn into_string(self) -> String { 259 self.0 260 } 261 } 262 263 impl AsRef<str> for FoodText { 264 fn as_ref(&self) -> &str { 265 self.as_str() 266 } 267 } 268 269 impl fmt::Display for FoodText { 270 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 271 formatter.write_str(self.as_str()) 272 } 273 } 274 275 #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] 276 pub struct FoodPublishedAt(u64); 277 278 impl FoodPublishedAt { 279 pub const fn new(value: u64) -> Result<Self, FoodAvailabilityError> { 280 if value == 0 { 281 return Err(FoodAvailabilityError::PublishedAtInvalid); 282 } 283 Ok(Self(value)) 284 } 285 286 pub fn parse(value: &str) -> Result<Self, FoodAvailabilityError> { 287 crate::require_invariant(canonical_unsigned_integer(value), &|| { 288 FoodAvailabilityError::PublishedAtInvalid 289 })?; 290 value 291 .parse::<u64>() 292 .ok() 293 .and_then(|parsed| Self::new(parsed).ok()) 294 .ok_or(FoodAvailabilityError::PublishedAtInvalid) 295 } 296 297 pub const fn as_u64(self) -> u64 { 298 self.0 299 } 300 301 pub const fn validate_created_at(self, created_at: u64) -> Result<(), FoodAvailabilityError> { 302 if self.0 > created_at { 303 return Err(FoodAvailabilityError::PublishedAtFuture { 304 published_at: self.0, 305 created_at, 306 }); 307 } 308 Ok(()) 309 } 310 } 311 312 impl fmt::Display for FoodPublishedAt { 313 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 314 write!(formatter, "{}", self.0) 315 } 316 } 317 318 impl FromStr for FoodPublishedAt { 319 type Err = FoodAvailabilityError; 320 321 fn from_str(value: &str) -> Result<Self, Self::Err> { 322 Self::parse(value) 323 } 324 } 325 326 #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] 327 pub struct FoodCurrency(String); 328 329 impl FoodCurrency { 330 pub fn parse(value: impl AsRef<str>) -> Result<Self, FoodAvailabilityError> { 331 let value = value.as_ref(); 332 crate::require_invariant( 333 [ 334 value.len() == 3, 335 value.bytes().all(|byte| byte.is_ascii_uppercase()), 336 ] == [true; 2], 337 &|| FoodAvailabilityError::PriceCurrencyInvalid, 338 )?; 339 Ok(Self(value.into())) 340 } 341 342 pub fn as_str(&self) -> &str { 343 self.0.as_str() 344 } 345 346 pub fn into_string(self) -> String { 347 self.0 348 } 349 } 350 351 impl AsRef<str> for FoodCurrency { 352 fn as_ref(&self) -> &str { 353 self.as_str() 354 } 355 } 356 357 impl fmt::Display for FoodCurrency { 358 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 359 formatter.write_str(self.as_str()) 360 } 361 } 362 363 impl FromStr for FoodCurrency { 364 type Err = FoodAvailabilityError; 365 366 fn from_str(value: &str) -> Result<Self, Self::Err> { 367 Self::parse(value) 368 } 369 } 370 371 #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] 372 pub enum FoodUnit { 373 Gram, 374 Kilogram, 375 Pound, 376 Ounce, 377 Each, 378 Dozen, 379 Bunch, 380 Punnet, 381 Bag, 382 Basket, 383 } 384 385 impl FoodUnit { 386 pub const fn as_str(self) -> &'static str { 387 match self { 388 Self::Gram => "g", 389 Self::Kilogram => "kg", 390 Self::Pound => "lb", 391 Self::Ounce => "oz", 392 Self::Each => "each", 393 Self::Dozen => "dozen", 394 Self::Bunch => "bunch", 395 Self::Punnet => "punnet", 396 Self::Bag => "bag", 397 Self::Basket => "basket", 398 } 399 } 400 401 pub fn parse(value: &str) -> Result<Self, FoodAvailabilityError> { 402 match value { 403 "g" => Ok(Self::Gram), 404 "kg" => Ok(Self::Kilogram), 405 "lb" => Ok(Self::Pound), 406 "oz" => Ok(Self::Ounce), 407 "each" => Ok(Self::Each), 408 "dozen" => Ok(Self::Dozen), 409 "bunch" => Ok(Self::Bunch), 410 "punnet" => Ok(Self::Punnet), 411 "bag" => Ok(Self::Bag), 412 "basket" => Ok(Self::Basket), 413 _ => Err(FoodAvailabilityError::PriceUnitInvalid), 414 } 415 } 416 } 417 418 impl fmt::Display for FoodUnit { 419 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 420 formatter.write_str(self.as_str()) 421 } 422 } 423 424 impl FromStr for FoodUnit { 425 type Err = FoodAvailabilityError; 426 427 fn from_str(value: &str) -> Result<Self, Self::Err> { 428 Self::parse(value) 429 } 430 } 431 432 #[derive(Clone, Debug, PartialEq, Eq)] 433 pub struct FoodPrice { 434 amount: String, 435 currency: FoodCurrency, 436 unit: FoodUnit, 437 } 438 439 impl FoodPrice { 440 pub fn new( 441 amount: impl Into<String>, 442 currency: FoodCurrency, 443 unit: FoodUnit, 444 ) -> Result<Self, FoodAvailabilityError> { 445 let amount = amount.into(); 446 validate_canonical_decimal(&amount) 447 .then_some(()) 448 .ok_or(FoodAvailabilityError::PriceInvalid)?; 449 Ok(Self { 450 amount, 451 currency, 452 unit, 453 }) 454 } 455 456 pub fn amount(&self) -> &str { 457 self.amount.as_str() 458 } 459 460 pub fn currency(&self) -> &FoodCurrency { 461 &self.currency 462 } 463 464 pub const fn unit(&self) -> FoodUnit { 465 self.unit 466 } 467 } 468 469 #[derive(Clone, Debug, PartialEq, Eq)] 470 pub struct FoodQuantity { 471 amount: String, 472 unit: FoodUnit, 473 } 474 475 impl FoodQuantity { 476 pub fn new(amount: impl Into<String>, unit: FoodUnit) -> Result<Self, FoodAvailabilityError> { 477 let amount = amount.into(); 478 if !validate_canonical_decimal(&amount) { 479 return Err(FoodAvailabilityError::QuantityInvalid); 480 } 481 if !amount.bytes().any(|byte| matches!(byte, b'1'..=b'9')) { 482 return Err(FoodAvailabilityError::QuantityZero); 483 } 484 Ok(Self { amount, unit }) 485 } 486 487 pub fn amount(&self) -> &str { 488 self.amount.as_str() 489 } 490 491 pub const fn unit(&self) -> FoodUnit { 492 self.unit 493 } 494 } 495 496 #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] 497 pub enum FoodAvailabilityStatus { 498 Active, 499 Sold, 500 } 501 502 impl FoodAvailabilityStatus { 503 pub const fn as_str(self) -> &'static str { 504 match self { 505 Self::Active => "active", 506 Self::Sold => "sold", 507 } 508 } 509 510 pub fn parse(value: &str) -> Result<Self, FoodAvailabilityError> { 511 match value { 512 "active" => Ok(Self::Active), 513 "sold" => Ok(Self::Sold), 514 _ => Err(FoodAvailabilityError::StatusInvalid), 515 } 516 } 517 } 518 519 impl fmt::Display for FoodAvailabilityStatus { 520 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 521 formatter.write_str(self.as_str()) 522 } 523 } 524 525 impl FromStr for FoodAvailabilityStatus { 526 type Err = FoodAvailabilityError; 527 528 fn from_str(value: &str) -> Result<Self, Self::Err> { 529 Self::parse(value) 530 } 531 } 532 533 #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] 534 pub struct FoodImageDimensions { 535 width: u32, 536 height: u32, 537 } 538 539 impl FoodImageDimensions { 540 pub const fn new(width: u32, height: u32) -> Result<Self, FoodAvailabilityError> { 541 if width == 0 || height == 0 { 542 return Err(FoodAvailabilityError::ImageDimensionsInvalid); 543 } 544 Ok(Self { width, height }) 545 } 546 547 pub fn parse(value: &str) -> Result<Self, FoodAvailabilityError> { 548 let Some((width, height)) = value.split_once('x') else { 549 return Err(FoodAvailabilityError::ImageDimensionsInvalid); 550 }; 551 crate::require_invariant( 552 [ 553 !height.contains('x'), 554 canonical_unsigned_integer(width), 555 canonical_unsigned_integer(height), 556 ] == [true; 3], 557 &|| FoodAvailabilityError::ImageDimensionsInvalid, 558 )?; 559 let width = width 560 .parse::<u32>() 561 .map_err(|_| FoodAvailabilityError::ImageDimensionsInvalid)?; 562 let height = height 563 .parse::<u32>() 564 .map_err(|_| FoodAvailabilityError::ImageDimensionsInvalid)?; 565 Self::new(width, height) 566 } 567 568 pub const fn width(self) -> u32 { 569 self.width 570 } 571 572 pub const fn height(self) -> u32 { 573 self.height 574 } 575 } 576 577 impl fmt::Display for FoodImageDimensions { 578 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 579 write!(formatter, "{}x{}", self.width, self.height) 580 } 581 } 582 583 impl FromStr for FoodImageDimensions { 584 type Err = FoodAvailabilityError; 585 586 fn from_str(value: &str) -> Result<Self, Self::Err> { 587 Self::parse(value) 588 } 589 } 590 591 /// One byte-verified Blossom image and its declared NIP-58 dimensions. 592 /// 593 /// This state proves descriptor-to-byte agreement and an `image/*` media type. 594 /// It does not prove upload completion, raster decoding, or network availability. 595 #[derive(Clone, Debug, PartialEq, Eq)] 596 pub struct FoodAvailabilityImage { 597 image: AuthoredImage, 598 dimensions: FoodImageDimensions, 599 } 600 601 impl FoodAvailabilityImage { 602 pub const fn new(image: AuthoredImage, dimensions: FoodImageDimensions) -> Self { 603 Self { image, dimensions } 604 } 605 606 pub fn image(&self) -> &AuthoredImage { 607 &self.image 608 } 609 610 pub const fn dimensions(&self) -> FoodImageDimensions { 611 self.dimensions 612 } 613 614 pub fn url(&self) -> &str { 615 self.image.descriptor().url().as_str() 616 } 617 } 618 619 /// Validated semantic inputs for a focused FoodAvailability event. 620 /// 621 /// This is intentionally not a signable event draft. The codec layer must 622 /// still derive canonical tags, enforce generic tag and compact-wire budgets, 623 /// and bind these details to a per-revision `created_at` before signing. 624 /// 625 /// ```compile_fail 626 /// let _: radroots_event::food::availability::FoodAvailabilityDetails = 627 /// serde_json::from_str(r#"{"content":"carrots"}"#).unwrap(); 628 /// ``` 629 #[derive(Clone, Debug, PartialEq, Eq)] 630 pub struct FoodAvailabilityDetails { 631 content: FoodContent, 632 identifier: FoodIdentifier, 633 title: FoodText, 634 summary: FoodText, 635 published_at: FoodPublishedAt, 636 location: FoodText, 637 price: FoodPrice, 638 quantity: Option<FoodQuantity>, 639 status: FoodAvailabilityStatus, 640 images: Vec<FoodAvailabilityImage>, 641 } 642 643 #[derive(Clone, Debug, PartialEq, Eq)] 644 pub struct FoodAvailabilityDetailsParts { 645 pub content: FoodContent, 646 pub identifier: FoodIdentifier, 647 pub title: FoodText, 648 pub summary: FoodText, 649 pub published_at: FoodPublishedAt, 650 pub location: FoodText, 651 pub price: FoodPrice, 652 pub quantity: Option<FoodQuantity>, 653 pub status: FoodAvailabilityStatus, 654 pub images: Vec<FoodAvailabilityImage>, 655 } 656 657 impl FoodAvailabilityDetails { 658 pub fn new(parts: FoodAvailabilityDetailsParts) -> Result<Self, FoodAvailabilityError> { 659 crate::require_invariant( 660 !parts 661 .quantity 662 .as_ref() 663 .is_some_and(|quantity| quantity.unit() != parts.price.unit()), 664 &|| FoodAvailabilityError::QuantityInvalid, 665 )?; 666 validate_images(&parts.images)?; 667 Ok(Self { 668 content: parts.content, 669 identifier: parts.identifier, 670 title: parts.title, 671 summary: parts.summary, 672 published_at: parts.published_at, 673 location: parts.location, 674 price: parts.price, 675 quantity: parts.quantity, 676 status: parts.status, 677 images: parts.images, 678 }) 679 } 680 681 pub fn validate_created_at(&self, created_at: u64) -> Result<(), FoodAvailabilityError> { 682 self.published_at.validate_created_at(created_at) 683 } 684 685 pub fn content(&self) -> &FoodContent { 686 &self.content 687 } 688 689 pub fn identifier(&self) -> &FoodIdentifier { 690 &self.identifier 691 } 692 693 pub fn title(&self) -> &FoodText { 694 &self.title 695 } 696 697 pub fn summary(&self) -> &FoodText { 698 &self.summary 699 } 700 701 pub const fn published_at(&self) -> FoodPublishedAt { 702 self.published_at 703 } 704 705 pub fn location(&self) -> &FoodText { 706 &self.location 707 } 708 709 pub fn price(&self) -> &FoodPrice { 710 &self.price 711 } 712 713 pub fn quantity(&self) -> Option<&FoodQuantity> { 714 self.quantity.as_ref() 715 } 716 717 pub const fn status(&self) -> FoodAvailabilityStatus { 718 self.status 719 } 720 721 pub fn images(&self) -> &[FoodAvailabilityImage] { 722 &self.images 723 } 724 } 725 726 fn validate_images(images: &[FoodAvailabilityImage]) -> Result<(), FoodAvailabilityError> { 727 crate::require_invariant(images.len() <= RADROOTS_FOOD_IMAGE_MAX_COUNT, &|| { 728 FoodAvailabilityError::ImageCountExceeded { 729 max: RADROOTS_FOOD_IMAGE_MAX_COUNT, 730 actual: images.len(), 731 } 732 })?; 733 for (index, image) in images.iter().enumerate() { 734 crate::require_invariant( 735 !images[..index] 736 .iter() 737 .any(|candidate| candidate.url() == image.url()), 738 &|| FoodAvailabilityError::ImageDuplicateUrl, 739 )?; 740 let digest = image.image().descriptor().sha256(); 741 crate::require_invariant( 742 !images[..index] 743 .iter() 744 .any(|candidate| candidate.image().descriptor().sha256() == digest), 745 &|| FoodAvailabilityError::ImageDuplicateDigest, 746 )?; 747 } 748 Ok(()) 749 } 750 751 /// Returns whether an inbound FoodAvailability image is a structural HTTP(S) URL. 752 /// 753 /// This is deliberately broader than strict authored Blossom policy. Success 754 /// makes no byte-verification, upload, reachability, or media-safety claim. 755 pub fn food_media_http_url_is_valid(value: &str) -> bool { 756 if [ 757 value.contains("://"), 758 !value.chars().any(|character| { 759 [ 760 character.is_whitespace(), 761 matches!( 762 get_general_category(character), 763 GeneralCategory::Control | GeneralCategory::Format 764 ), 765 ] 766 .contains(&true) 767 }), 768 ] != [true; 2] 769 { 770 return false; 771 } 772 773 let Ok(url) = Url::parse(value) else { 774 return false; 775 }; 776 if [ 777 matches!(url.scheme(), "http" | "https"), 778 url.username().is_empty(), 779 url.password().is_none(), 780 ] != [true; 3] 781 { 782 return false; 783 } 784 785 let Some((raw_host, raw_path)) = raw_food_media_host_and_path(value) else { 786 return false; 787 }; 788 if [!raw_path.is_empty(), raw_path.starts_with('/')] != [true; 2] { 789 return false; 790 } 791 792 match url.host() { 793 Some(Host::Domain(_)) => raw_food_dns_host_is_valid(raw_host), 794 Some(Host::Ipv4(_)) => [raw_host.is_ascii(), !raw_host.is_empty()] == [true; 2], 795 Some(Host::Ipv6(_)) => [raw_host.is_ascii(), !raw_host.is_empty()] == [true; 2], 796 None => false, 797 } 798 } 799 800 /// Extracts a structural Blossom hash from an otherwise valid inbound URL. 801 /// 802 /// A `None` result does not invalidate a standard inbound NIP-58 image URL; it 803 /// only means duplicate-digest diagnostics cannot be derived from its path. 804 pub fn food_media_blossom_digest(value: &str) -> Option<Sha256> { 805 if !food_media_http_url_is_valid(value) { 806 return None; 807 } 808 let url = Url::parse(value).ok()?; 809 HashPath::parse(url.path()).ok().map(|path| path.hash()) 810 } 811 812 fn raw_food_media_host_and_path(value: &str) -> Option<(&str, &str)> { 813 let (_, remainder) = value.split_once("://")?; 814 let authority_end = remainder.find(['/', '?', '#']).unwrap_or(remainder.len()); 815 let authority = &remainder[..authority_end]; 816 if [!authority.is_empty(), !authority.contains('@')] != [true; 2] { 817 return None; 818 } 819 let path_and_suffix = &remainder[authority_end..]; 820 let path_end = path_and_suffix 821 .find(['?', '#']) 822 .unwrap_or(path_and_suffix.len()); 823 let raw_path = &path_and_suffix[..path_end]; 824 825 let raw_host = if let Some(bracketed) = authority.strip_prefix('[') { 826 let (host, suffix) = bracketed.split_once(']')?; 827 if [!suffix.is_empty(), !suffix.starts_with(':')] == [true; 2] { 828 return None; 829 } 830 host 831 } else if let Some((host, _port)) = authority.rsplit_once(':') { 832 if host.contains(':') { 833 return None; 834 } 835 host 836 } else { 837 authority 838 }; 839 840 Some((raw_host, raw_path)) 841 } 842 843 fn raw_food_dns_host_is_valid(host: &str) -> bool { 844 !host.is_empty() 845 && host.is_ascii() 846 && host.len() <= 253 847 && host.split('.').all(|label| { 848 let bytes = label.as_bytes(); 849 !bytes.is_empty() 850 && bytes.len() <= 63 851 && bytes.first().is_some_and(u8::is_ascii_alphanumeric) 852 && bytes.last().is_some_and(u8::is_ascii_alphanumeric) 853 && bytes 854 .iter() 855 .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'-') 856 }) 857 } 858 859 fn validate_canonical_decimal(value: &str) -> bool { 860 let mut digits = 0usize; 861 let mut seen_dot = false; 862 let mut digit_after_dot = false; 863 for byte in value.bytes() { 864 match byte { 865 b'0'..=b'9' => { 866 digits += 1; 867 digit_after_dot |= seen_dot; 868 } 869 b'.' if !seen_dot => seen_dot = true, 870 _ => return false, 871 } 872 } 873 if [digits > 0, digits <= RADROOTS_FOOD_DECIMAL_MAX_DIGITS] != [true; 2] { 874 return false; 875 } 876 if [ 877 seen_dot, 878 [!digit_after_dot, value.ends_with('0')].contains(&true), 879 ] == [true; 2] 880 { 881 return false; 882 } 883 let integer = value.split_once('.').map_or(value, |(integer, _)| integer); 884 [ 885 !integer.is_empty(), 886 [integer == "0", !integer.starts_with('0')].contains(&true), 887 ] == [true; 2] 888 } 889 890 fn canonical_unsigned_integer(value: &str) -> bool { 891 [ 892 !value.is_empty(), 893 value.bytes().all(|byte| byte.is_ascii_digit()), 894 [value == "0", !value.starts_with('0')].contains(&true), 895 ] == [true; 3] 896 } 897 898 fn is_food_contract_whitespace(character: char) -> bool { 899 [ 900 character.is_whitespace(), 901 matches!(character, '\u{1c}'..='\u{1f}'), 902 ] 903 .contains(&true) 904 } 905 906 fn is_control_or_format(character: char) -> bool { 907 matches!( 908 get_general_category(character), 909 GeneralCategory::Control | GeneralCategory::Format 910 ) 911 } 912 913 #[cfg(test)] 914 #[cfg_attr(coverage_nightly, coverage(off))] 915 mod tests { 916 use super::*; 917 use radroots_blossom::{BlobDescriptor, BlobUrl, MediaType, Sha256}; 918 919 #[test] 920 fn error_codes_and_messages_are_stable_for_every_variant() { 921 let cases = [ 922 ( 923 FoodAvailabilityError::ContentMissing, 924 "food_content_missing", 925 ), 926 ( 927 FoodAvailabilityError::ContentTooLarge { max: 1, actual: 2 }, 928 "food_content_too_large", 929 ), 930 ( 931 FoodAvailabilityError::IdentifierInvalid, 932 "food_identifier_invalid", 933 ), 934 ( 935 FoodAvailabilityError::IdentifierTooLarge { max: 1, actual: 2 }, 936 "food_identifier_invalid", 937 ), 938 (FoodAvailabilityError::TextInvalid, "food_text_invalid"), 939 ( 940 FoodAvailabilityError::TextTooLarge { max: 1, actual: 2 }, 941 "food_text_invalid", 942 ), 943 ( 944 FoodAvailabilityError::PublishedAtInvalid, 945 "food_published_at_invalid", 946 ), 947 ( 948 FoodAvailabilityError::PublishedAtFuture { 949 published_at: 2, 950 created_at: 1, 951 }, 952 "food_published_at_future", 953 ), 954 (FoodAvailabilityError::PriceInvalid, "price_invalid"), 955 ( 956 FoodAvailabilityError::PriceCurrencyInvalid, 957 "price_currency_invalid", 958 ), 959 ( 960 FoodAvailabilityError::PriceUnitInvalid, 961 "price_unit_invalid", 962 ), 963 (FoodAvailabilityError::QuantityInvalid, "quantity_invalid"), 964 (FoodAvailabilityError::QuantityZero, "quantity_zero"), 965 (FoodAvailabilityError::StatusInvalid, "food_status_invalid"), 966 ( 967 FoodAvailabilityError::ImageDimensionsInvalid, 968 "food_image_dimensions_invalid", 969 ), 970 ( 971 FoodAvailabilityError::ImageCountExceeded { max: 1, actual: 2 }, 972 "food_image_count_exceeded", 973 ), 974 ( 975 FoodAvailabilityError::ImageDuplicateUrl, 976 "food_image_duplicate_url", 977 ), 978 ( 979 FoodAvailabilityError::ImageDuplicateDigest, 980 "food_image_duplicate_digest", 981 ), 982 ]; 983 984 for (error, code) in cases { 985 assert_eq!(error.code(), code); 986 assert!(!error.to_string().is_empty(), "{code}"); 987 } 988 } 989 990 #[test] 991 fn content_enforces_only_non_whitespace_and_utf8_byte_bound() { 992 for invalid in [ 993 "", 994 " \t", 995 "\u{1c}", 996 "\u{1d}", 997 "\u{1e}", 998 "\u{1f}", 999 "\u{1c}\u{2003}\t", 1000 ] { 1001 assert_eq!( 1002 FoodContent::new(invalid).unwrap_err(), 1003 FoodAvailabilityError::ContentMissing, 1004 "{invalid:?}" 1005 ); 1006 } 1007 let exact = "é".repeat(RADROOTS_FOOD_CONTENT_MAX_BYTES / 2); 1008 assert_eq!( 1009 FoodContent::new(exact.clone()).unwrap().as_str().len(), 1010 RADROOTS_FOOD_CONTENT_MAX_BYTES 1011 ); 1012 assert_eq!( 1013 FoodContent::new(exact + "a").unwrap_err(), 1014 FoodAvailabilityError::ContentTooLarge { 1015 max: RADROOTS_FOOD_CONTENT_MAX_BYTES, 1016 actual: RADROOTS_FOOD_CONTENT_MAX_BYTES + 1, 1017 } 1018 ); 1019 assert!(FoodContent::new(" harvest\nnotes ").is_ok()); 1020 assert!(FoodContent::new("carrots\u{1c}").is_ok()); 1021 } 1022 1023 #[test] 1024 fn inbound_food_media_urls_are_structural_without_claiming_blossom() { 1025 let hash = Sha256::digest(b"carrots").to_string(); 1026 for valid in [ 1027 format!("https://media.example/{hash}.webp"), 1028 format!("http://media.example:0/{hash}?download=1"), 1029 "https://media.example/not-a-blossom-path.jpg".to_string(), 1030 format!("https://[::1]/{hash}"), 1031 format!("https://127.0.0.1/{hash}"), 1032 format!("https://localhost/{hash}"), 1033 ] { 1034 assert!(food_media_http_url_is_valid(&valid), "{valid}"); 1035 } 1036 for invalid in [ 1037 format!("ftp://media.example/{hash}"), 1038 format!("https://user@media.example/{hash}"), 1039 format!("https://user:password@media.example/{hash}"), 1040 "https://media.example".to_string(), 1041 "https://[".to_string(), 1042 format!("https://-media.example/{hash}"), 1043 format!("https://media-.example/{hash}"), 1044 format!("https://media..example/{hash}"), 1045 format!("https://bad_host.example/{hash}"), 1046 format!("https://{}.example/{hash}", "a".repeat(64)), 1047 format!( 1048 "https://{}.{}.{}.{}/{hash}", 1049 "a".repeat(63), 1050 "b".repeat(63), 1051 "c".repeat(63), 1052 "d".repeat(63) 1053 ), 1054 format!("https://média.example/{hash}"), 1055 format!("https://media.example/a b/{hash}"), 1056 format!("https://media.example/a\0b/{hash}"), 1057 format!("https://media.example/\u{200b}{hash}"), 1058 ] { 1059 assert!(!food_media_http_url_is_valid(&invalid), "{invalid}"); 1060 } 1061 1062 let blossom = format!("https://media.example/{hash}.webp?download=1"); 1063 assert_eq!( 1064 food_media_blossom_digest(&blossom), 1065 Some(Sha256::from_hex(&hash).unwrap()) 1066 ); 1067 assert_eq!( 1068 food_media_blossom_digest("https://media.example/not-a-hash.jpg"), 1069 None 1070 ); 1071 assert_eq!(food_media_blossom_digest("not a URL"), None); 1072 } 1073 1074 #[test] 1075 fn identifier_enforces_bytes_whitespace_and_unicode_categories() { 1076 let exact = "a".repeat(RADROOTS_FOOD_IDENTIFIER_MAX_BYTES); 1077 assert_eq!(FoodIdentifier::parse(&exact).unwrap().as_str(), exact); 1078 assert!(matches!( 1079 FoodIdentifier::parse(exact + "a"), 1080 Err(FoodAvailabilityError::IdentifierTooLarge { .. }) 1081 )); 1082 for invalid in [ 1083 "", 1084 "fresh carrots", 1085 "carrots\0", 1086 "carrots\u{85}", 1087 "carrots\u{200b}", 1088 "carrots\u{2060}", 1089 ] { 1090 assert_eq!( 1091 FoodIdentifier::parse(invalid).unwrap_err().code(), 1092 "food_identifier_invalid" 1093 ); 1094 } 1095 } 1096 1097 #[test] 1098 fn text_enforces_trim_bytes_and_unicode_categories() { 1099 let exact = "é".repeat(RADROOTS_FOOD_TEXT_MAX_BYTES / 2); 1100 assert_eq!(FoodText::new(exact.clone()).unwrap().as_str(), exact); 1101 assert!(matches!( 1102 FoodText::new(exact + "a"), 1103 Err(FoodAvailabilityError::TextTooLarge { .. }) 1104 )); 1105 for invalid in [ 1106 "", 1107 " Carrots", 1108 "Carrots ", 1109 "Car\nrots", 1110 "Car\u{85}rots", 1111 "Car\u{200b}rots", 1112 "Car\u{2060}rots", 1113 ] { 1114 assert_eq!( 1115 FoodText::new(invalid).unwrap_err().code(), 1116 "food_text_invalid" 1117 ); 1118 } 1119 } 1120 1121 #[test] 1122 fn published_at_is_nonzero_canonical_u64_and_not_in_the_future() { 1123 assert_eq!( 1124 FoodPublishedAt::new(0).unwrap_err(), 1125 FoodAvailabilityError::PublishedAtInvalid 1126 ); 1127 for invalid in ["", "0", "01", "+1", "18446744073709551616"] { 1128 assert_eq!( 1129 FoodPublishedAt::parse(invalid).unwrap_err(), 1130 FoodAvailabilityError::PublishedAtInvalid 1131 ); 1132 } 1133 let timestamp = FoodPublishedAt::parse("18446744073709551615").unwrap(); 1134 assert_eq!(timestamp.as_u64(), u64::MAX); 1135 assert_eq!(timestamp.to_string(), u64::MAX.to_string()); 1136 assert_eq!( 1137 FoodPublishedAt::new(11) 1138 .unwrap() 1139 .validate_created_at(10) 1140 .unwrap_err(), 1141 FoodAvailabilityError::PublishedAtFuture { 1142 published_at: 11, 1143 created_at: 10, 1144 } 1145 ); 1146 FoodPublishedAt::new(1) 1147 .unwrap() 1148 .validate_created_at(1) 1149 .unwrap(); 1150 } 1151 1152 #[test] 1153 fn food_units_are_exact_and_closed() { 1154 let cases = [ 1155 ("g", FoodUnit::Gram), 1156 ("kg", FoodUnit::Kilogram), 1157 ("lb", FoodUnit::Pound), 1158 ("oz", FoodUnit::Ounce), 1159 ("each", FoodUnit::Each), 1160 ("dozen", FoodUnit::Dozen), 1161 ("bunch", FoodUnit::Bunch), 1162 ("punnet", FoodUnit::Punnet), 1163 ("bag", FoodUnit::Bag), 1164 ("basket", FoodUnit::Basket), 1165 ]; 1166 for (wire, unit) in cases { 1167 assert_eq!(FoodUnit::parse(wire).unwrap(), unit); 1168 assert_eq!(unit.as_str(), wire); 1169 assert_eq!(unit.to_string(), wire); 1170 } 1171 for invalid in ["", "G", "lbs", "crate", " each"] { 1172 assert_eq!( 1173 FoodUnit::parse(invalid).unwrap_err(), 1174 FoodAvailabilityError::PriceUnitInvalid 1175 ); 1176 } 1177 } 1178 1179 #[test] 1180 fn price_decimal_is_canonical_bounded_and_may_be_zero() { 1181 let currency = FoodCurrency::parse("CAD").unwrap(); 1182 for valid in ["0", "1", "0.1", "10.25", "1234567890123456789012345678"] { 1183 let price = FoodPrice::new(valid, currency.clone(), FoodUnit::Pound).unwrap(); 1184 assert_eq!(price.amount(), valid); 1185 } 1186 for invalid in [ 1187 "", 1188 ".1", 1189 "1.", 1190 "00", 1191 "01", 1192 "01.2", 1193 "1.0", 1194 "1.20", 1195 "+1", 1196 "-1", 1197 "1e3", 1198 "١", 1199 "12345678901234567890123456789", 1200 ] { 1201 assert_eq!( 1202 FoodPrice::new(invalid, currency.clone(), FoodUnit::Pound).unwrap_err(), 1203 FoodAvailabilityError::PriceInvalid 1204 ); 1205 } 1206 } 1207 1208 #[test] 1209 fn currency_is_three_uppercase_ascii_letters_without_registry_semantics() { 1210 for valid in ["CAD", "USD", "ZZZ"] { 1211 assert_eq!(FoodCurrency::parse(valid).unwrap().as_str(), valid); 1212 } 1213 for invalid in ["", "CA", "CADD", "cad", "C1D", "CÁD"] { 1214 assert_eq!( 1215 FoodCurrency::parse(invalid).unwrap_err(), 1216 FoodAvailabilityError::PriceCurrencyInvalid 1217 ); 1218 } 1219 } 1220 1221 #[test] 1222 fn quantity_is_positive_canonical_and_retains_its_unit() { 1223 let quantity = FoodQuantity::new("20.5", FoodUnit::Pound).unwrap(); 1224 assert_eq!(quantity.amount(), "20.5"); 1225 assert_eq!(quantity.unit(), FoodUnit::Pound); 1226 assert_eq!( 1227 FoodQuantity::new("0", FoodUnit::Pound).unwrap_err(), 1228 FoodAvailabilityError::QuantityZero 1229 ); 1230 assert_eq!( 1231 FoodQuantity::new("01", FoodUnit::Pound).unwrap_err(), 1232 FoodAvailabilityError::QuantityInvalid 1233 ); 1234 } 1235 1236 #[test] 1237 fn status_is_exact_and_withdrawal_is_not_a_status() { 1238 assert_eq!( 1239 FoodAvailabilityStatus::parse("active").unwrap(), 1240 FoodAvailabilityStatus::Active 1241 ); 1242 assert_eq!( 1243 FoodAvailabilityStatus::parse("sold").unwrap(), 1244 FoodAvailabilityStatus::Sold 1245 ); 1246 for invalid in ["", "Active", "withdrawn"] { 1247 assert_eq!( 1248 FoodAvailabilityStatus::parse(invalid).unwrap_err(), 1249 FoodAvailabilityError::StatusInvalid 1250 ); 1251 } 1252 } 1253 1254 #[test] 1255 fn image_dimensions_are_canonical_nonzero_u32_values() { 1256 let dimensions = FoodImageDimensions::new(u32::MAX, 1).unwrap(); 1257 assert_eq!(dimensions.width(), u32::MAX); 1258 assert_eq!(dimensions.height(), 1); 1259 assert_eq!(dimensions.to_string(), "4294967295x1"); 1260 assert_eq!( 1261 FoodImageDimensions::parse("800x600").unwrap(), 1262 FoodImageDimensions::new(800, 600).unwrap() 1263 ); 1264 assert_eq!( 1265 FoodImageDimensions::new(0, 1).unwrap_err(), 1266 FoodAvailabilityError::ImageDimensionsInvalid 1267 ); 1268 assert_eq!( 1269 FoodImageDimensions::new(1, 0).unwrap_err(), 1270 FoodAvailabilityError::ImageDimensionsInvalid 1271 ); 1272 for invalid in [ 1273 "", 1274 "0x1", 1275 "1x0", 1276 "01x1", 1277 "1x01", 1278 "1X1", 1279 "1x1x1", 1280 "4294967296x1", 1281 ] { 1282 assert_eq!( 1283 FoodImageDimensions::parse(invalid).unwrap_err(), 1284 FoodAvailabilityError::ImageDimensionsInvalid 1285 ); 1286 } 1287 } 1288 1289 #[test] 1290 fn details_enforce_quantity_unit_and_created_at() { 1291 let mut parts = details_parts(Vec::new()); 1292 parts.quantity = Some(FoodQuantity::new("12", FoodUnit::Kilogram).unwrap()); 1293 assert_eq!( 1294 FoodAvailabilityDetails::new(parts).unwrap_err(), 1295 FoodAvailabilityError::QuantityInvalid 1296 ); 1297 1298 let details = FoodAvailabilityDetails::new(details_parts(Vec::new())).unwrap(); 1299 details.validate_created_at(100).unwrap(); 1300 assert_eq!(details.identifier().as_str(), "nantes-carrots"); 1301 assert_eq!(details.title().as_str(), "Nantes Carrots"); 1302 assert_eq!(details.summary().as_str(), "Fresh bunches"); 1303 assert_eq!(details.location().as_str(), "Central Saanich, BC"); 1304 assert_eq!( 1305 details.content().as_str(), 1306 "Nantes carrots available this week." 1307 ); 1308 assert_eq!(details.price().amount(), "4"); 1309 assert_eq!(details.price().currency().as_str(), "CAD"); 1310 assert_eq!(details.quantity().unwrap().amount(), "24"); 1311 assert_eq!(details.status(), FoodAvailabilityStatus::Active); 1312 assert!(details.images().is_empty()); 1313 assert_eq!( 1314 details.validate_created_at(99).unwrap_err().code(), 1315 "food_published_at_future" 1316 ); 1317 } 1318 1319 #[test] 1320 fn details_bound_images_and_apply_url_before_digest_duplicate_precedence() { 1321 let dimensions = FoodImageDimensions::new(800, 600).unwrap(); 1322 let images = (0..RADROOTS_FOOD_IMAGE_MAX_COUNT) 1323 .map(|index| { 1324 food_image( 1325 "https://media.example", 1326 index.to_be_bytes().as_slice(), 1327 dimensions, 1328 ) 1329 }) 1330 .collect::<Vec<_>>(); 1331 assert_eq!( 1332 FoodAvailabilityDetails::new(details_parts(images)) 1333 .unwrap() 1334 .images() 1335 .len(), 1336 RADROOTS_FOOD_IMAGE_MAX_COUNT 1337 ); 1338 let too_many = (0..=RADROOTS_FOOD_IMAGE_MAX_COUNT) 1339 .map(|index| { 1340 food_image( 1341 "https://media.example", 1342 index.to_be_bytes().as_slice(), 1343 dimensions, 1344 ) 1345 }) 1346 .collect::<Vec<_>>(); 1347 assert_eq!( 1348 FoodAvailabilityDetails::new(details_parts(too_many)).unwrap_err(), 1349 FoodAvailabilityError::ImageCountExceeded { 1350 max: RADROOTS_FOOD_IMAGE_MAX_COUNT, 1351 actual: RADROOTS_FOOD_IMAGE_MAX_COUNT + 1, 1352 } 1353 ); 1354 1355 let image = food_image("https://media.example", b"carrot", dimensions); 1356 assert_eq!( 1357 FoodAvailabilityDetails::new(details_parts(vec![image.clone(), image])).unwrap_err(), 1358 FoodAvailabilityError::ImageDuplicateUrl 1359 ); 1360 assert_eq!( 1361 FoodAvailabilityDetails::new(details_parts(vec![ 1362 food_image("https://media.example", b"same", dimensions), 1363 food_image("https://cache.example", b"same", dimensions), 1364 ])) 1365 .unwrap_err(), 1366 FoodAvailabilityError::ImageDuplicateDigest 1367 ); 1368 } 1369 1370 fn details_parts(images: Vec<FoodAvailabilityImage>) -> FoodAvailabilityDetailsParts { 1371 FoodAvailabilityDetailsParts { 1372 content: FoodContent::new("Nantes carrots available this week.").unwrap(), 1373 identifier: FoodIdentifier::parse("nantes-carrots").unwrap(), 1374 title: FoodText::new("Nantes Carrots").unwrap(), 1375 summary: FoodText::new("Fresh bunches").unwrap(), 1376 published_at: FoodPublishedAt::new(100).unwrap(), 1377 location: FoodText::new("Central Saanich, BC").unwrap(), 1378 price: FoodPrice::new("4", FoodCurrency::parse("CAD").unwrap(), FoodUnit::Pound) 1379 .unwrap(), 1380 quantity: Some(FoodQuantity::new("24", FoodUnit::Pound).unwrap()), 1381 status: FoodAvailabilityStatus::Active, 1382 images, 1383 } 1384 } 1385 1386 fn food_image( 1387 origin: &str, 1388 bytes: &[u8], 1389 dimensions: FoodImageDimensions, 1390 ) -> FoodAvailabilityImage { 1391 let hash = Sha256::digest(bytes); 1392 let media_type = MediaType::parse("image/webp").unwrap(); 1393 let descriptor = BlobDescriptor::new( 1394 BlobUrl::parse(&format!("{origin}/{hash}.webp")).unwrap(), 1395 hash, 1396 bytes.len() as u64, 1397 media_type.clone(), 1398 1_784_347_200, 1399 ) 1400 .unwrap() 1401 .approve_reference() 1402 .unwrap() 1403 .verify_bytes(bytes, &media_type) 1404 .unwrap(); 1405 FoodAvailabilityImage::new(AuthoredImage::try_from(descriptor).unwrap(), dimensions) 1406 } 1407 }