lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

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 }