query.rs (16978B)
1 //! Bounded local availability query values and public snapshot continuations. 2 //! 3 //! These values retain structural bindings supplied by a caller. They prove no 4 //! installed-account admission, event signature, physical inventory, network 5 //! completeness or current database state. The storage adapter must supply its 6 //! actual validated store identity, source revision and projection snapshot. 7 8 use std::cmp::Ordering; 9 use std::fmt; 10 11 pub use radroots_event::envelope::EventTimestamp; 12 pub use radroots_event::food::availability::FoodAvailabilityStatus; 13 use sha2::{Digest, Sha256}; 14 15 use crate::PublicKey; 16 17 use super::{AvailabilityEventVersion, PublicPublisher}; 18 19 pub const AVAILABILITY_PAGE_DEFAULT_ROWS: u16 = 50; 20 pub const AVAILABILITY_PAGE_MAX_ROWS: u16 = 100; 21 pub const AVAILABILITY_QUERY_TEXT_MAX_BYTES: usize = 512; 22 pub const AVAILABILITY_CURSOR_MAX_BYTES: usize = 512; 23 24 const QUERY_FINGERPRINT_DOMAIN: &[u8] = b"harvestcircle.availability.query.v1\0"; 25 const CURSOR_ENCODED_BYTES: usize = 151; 26 const HEX_DIGITS: &[u8; 16] = b"0123456789abcdef"; 27 28 /// Static query-contract failures without untrusted input or dependent errors. 29 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 30 pub enum AvailabilityQueryError { 31 InvalidInput, 32 InputTooLarge, 33 ScopeMismatch, 34 StaleQuery, 35 Capacity, 36 } 37 38 impl fmt::Display for AvailabilityQueryError { 39 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 40 formatter.write_str(match self { 41 Self::InvalidInput => "The availability query input is invalid.", 42 Self::InputTooLarge => "The availability query input exceeds its byte limit.", 43 Self::ScopeMismatch => "The availability query scope does not match.", 44 Self::StaleQuery => "The availability query is stale.", 45 Self::Capacity => "The availability page exceeds its row limit.", 46 }) 47 } 48 } 49 50 impl std::error::Error for AvailabilityQueryError {} 51 52 /// A positive row limit bounded by the local page contract. 53 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 54 pub struct AvailabilityPageLimit(u16); 55 56 impl AvailabilityPageLimit { 57 /// Admits exactly one through one hundred rows. 58 /// 59 /// # Errors 60 /// 61 /// Returns `InvalidInput` for zero or a limit above the maximum. 62 pub const fn new(rows: u16) -> Result<Self, AvailabilityQueryError> { 63 if rows == 0 || rows > AVAILABILITY_PAGE_MAX_ROWS { 64 return Err(AvailabilityQueryError::InvalidInput); 65 } 66 Ok(Self(rows)) 67 } 68 69 #[must_use] 70 pub const fn rows(self) -> u16 { 71 self.0 72 } 73 } 74 75 impl Default for AvailabilityPageLimit { 76 fn default() -> Self { 77 Self(AVAILABILITY_PAGE_DEFAULT_ROWS) 78 } 79 } 80 81 /// Exact opaque cached search text, including a present empty value. 82 #[derive(Clone, Eq, PartialEq)] 83 pub struct AvailabilitySearchText(String); 84 85 impl AvailabilitySearchText { 86 /// Retains exact UTF-8 bytes after checking their bound before copying. 87 /// 88 /// This applies no trimming, normalization, location policy or SQL wildcard 89 /// interpretation and initiates no remote search. 90 /// 91 /// # Errors 92 /// 93 /// Returns `InputTooLarge` when the borrowed input exceeds 512 bytes. 94 pub fn new(value: &str) -> Result<Self, AvailabilityQueryError> { 95 if value.len() > AVAILABILITY_QUERY_TEXT_MAX_BYTES { 96 return Err(AvailabilityQueryError::InputTooLarge); 97 } 98 Ok(Self(value.to_owned())) 99 } 100 101 #[must_use] 102 pub fn as_str(&self) -> &str { 103 &self.0 104 } 105 } 106 107 impl fmt::Debug for AvailabilitySearchText { 108 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 109 formatter 110 .debug_struct("AvailabilitySearchText") 111 .field("bytes", &self.0.len()) 112 .finish() 113 } 114 } 115 116 /// Immutable optional local filters using the selected shared status enum. 117 #[derive(Clone, Eq, PartialEq)] 118 pub struct AvailabilityQueryFilters { 119 search: Option<AvailabilitySearchText>, 120 publisher: Option<PublicPublisher>, 121 status: Option<FoodAvailabilityStatus>, 122 } 123 124 impl AvailabilityQueryFilters { 125 #[must_use] 126 pub fn new( 127 search: Option<AvailabilitySearchText>, 128 publisher: Option<PublicPublisher>, 129 status: Option<FoodAvailabilityStatus>, 130 ) -> Self { 131 Self { 132 search, 133 publisher, 134 status, 135 } 136 } 137 138 #[must_use] 139 pub const fn search(&self) -> Option<&AvailabilitySearchText> { 140 self.search.as_ref() 141 } 142 143 #[must_use] 144 pub const fn publisher(&self) -> Option<PublicPublisher> { 145 self.publisher 146 } 147 148 #[must_use] 149 pub const fn status(&self) -> Option<FoodAvailabilityStatus> { 150 self.status 151 } 152 } 153 154 impl fmt::Debug for AvailabilityQueryFilters { 155 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 156 formatter 157 .debug_struct("AvailabilityQueryFilters") 158 .field( 159 "search_bytes", 160 &self.search().map(|value| value.as_str().len()), 161 ) 162 .field("publisher_present", &self.publisher.is_some()) 163 .field("status", &self.status) 164 .finish() 165 } 166 } 167 168 /// Caller-supplied selected-context and local snapshot bindings. 169 /// 170 /// Nonzero opaque identities are structural values, not proof that a context 171 /// or store is installed, admitted, current or validated by this module. 172 #[derive(Clone, Copy, Eq, PartialEq)] 173 pub struct AvailabilityQueryContext { 174 context_id: [u8; 32], 175 store_generation: [u8; 32], 176 source_revision: u64, 177 projection_generation: u64, 178 } 179 180 impl AvailabilityQueryContext { 181 /// Retains exact identities and full-width revisions and generations. 182 /// 183 /// # Errors 184 /// 185 /// Returns `InvalidInput` if either opaque identity is all zero. 186 pub fn new( 187 context_id: [u8; 32], 188 store_generation: [u8; 32], 189 source_revision: u64, 190 projection_generation: u64, 191 ) -> Result<Self, AvailabilityQueryError> { 192 if context_id == [0; 32] || store_generation == [0; 32] { 193 return Err(AvailabilityQueryError::InvalidInput); 194 } 195 Ok(Self { 196 context_id, 197 store_generation, 198 source_revision, 199 projection_generation, 200 }) 201 } 202 203 #[must_use] 204 pub const fn context_id(&self) -> &[u8; 32] { 205 &self.context_id 206 } 207 208 #[must_use] 209 pub const fn store_generation(&self) -> &[u8; 32] { 210 &self.store_generation 211 } 212 213 #[must_use] 214 pub const fn source_revision(&self) -> u64 { 215 self.source_revision 216 } 217 218 #[must_use] 219 pub const fn projection_generation(&self) -> u64 { 220 self.projection_generation 221 } 222 } 223 224 impl fmt::Debug for AvailabilityQueryContext { 225 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 226 formatter 227 .debug_struct("AvailabilityQueryContext") 228 .field("source_revision", &self.source_revision) 229 .field("projection_generation", &self.projection_generation) 230 .finish_non_exhaustive() 231 } 232 } 233 234 /// A page position ordered by descending signed event time and ascending ID. 235 /// 236 /// Signed event time retains the shared timestamp's complete `u64` range. 237 /// Lower `Ord` values come earlier in the page; this selects no event head. 238 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 239 pub struct AvailabilityOrderKey { 240 created_at: EventTimestamp, 241 version: AvailabilityEventVersion, 242 } 243 244 impl AvailabilityOrderKey { 245 #[must_use] 246 pub const fn new(created_at: EventTimestamp, version: AvailabilityEventVersion) -> Self { 247 Self { 248 created_at, 249 version, 250 } 251 } 252 253 #[must_use] 254 pub const fn created_at(self) -> EventTimestamp { 255 self.created_at 256 } 257 258 #[must_use] 259 pub const fn version(self) -> AvailabilityEventVersion { 260 self.version 261 } 262 263 #[must_use] 264 pub fn is_after(self, previous: Self) -> bool { 265 self > previous 266 } 267 } 268 269 impl Ord for AvailabilityOrderKey { 270 fn cmp(&self, other: &Self) -> Ordering { 271 other.created_at.cmp(&self.created_at).then_with(|| { 272 self.version 273 .event_id() 274 .as_bytes() 275 .cmp(other.version.event_id().as_bytes()) 276 }) 277 } 278 } 279 280 impl PartialOrd for AvailabilityOrderKey { 281 fn partial_cmp(&self, other: &Self) -> Option<Ordering> { 282 Some(self.cmp(other)) 283 } 284 } 285 286 /// A versioned public request digest, with no signature or authorization claim. 287 #[derive(Clone, Copy, Eq, PartialEq)] 288 pub struct AvailabilityQueryFingerprint([u8; 32]); 289 290 impl AvailabilityQueryFingerprint { 291 /// Streams the fixed v1 frame through standard SHA-256. 292 /// 293 /// The frame binds full owner/context/store bytes, big-endian counters, 294 /// exact option discriminants and search byte length, shared status and 295 /// row limit. No secret, host entropy or intermediate request string exists. 296 #[must_use] 297 pub fn new( 298 owner: PublicKey, 299 context: &AvailabilityQueryContext, 300 session_generation: u64, 301 filters: &AvailabilityQueryFilters, 302 limit: AvailabilityPageLimit, 303 ) -> Self { 304 let mut digest = Sha256::new(); 305 digest.update(QUERY_FINGERPRINT_DOMAIN); 306 digest.update(owner.as_bytes()); 307 digest.update(context.context_id()); 308 digest.update(context.store_generation()); 309 digest.update(context.source_revision().to_be_bytes()); 310 digest.update(context.projection_generation().to_be_bytes()); 311 digest.update(session_generation.to_be_bytes()); 312 match filters.search() { 313 None => digest.update([0]), 314 Some(search) => { 315 digest.update([1]); 316 // The validated text type bounds this length to at most 512. 317 digest.update((search.as_str().len() as u64).to_be_bytes()); 318 digest.update(search.as_str().as_bytes()); 319 } 320 } 321 match filters.publisher() { 322 None => digest.update([0]), 323 Some(publisher) => { 324 digest.update([1]); 325 digest.update(publisher.public_key().as_bytes()); 326 } 327 } 328 digest.update([match filters.status() { 329 None => 0, 330 Some(FoodAvailabilityStatus::Active) => 1, 331 Some(FoodAvailabilityStatus::Sold) => 2, 332 }]); 333 digest.update(limit.rows().to_be_bytes()); 334 Self(digest.finalize().into()) 335 } 336 337 #[must_use] 338 pub const fn bytes(&self) -> &[u8; 32] { 339 &self.0 340 } 341 } 342 343 impl fmt::Debug for AvailabilityQueryFingerprint { 344 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 345 formatter 346 .debug_struct("AvailabilityQueryFingerprint") 347 .finish_non_exhaustive() 348 } 349 } 350 351 /// Canonical public continuation for one exact structural query snapshot. 352 /// 353 /// A cursor is forgeable by design. Its fingerprint is correspondence, not a 354 /// permission token; a future consumer must separately admit the local scope. 355 #[derive(Clone, Eq, PartialEq)] 356 pub struct AvailabilityPageCursor { 357 value: String, 358 after: AvailabilityOrderKey, 359 } 360 361 impl AvailabilityPageCursor { 362 #[must_use] 363 pub fn encode(fingerprint: AvailabilityQueryFingerprint, after: AvailabilityOrderKey) -> Self { 364 let mut value = String::with_capacity(CURSOR_ENCODED_BYTES); 365 value.push_str("hcq1:"); 366 append_hex(&mut value, fingerprint.bytes()); 367 value.push(':'); 368 append_hex(&mut value, &after.created_at().as_u64().to_be_bytes()); 369 value.push(':'); 370 append_hex(&mut value, after.version().event_id().as_bytes()); 371 Self { value, after } 372 } 373 374 /// Validates the complete borrowed cursor before allocating its owned text. 375 /// 376 /// # Errors 377 /// 378 /// Returns `InputTooLarge` first for input above 512 bytes, `InvalidInput` 379 /// for noncanonical version/framing/hex, or `StaleQuery` for a different 380 /// complete request fingerprint. 381 pub fn parse( 382 value: &str, 383 expected: AvailabilityQueryFingerprint, 384 ) -> Result<Self, AvailabilityQueryError> { 385 if value.len() > AVAILABILITY_CURSOR_MAX_BYTES { 386 return Err(AvailabilityQueryError::InputTooLarge); 387 } 388 if value.len() != CURSOR_ENCODED_BYTES || !value.is_ascii() { 389 return Err(AvailabilityQueryError::InvalidInput); 390 } 391 let bytes = value.as_bytes(); 392 if &bytes[..5] != b"hcq1:" || bytes[69] != b':' || bytes[86] != b':' { 393 return Err(AvailabilityQueryError::InvalidInput); 394 } 395 let fingerprint = decode_hex::<32>(&bytes[5..69])?; 396 let timestamp = u64::from_be_bytes(decode_hex::<8>(&bytes[70..86])?); 397 let event_id = decode_hex::<32>(&bytes[87..151])?; 398 if &fingerprint != expected.bytes() { 399 return Err(AvailabilityQueryError::StaleQuery); 400 } 401 let after = AvailabilityOrderKey::new( 402 EventTimestamp::new(timestamp), 403 AvailabilityEventVersion::from_canonical(radroots_event::EventId::from_bytes(event_id)), 404 ); 405 Ok(Self { 406 value: value.to_owned(), 407 after, 408 }) 409 } 410 411 #[must_use] 412 pub fn as_str(&self) -> &str { 413 &self.value 414 } 415 416 #[must_use] 417 pub const fn after(&self) -> AvailabilityOrderKey { 418 self.after 419 } 420 } 421 422 impl fmt::Debug for AvailabilityPageCursor { 423 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 424 formatter 425 .debug_struct("AvailabilityPageCursor") 426 .field("bytes", &self.value.len()) 427 .finish() 428 } 429 } 430 431 fn append_hex(value: &mut String, bytes: &[u8]) { 432 for byte in bytes { 433 value.push(char::from(HEX_DIGITS[usize::from(byte >> 4)])); 434 value.push(char::from(HEX_DIGITS[usize::from(byte & 0x0f)])); 435 } 436 } 437 438 fn decode_hex<const N: usize>(input: &[u8]) -> Result<[u8; N], AvailabilityQueryError> { 439 if input.len() != N * 2 { 440 return Err(AvailabilityQueryError::InvalidInput); 441 } 442 let mut result = [0; N]; 443 for (output, pair) in result.iter_mut().zip(input.chunks_exact(2)) { 444 *output = (hex_digit(pair[0])? << 4) | hex_digit(pair[1])?; 445 } 446 Ok(result) 447 } 448 449 const fn hex_digit(byte: u8) -> Result<u8, AvailabilityQueryError> { 450 match byte { 451 b'0'..=b'9' => Ok(byte - b'0'), 452 b'a'..=b'f' => Ok(byte - b'a' + 10), 453 _ => Err(AvailabilityQueryError::InvalidInput), 454 } 455 } 456 457 /// End or continuation of this local query snapshot only. 458 /// 459 /// `End` proves no exhaustive network/deletion knowledge, fresh inventory or 460 /// absence of food outside the rows supplied by the local query adapter. 461 #[derive(Clone, Eq, PartialEq)] 462 pub enum AvailabilityPageContinuation { 463 End, 464 More(AvailabilityPageCursor), 465 } 466 467 impl fmt::Debug for AvailabilityPageContinuation { 468 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 469 formatter.write_str(match self { 470 Self::End => "End", 471 Self::More(_) => "More", 472 }) 473 } 474 } 475 476 /// A bounded-row owned result without a database or serialization-cap claim. 477 /// 478 /// The storage adapter remains responsible for bounded scanning, actual 479 /// snapshot selection, response bytes and deadlines. 480 pub struct AvailabilityPage<T> { 481 items: Vec<T>, 482 continuation: AvailabilityPageContinuation, 483 projection_generation: u64, 484 } 485 486 impl<T> AvailabilityPage<T> { 487 /// Moves the supplied vector after checking its configured row bound. 488 /// 489 /// No item is cloned and no additional vector allocation is made. 490 /// 491 /// # Errors 492 /// 493 /// Returns `Capacity` when the owned row count exceeds the limit. 494 pub fn new( 495 limit: AvailabilityPageLimit, 496 items: Vec<T>, 497 continuation: AvailabilityPageContinuation, 498 projection_generation: u64, 499 ) -> Result<Self, AvailabilityQueryError> { 500 if items.len() > usize::from(limit.rows()) { 501 return Err(AvailabilityQueryError::Capacity); 502 } 503 Ok(Self { 504 items, 505 continuation, 506 projection_generation, 507 }) 508 } 509 510 #[must_use] 511 pub fn items(&self) -> &[T] { 512 &self.items 513 } 514 515 #[must_use] 516 pub const fn continuation(&self) -> &AvailabilityPageContinuation { 517 &self.continuation 518 } 519 520 #[must_use] 521 pub const fn projection_generation(&self) -> u64 { 522 self.projection_generation 523 } 524 525 #[must_use] 526 pub fn into_items(self) -> Vec<T> { 527 self.items 528 } 529 } 530 531 impl<T> fmt::Debug for AvailabilityPage<T> { 532 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 533 formatter 534 .debug_struct("AvailabilityPage") 535 .field("item_count", &self.items.len()) 536 .field("projection_generation", &self.projection_generation) 537 .field("continuation", &self.continuation) 538 .finish() 539 } 540 }