app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

identity.rs (20527B)


      1 //! Bounded public references for availability publishers, versions and listings.
      2 //!
      3 //! These references identify public data. Their construction proves no event
      4 //! signature, installed account, signing custody, ownership or physical stock.
      5 
      6 use radroots_event::envelope::kind::KIND_CLASSIFIED_LISTING;
      7 use radroots_event::id::{Nip01Coordinate, RADROOTS_NIP01_COORDINATE_MAX_BYTES};
      8 
      9 use crate::{PublicKey, SafeError, SafeErrorCode, SafeMessage};
     10 
     11 const EVENT_ID_HEX_BYTES: usize = 64;
     12 const LISTING_COORDINATE_PREFIX: &str = "30402:";
     13 
     14 /// A validated public publisher identity, independent of installed accounts.
     15 #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
     16 pub struct PublicPublisher(PublicKey);
     17 
     18 impl PublicPublisher {
     19     #[must_use]
     20     pub const fn from_public_key(value: PublicKey) -> Self {
     21         Self(value)
     22     }
     23 
     24     /// Parses a canonical lowercase hexadecimal public author.
     25     ///
     26     /// # Errors
     27     ///
     28     /// Returns a safe invalid-public-key error for malformed or invalid keys.
     29     pub fn from_hex(value: &str) -> Result<Self, SafeError> {
     30         PublicKey::from_hex(value).map(Self)
     31     }
     32 
     33     /// Validates an x-only secp256k1 public author using shared identity policy.
     34     ///
     35     /// # Errors
     36     ///
     37     /// Returns a safe invalid-public-key error for an invalid curve point.
     38     pub fn from_bytes(value: [u8; 32]) -> Result<Self, SafeError> {
     39         PublicKey::from_bytes(value).map(Self)
     40     }
     41 
     42     #[must_use]
     43     pub const fn public_key(self) -> PublicKey {
     44         self.0
     45     }
     46 }
     47 
     48 /// An exact event-ID reference, separate from a logical listing coordinate.
     49 ///
     50 /// Possessing an ID does not establish the referenced event's signature or its
     51 /// relationship to any listing. Those facts require shared verified evidence.
     52 #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
     53 pub struct AvailabilityEventVersion(radroots_event::EventId);
     54 
     55 impl AvailabilityEventVersion {
     56     #[must_use]
     57     pub const fn from_canonical(value: radroots_event::EventId) -> Self {
     58         Self(value)
     59     }
     60 
     61     /// Parses exactly 64 canonical lowercase hexadecimal event-ID bytes.
     62     ///
     63     /// # Errors
     64     ///
     65     /// Returns a safe public-reference error for malformed or ambiguous input.
     66     pub fn from_hex(value: &str) -> Result<Self, SafeError> {
     67         if value.len() != EVENT_ID_HEX_BYTES
     68             || !value
     69                 .bytes()
     70                 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
     71         {
     72             return Err(invalid_reference());
     73         }
     74         radroots_event::EventId::parse(value)
     75             .map(Self)
     76             .map_err(|_| invalid_reference())
     77     }
     78 
     79     #[must_use]
     80     pub const fn event_id(self) -> radroots_event::EventId {
     81         self.0
     82     }
     83 }
     84 
     85 /// A nonempty kind-30402 logical listing reference with shared coordinate bounds.
     86 ///
     87 /// The identifier remains opaque, including colons, whitespace, controls and
     88 /// Unicode. This reference does not apply strict authored FoodIdentifier policy
     89 /// and must not replace broader raw inbound or event-head admission. Empty or
     90 /// longer raw head identifiers remain evidence at their shared owner boundary.
     91 #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
     92 pub struct AvailabilityListingCoordinate(Nip01Coordinate);
     93 
     94 impl AvailabilityListingCoordinate {
     95     /// Parses a canonical listing reference without normalizing its identifier.
     96     ///
     97     /// App bounds, framing and author validation precede shared owned copies.
     98     /// Shared parsing owns coordinate interpretation and canonical construction.
     99     ///
    100     /// # Errors
    101     ///
    102     /// Returns a safe public-reference error for invalid, noncanonical, empty or
    103     /// oversized listing references.
    104     pub fn parse(value: &str) -> Result<Self, SafeError> {
    105         if value.len() > RADROOTS_NIP01_COORDINATE_MAX_BYTES {
    106             return Err(invalid_reference());
    107         }
    108         let remainder = value
    109             .strip_prefix(LISTING_COORDINATE_PREFIX)
    110             .ok_or_else(invalid_reference)?;
    111         let (author, identifier) = remainder.split_once(':').ok_or_else(invalid_reference)?;
    112         if identifier.is_empty() {
    113             return Err(invalid_reference());
    114         }
    115         PublicKey::from_hex(author).map_err(|_| invalid_reference())?;
    116         let coordinate = Nip01Coordinate::parse(value).map_err(|_| invalid_reference())?;
    117         Self::from_canonical(coordinate)
    118     }
    119 
    120     /// Moves a shared coordinate after checking the app's listing invariants.
    121     ///
    122     /// # Errors
    123     ///
    124     /// Returns a safe public-reference error for another kind, an empty
    125     /// identifier or a coordinate exceeding the selected shared byte bound.
    126     pub fn from_canonical(value: Nip01Coordinate) -> Result<Self, SafeError> {
    127         if value.kind() != KIND_CLASSIFIED_LISTING
    128             || value.identifier().is_empty()
    129             || value.as_str().len() > RADROOTS_NIP01_COORDINATE_MAX_BYTES
    130         {
    131             return Err(invalid_reference());
    132         }
    133         Ok(Self(value))
    134     }
    135 
    136     #[must_use]
    137     pub const fn canonical(&self) -> &Nip01Coordinate {
    138         &self.0
    139     }
    140 
    141     #[must_use]
    142     pub fn as_str(&self) -> &str {
    143         self.0.as_str()
    144     }
    145 
    146     #[must_use]
    147     pub fn publisher(&self) -> PublicPublisher {
    148         PublicPublisher::from_public_key(PublicKey::from_canonical(*self.0.pubkey()))
    149     }
    150 
    151     #[must_use]
    152     pub const fn kind(&self) -> u32 {
    153         self.0.kind()
    154     }
    155 
    156     #[must_use]
    157     pub fn identifier(&self) -> &str {
    158         self.0.identifier()
    159     }
    160 }
    161 
    162 const fn invalid_reference() -> SafeError {
    163     SafeError::new(
    164         SafeErrorCode::InvalidProfileMetadata,
    165         SafeMessage::new("The public availability reference is invalid."),
    166     )
    167 }
    168 
    169 #[cfg(test)]
    170 mod tests {
    171     use std::collections::HashSet;
    172     use std::error::Error;
    173 
    174     use radroots_event::food::availability::FoodIdentifier;
    175     use radroots_event::id::{Nip01Coordinate, RADROOTS_NIP01_COORDINATE_MAX_BYTES};
    176 
    177     use super::{AvailabilityEventVersion, AvailabilityListingCoordinate, PublicPublisher};
    178     use crate::{PublicKey, SafeError, SafeErrorCode};
    179 
    180     const AUTHOR_HEX: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
    181 
    182     fn author() -> PublicKey {
    183         PublicKey::from_hex(AUTHOR_HEX).expect("valid public author")
    184     }
    185 
    186     fn coordinate_text(identifier: &str) -> String {
    187         format!("30402:{AUTHOR_HEX}:{identifier}")
    188     }
    189 
    190     fn coordinate(identifier: &str) -> AvailabilityListingCoordinate {
    191         AvailabilityListingCoordinate::parse(&coordinate_text(identifier))
    192             .expect("valid listing reference")
    193     }
    194 
    195     fn identifier_at_byte_limit(unit: &str, maximum: usize) -> String {
    196         unit.repeat(maximum / unit.len()) + &"x".repeat(maximum % unit.len())
    197     }
    198 
    199     fn assert_invalid_reference(error: SafeError) {
    200         assert_eq!(error.code(), SafeErrorCode::InvalidProfileMetadata);
    201     }
    202 
    203     #[test]
    204     fn public_publisher_uses_shared_identity_without_installed_account() {
    205         let shared =
    206             radroots_identity::PublicKey::from_hex(AUTHOR_HEX).expect("valid shared public author");
    207         let publisher = PublicPublisher::from_public_key(PublicKey::from_canonical(shared));
    208 
    209         assert_eq!(publisher.public_key().canonical(), shared);
    210         assert_eq!(
    211             PublicPublisher::from_hex(AUTHOR_HEX).expect("public author"),
    212             publisher
    213         );
    214         assert_eq!(
    215             PublicPublisher::from_bytes(*shared.as_bytes()).expect("public author bytes"),
    216             publisher
    217         );
    218         assert_eq!(HashSet::from([publisher, publisher]).len(), 1);
    219     }
    220 
    221     #[test]
    222     fn public_publisher_rejects_invalid_curve_and_noncanonical_author() {
    223         let invalid_curve = "ff".repeat(32);
    224         let uppercase = AUTHOR_HEX.to_ascii_uppercase();
    225         let short = &AUTHOR_HEX[..63];
    226         let padded = format!(" {AUTHOR_HEX}");
    227         let invalid_character = "g".repeat(64);
    228 
    229         for invalid in [
    230             "",
    231             invalid_curve.as_str(),
    232             uppercase.as_str(),
    233             short,
    234             padded.as_str(),
    235             invalid_character.as_str(),
    236             "npub1invalid",
    237         ] {
    238             assert_eq!(
    239                 PublicPublisher::from_hex(invalid)
    240                     .expect_err("invalid public author")
    241                     .code(),
    242                 SafeErrorCode::InvalidPublicKey
    243             );
    244         }
    245         assert_eq!(
    246             PublicPublisher::from_bytes([u8::MAX; 32])
    247                 .expect_err("invalid x-only curve point")
    248                 .code(),
    249             SafeErrorCode::InvalidPublicKey
    250         );
    251     }
    252 
    253     #[test]
    254     fn event_version_retains_exact_shared_event_id() {
    255         let shared = radroots_event::EventId::from_bytes([0xab; 32]);
    256         let version = AvailabilityEventVersion::from_canonical(shared);
    257 
    258         assert_eq!(version.event_id(), shared);
    259         assert_eq!(version.event_id().as_bytes(), &[0xab; 32]);
    260         assert_eq!(
    261             AvailabilityEventVersion::from_hex(&"ab".repeat(32)).expect("exact event ID"),
    262             version
    263         );
    264         assert_eq!(HashSet::from([version, version]).len(), 1);
    265         assert_ne!(
    266             version,
    267             AvailabilityEventVersion::from_canonical(radroots_event::EventId::from_bytes(
    268                 [0xcd; 32]
    269             ))
    270         );
    271     }
    272 
    273     #[test]
    274     fn event_version_rejects_malformed_and_ambiguous_hex() {
    275         let uppercase = "AB".repeat(32);
    276         let short = "a".repeat(63);
    277         let long = "a".repeat(65);
    278         let invalid_character = "g".repeat(64);
    279         let leading_space = format!(" {}", "ab".repeat(32));
    280         let prefixed = format!("0x{}", "ab".repeat(32));
    281         let multibyte = "é".repeat(32);
    282 
    283         for invalid in [
    284             "",
    285             uppercase.as_str(),
    286             short.as_str(),
    287             long.as_str(),
    288             invalid_character.as_str(),
    289             leading_space.as_str(),
    290             prefixed.as_str(),
    291             multibyte.as_str(),
    292         ] {
    293             assert_invalid_reference(
    294                 AvailabilityEventVersion::from_hex(invalid).expect_err("invalid exact event ID"),
    295             );
    296         }
    297     }
    298 
    299     #[test]
    300     fn listing_coordinate_delegates_to_shared_canonical_coordinate() {
    301         let text = coordinate_text("farm:lot-1");
    302         let shared = Nip01Coordinate::parse(&text).expect("shared coordinate");
    303         let reference = AvailabilityListingCoordinate::parse(&text).expect("listing coordinate");
    304 
    305         assert_eq!(reference.canonical(), &shared);
    306         assert_eq!(reference.as_str(), shared.as_str());
    307         assert_eq!(reference.kind(), 30_402);
    308         assert_eq!(reference.publisher().public_key(), author());
    309         assert_eq!(reference.identifier(), "farm:lot-1");
    310         assert_eq!(
    311             AvailabilityListingCoordinate::from_canonical(shared).expect("typed coordinate"),
    312             reference
    313         );
    314     }
    315 
    316     #[test]
    317     fn listing_coordinate_rejects_wrong_kind_and_empty_identifier() {
    318         for kind in [0, 3, 5, 10_000, 30_000, 30_401, 30_403, 39_999, 40_000] {
    319             assert_invalid_reference(
    320                 AvailabilityListingCoordinate::parse(&format!("{kind}:{AUTHOR_HEX}:lot"))
    321                     .expect_err("wrong listing kind"),
    322             );
    323         }
    324         for invalid in [
    325             String::new(),
    326             coordinate_text(""),
    327             format!("30402:{AUTHOR_HEX}"),
    328             "30402:bad:lot".to_owned(),
    329             format!("30402:{}:lot", "ff".repeat(32)),
    330         ] {
    331             assert_invalid_reference(
    332                 AvailabilityListingCoordinate::parse(&invalid)
    333                     .expect_err("invalid listing coordinate"),
    334             );
    335         }
    336         assert!(
    337             Nip01Coordinate::parse(coordinate_text("")).is_ok(),
    338             "raw shared coordinate admission remains broader than this listing reference"
    339         );
    340     }
    341 
    342     #[test]
    343     fn listing_coordinate_rejects_noncanonical_kind_and_author_aliases() {
    344         for kind in ["+30402", "030402", "+030402", " 30402", "30402 "] {
    345             assert_invalid_reference(
    346                 AvailabilityListingCoordinate::parse(&format!("{kind}:{AUTHOR_HEX}:lot"))
    347                     .expect_err("noncanonical kind encoding"),
    348             );
    349         }
    350         for invalid in [
    351             format!("30402:{}:lot", AUTHOR_HEX.to_ascii_uppercase()),
    352             format!("30402: {AUTHOR_HEX}:lot"),
    353             format!("30402:{AUTHOR_HEX} :lot"),
    354             format!("\u{2003}30402:{AUTHOR_HEX}:lot"),
    355         ] {
    356             assert_invalid_reference(
    357                 AvailabilityListingCoordinate::parse(&invalid)
    358                     .expect_err("noncanonical author or coordinate framing"),
    359             );
    360         }
    361     }
    362 
    363     #[test]
    364     fn listing_coordinate_preserves_opaque_identifier_after_second_colon() {
    365         for identifier in [
    366             ":",
    367             "::",
    368             "farm:lot:2026",
    369             "  victoria:\0seed:\u{2603}\n",
    370             "a\u{200d}b",
    371             "e\u{301}",
    372             "a\r\tb",
    373             " trailing ",
    374         ] {
    375             let reference = coordinate(identifier);
    376             let shared = Nip01Coordinate::parse(coordinate_text(identifier))
    377                 .expect("shared opaque coordinate");
    378 
    379             assert_eq!(reference.identifier().as_bytes(), identifier.as_bytes());
    380             assert_eq!(
    381                 reference.as_str().as_bytes(),
    382                 coordinate_text(identifier).as_bytes()
    383             );
    384             assert_eq!(reference.canonical(), &shared);
    385         }
    386     }
    387 
    388     #[test]
    389     fn listing_reference_identifier_policy_is_distinct_from_authored_food_identifier() {
    390         let oversized_authored = "x".repeat(513);
    391         for identifier in [oversized_authored.as_str(), " farm ", "a\0b", "a\u{200d}b"] {
    392             assert_eq!(coordinate(identifier).identifier(), identifier);
    393             assert!(
    394                 FoodIdentifier::parse(identifier).is_err(),
    395                 "opaque public references cannot inherit strict authored identifier policy"
    396             );
    397         }
    398         let strict = FoodIdentifier::parse("farm:lot-1").expect("strict colon identifier");
    399         assert_eq!(coordinate(strict.as_str()).identifier(), strict.as_str());
    400     }
    401 
    402     #[test]
    403     fn listing_coordinate_accepts_exact_ascii_byte_limit_and_rejects_overflow() {
    404         assert_eq!(RADROOTS_NIP01_COORDINATE_MAX_BYTES, 4_096);
    405         let prefix = coordinate_text("");
    406         assert_eq!(prefix.len(), 71);
    407         let identifier = "x".repeat(RADROOTS_NIP01_COORDINATE_MAX_BYTES - prefix.len());
    408         let exact = coordinate_text(&identifier);
    409 
    410         assert_eq!(identifier.len(), 4_025);
    411         assert_eq!(exact.len(), RADROOTS_NIP01_COORDINATE_MAX_BYTES);
    412         assert_eq!(
    413             AvailabilityListingCoordinate::parse(&exact)
    414                 .expect("exact full coordinate limit")
    415                 .identifier(),
    416             identifier
    417         );
    418         assert_invalid_reference(
    419             AvailabilityListingCoordinate::parse(&(exact + "x"))
    420                 .expect_err("one byte above full coordinate limit"),
    421         );
    422     }
    423 
    424     #[test]
    425     fn listing_coordinate_enforces_multibyte_and_combining_byte_limits() {
    426         let maximum = RADROOTS_NIP01_COORDINATE_MAX_BYTES - coordinate_text("").len();
    427         for unit in ["é", "🥕", "e\u{301}"] {
    428             let identifier = identifier_at_byte_limit(unit, maximum);
    429             let exact = coordinate_text(&identifier);
    430 
    431             assert_eq!(identifier.len(), maximum);
    432             assert_eq!(exact.len(), RADROOTS_NIP01_COORDINATE_MAX_BYTES);
    433             assert_eq!(
    434                 AvailabilityListingCoordinate::parse(&exact)
    435                     .expect("exact UTF-8 coordinate limit")
    436                     .identifier()
    437                     .as_bytes(),
    438                 identifier.as_bytes()
    439             );
    440             assert_invalid_reference(
    441                 AvailabilityListingCoordinate::parse(&(exact + "x"))
    442                     .expect_err("one UTF-8 byte above coordinate limit"),
    443             );
    444         }
    445         assert_ne!(coordinate("é"), coordinate("e\u{301}"));
    446     }
    447 
    448     #[test]
    449     fn typed_coordinate_constructor_cannot_bypass_listing_invariants() {
    450         for text in [
    451             format!("30000:{AUTHOR_HEX}:lot"),
    452             format!("0:{AUTHOR_HEX}:"),
    453             coordinate_text(""),
    454         ] {
    455             let shared = Nip01Coordinate::parse(text).expect("valid broader shared coordinate");
    456             assert_invalid_reference(
    457                 AvailabilityListingCoordinate::from_canonical(shared)
    458                     .expect_err("shared coordinate is not a nonempty listing reference"),
    459             );
    460         }
    461         let canonicalized = Nip01Coordinate::parse(format!(
    462             "030402:{}:farm:lot",
    463             AUTHOR_HEX.to_ascii_uppercase()
    464         ))
    465         .expect("shared canonicalization");
    466         let reference = AvailabilityListingCoordinate::from_canonical(canonicalized)
    467             .expect("already canonical shared listing reference");
    468         assert_eq!(reference.as_str(), coordinate_text("farm:lot"));
    469     }
    470 
    471     #[test]
    472     fn event_versions_and_listing_coordinates_preserve_distinct_identity() {
    473         let first_id = radroots_event::EventId::from_bytes([0xab; 32]);
    474         let first = AvailabilityEventVersion::from_canonical(first_id);
    475         let repeated = AvailabilityEventVersion::from_hex(&first_id.to_hex())
    476             .expect("repeated exact event version");
    477         let revision = AvailabilityEventVersion::from_canonical(
    478             radroots_event::EventId::from_bytes([0xcd; 32]),
    479         );
    480         let listing = coordinate("lot-1");
    481         let same_listing = AvailabilityListingCoordinate::from_canonical(
    482             Nip01Coordinate::parse(coordinate_text("lot-1")).expect("same shared coordinate"),
    483         )
    484         .expect("same listing coordinate");
    485         let different_identifier = coordinate("lot-2");
    486         let other_author = PublicKey::from_bytes([7; 32]).expect("another public author");
    487         let different_publisher =
    488             AvailabilityListingCoordinate::parse(&format!("30402:{}:lot-1", other_author.to_hex()))
    489                 .expect("distinct publisher coordinate");
    490 
    491         assert_eq!(first, repeated);
    492         assert_ne!(first, revision);
    493         assert_eq!(listing, same_listing);
    494         assert_ne!(listing, different_identifier);
    495         assert_ne!(listing, different_publisher);
    496         assert_eq!(HashSet::from([first, repeated, revision]).len(), 2);
    497         assert_eq!(
    498             HashSet::from([
    499                 listing,
    500                 same_listing,
    501                 different_identifier,
    502                 different_publisher
    503             ])
    504             .len(),
    505             3
    506         );
    507     }
    508 
    509     #[test]
    510     fn availability_reference_errors_contain_only_static_safe_messages() {
    511         const INPUT_MARKER: &str = "raw_availability_input_diagnostic_marker";
    512         let publisher_error =
    513             PublicPublisher::from_hex(INPUT_MARKER).expect_err("malformed public author");
    514         let version_error =
    515             AvailabilityEventVersion::from_hex(INPUT_MARKER).expect_err("malformed exact event ID");
    516         let coordinate_error =
    517             AvailabilityListingCoordinate::parse(&format!("30403:{AUTHOR_HEX}:{INPUT_MARKER}"))
    518                 .expect_err("wrong kind with opaque input marker");
    519 
    520         assert_eq!(publisher_error.code(), SafeErrorCode::InvalidPublicKey);
    521         assert_invalid_reference(version_error);
    522         assert_invalid_reference(coordinate_error);
    523         assert_eq!(
    524             publisher_error.message(),
    525             PublicPublisher::from_hex("")
    526                 .expect_err("empty public author")
    527                 .message()
    528         );
    529         assert_eq!(
    530             version_error.message(),
    531             AvailabilityEventVersion::from_hex("")
    532                 .expect_err("empty event ID")
    533                 .message()
    534         );
    535         assert_eq!(
    536             coordinate_error.message(),
    537             AvailabilityListingCoordinate::parse("")
    538                 .expect_err("empty coordinate")
    539                 .message()
    540         );
    541         for error in [publisher_error, version_error, coordinate_error] {
    542             assert!(!error.message().as_str().contains(INPUT_MARKER));
    543             assert!(!error.to_string().contains(INPUT_MARKER));
    544             assert!(!format!("{error:?}").contains(INPUT_MARKER));
    545             assert!(error.source().is_none());
    546         }
    547     }
    548 }