projection.rs (16134B)
1 //! Immutable public availability evidence and retained head views. 2 //! 3 //! Shared verification, tolerant admission and deletion evaluation own protocol 4 //! meaning. These views establish no physical stock, publisher ownership or 5 //! network authority. Original JSON extras remain unauthenticated observations. 6 7 use std::fmt; 8 9 use radroots_event::envelope::EventTimestamp; 10 use radroots_event::envelope::event_head::{ 11 CurrentEventHead, EventHeadCandidateResult, EventHeadCoordinate, 12 event_head_candidate_for_nip01_event, 13 }; 14 use radroots_event::envelope::kind::KIND_CLASSIFIED_LISTING; 15 use radroots_event::id::{RADROOTS_NIP01_COORDINATE_MAX_BYTES, RelayUrl}; 16 use radroots_event::listing::classified::ClassifiedListingPartition; 17 use radroots_event::wire::{DEFAULT_RAW_JSON_MAX_BYTES, Nip01EventWire}; 18 use radroots_event_codec::admission::deletion::{ 19 RadrootsAdmittedNip09DeletionRequestEvent, RadrootsNip09SuppressionDecision, 20 RadrootsNip09SuppressionOutcome, evaluate_nip09_suppression_from_borrowed_requests_v1, 21 }; 22 use radroots_event_codec::admission::food_availability::{ 23 RadrootsFoodAvailabilityAdmissionError, RadrootsFoodAvailabilityAdmissionOutcome, 24 admit_verified_food_availability_event, 25 }; 26 use radroots_event_codec::decode::food_availability::RadrootsInboundFoodAvailabilityProjection; 27 use radroots_event_codec::verify::RadrootsSignatureVerifiedEvent; 28 29 use crate::{ 30 Kind0ProfileCandidate, ProfileMetadata, PublicKey, SafeError, SafeErrorCode, SafeMessage, 31 UnixTimestamp, 32 }; 33 34 use super::{AvailabilityEventVersion, AvailabilityListingCoordinate, PublicPublisher}; 35 36 const MAX_OBSERVATION_SOURCE_BYTES: usize = 2048; 37 const MAX_DELETION_REQUESTS: usize = 4096; 38 const LISTING_COORDINATE_FRAMING_BYTES: usize = "30402:".len() + 64 + 1; 39 40 /// A bounded, exact source reference and local observation time. 41 /// 42 /// The source records provenance only. Pure shared URL validation neither 43 /// normalizes endpoint aliases nor authorizes a destination or connection. 44 #[derive(Clone, Eq, PartialEq)] 45 pub struct AvailabilityObservation { 46 source: RelayUrl, 47 observed_at: UnixTimestamp, 48 } 49 50 impl AvailabilityObservation { 51 /// Parses a pure source reference after checking its UTF-8 byte bound. 52 /// 53 /// # Errors 54 /// 55 /// Returns a static safe error for an oversized or invalid source reference. 56 pub fn parse(source: &str, observed_at: UnixTimestamp) -> Result<Self, SafeError> { 57 if source.len() > MAX_OBSERVATION_SOURCE_BYTES { 58 return Err(invalid_view()); 59 } 60 let source = RelayUrl::parse(source).map_err(|_| invalid_view())?; 61 Ok(Self { 62 source, 63 observed_at, 64 }) 65 } 66 67 #[must_use] 68 pub const fn source(&self) -> &RelayUrl { 69 &self.source 70 } 71 72 #[must_use] 73 pub const fn observed_at(&self) -> UnixTimestamp { 74 self.observed_at 75 } 76 } 77 78 impl fmt::Debug for AvailabilityObservation { 79 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 80 formatter 81 .debug_struct("AvailabilityObservation") 82 .field("source_bytes", &self.source.as_str().len()) 83 .field("observed_at", &self.observed_at) 84 .finish() 85 } 86 } 87 88 /// The shared reason a verified listing cannot provide focused Food data. 89 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 90 pub enum AvailabilityUnsupportedReason { 91 Excluded(ClassifiedListingPartition), 92 ProjectionRejected(&'static str), 93 } 94 95 #[derive(Clone, Eq, PartialEq)] 96 enum AvailabilityProjection { 97 Focused(Box<RadrootsInboundFoodAvailabilityProjection>), 98 Unsupported(AvailabilityUnsupportedReason), 99 } 100 101 /// One exact verified version, its original wire and tolerant admission result. 102 /// 103 /// The raw head coordinate survives independently of the narrower app reference. 104 /// Profile metadata, when present, is caller-supplied author-associated display 105 /// data; it carries no signature typestate or farm-ownership proof. 106 #[derive(Clone, Eq, PartialEq)] 107 pub struct AvailabilityVersionView { 108 verified_event: RadrootsSignatureVerifiedEvent, 109 original_json: String, 110 raw_coordinate: EventHeadCoordinate, 111 listing_coordinate: Option<AvailabilityListingCoordinate>, 112 projection: AvailabilityProjection, 113 observation: AvailabilityObservation, 114 profile_metadata: Option<ProfileMetadata>, 115 } 116 117 impl AvailabilityVersionView { 118 /// Binds shared signature evidence to the complete original wire envelope. 119 /// 120 /// Original formatting and unknown JSON extras are retained exactly. Extras 121 /// are outside the signed envelope and do not gain authenticated meaning. 122 /// Shared tolerant admission preserves valid exclusions and projection 123 /// rejections as unsupported evidence of this version. 124 /// 125 /// # Errors 126 /// 127 /// Returns a static safe error for wire limits, structural errors, a wire 128 /// mismatch, an unsupported kind or an unavailable shared admission result. 129 pub fn from_verified( 130 verified_event: RadrootsSignatureVerifiedEvent, 131 original_json: &str, 132 observation: AvailabilityObservation, 133 ) -> Result<Self, SafeError> { 134 if original_json.len() > DEFAULT_RAW_JSON_MAX_BYTES 135 || verified_event.event().kind_u32() != KIND_CLASSIFIED_LISTING 136 { 137 return Err(invalid_view()); 138 } 139 let envelope = Nip01EventWire::parse_json_unverified(original_json) 140 .map_err(|_| invalid_view())? 141 .into_unverified_envelope() 142 .map_err(|_| invalid_view())?; 143 if &envelope != verified_event.event() { 144 return Err(invalid_view()); 145 } 146 let raw_coordinate = match event_head_candidate_for_nip01_event(verified_event.event()) { 147 EventHeadCandidateResult::Candidate(candidate) => candidate.coordinate, 148 _ => return Err(invalid_view()), 149 }; 150 let listing_coordinate = app_listing_coordinate(&raw_coordinate); 151 // Full default-limit wire equality precedes this bounded evidence copy. 152 let projection = match admit_verified_food_availability_event(verified_event.clone()) { 153 Ok(RadrootsFoodAvailabilityAdmissionOutcome::Admitted(admitted)) => { 154 let (_, projection) = (*admitted).into_parts(); 155 AvailabilityProjection::Focused(Box::new(projection)) 156 } 157 Ok(RadrootsFoodAvailabilityAdmissionOutcome::Excluded(excluded)) => { 158 AvailabilityProjection::Unsupported(AvailabilityUnsupportedReason::Excluded( 159 excluded.partition(), 160 )) 161 } 162 Err(RadrootsFoodAvailabilityAdmissionError::Projection(error)) => { 163 AvailabilityProjection::Unsupported( 164 AvailabilityUnsupportedReason::ProjectionRejected(error.code()), 165 ) 166 } 167 _ => return Err(invalid_view()), 168 }; 169 Ok(Self { 170 verified_event, 171 original_json: original_json.to_owned(), 172 raw_coordinate, 173 listing_coordinate, 174 projection, 175 observation, 176 profile_metadata: None, 177 }) 178 } 179 180 #[must_use] 181 pub fn version(&self) -> AvailabilityEventVersion { 182 AvailabilityEventVersion::from_canonical(*self.verified_event.event().id()) 183 } 184 185 #[must_use] 186 pub fn publisher(&self) -> PublicPublisher { 187 PublicPublisher::from_public_key(PublicKey::from_canonical( 188 *self.verified_event.event().author(), 189 )) 190 } 191 192 #[must_use] 193 pub fn created_at(&self) -> EventTimestamp { 194 self.verified_event.event().created_at() 195 } 196 197 #[must_use] 198 pub fn original_json(&self) -> &str { 199 &self.original_json 200 } 201 202 #[must_use] 203 pub const fn observation(&self) -> &AvailabilityObservation { 204 &self.observation 205 } 206 207 #[must_use] 208 pub const fn raw_coordinate(&self) -> &EventHeadCoordinate { 209 &self.raw_coordinate 210 } 211 212 #[must_use] 213 pub fn listing_coordinate(&self) -> Option<&AvailabilityListingCoordinate> { 214 self.listing_coordinate.as_ref() 215 } 216 217 #[must_use] 218 pub fn focused(&self) -> Option<&RadrootsInboundFoodAvailabilityProjection> { 219 match &self.projection { 220 AvailabilityProjection::Focused(projection) => Some(projection), 221 AvailabilityProjection::Unsupported(_) => None, 222 } 223 } 224 225 #[must_use] 226 pub const fn unsupported_reason(&self) -> Option<&AvailabilityUnsupportedReason> { 227 match &self.projection { 228 AvailabilityProjection::Focused(_) => None, 229 AvailabilityProjection::Unsupported(reason) => Some(reason), 230 } 231 } 232 233 #[must_use] 234 pub fn profile_metadata(&self) -> Option<&ProfileMetadata> { 235 self.profile_metadata.as_ref() 236 } 237 238 /// Returns a copy with optional caller-supplied, author-associated metadata. 239 /// 240 /// Matching authors do not establish a verified profile or ownership. 241 /// Rejection leaves this immutable listing view usable. 242 /// 243 /// # Errors 244 /// 245 /// Returns a static safe error when the supplied candidate author differs. 246 pub fn with_profile(&self, profile: Option<&Kind0ProfileCandidate>) -> Result<Self, SafeError> { 247 if profile.is_some_and(|candidate| candidate.author() != self.publisher().public_key()) { 248 return Err(invalid_view()); 249 } 250 let mut view = self.clone(); 251 view.profile_metadata = profile.map(|candidate| candidate.metadata().clone()); 252 Ok(view) 253 } 254 } 255 256 impl fmt::Debug for AvailabilityVersionView { 257 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 258 formatter 259 .debug_struct("AvailabilityVersionView") 260 .field("created_at", &self.created_at()) 261 .field("wire_bytes", &self.original_json.len()) 262 .field("focused", &self.focused().is_some()) 263 .field("unsupported_reason", &self.unsupported_reason()) 264 .field("profile_present", &self.profile_metadata.is_some()) 265 .field("observation", &self.observation) 266 .finish() 267 } 268 } 269 270 /// The retained selected head's state, without a physical-supply claim. 271 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 272 pub enum AvailabilityHeadState { 273 Missing, 274 Focused, 275 Unsupported, 276 Deleted, 277 } 278 279 #[derive(Clone, Eq, PartialEq)] 280 enum AvailabilityHeadEvidence { 281 Missing(AvailabilityListingCoordinate), 282 Selected { 283 version: Box<AvailabilityVersionView>, 284 suppression: RadrootsNip09SuppressionDecision, 285 }, 286 } 287 288 /// Absence of retained knowledge or an exact selected version and shared decision. 289 /// 290 /// Deleted versions remain available as historical evidence. Neither missing 291 /// knowledge nor listing query completion establishes exhaustive deletion data. 292 #[derive(Clone, Eq, PartialEq)] 293 pub struct AvailabilityHeadView { 294 evidence: AvailabilityHeadEvidence, 295 } 296 297 impl AvailabilityHeadView { 298 #[must_use] 299 pub const fn missing(coordinate: AvailabilityListingCoordinate) -> Self { 300 Self { 301 evidence: AvailabilityHeadEvidence::Missing(coordinate), 302 } 303 } 304 305 /// Validates exact correspondence to the supplied shared selected head and 306 /// delegates suppression to the shared evaluator over borrowed requests. 307 /// 308 /// # Errors 309 /// 310 /// Returns a static safe error when coordinate, event ID or signed time 311 /// differs, or more than4096 admitted deletion requests are supplied. 312 pub fn from_selected( 313 selected: CurrentEventHead, 314 version: AvailabilityVersionView, 315 requests: &[RadrootsAdmittedNip09DeletionRequestEvent], 316 ) -> Result<Self, SafeError> { 317 if requests.len() > MAX_DELETION_REQUESTS 318 || &selected.coordinate != version.raw_coordinate() 319 || selected.event_id != version.version().event_id() 320 || selected.created_at != version.created_at().as_u64() 321 { 322 return Err(invalid_view()); 323 } 324 let suppression = evaluate_nip09_suppression_from_borrowed_requests_v1( 325 &version.verified_event, 326 requests.iter(), 327 ); 328 Ok(Self { 329 evidence: AvailabilityHeadEvidence::Selected { 330 version: Box::new(version), 331 suppression, 332 }, 333 }) 334 } 335 336 #[must_use] 337 pub fn state(&self) -> AvailabilityHeadState { 338 match &self.evidence { 339 AvailabilityHeadEvidence::Missing(_) => AvailabilityHeadState::Missing, 340 AvailabilityHeadEvidence::Selected { 341 version, 342 suppression, 343 } => { 344 if suppression.outcome() == RadrootsNip09SuppressionOutcome::Suppressed { 345 AvailabilityHeadState::Deleted 346 } else if version.focused().is_some() { 347 AvailabilityHeadState::Focused 348 } else { 349 AvailabilityHeadState::Unsupported 350 } 351 } 352 } 353 } 354 355 /// Returns the retained logical coordinate even when no head is known. 356 /// 357 /// Selected raw empty or overlong identifiers remain broader evidence and 358 /// have no bounded application coordinate. Suppression is unchanged. 359 #[must_use] 360 pub fn listing_coordinate(&self) -> Option<&AvailabilityListingCoordinate> { 361 match &self.evidence { 362 AvailabilityHeadEvidence::Missing(coordinate) => Some(coordinate), 363 AvailabilityHeadEvidence::Selected { version, .. } => version.listing_coordinate(), 364 } 365 } 366 367 /// Returns the retained version, including historical deleted evidence. 368 #[must_use] 369 pub fn version(&self) -> Option<&AvailabilityVersionView> { 370 match &self.evidence { 371 AvailabilityHeadEvidence::Missing(_) => None, 372 AvailabilityHeadEvidence::Selected { version, .. } => Some(version), 373 } 374 } 375 376 #[must_use] 377 pub const fn suppression(&self) -> Option<&RadrootsNip09SuppressionDecision> { 378 match &self.evidence { 379 AvailabilityHeadEvidence::Missing(_) => None, 380 AvailabilityHeadEvidence::Selected { suppression, .. } => Some(suppression), 381 } 382 } 383 384 /// Returns focused data only when the selected version remains visible. 385 #[must_use] 386 pub fn focused(&self) -> Option<&RadrootsInboundFoodAvailabilityProjection> { 387 if self.state() == AvailabilityHeadState::Focused { 388 self.version().and_then(AvailabilityVersionView::focused) 389 } else { 390 None 391 } 392 } 393 } 394 395 impl fmt::Debug for AvailabilityHeadView { 396 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 397 let mut summary = formatter.debug_struct("AvailabilityHeadView"); 398 summary.field("state", &self.state()); 399 match &self.evidence { 400 AvailabilityHeadEvidence::Missing(coordinate) => { 401 summary.field("coordinate_bytes", &coordinate.as_str().len()); 402 } 403 AvailabilityHeadEvidence::Selected { 404 version, 405 suppression, 406 } => { 407 summary 408 .field("version", version) 409 .field("suppression_reason", &suppression.reason()); 410 } 411 } 412 summary.finish() 413 } 414 } 415 416 fn app_listing_coordinate(raw: &EventHeadCoordinate) -> Option<AvailabilityListingCoordinate> { 417 let EventHeadCoordinate::Addressable { 418 kind, 419 pubkey, 420 d_tag, 421 } = raw 422 else { 423 return None; 424 }; 425 if *kind != KIND_CLASSIFIED_LISTING 426 || d_tag.is_empty() 427 || d_tag.len() > RADROOTS_NIP01_COORDINATE_MAX_BYTES - LISTING_COORDINATE_FRAMING_BYTES 428 { 429 return None; 430 } 431 // Borrowed framing bounds precede author encoding and coordinate allocation. 432 AvailabilityListingCoordinate::parse(&format!("30402:{}:{d_tag}", pubkey.to_hex())).ok() 433 } 434 435 const fn invalid_view() -> SafeError { 436 SafeError::new( 437 SafeErrorCode::InvalidProfileMetadata, 438 SafeMessage::new("The public availability view is invalid."), 439 ) 440 }