draft.rs (18836B)
1 //! Canonicalization for Radroots Listing v1 drafts. 2 3 #![forbid(unsafe_code)] 4 5 use core::fmt; 6 7 use std::{format, vec::Vec}; 8 9 use radroots_event::{ 10 envelope::kind::KIND_CLASSIFIED_LISTING, 11 id::{ClassifiedListingAddress, InventoryBinId, ParseError}, 12 listing::operational::OperationalListing, 13 trade::validation::OperationalListingValidationError, 14 }; 15 use radroots_identity::{Error as PublicKeyError, PublicKey}; 16 17 use super::validation::validate_operational_listing_model; 18 19 #[derive(Clone, Debug)] 20 pub struct RadrootsOperationalListingEditDocumentV1 { 21 pub listing: OperationalListing, 22 } 23 24 impl RadrootsOperationalListingEditDocumentV1 { 25 pub fn new(listing: OperationalListing) -> Self { 26 Self { listing } 27 } 28 } 29 30 #[derive(Clone, Debug)] 31 pub struct RadrootsOperationalListingCanonicalEdit { 32 listing: OperationalListing, 33 seller_pubkey: PublicKey, 34 public_listing_addr: ClassifiedListingAddress, 35 } 36 37 impl RadrootsOperationalListingCanonicalEdit { 38 pub fn new( 39 mut listing: OperationalListing, 40 seller_pubkey: PublicKey, 41 ) -> Result<Self, RadrootsOperationalListingEditError> { 42 let farm_pubkey = PublicKey::from_hex(listing.farm.pubkey.as_str()) 43 .map_err(RadrootsOperationalListingEditError::InvalidFarmPubkey)?; 44 if farm_pubkey != seller_pubkey { 45 return Err(RadrootsOperationalListingEditError::FarmPubkeyMismatch { 46 expected_pubkey: seller_pubkey, 47 actual_pubkey: farm_pubkey, 48 }); 49 } 50 listing.farm.pubkey = farm_pubkey.to_hex(); 51 validate_listing_bins(&listing)?; 52 let listing = validate_operational_listing_model(listing, &seller_pubkey) 53 .map_err(RadrootsOperationalListingEditError::InvalidModel)? 54 .listing; 55 56 let public_listing_addr = listing_addr( 57 KIND_CLASSIFIED_LISTING, 58 &seller_pubkey, 59 listing.d_tag.as_str(), 60 ); 61 62 Ok(Self { 63 listing, 64 seller_pubkey, 65 public_listing_addr, 66 }) 67 } 68 69 pub fn listing(&self) -> &OperationalListing { 70 &self.listing 71 } 72 73 pub fn seller_pubkey(&self) -> &PublicKey { 74 &self.seller_pubkey 75 } 76 77 pub fn public_listing_addr(&self) -> &ClassifiedListingAddress { 78 &self.public_listing_addr 79 } 80 } 81 82 #[derive(Clone, Debug, PartialEq, Eq)] 83 pub enum RadrootsOperationalListingEditError { 84 InvalidFarmPubkey(PublicKeyError), 85 InvalidClassifiedListingAddress(ParseError), 86 InvalidModel(OperationalListingValidationError), 87 FarmPubkeyMismatch { 88 expected_pubkey: PublicKey, 89 actual_pubkey: PublicKey, 90 }, 91 MissingPrimaryBin { 92 primary_bin_id: InventoryBinId, 93 }, 94 DuplicateBinId { 95 bin_id: InventoryBinId, 96 }, 97 } 98 99 impl fmt::Display for RadrootsOperationalListingEditError { 100 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 101 match self { 102 Self::InvalidFarmPubkey(error) => { 103 write!(f, "invalid listing edit farm pubkey: {error}") 104 } 105 Self::InvalidClassifiedListingAddress(error) => { 106 write!(f, "invalid listing edit address: {error}") 107 } 108 Self::InvalidModel(error) => { 109 write!(f, "invalid listing edit model: {error}") 110 } 111 Self::FarmPubkeyMismatch { .. } => { 112 f.write_str("listing edit farm pubkey does not match seller") 113 } 114 Self::MissingPrimaryBin { .. } => f.write_str("listing edit primary bin is missing"), 115 Self::DuplicateBinId { .. } => f.write_str("listing edit contains duplicate bin ID"), 116 } 117 } 118 } 119 120 impl core::error::Error for RadrootsOperationalListingEditError {} 121 122 fn validate_listing_bins( 123 listing: &OperationalListing, 124 ) -> Result<(), RadrootsOperationalListingEditError> { 125 let primary_bin_id = listing.primary_bin_id.clone(); 126 let mut seen_bin_ids = Vec::new(); 127 let mut primary_bin_found = false; 128 for bin in &listing.bins { 129 if seen_bin_ids 130 .iter() 131 .any(|seen_bin_id| seen_bin_id == &bin.bin_id) 132 { 133 return Err(RadrootsOperationalListingEditError::DuplicateBinId { 134 bin_id: bin.bin_id.clone(), 135 }); 136 } 137 if bin.bin_id == primary_bin_id { 138 primary_bin_found = true; 139 } 140 seen_bin_ids.push(bin.bin_id.clone()); 141 } 142 143 if !primary_bin_found { 144 return Err(RadrootsOperationalListingEditError::MissingPrimaryBin { primary_bin_id }); 145 } 146 Ok(()) 147 } 148 149 fn listing_addr(kind: u32, seller_pubkey: &PublicKey, d_tag: &str) -> ClassifiedListingAddress { 150 ClassifiedListingAddress::parse(format!("{kind}:{seller_pubkey}:{d_tag}")) 151 .expect("typed listing identity must form a listing address") 152 } 153 154 /// Canonicalizes an untrusted listing edit for an already-authorized seller. 155 /// 156 /// Actor provenance and role authorization belong to the signing/workflow 157 /// boundary. This deterministic function validates only the supplied public 158 /// identity and listing data and performs no signing or authorization. 159 pub fn canonicalize_operational_listing_edit( 160 seller_pubkey: PublicKey, 161 mut document: RadrootsOperationalListingEditDocumentV1, 162 ) -> Result<RadrootsOperationalListingCanonicalEdit, RadrootsOperationalListingEditError> { 163 let farm_pubkey = document.listing.farm.pubkey.as_str(); 164 if farm_pubkey.is_empty() { 165 document.listing.farm.pubkey = seller_pubkey.to_hex(); 166 } 167 168 RadrootsOperationalListingCanonicalEdit::new(document.listing, seller_pubkey) 169 } 170 171 #[cfg(test)] 172 mod tests { 173 const FIXTURE_ALICE_PUBLIC_KEY_HEX: &str = 174 "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; 175 const FIXTURE_BOB_PUBLIC_KEY_HEX: &str = 176 "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"; 177 use radroots_core::{Currency, Decimal, Money, Quantity, QuantityPrice, Unit}; 178 use radroots_event::{ 179 envelope::kind::KIND_CLASSIFIED_LISTING, 180 farm::FarmRef, 181 id::{ClassifiedListingAddress, DTag, InventoryBinId}, 182 listing::operational::{ 183 OperationalListing, OperationalListingAvailability, OperationalListingBin, 184 OperationalListingDeliveryMethod, OperationalListingProduct, 185 OperationalListingPublicLocation, OperationalListingStatus, 186 }, 187 trade::validation::OperationalListingValidationError, 188 }; 189 use radroots_identity::PublicKey; 190 191 use super::{ 192 RadrootsOperationalListingCanonicalEdit, RadrootsOperationalListingEditDocumentV1, 193 RadrootsOperationalListingEditError, canonicalize_operational_listing_edit, 194 }; 195 196 const SELLER: &str = FIXTURE_ALICE_PUBLIC_KEY_HEX; 197 const OTHER: &str = FIXTURE_BOB_PUBLIC_KEY_HEX; 198 199 fn d_tag(raw: &str) -> DTag { 200 DTag::parse(raw).expect("d tag") 201 } 202 203 fn bin_id(raw: &str) -> InventoryBinId { 204 InventoryBinId::parse(raw).expect("bin id") 205 } 206 207 fn listing() -> OperationalListing { 208 OperationalListing { 209 d_tag: d_tag("AAAAAAAAAAAAAAAAAAAAAg"), 210 published_at: None, 211 farm: FarmRef { 212 pubkey: SELLER.to_string(), 213 d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(), 214 }, 215 product: OperationalListingProduct { 216 key: "coffee".to_string(), 217 title: "Coffee".to_string(), 218 category: "coffee".to_string(), 219 summary: Some("Single origin coffee".to_string()), 220 process: None, 221 lot: None, 222 location: None, 223 profile: None, 224 year: None, 225 }, 226 primary_bin_id: bin_id("bin-1"), 227 bins: vec![OperationalListingBin { 228 bin_id: bin_id("bin-1"), 229 quantity: Quantity::try_new(Decimal::from(1000u32), Unit::MassG).unwrap(), 230 price_per_canonical_unit: QuantityPrice::try_new( 231 Money::try_new(Decimal::from(20u32), Currency::USD).unwrap(), 232 Quantity::try_new(Decimal::from(1u32), Unit::MassG).unwrap(), 233 ) 234 .unwrap(), 235 display_amount: None, 236 display_unit: None, 237 display_label: None, 238 display_price: None, 239 display_price_unit: None, 240 }], 241 resource_area: None, 242 plot: None, 243 discounts: None, 244 inventory_available: Some(Decimal::from(5u32)), 245 availability: Some(OperationalListingAvailability::Status { 246 status: OperationalListingStatus::Active, 247 }), 248 delivery_method: Some(OperationalListingDeliveryMethod::Pickup), 249 location: Some(OperationalListingPublicLocation { 250 primary: "Victoria".to_string(), 251 city: Some("Victoria".to_string()), 252 region: Some("British Columbia".to_string()), 253 country: Some("CA".to_string()), 254 geohash: "c287g".to_string(), 255 }), 256 images: None, 257 } 258 } 259 260 fn seller_pubkey() -> PublicKey { 261 PublicKey::from_hex(SELLER).expect("seller public key") 262 } 263 264 #[test] 265 fn draft_document_wraps_listing() { 266 let document = RadrootsOperationalListingEditDocumentV1::new(listing()); 267 268 assert_eq!(document.listing.d_tag.as_str(), "AAAAAAAAAAAAAAAAAAAAAg"); 269 assert_eq!(document.listing.product.title, "Coffee"); 270 } 271 272 #[cfg(any())] 273 #[test] 274 fn draft_document_deserializes_as_untrusted_input() { 275 let json = serde_json::to_string(&RadrootsOperationalListingEditDocumentV1::new(listing())) 276 .expect("serialize document"); 277 278 let document: RadrootsOperationalListingEditDocumentV1 = 279 serde_json::from_str(&json).expect("deserialize document"); 280 let canonical = canonicalize_operational_listing_edit(seller_pubkey(), document) 281 .expect("canonical draft"); 282 283 assert_eq!(canonical.seller_pubkey().to_hex(), SELLER); 284 assert_eq!(canonical.listing().product.title, "Coffee"); 285 } 286 287 #[test] 288 fn canonical_draft_carries_seller_listing_and_addresses() { 289 let seller_pubkey = PublicKey::from_hex(SELLER).expect("seller"); 290 let listing = listing(); 291 292 let canonical = RadrootsOperationalListingCanonicalEdit::new(listing, seller_pubkey) 293 .expect("canonical"); 294 295 assert_eq!(canonical.seller_pubkey(), &seller_pubkey); 296 assert_eq!( 297 canonical.public_listing_addr().as_str(), 298 format!("{KIND_CLASSIFIED_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg") 299 ); 300 assert_eq!(canonical.listing().d_tag.as_str(), "AAAAAAAAAAAAAAAAAAAAAg"); 301 } 302 303 #[test] 304 fn listing_edit_error_variants_are_precise() { 305 assert!(matches!( 306 RadrootsOperationalListingEditError::InvalidFarmPubkey( 307 PublicKey::from_hex("bad").unwrap_err() 308 ), 309 RadrootsOperationalListingEditError::InvalidFarmPubkey(_) 310 )); 311 assert!(matches!( 312 RadrootsOperationalListingEditError::InvalidClassifiedListingAddress( 313 ClassifiedListingAddress::parse("bad").unwrap_err() 314 ), 315 RadrootsOperationalListingEditError::InvalidClassifiedListingAddress(_) 316 )); 317 } 318 319 #[test] 320 fn canonicalize_operational_listing_edit_fills_missing_farm_pubkey_and_derives_address() { 321 let mut listing = listing(); 322 listing.farm.pubkey.clear(); 323 let document = RadrootsOperationalListingEditDocumentV1::new(listing); 324 325 let canonical = canonicalize_operational_listing_edit(seller_pubkey(), document) 326 .expect("canonical draft"); 327 328 assert_eq!(canonical.seller_pubkey().to_hex(), SELLER); 329 assert_eq!( 330 canonical.public_listing_addr().as_str(), 331 format!("{KIND_CLASSIFIED_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg") 332 ); 333 assert_eq!(canonical.listing().farm.pubkey, SELLER); 334 } 335 336 #[test] 337 fn canonicalize_operational_listing_edit_rejects_mismatched_farm_pubkey() { 338 let mut listing = listing(); 339 listing.farm.pubkey = OTHER.to_string(); 340 let document = RadrootsOperationalListingEditDocumentV1::new(listing); 341 342 let error = canonicalize_operational_listing_edit(seller_pubkey(), document).unwrap_err(); 343 344 assert!(matches!( 345 error, 346 RadrootsOperationalListingEditError::FarmPubkeyMismatch { .. } 347 )); 348 } 349 350 #[test] 351 fn canonicalize_operational_listing_edit_rejects_invalid_farm_pubkey() { 352 let mut listing = listing(); 353 listing.farm.pubkey = "bad".to_string(); 354 let document = RadrootsOperationalListingEditDocumentV1::new(listing); 355 356 let error = canonicalize_operational_listing_edit(seller_pubkey(), document).unwrap_err(); 357 358 assert!(matches!( 359 error, 360 RadrootsOperationalListingEditError::InvalidFarmPubkey(_) 361 )); 362 } 363 364 #[test] 365 fn canonical_draft_new_rejects_mismatched_farm_pubkey() { 366 let mut listing = listing(); 367 listing.farm.pubkey = OTHER.to_string(); 368 369 let error = RadrootsOperationalListingCanonicalEdit::new( 370 listing, 371 PublicKey::from_hex(SELLER).expect("seller"), 372 ) 373 .unwrap_err(); 374 375 assert!(matches!( 376 error, 377 RadrootsOperationalListingEditError::FarmPubkeyMismatch { .. } 378 )); 379 } 380 381 #[test] 382 fn canonical_draft_new_rejects_invalid_farm_pubkey() { 383 let mut listing = listing(); 384 listing.farm.pubkey = "bad".to_string(); 385 386 let error = RadrootsOperationalListingCanonicalEdit::new( 387 listing, 388 PublicKey::from_hex(SELLER).expect("seller"), 389 ) 390 .unwrap_err(); 391 392 assert!(matches!( 393 error, 394 RadrootsOperationalListingEditError::InvalidFarmPubkey(_) 395 )); 396 } 397 398 #[test] 399 fn canonical_draft_new_rejects_empty_farm_pubkey() { 400 let mut listing = listing(); 401 listing.farm.pubkey.clear(); 402 403 let error = RadrootsOperationalListingCanonicalEdit::new( 404 listing, 405 PublicKey::from_hex(SELLER).expect("seller"), 406 ) 407 .unwrap_err(); 408 409 assert!(matches!( 410 error, 411 RadrootsOperationalListingEditError::InvalidFarmPubkey(_) 412 )); 413 } 414 415 #[test] 416 fn canonicalize_operational_listing_edit_rejects_missing_primary_bin() { 417 let mut listing = listing(); 418 listing.primary_bin_id = bin_id("bin-2"); 419 let document = RadrootsOperationalListingEditDocumentV1::new(listing); 420 421 let error = canonicalize_operational_listing_edit(seller_pubkey(), document).unwrap_err(); 422 423 assert_eq!( 424 error, 425 RadrootsOperationalListingEditError::MissingPrimaryBin { 426 primary_bin_id: bin_id("bin-2") 427 } 428 ); 429 } 430 431 #[test] 432 fn canonical_draft_new_rejects_missing_primary_bin() { 433 let mut listing = listing(); 434 listing.primary_bin_id = bin_id("bin-2"); 435 436 let error = RadrootsOperationalListingCanonicalEdit::new( 437 listing, 438 PublicKey::from_hex(SELLER).expect("seller"), 439 ) 440 .unwrap_err(); 441 442 assert_eq!( 443 error, 444 RadrootsOperationalListingEditError::MissingPrimaryBin { 445 primary_bin_id: bin_id("bin-2") 446 } 447 ); 448 } 449 450 #[test] 451 fn canonicalize_operational_listing_edit_rejects_duplicate_bin_ids() { 452 let mut listing = listing(); 453 listing.bins.push(listing.bins[0].clone()); 454 let document = RadrootsOperationalListingEditDocumentV1::new(listing); 455 456 let error = canonicalize_operational_listing_edit(seller_pubkey(), document).unwrap_err(); 457 458 assert_eq!( 459 error, 460 RadrootsOperationalListingEditError::DuplicateBinId { 461 bin_id: bin_id("bin-1") 462 } 463 ); 464 } 465 466 #[test] 467 fn canonical_draft_new_rejects_invalid_model() { 468 let mut listing = listing(); 469 listing.inventory_available = None; 470 471 let error = RadrootsOperationalListingCanonicalEdit::new( 472 listing, 473 PublicKey::from_hex(SELLER).expect("seller"), 474 ) 475 .unwrap_err(); 476 477 assert_eq!( 478 error, 479 RadrootsOperationalListingEditError::InvalidModel( 480 OperationalListingValidationError::MissingInventory 481 ) 482 ); 483 } 484 485 #[test] 486 fn canonical_draft_new_rejects_invalid_secondary_bin() { 487 let mut invalid_quantity_listing = listing(); 488 let mut secondary_bin = invalid_quantity_listing.bins[0].clone(); 489 secondary_bin.bin_id = bin_id("bin-2"); 490 secondary_bin.quantity = Quantity::try_new(Decimal::ONE, Unit::MassKg).unwrap(); 491 invalid_quantity_listing.bins.push(secondary_bin); 492 493 let error = RadrootsOperationalListingCanonicalEdit::new( 494 invalid_quantity_listing, 495 PublicKey::from_hex(SELLER).expect("seller"), 496 ) 497 .unwrap_err(); 498 499 assert_eq!( 500 error, 501 RadrootsOperationalListingEditError::InvalidModel( 502 OperationalListingValidationError::InvalidBin 503 ) 504 ); 505 506 let mut mismatched_unit_listing = listing(); 507 let mut secondary_bin = mismatched_unit_listing.bins[0].clone(); 508 secondary_bin.bin_id = bin_id("bin-2"); 509 secondary_bin.price_per_canonical_unit = QuantityPrice::try_new( 510 secondary_bin.price_per_canonical_unit.amount().clone(), 511 Quantity::try_new(Decimal::ONE, Unit::Each).unwrap(), 512 ) 513 .unwrap(); 514 mismatched_unit_listing.bins.push(secondary_bin); 515 516 let error = RadrootsOperationalListingCanonicalEdit::new( 517 mismatched_unit_listing, 518 PublicKey::from_hex(SELLER).expect("seller"), 519 ) 520 .unwrap_err(); 521 522 assert_eq!( 523 error, 524 RadrootsOperationalListingEditError::InvalidModel( 525 OperationalListingValidationError::InvalidPrice 526 ) 527 ); 528 } 529 530 #[test] 531 fn canonical_draft_new_rejects_duplicate_bin_ids() { 532 let mut listing = listing(); 533 listing.bins.push(listing.bins[0].clone()); 534 535 let error = RadrootsOperationalListingCanonicalEdit::new( 536 listing, 537 PublicKey::from_hex(SELLER).expect("seller"), 538 ) 539 .unwrap_err(); 540 541 assert_eq!( 542 error, 543 RadrootsOperationalListingEditError::DuplicateBinId { 544 bin_id: bin_id("bin-1") 545 } 546 ); 547 } 548 }