1use std::collections::VecDeque;
2use std::error::Error;
3use std::fmt::{self, Display, Formatter};
4use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
5use std::sync::{Arc, Mutex};
6use std::time::{Duration, SystemTime, UNIX_EPOCH};
7
8use lodestone_proto::{MAX_PUBLIC_SESSION_BYTES, PUBLIC_SESSION_FORMAT_VERSION};
9use lodestone_wire::{
10 Action, Command, CoreBinding, DiscoverRequest, DiscoverResult, ErrorBody, ErrorDisposition, GetBindingRequest,
11 GetBindingResult, Identity, LODESTONE_API_ALPN, LODESTONE_API_COMMAND_CONTRACTS, LODESTONE_API_COMMAND_OUTCOMES,
12 LODESTONE_API_COMMAND_SEMANTICS, LODESTONE_API_FORMAT_VERSION, LODESTONE_API_RESPONSE_META_FORMAT_VERSION,
13 LODESTONE_API_SCHEMA_ID, LODESTONE_API_SPEC_SHA256, LODESTONE_API_STREAM_CONTRACTS, RawJson, RecoveryAction,
14 RequestBody, RequestEnvelope, RequestMeta, ResponseEnvelope, ResultBody, U64, WorkloadClass, command_for_result,
15 validate_error_body,
16};
17use rustls::pki_types::{CertificateDer, ServerName};
18use tokio::io::{AsyncReadExt, AsyncWriteExt};
19use tokio::net::TcpStream;
20use tokio::sync::{Mutex as AsyncMutex, Notify, Semaphore};
21use tokio_rustls::TlsConnector;
22use tokio_rustls::client::TlsStream;
23
24use crate::config::{
25 CellTransportConfig, CellTransportIdentity, ClientConfigError, CollectionSessionConfig, PoolConfig, parse_identity,
26};
27use crate::credential::{CredentialMaterial, CredentialProviderError};
28use crate::generated::{
29 GeneratedCollectionIndexRef, MAX_NATIVE_REQUEST_BYTES, MAX_NATIVE_RESPONSE_BYTES, request_collection_index_ref,
30 request_command,
31};
32
33const MAX_REQUEST_ID_BYTES: usize = 128;
34const MAX_CALL_TIMEOUT: Duration = Duration::from_secs(10 * 60);
35const DEFAULT_CALL_TIMEOUT: Duration = Duration::from_secs(30);
36pub const MAX_VERIFIED_SESSION_METADATA: usize = 4_096;
37
38#[derive(Clone)]
39pub struct CallCancellation {
40 inner: Arc<CallCancellationInner>,
41}
42
43struct CallCancellationInner {
44 cancelled: AtomicBool,
45 notify: Notify,
46}
47
48impl CallCancellation {
49 #[must_use]
50 pub fn new() -> Self {
51 Self {
52 inner: Arc::new(CallCancellationInner {
53 cancelled: AtomicBool::new(false),
54 notify: Notify::new(),
55 }),
56 }
57 }
58
59 pub fn cancel(&self) {
60 if !self.inner.cancelled.swap(true, Ordering::AcqRel) {
61 self.inner.notify.notify_waiters();
62 }
63 }
64
65 #[must_use]
66 pub fn is_cancelled(&self) -> bool {
67 self.inner.cancelled.load(Ordering::Acquire)
68 }
69
70 pub async fn cancelled(&self) {
72 loop {
73 let notified = self.inner.notify.notified();
74 if self.is_cancelled() {
75 return;
76 }
77 notified.await;
78 }
79 }
80}
81
82impl Default for CallCancellation {
83 fn default() -> Self {
84 Self::new()
85 }
86}
87
88#[derive(Clone)]
89pub struct CallOptions {
90 deadline: CallDeadline,
91 cancellation: CallCancellation,
92 index_name: Option<String>,
93 workload_class: Option<WorkloadClass>,
94}
95
96#[derive(Clone, Copy)]
97struct CallDeadline {
98 absolute_unix_millis: Option<u64>,
99 local: tokio::time::Instant,
100}
101
102impl CallOptions {
103 pub fn new(timeout: Duration) -> Result<Self, ClientError> {
108 Ok(Self {
109 deadline: CallDeadline::after(timeout)?,
110 cancellation: CallCancellation::new(),
111 index_name: None,
112 workload_class: None,
113 })
114 }
115
116 pub fn from_absolute_deadline_unix_millis(absolute_unix_millis: u64) -> Result<Self, ClientError> {
120 Ok(Self {
121 deadline: CallDeadline::from_absolute_unix_millis(absolute_unix_millis)?,
122 cancellation: CallCancellation::new(),
123 index_name: None,
124 workload_class: None,
125 })
126 }
127
128 #[must_use]
129 pub fn with_cancellation(mut self, cancellation: CallCancellation) -> Self {
130 self.cancellation = cancellation;
131 self
132 }
133
134 pub fn with_index_name(mut self, index_name: String) -> Result<Self, ClientError> {
135 validate_index_name(&index_name)?;
136 self.index_name = Some(index_name);
137 Ok(self)
138 }
139
140 #[must_use]
143 pub fn with_workload_class(mut self, workload_class: WorkloadClass) -> Self {
144 self.workload_class = Some(workload_class);
145 self
146 }
147
148 #[must_use]
149 pub const fn cancellation(&self) -> &CallCancellation {
150 &self.cancellation
151 }
152
153 pub fn absolute_deadline_unix_millis(&self) -> Result<u64, ClientError> {
156 self.deadline.absolute_unix_millis.ok_or(ClientError::ClockUnavailable)
157 }
158
159 pub fn remaining(&self) -> Result<Duration, ClientError> {
162 self.local_deadline()?
163 .checked_duration_since(tokio::time::Instant::now())
164 .ok_or(ClientError::Transport {
165 kind: TransportErrorKind::Deadline,
166 disposition: SubmissionDisposition::NotSubmitted,
167 })
168 }
169
170 pub(crate) fn local_deadline(&self) -> Result<tokio::time::Instant, ClientError> {
171 self.absolute_deadline_unix_millis().map(|_| self.deadline.local)
172 }
173
174 pub(crate) fn for_resolution(&self) -> Self {
175 Self {
176 deadline: self.deadline,
177 cancellation: CallCancellation::new(),
178 index_name: self.index_name.clone(),
179 workload_class: self.workload_class,
180 }
181 }
182}
183
184impl CallDeadline {
185 fn after(timeout: Duration) -> Result<Self, ClientError> {
186 let timeout = normalized_timeout(timeout)?;
187 let local_now = tokio::time::Instant::now();
188 let wall_deadline = SystemTime::now()
189 .checked_add(timeout)
190 .ok_or(ClientError::ClockUnavailable)?;
191 let since_epoch = wall_deadline
192 .duration_since(UNIX_EPOCH)
193 .map_err(|_| ClientError::ClockUnavailable)?;
194 Ok(Self {
195 absolute_unix_millis: Some(duration_millis_ceil(since_epoch)?),
196 local: local_now + timeout,
197 })
198 }
199
200 fn from_absolute_unix_millis(absolute_unix_millis: u64) -> Result<Self, ClientError> {
201 let local_now = tokio::time::Instant::now();
202 let wall_deadline = UNIX_EPOCH
203 .checked_add(Duration::from_millis(absolute_unix_millis))
204 .ok_or(ClientError::InvalidCallOptions)?;
205 let remaining = wall_deadline
206 .duration_since(SystemTime::now())
207 .map_err(|_| ClientError::InvalidCallOptions)?;
208 if remaining.is_zero() || remaining > MAX_CALL_TIMEOUT {
209 return Err(ClientError::InvalidCallOptions);
210 }
211 Ok(Self {
212 absolute_unix_millis: Some(absolute_unix_millis),
213 local: local_now + remaining,
214 })
215 }
216}
217
218impl Default for CallOptions {
219 fn default() -> Self {
220 match Self::new(DEFAULT_CALL_TIMEOUT) {
221 Ok(options) => options,
222 Err(_) => Self {
223 deadline: CallDeadline {
224 absolute_unix_millis: None,
225 local: tokio::time::Instant::now(),
226 },
227 cancellation: CallCancellation::new(),
228 index_name: None,
229 workload_class: None,
230 },
231 }
232 }
233}
234
235#[derive(Clone, Debug, Eq, PartialEq)]
236pub struct ProfileVerification {
237 pub artifact_version: String,
238 pub principal_id: Identity,
239 pub collection_id: Identity,
240 pub policy_revision: U64,
241 pub cluster_id: Identity,
242 pub configuration_generation: U64,
243 pub binding_revision: U64,
244 pub schema_id: String,
245 pub schema_digest: lodestone_wire::Digest,
246 pub native_endpoints: Vec<String>,
247 pub selected_stream_endpoint: String,
248}
249
250#[derive(Clone, Copy, Debug, Eq, PartialEq)]
251pub struct CellTransportMetricsSnapshot {
252 pub transport_count: usize,
253 pub live_connections: usize,
254 pub idle_connections: usize,
255 pub live_collection_sessions: usize,
256 pub session_cache_entries: usize,
257 pub session_cache_hits: u64,
258 pub session_cache_misses: u64,
259 pub session_cache_evictions: u64,
260 pub credential_fetches: u64,
261 pub bulkhead_saturation: u64,
262 pub isolation_failures: u64,
263}
264
265#[derive(Default)]
266struct CellTransportMetrics {
267 live_connections: AtomicUsize,
268 idle_connections: AtomicUsize,
269 live_collection_sessions: AtomicUsize,
270 session_cache_entries: AtomicUsize,
271 session_cache_hits: AtomicU64,
272 session_cache_misses: AtomicU64,
273 session_cache_evictions: AtomicU64,
274 credential_fetches: AtomicU64,
275 bulkhead_saturation: AtomicU64,
276 isolation_failures: AtomicU64,
277}
278
279impl CellTransportMetrics {
280 fn snapshot(&self) -> CellTransportMetricsSnapshot {
281 CellTransportMetricsSnapshot {
282 transport_count: 1,
283 live_connections: self.live_connections.load(Ordering::Relaxed),
284 idle_connections: self.idle_connections.load(Ordering::Relaxed),
285 live_collection_sessions: self.live_collection_sessions.load(Ordering::Relaxed),
286 session_cache_entries: self.session_cache_entries.load(Ordering::Relaxed),
287 session_cache_hits: self.session_cache_hits.load(Ordering::Relaxed),
288 session_cache_misses: self.session_cache_misses.load(Ordering::Relaxed),
289 session_cache_evictions: self.session_cache_evictions.load(Ordering::Relaxed),
290 credential_fetches: self.credential_fetches.load(Ordering::Relaxed),
291 bulkhead_saturation: self.bulkhead_saturation.load(Ordering::Relaxed),
292 isolation_failures: self.isolation_failures.load(Ordering::Relaxed),
293 }
294 }
295}
296
297#[derive(Clone, Debug, Eq, PartialEq)]
298struct VerifiedSessionCacheKey {
299 collection_id: String,
300 principal_id: String,
301 profile_generation: String,
302 credential_id: String,
303 credential_generation: String,
304 secret_reference: String,
305}
306
307impl VerifiedSessionCacheKey {
308 fn from_config(config: &CollectionSessionConfig) -> Self {
309 let profile = config.profile.as_wire();
310 Self {
311 collection_id: profile.expected_collection_id.value.clone(),
312 principal_id: profile.expected_principal_id.value.clone(),
313 profile_generation: profile.profile_generation.value.clone(),
314 credential_id: profile.credential_id.value.clone(),
315 credential_generation: profile.credential_generation.value.clone(),
316 secret_reference: profile.secret_reference.clone(),
317 }
318 }
319}
320
321struct VerifiedSessionMetadata {
322 key: VerifiedSessionCacheKey,
323 verification: ProfileVerification,
324}
325
326#[derive(Default)]
327struct VerifiedSessionCache {
328 entries: VecDeque<VerifiedSessionMetadata>,
329}
330
331impl VerifiedSessionCache {
332 fn get(&mut self, key: &VerifiedSessionCacheKey, metrics: &CellTransportMetrics) -> Option<ProfileVerification> {
333 let Some(position) = self.entries.iter().position(|entry| &entry.key == key) else {
334 metrics.session_cache_misses.fetch_add(1, Ordering::Relaxed);
335 return None;
336 };
337 let entry = self.entries.remove(position)?;
338 let verification = entry.verification.clone();
339 self.entries.push_back(entry);
340 metrics.session_cache_hits.fetch_add(1, Ordering::Relaxed);
341 Some(verification)
342 }
343
344 fn insert(
345 &mut self,
346 key: VerifiedSessionCacheKey,
347 verification: ProfileVerification,
348 metrics: &CellTransportMetrics,
349 ) {
350 if let Some(position) = self.entries.iter().position(|entry| entry.key == key) {
351 let _ = self.entries.remove(position);
352 } else if self.entries.len() == MAX_VERIFIED_SESSION_METADATA {
353 let _ = self.entries.pop_front();
354 metrics.session_cache_evictions.fetch_add(1, Ordering::Relaxed);
355 }
356 self.entries.push_back(VerifiedSessionMetadata { key, verification });
357 metrics
358 .session_cache_entries
359 .store(self.entries.len(), Ordering::Relaxed);
360 }
361}
362
363#[derive(Clone)]
364pub struct CellTransport {
365 inner: Arc<CellTransportInner>,
366}
367
368struct CellTransportInner {
369 config: CellTransportConfig,
370 tls: Arc<rustls::ClientConfig>,
371 pool: AsyncMutex<Vec<NativeConnection>>,
372 connection_slots: Arc<Semaphore>,
373 next_endpoint: AtomicUsize,
374 verified_sessions: Mutex<VerifiedSessionCache>,
375 metrics: Arc<CellTransportMetrics>,
376}
377
378#[derive(Clone)]
379pub struct Client {
380 inner: Arc<ClientInner>,
381}
382
383pub type CollectionSession = Client;
384
385struct ClientInner {
386 transport: CellTransport,
387 config: CollectionSessionConfig,
388 verified: Mutex<Option<ProfileVerification>>,
389}
390
391impl Drop for ClientInner {
392 fn drop(&mut self) {
393 self.transport
394 .inner
395 .metrics
396 .live_collection_sessions
397 .fetch_sub(1, Ordering::Relaxed);
398 }
399}
400
401struct NativeConnection {
402 stream: TlsStream<TcpStream>,
403 metrics: Arc<CellTransportMetrics>,
404 idle_since: tokio::time::Instant,
405}
406
407impl Drop for NativeConnection {
408 fn drop(&mut self) {
409 self.metrics.live_connections.fetch_sub(1, Ordering::Relaxed);
410 }
411}
412
413impl CellTransport {
414 pub fn new(config: CellTransportConfig) -> Result<Self, ClientError> {
415 let tls = build_tls_config(&config)?;
416 let metrics = Arc::new(CellTransportMetrics::default());
417 Ok(Self {
418 inner: Arc::new(CellTransportInner {
419 connection_slots: Arc::new(Semaphore::new(config.pool.maximum_connections)),
420 config,
421 tls: Arc::new(tls),
422 pool: AsyncMutex::new(Vec::new()),
423 next_endpoint: AtomicUsize::new(0),
424 verified_sessions: Mutex::new(VerifiedSessionCache::default()),
425 metrics,
426 }),
427 })
428 }
429
430 #[must_use]
431 pub fn identity(&self) -> &CellTransportIdentity {
432 self.inner.config.identity()
433 }
434
435 #[must_use]
436 pub fn pool_config(&self) -> PoolConfig {
437 self.inner.config.pool
438 }
439
440 #[must_use]
441 pub fn metrics(&self) -> CellTransportMetricsSnapshot {
442 self.inner.metrics.snapshot()
443 }
444}
445
446impl Client {
447 pub async fn connect(
450 transport: CellTransport,
451 config: CollectionSessionConfig,
452 options: CallOptions,
453 ) -> Result<Self, ClientError> {
454 let client = Self::new(transport, config)?;
455 client.verify_connection_profile(options).await?;
456 Ok(client)
457 }
458
459 pub fn new(transport: CellTransport, config: CollectionSessionConfig) -> Result<Self, ClientError> {
460 if let Err(error) = config.validate_transport(&transport.inner.config) {
461 transport
462 .inner
463 .metrics
464 .isolation_failures
465 .fetch_add(1, Ordering::Relaxed);
466 return Err(error.into());
467 }
468 let cache_key = VerifiedSessionCacheKey::from_config(&config);
469 let cached = transport
470 .inner
471 .verified_sessions
472 .lock()
473 .map_err(|_| ClientError::InternalStateUnavailable)?
474 .get(&cache_key, &transport.inner.metrics);
475 if let Some(verification) = &cached
476 && validate_cached_verification(&config, verification).is_err()
477 {
478 transport
479 .inner
480 .metrics
481 .isolation_failures
482 .fetch_add(1, Ordering::Relaxed);
483 return Err(ClientError::ProfileIdentityMismatch);
484 }
485 transport
486 .inner
487 .metrics
488 .live_collection_sessions
489 .fetch_add(1, Ordering::Relaxed);
490 Ok(Self {
491 inner: Arc::new(ClientInner {
492 transport,
493 config,
494 verified: Mutex::new(None),
498 }),
499 })
500 }
501
502 #[must_use]
503 pub fn connection_profile(&self) -> &crate::ValidatedConnectionProfile {
504 &self.inner.config.profile
505 }
506
507 #[must_use]
510 pub fn cell_transport(&self) -> CellTransport {
511 self.inner.transport.clone()
512 }
513
514 #[must_use]
515 pub fn profile_verification(&self) -> Option<ProfileVerification> {
516 self.inner.verified.lock().ok().and_then(|verified| verified.clone())
517 }
518
519 pub async fn verify_connection_profile(&self, options: CallOptions) -> Result<ProfileVerification, ClientError> {
520 let discovered = self.discover(DiscoverRequest {}, options.clone()).await?;
521 self.validate_discovery_observation(&discovered)?;
522 let binding = self.get_binding(GetBindingRequest {}, options).await?;
523 self.verify_authority_binding_observation(&discovered, &binding)
524 }
525
526 pub fn validate_discovery_observation(&self, discovered: &DiscoverResult) -> Result<(), ClientError> {
529 validate_discovery(&self.inner.config, discovered)
530 }
531
532 pub fn verify_authority_binding_observation(
535 &self,
536 discovered: &DiscoverResult,
537 binding: &GetBindingResult,
538 ) -> Result<ProfileVerification, ClientError> {
539 let verified = self.validate_authority_binding_observation(discovered, binding)?;
540 self.install_profile_verification(verified)
541 }
542
543 pub fn validate_authority_binding_observation(
547 &self,
548 discovered: &DiscoverResult,
549 binding: &GetBindingResult,
550 ) -> Result<ProfileVerification, ClientError> {
551 validate_discovery(&self.inner.config, discovered)?;
552 validate_binding(&self.inner.config, &binding.binding, binding.derived, &binding.revision)?;
553 Ok(ProfileVerification {
554 artifact_version: discovered.artifact_version.clone(),
555 principal_id: discovered.principal_id.clone(),
556 collection_id: discovered.collection_id.clone(),
557 policy_revision: discovered.policy_revision.clone(),
558 cluster_id: binding.binding.cluster_id.clone(),
559 configuration_generation: binding.binding.configuration_generation.clone(),
560 binding_revision: binding.revision.clone(),
561 schema_id: binding.binding.schema_id.clone(),
562 schema_digest: binding.binding.schema_digest.clone(),
563 native_endpoints: binding.binding.native_endpoints.clone(),
564 selected_stream_endpoint: binding.binding.selected_stream_endpoint.clone(),
565 })
566 }
567
568 fn install_profile_verification(&self, verified: ProfileVerification) -> Result<ProfileVerification, ClientError> {
569 let mut slot = self
570 .inner
571 .verified
572 .lock()
573 .map_err(|_| ClientError::InternalStateUnavailable)?;
574 *slot = Some(verified.clone());
575 self.inner
576 .transport
577 .inner
578 .verified_sessions
579 .lock()
580 .map_err(|_| ClientError::InternalStateUnavailable)?
581 .insert(
582 VerifiedSessionCacheKey::from_config(&self.inner.config),
583 verified.clone(),
584 &self.inner.transport.inner.metrics,
585 );
586 Ok(verified)
587 }
588
589 pub async fn call(&self, request: RequestBody, options: CallOptions) -> Result<ResultBody, ClientError> {
596 let local_deadline = options.local_deadline()?;
597 if tokio::time::Instant::now() >= local_deadline {
598 return Err(ClientError::Transport {
599 kind: TransportErrorKind::Deadline,
600 disposition: SubmissionDisposition::NotSubmitted,
601 });
602 }
603 let command = request_command(&request);
604 let command_contract = LODESTONE_API_COMMAND_CONTRACTS
605 .iter()
606 .find(|contract| contract.command == command)
607 .ok_or(ClientError::MissingCommandContract)?;
608 let outcome_contract = LODESTONE_API_COMMAND_OUTCOMES
609 .iter()
610 .find(|contract| contract.command == command)
611 .ok_or(ClientError::MissingCommandContract)?;
612 let mutation = outcome_contract.possible_submission_states.contains(&"submitted");
613 if mutation && self.profile_verification().is_none() {
614 return Err(ClientError::ProfileNotVerified);
615 }
616 let index_name = resolve_index_name(&request, options.index_name.as_deref())?;
617 let correlation = random_identity()?;
618 let deadline = options.absolute_deadline_unix_millis()?;
619 let request_id = format!("r-{}", lower_hex(&correlation));
620 let workload_class = options
621 .workload_class
622 .unwrap_or_else(|| default_workload_class(command_contract.action));
623 let envelope = RequestEnvelope {
624 meta: RequestMeta {
625 format_version: LODESTONE_API_FORMAT_VERSION,
626 request_id: request_id.clone(),
627 correlation_id: Identity {
628 value: lower_hex(&correlation),
629 },
630 absolute_deadline_unix_millis: U64 {
631 value: deadline.to_string(),
632 },
633 workload_class,
634 },
635 request,
636 };
637 let payload = canonical_request(&envelope)?;
638 let started = Arc::new(AtomicBool::new(false));
639 let maximum_attempts = usize::from(self.pool_config().maximum_read_retries).saturating_add(1);
640 let mut attempt = 0_usize;
641 loop {
642 attempt = attempt.saturating_add(1);
643 let result = tokio::select! {
644 result = tokio::time::timeout_at(
645 local_deadline,
646 self.transport_attempt(
647 &payload,
648 &request_id,
649 command,
650 command_contract.action,
651 workload_class,
652 index_name.as_deref(),
653 correlation,
654 Arc::clone(&started),
655 ),
656 ) => match result {
657 Ok(result) => result,
658 Err(_) => Err(ClientError::Transport {
659 kind: TransportErrorKind::Deadline,
660 disposition: started_disposition(&started),
661 }),
662 },
663 () = options.cancellation.cancelled() => Err(ClientError::Transport {
664 kind: TransportErrorKind::Cancelled,
665 disposition: started_disposition(&started),
666 }),
667 };
668 match result {
669 Err(ref error)
670 if attempt < maximum_attempts
671 && !options.cancellation.is_cancelled()
672 && error.automatic_retry_admitted() =>
673 {
674 continue;
675 }
676 result => return result,
677 }
678 }
679 }
680
681 #[allow(clippy::too_many_arguments)]
682 async fn transport_attempt(
683 &self,
684 payload: &[u8],
685 request_id: &str,
686 command: Command,
687 action: Action,
688 workload_class: WorkloadClass,
689 index_name: Option<&str>,
690 correlation: [u8; 16],
691 started: Arc<AtomicBool>,
692 ) -> Result<ResultBody, ClientError> {
693 let credential = self.credential().await?;
697 let native_credential = native_credential(&credential)?;
698 let session = PublicSession {
699 format_version: PUBLIC_SESSION_FORMAT_VERSION,
700 api_key_credential: native_credential.as_slice(),
701 collection_id: &self.inner.config.profile.as_wire().expected_collection_id.value,
702 request_id,
703 action: action_name(action),
704 workload_class,
705 index_name,
706 request_correlation_id: correlation,
707 };
708 let session = SensitiveBuffer(serde_json::to_vec(&session).map_err(|_| ClientError::RequestCodec)?);
709 if session.is_empty() || session.len() > MAX_PUBLIC_SESSION_BYTES {
710 return Err(ClientError::RequestTooLarge);
711 }
712 let mut wire = SensitiveBuffer::new();
713 wire.extend_u32(session.len())?;
714 wire.extend(session.as_slice());
715 wire.extend_u32(payload.len())?;
716 wire.extend(payload);
717
718 if self.inner.transport.inner.connection_slots.available_permits() == 0 {
719 self.inner
720 .transport
721 .inner
722 .metrics
723 .bulkhead_saturation
724 .fetch_add(1, Ordering::Relaxed);
725 }
726 let _permit = self
727 .inner
728 .transport
729 .inner
730 .connection_slots
731 .clone()
732 .acquire_owned()
733 .await
734 .map_err(|_| ClientError::Transport {
735 kind: TransportErrorKind::PoolClosed,
736 disposition: SubmissionDisposition::NotSubmitted,
737 })?;
738 let endpoint_index = self.inner.transport.inner.next_endpoint.fetch_add(1, Ordering::Relaxed)
739 % self.inner.transport.inner.config.native_endpoints.len();
740 let mut connection = {
741 let mut pool = self.inner.transport.inner.pool.lock().await;
742 let now = tokio::time::Instant::now();
750 let connection = loop {
751 match pool.pop() {
752 Some(connection) if connection_idle_is_reusable(connection.idle_since, now) => {
753 break Some(connection);
754 }
755 Some(_) => {}
756 None => break None,
757 }
758 };
759 self.inner
760 .transport
761 .inner
762 .metrics
763 .idle_connections
764 .store(pool.len(), Ordering::Relaxed);
765 connection
766 };
767 if connection.is_none() {
768 connection = Some(self.connect_native(endpoint_index).await?);
769 }
770 let mut connection = connection.ok_or(ClientError::Transport {
771 kind: TransportErrorKind::Connect,
772 disposition: SubmissionDisposition::NotSubmitted,
773 })?;
774 started.store(true, Ordering::Release);
775 if connection.stream.write_all(wire.as_slice()).await.is_err() || connection.stream.flush().await.is_err() {
776 return Err(ClientError::Transport {
777 kind: TransportErrorKind::Write,
778 disposition: SubmissionDisposition::Indeterminate,
779 });
780 }
781 let response_length = connection.stream.read_u32().await.map_err(|_| ClientError::Transport {
782 kind: TransportErrorKind::Read,
783 disposition: SubmissionDisposition::Indeterminate,
784 })?;
785 let response_length = usize::try_from(response_length).map_err(|_| ClientError::ResponseTooLarge)?;
786 if response_length == 0 || response_length > MAX_NATIVE_RESPONSE_BYTES {
787 return Err(ClientError::ResponseTooLarge);
788 }
789 let mut response = vec![0; response_length];
790 connection
791 .stream
792 .read_exact(&mut response)
793 .await
794 .map_err(|_| ClientError::Transport {
795 kind: TransportErrorKind::Read,
796 disposition: SubmissionDisposition::Indeterminate,
797 })?;
798 let decoded: ResponseEnvelope = serde_json::from_slice(&response).map_err(|_| ClientError::ResponseCodec)?;
799 if serde_json::to_vec(&decoded).map_err(|_| ClientError::ResponseCodec)? != response {
800 return Err(ClientError::NonCanonicalResponse);
801 }
802 let result = match decoded {
803 ResponseEnvelope::Success { meta, result } if response_meta_matches(&meta, request_id, &correlation) => {
804 if command_for_result(&result) != command {
805 Err(ClientError::ResponseCommandMismatch)
806 } else {
807 Ok(*result)
808 }
809 }
810 ResponseEnvelope::Success { .. } => Err(ClientError::ResponseIdentityMismatch),
811 ResponseEnvelope::Error { meta, error } => {
812 if !response_meta_matches(&meta, request_id, &correlation) {
813 return Err(ClientError::ResponseIdentityMismatch);
814 }
815 validate_error_body(&error).map_err(|_| ClientError::InvalidErrorShape)?;
816 let semantic = LODESTONE_API_COMMAND_SEMANTICS
817 .iter()
818 .find(|contract| contract.command == command)
819 .ok_or(ClientError::MissingCommandContract)?;
820 if !semantic.admissible_errors.contains(&error.code) {
821 return Err(ClientError::InvalidErrorShape);
822 }
823 Err(ClientError::Server(error))
824 }
825 };
826 if response_keeps_connection(&result) {
834 let mut pool = self.inner.transport.inner.pool.lock().await;
835 if pool.len() < self.pool_config().maximum_idle_connections {
836 connection.idle_since = tokio::time::Instant::now();
837 pool.push(connection);
838 self.inner
839 .transport
840 .inner
841 .metrics
842 .idle_connections
843 .store(pool.len(), Ordering::Relaxed);
844 }
845 }
846 result
847 }
848
849 async fn connect_native(&self, endpoint_index: usize) -> Result<NativeConnection, ClientError> {
850 let endpoint = self
851 .inner
852 .transport
853 .inner
854 .config
855 .native_endpoints
856 .get(endpoint_index)
857 .ok_or(ClientError::InvalidEndpoint)?;
858 let stream = tokio::time::timeout(
859 self.pool_config().connect_timeout,
860 TcpStream::connect((endpoint.host(), endpoint.port())),
861 )
862 .await
863 .map_err(|_| ClientError::Transport {
864 kind: TransportErrorKind::Connect,
865 disposition: SubmissionDisposition::NotSubmitted,
866 })?
867 .map_err(|_| ClientError::Transport {
868 kind: TransportErrorKind::Connect,
869 disposition: SubmissionDisposition::NotSubmitted,
870 })?;
871 let server_name = ServerName::try_from(self.inner.transport.inner.config.identity.tls_server_name().to_owned())
872 .map_err(|_| ClientError::Config(ClientConfigError::InvalidServerName))?;
873 let connector = TlsConnector::from(Arc::clone(&self.inner.transport.inner.tls));
874 let stream = tokio::time::timeout(
875 self.pool_config().connect_timeout,
876 connector.connect(server_name, stream),
877 )
878 .await
879 .map_err(|_| ClientError::Transport {
880 kind: TransportErrorKind::Tls,
881 disposition: SubmissionDisposition::NotSubmitted,
882 })?
883 .map_err(|_| ClientError::Transport {
884 kind: TransportErrorKind::Tls,
885 disposition: SubmissionDisposition::NotSubmitted,
886 })?;
887 if stream.get_ref().1.alpn_protocol() != Some(LODESTONE_API_ALPN.as_bytes()) {
888 return Err(ClientError::Transport {
889 kind: TransportErrorKind::Tls,
890 disposition: SubmissionDisposition::NotSubmitted,
891 });
892 }
893 self.inner
894 .transport
895 .inner
896 .metrics
897 .live_connections
898 .fetch_add(1, Ordering::Relaxed);
899 Ok(NativeConnection {
900 stream,
901 metrics: Arc::clone(&self.inner.transport.inner.metrics),
902 idle_since: tokio::time::Instant::now(),
903 })
904 }
905
906 pub(crate) async fn credential(&self) -> Result<CredentialMaterial, ClientError> {
907 let profile = self.inner.config.profile.as_wire();
908 self.inner
909 .transport
910 .inner
911 .metrics
912 .credential_fetches
913 .fetch_add(1, Ordering::Relaxed);
914 let material = self
915 .inner
916 .config
917 .credentials
918 .provide(&profile.secret_reference)
919 .await
920 .map_err(ClientError::Credential)?;
921 if material.api_key_id() != &profile.credential_id || material.generation() != &profile.credential_generation {
922 return Err(ClientError::Credential(CredentialProviderError::InvalidMaterial));
923 }
924 Ok(material)
925 }
926
927 pub(crate) fn transport_config(&self) -> &CellTransportConfig {
928 &self.inner.transport.inner.config
929 }
930
931 pub(crate) fn pool_config(&self) -> PoolConfig {
932 self.inner.transport.pool_config()
933 }
934}
935
936fn response_keeps_connection(result: &Result<ResultBody, ClientError>) -> bool {
937 result.is_ok()
938}
939
940const MAX_REUSABLE_CONNECTION_IDLE: Duration = Duration::from_secs(1);
941
942fn connection_idle_is_reusable(idle_since: tokio::time::Instant, now: tokio::time::Instant) -> bool {
943 now.checked_duration_since(idle_since)
944 .is_some_and(|idle| idle <= MAX_REUSABLE_CONNECTION_IDLE)
945}
946
947const fn default_workload_class(action: Action) -> WorkloadClass {
948 match action {
949 Action::DataRead | Action::SubscriptionRead => WorkloadClass::InteractiveSearch,
950 Action::DataWrite | Action::Append | Action::ResolveAppend => WorkloadClass::StreamingIngest,
951 Action::IdentityDiscovery
952 | Action::IndexAdmin
953 | Action::CatalogRead
954 | Action::TaskRead
955 | Action::DiagnosticsRead
956 | Action::DiagnosticsCreate
957 | Action::SecurityRead
958 | Action::SecurityAdmin
959 | Action::AuditRead
960 | Action::QuotaRead
961 | Action::QuotaAdmin
962 | Action::BindingRead
963 | Action::LifecycleAdmin
964 | Action::AdmissionRead
965 | Action::AdmissionAdmin
966 | Action::UsageRead
967 | Action::OperationRead
968 | Action::OperationCancel
969 | Action::CollectionExport
970 | Action::CollectionRestore => WorkloadClass::BatchAnalytics,
971 }
972}
973
974#[derive(serde::Serialize)]
975struct PublicSession<'a> {
976 format_version: u16,
977 api_key_credential: &'a [u8],
978 collection_id: &'a str,
979 request_id: &'a str,
980 action: &'static str,
981 workload_class: WorkloadClass,
982 #[serde(skip_serializing_if = "Option::is_none")]
983 index_name: Option<&'a str>,
984 request_correlation_id: [u8; 16],
985}
986
987fn canonical_request(envelope: &RequestEnvelope) -> Result<Vec<u8>, ClientError> {
988 if envelope.meta.request_id.is_empty()
989 || envelope.meta.request_id.len() > MAX_REQUEST_ID_BYTES
990 || envelope.meta.request_id.bytes().any(|byte| byte.is_ascii_control())
991 {
992 return Err(ClientError::InvalidRequestIdentity);
993 }
994 let payload = serde_json::to_vec(envelope).map_err(|_| ClientError::RequestCodec)?;
995 if payload.is_empty() || payload.len() > MAX_NATIVE_REQUEST_BYTES {
996 return Err(ClientError::RequestTooLarge);
997 }
998 let decoded: RequestEnvelope = serde_json::from_slice(&payload).map_err(|_| ClientError::RequestCodec)?;
999 if decoded != *envelope {
1000 return Err(ClientError::RequestCodec);
1001 }
1002 Ok(payload)
1003}
1004
1005fn build_tls_config(config: &CellTransportConfig) -> Result<rustls::ClientConfig, ClientError> {
1006 let mut roots = rustls::RootCertStore::empty();
1007 for certificate in config.trust.certificates_der() {
1008 roots
1009 .add(CertificateDer::from(certificate.clone()))
1010 .map_err(|_| ClientError::Config(ClientConfigError::InvalidTrustMaterial))?;
1011 }
1012 let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
1013 let mut tls = rustls::ClientConfig::builder_with_provider(provider)
1014 .with_protocol_versions(&[&rustls::version::TLS13])
1015 .map_err(|_| ClientError::Config(ClientConfigError::InvalidTrustMaterial))?
1016 .with_root_certificates(roots)
1017 .with_no_client_auth();
1018 tls.alpn_protocols = vec![LODESTONE_API_ALPN.as_bytes().to_vec()];
1019 Ok(tls)
1020}
1021
1022fn validate_discovery(config: &CollectionSessionConfig, discovered: &DiscoverResult) -> Result<(), ClientError> {
1023 let profile = config.profile.as_wire();
1024 if discovered.collection_id != profile.expected_collection_id
1025 || discovered.principal_id != profile.expected_principal_id
1026 || discovered.schema_id != LODESTONE_API_SCHEMA_ID
1027 || discovered.schema_digest.value != LODESTONE_API_SPEC_SHA256
1028 {
1029 return Err(ClientError::ProfileIdentityMismatch);
1030 }
1031 let expected = LODESTONE_API_STREAM_CONTRACTS
1032 .first()
1033 .ok_or(ClientError::MissingCommandContract)?;
1034 if discovered.streams.len() != 1
1035 || discovered.streams.first().is_none_or(|stream| {
1036 stream.name != expected.name
1037 || stream.transport != expected.transport
1038 || stream.service != expected.service
1039 || stream.method != expected.method
1040 || stream.endpoint != profile.selected_stream_endpoint
1041 || stream.auth_metadata != expected.auth_metadata
1042 || stream.auth_scheme != expected.auth_scheme
1043 || stream.semantic_format_version != u32::from(expected.semantic_format_version)
1044 || stream.schema_digest.value != LODESTONE_API_SPEC_SHA256
1045 })
1046 {
1047 return Err(ClientError::ProfileStreamMismatch);
1048 }
1049 Ok(())
1050}
1051
1052fn validate_binding(
1053 config: &CollectionSessionConfig,
1054 binding: &CoreBinding,
1055 derived: bool,
1056 revision: &U64,
1057) -> Result<(), ClientError> {
1058 let profile = config.profile.as_wire();
1059 if !derived
1060 || binding.cluster_id != profile.expected_cluster_id
1061 || binding.collection_id != profile.expected_collection_id
1062 || binding.native_endpoints != profile.native_endpoints
1063 || binding.selected_stream_endpoint != profile.selected_stream_endpoint
1064 || binding.schema_id != profile.schema_id
1065 || binding.schema_digest != profile.schema_digest
1066 || binding
1067 .configuration_generation
1068 .value
1069 .parse::<u64>()
1070 .ok()
1071 .is_none_or(|generation| generation == 0)
1072 || revision.value.parse::<u64>().ok().is_none_or(|revision| revision == 0)
1073 {
1074 return Err(ClientError::ProfileIdentityMismatch);
1075 }
1076 Ok(())
1077}
1078
1079fn validate_cached_verification(
1080 config: &CollectionSessionConfig,
1081 verification: &ProfileVerification,
1082) -> Result<(), ClientError> {
1083 let profile = config.profile.as_wire();
1084 if verification.collection_id != profile.expected_collection_id
1085 || verification.principal_id != profile.expected_principal_id
1086 || verification.cluster_id != profile.expected_cluster_id
1087 || verification.schema_id != profile.schema_id
1088 || verification.schema_digest != profile.schema_digest
1089 || verification.native_endpoints != profile.native_endpoints
1090 || verification.selected_stream_endpoint != profile.selected_stream_endpoint
1091 || verification
1092 .configuration_generation
1093 .value
1094 .parse::<u64>()
1095 .ok()
1096 .is_none_or(|generation| generation == 0)
1097 || verification
1098 .binding_revision
1099 .value
1100 .parse::<u64>()
1101 .ok()
1102 .is_none_or(|revision| revision == 0)
1103 {
1104 return Err(ClientError::ProfileIdentityMismatch);
1105 }
1106 Ok(())
1107}
1108
1109fn resolve_index_name(request: &RequestBody, supplied: Option<&str>) -> Result<Option<String>, ClientError> {
1110 let scope = request_collection_index_ref(request)?;
1111 let inferred = match &scope {
1112 GeneratedCollectionIndexRef::Exact(name) => Some(name.clone()),
1113 GeneratedCollectionIndexRef::None | GeneratedCollectionIndexRef::Caller => None,
1114 };
1115 if let Some(supplied) = supplied {
1116 validate_index_name(supplied)?;
1117 if inferred.as_deref().is_some_and(|inferred| inferred != supplied) {
1118 return Err(ClientError::CollectionIndexRefMismatch);
1119 }
1120 }
1121 let resolved = supplied.map(str::to_owned).or(inferred);
1122 if let Some(name) = &resolved {
1123 validate_index_name(name)?;
1124 }
1125 match scope {
1126 GeneratedCollectionIndexRef::Caller | GeneratedCollectionIndexRef::Exact(_) if resolved.is_none() => {
1127 return Err(ClientError::CollectionIndexRefRequired);
1128 }
1129 GeneratedCollectionIndexRef::None if resolved.is_some() => return Err(ClientError::CollectionIndexRefMismatch),
1130 GeneratedCollectionIndexRef::None
1131 | GeneratedCollectionIndexRef::Caller
1132 | GeneratedCollectionIndexRef::Exact(_) => {}
1133 }
1134 Ok(resolved)
1135}
1136
1137const fn action_name(action: Action) -> &'static str {
1138 match action {
1139 Action::IdentityDiscovery => "identity_discovery",
1140 Action::DataRead => "data_read",
1141 Action::DataWrite => "data_write",
1142 Action::Append => "append",
1143 Action::ResolveAppend => "resolve_append",
1144 Action::IndexAdmin => "index_admin",
1145 Action::CatalogRead => "catalog_read",
1146 Action::TaskRead => "task_read",
1147 Action::SubscriptionRead => "subscription_read",
1148 Action::DiagnosticsRead => "diagnostics_read",
1149 Action::DiagnosticsCreate => "diagnostics_create",
1150 Action::SecurityRead => "security_read",
1151 Action::SecurityAdmin => "security_admin",
1152 Action::AuditRead => "audit_read",
1153 Action::QuotaRead => "quota_read",
1154 Action::QuotaAdmin => "quota_admin",
1155 Action::BindingRead => "binding_read",
1156 Action::LifecycleAdmin => "lifecycle_admin",
1157 Action::AdmissionRead => "admission_read",
1158 Action::AdmissionAdmin => "admission_admin",
1159 Action::UsageRead => "usage_read",
1160 Action::OperationRead => "operation_read",
1161 Action::OperationCancel => "operation_cancel",
1162 Action::CollectionExport => "collection_export",
1163 Action::CollectionRestore => "collection_restore",
1164 }
1165}
1166
1167fn native_credential(material: &CredentialMaterial) -> Result<SensitiveBuffer, ClientError> {
1168 let identifier = parse_identity(&material.api_key_id().value)
1169 .ok_or(ClientError::Credential(CredentialProviderError::InvalidMaterial))?;
1170 let mut output = SensitiveBuffer::new();
1171 output.extend(identifier.to_string().as_bytes());
1172 output.extend(b":");
1173 output.extend(&base64_url_no_pad(material.secret().expose()));
1174 Ok(output)
1175}
1176
1177pub(crate) fn authorization_value(material: &CredentialMaterial) -> Result<SensitiveBuffer, ClientError> {
1178 let native = native_credential(material)?;
1179 let mut output = SensitiveBuffer::new();
1180 output.extend(b"ApiKey ");
1181 output.extend(&base64_standard(native.as_slice()));
1182 Ok(output)
1183}
1184
1185fn base64_url_no_pad(bytes: &[u8]) -> Vec<u8> {
1186 base64_encode(
1187 bytes,
1188 b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_",
1189 false,
1190 )
1191}
1192
1193fn base64_standard(bytes: &[u8]) -> Vec<u8> {
1194 base64_encode(
1195 bytes,
1196 b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",
1197 true,
1198 )
1199}
1200
1201pub fn raw_json_text(text: &str) -> Result<RawJson, ClientError> {
1204 let bytes_base64 = String::from_utf8(base64_standard(text.as_bytes())).map_err(|_| ClientError::RequestCodec)?;
1205 Ok(RawJson { bytes_base64 })
1206}
1207
1208fn base64_encode(bytes: &[u8], alphabet: &[u8; 64], padded: bool) -> Vec<u8> {
1209 let mut output = Vec::with_capacity(bytes.len().div_ceil(3).saturating_mul(4));
1210 for chunk in bytes.chunks(3) {
1211 let first = chunk[0];
1212 let second = chunk.get(1).copied().unwrap_or(0);
1213 let third = chunk.get(2).copied().unwrap_or(0);
1214 output.push(alphabet[usize::from(first >> 2)]);
1215 output.push(alphabet[usize::from(((first & 0x03) << 4) | (second >> 4))]);
1216 if chunk.len() > 1 {
1217 output.push(alphabet[usize::from(((second & 0x0f) << 2) | (third >> 6))]);
1218 } else if padded {
1219 output.push(b'=');
1220 }
1221 if chunk.len() > 2 {
1222 output.push(alphabet[usize::from(third & 0x3f)]);
1223 } else if padded {
1224 output.push(b'=');
1225 }
1226 }
1227 output
1228}
1229
1230fn random_identity() -> Result<[u8; 16], ClientError> {
1231 let mut output = [0; 16];
1232 getrandom::fill(&mut output).map_err(|_| ClientError::RandomUnavailable)?;
1233 if output == [0; 16] {
1234 return Err(ClientError::RandomUnavailable);
1235 }
1236 Ok(output)
1237}
1238
1239fn normalized_timeout(timeout: Duration) -> Result<Duration, ClientError> {
1240 if timeout.is_zero() || timeout > MAX_CALL_TIMEOUT {
1241 return Err(ClientError::InvalidCallOptions);
1242 }
1243 let mut millis = timeout.as_millis();
1244 if !timeout.subsec_nanos().is_multiple_of(1_000_000) {
1245 millis = millis.checked_add(1).ok_or(ClientError::InvalidCallOptions)?;
1246 }
1247 let millis = u64::try_from(millis).map_err(|_| ClientError::InvalidCallOptions)?;
1248 let normalized = Duration::from_millis(millis);
1249 if normalized > MAX_CALL_TIMEOUT {
1250 return Err(ClientError::InvalidCallOptions);
1251 }
1252 Ok(normalized)
1253}
1254
1255fn duration_millis_ceil(duration: Duration) -> Result<u64, ClientError> {
1256 let mut millis = duration.as_millis();
1257 if !duration.subsec_nanos().is_multiple_of(1_000_000) {
1258 millis = millis.checked_add(1).ok_or(ClientError::ClockUnavailable)?;
1259 }
1260 u64::try_from(millis).map_err(|_| ClientError::ClockUnavailable)
1261}
1262
1263pub(crate) fn validate_index_name(value: &str) -> Result<(), ClientError> {
1264 if value.is_empty()
1265 || value.len() > 255
1266 || value
1267 .bytes()
1268 .any(|byte| byte.is_ascii_control() || byte.is_ascii_whitespace())
1269 {
1270 return Err(ClientError::InvalidCollectionIndexRef);
1271 }
1272 Ok(())
1273}
1274
1275fn lower_hex(bytes: &[u8]) -> String {
1276 const HEX: &[u8; 16] = b"0123456789abcdef";
1277 let mut output = String::with_capacity(bytes.len().saturating_mul(2));
1278 for byte in bytes {
1279 output.push(char::from(HEX[usize::from(byte >> 4)]));
1280 output.push(char::from(HEX[usize::from(byte & 0x0f)]));
1281 }
1282 output
1283}
1284
1285pub(crate) struct SensitiveBuffer(Vec<u8>);
1286
1287impl SensitiveBuffer {
1288 fn new() -> Self {
1289 Self(Vec::new())
1290 }
1291
1292 fn extend(&mut self, bytes: &[u8]) {
1293 self.0.extend_from_slice(bytes);
1294 }
1295
1296 fn extend_u32(&mut self, value: usize) -> Result<(), ClientError> {
1297 let value = u32::try_from(value).map_err(|_| ClientError::RequestTooLarge)?;
1298 self.extend(&value.to_be_bytes());
1299 Ok(())
1300 }
1301
1302 fn is_empty(&self) -> bool {
1303 self.0.is_empty()
1304 }
1305
1306 fn len(&self) -> usize {
1307 self.0.len()
1308 }
1309
1310 pub(crate) fn as_slice(&self) -> &[u8] {
1311 &self.0
1312 }
1313}
1314
1315impl Drop for SensitiveBuffer {
1316 fn drop(&mut self) {
1317 self.0.fill(0);
1318 std::hint::black_box(&mut self.0);
1319 }
1320}
1321
1322fn started_disposition(started: &AtomicBool) -> SubmissionDisposition {
1323 if started.load(Ordering::Acquire) {
1324 SubmissionDisposition::Indeterminate
1325 } else {
1326 SubmissionDisposition::NotSubmitted
1327 }
1328}
1329
1330#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1331pub enum SubmissionDisposition {
1332 NotSubmitted,
1333 Submitted,
1334 Resolved,
1335 KnownRetired,
1336 Indeterminate,
1337}
1338
1339#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1340pub enum TransportErrorKind {
1341 PoolClosed,
1342 Connect,
1343 Tls,
1344 Write,
1345 Read,
1346 Deadline,
1347 Cancelled,
1348}
1349
1350#[derive(Debug)]
1351pub enum ClientError {
1352 Config(ClientConfigError),
1353 Credential(CredentialProviderError),
1354 InvalidCallOptions,
1355 InvalidEndpoint,
1356 InvalidCollectionIndexRef,
1357 InvalidStateIndex,
1358 InvalidStateActionId,
1359 InvalidStateKey,
1360 InvalidStateBatch,
1361 UnexpectedStateResult,
1362 CollectionIndexRefRequired,
1363 CollectionIndexRefMismatch,
1364 ProfileNotVerified,
1365 ProfileIdentityMismatch,
1366 ProfileStreamMismatch,
1367 MissingCommandContract,
1368 RequestCodec,
1369 RequestTooLarge,
1370 InvalidRequestIdentity,
1371 ResponseCodec,
1372 ResponseTooLarge,
1373 NonCanonicalResponse,
1374 ResponseIdentityMismatch,
1375 ResponseCommandMismatch,
1376 InvalidErrorShape,
1377 RandomUnavailable,
1378 ClockUnavailable,
1379 InternalStateUnavailable,
1380 Transport {
1381 kind: TransportErrorKind,
1382 disposition: SubmissionDisposition,
1383 },
1384 Server(ErrorBody),
1385}
1386
1387impl ClientError {
1388 #[must_use]
1389 pub fn submission_disposition(&self) -> SubmissionDisposition {
1390 match self {
1391 Self::Transport { disposition, .. } => *disposition,
1392 Self::Server(error) => server_error_disposition(error),
1393 Self::UnexpectedStateResult
1394 | Self::ResponseCodec
1395 | Self::ResponseTooLarge
1396 | Self::NonCanonicalResponse
1397 | Self::ResponseIdentityMismatch
1398 | Self::ResponseCommandMismatch
1399 | Self::InvalidErrorShape => SubmissionDisposition::Indeterminate,
1400 _ => SubmissionDisposition::NotSubmitted,
1401 }
1402 }
1403
1404 #[must_use]
1408 pub fn automatic_retry_admitted(&self) -> bool {
1409 matches!(
1410 self.retry_directive(),
1411 Some((
1412 ErrorDisposition::NotSubmitted,
1413 RecoveryAction::Retry | RecoveryAction::RetryAfter
1414 ))
1415 )
1416 }
1417
1418 fn retry_directive(&self) -> Option<(ErrorDisposition, RecoveryAction)> {
1419 match self {
1420 Self::Server(error) => Some((error.disposition, error.recovery)),
1421 Self::Transport {
1422 kind: TransportErrorKind::Deadline | TransportErrorKind::Cancelled,
1423 ..
1424 } => Some((ErrorDisposition::NotSubmitted, RecoveryAction::Stop)),
1425 Self::Transport {
1426 disposition: SubmissionDisposition::NotSubmitted,
1427 ..
1428 } => Some((ErrorDisposition::NotSubmitted, RecoveryAction::Retry)),
1429 Self::Transport { .. } => Some((ErrorDisposition::Indeterminate, RecoveryAction::Resolve)),
1430 _ => None,
1431 }
1432 }
1433
1434 pub(crate) fn is_deadline_exceeded(&self) -> bool {
1435 matches!(
1436 self,
1437 Self::Transport {
1438 kind: TransportErrorKind::Deadline,
1439 ..
1440 } | Self::Server(ErrorBody {
1441 code: lodestone_wire::ErrorCode::DeadlineExceeded,
1442 ..
1443 })
1444 )
1445 }
1446}
1447
1448fn server_error_disposition(error: &ErrorBody) -> SubmissionDisposition {
1449 match error.disposition {
1450 lodestone_wire::ErrorDisposition::NotSubmitted => SubmissionDisposition::NotSubmitted,
1451 lodestone_wire::ErrorDisposition::Submitted => SubmissionDisposition::Submitted,
1452 lodestone_wire::ErrorDisposition::Resolved => SubmissionDisposition::Resolved,
1453 lodestone_wire::ErrorDisposition::KnownRetired => SubmissionDisposition::KnownRetired,
1454 lodestone_wire::ErrorDisposition::Indeterminate => SubmissionDisposition::Indeterminate,
1455 }
1456}
1457
1458fn response_meta_matches(meta: &lodestone_wire::ResponseMeta, request_id: &str, correlation: &[u8; 16]) -> bool {
1459 meta.format_version == u32::from(LODESTONE_API_RESPONSE_META_FORMAT_VERSION)
1460 && meta.request_id == request_id
1461 && meta.correlation_id.value == lower_hex(correlation)
1462 && canonical_identity(&meta.diagnostic_id.value)
1463}
1464
1465fn canonical_identity(value: &str) -> bool {
1466 value.len() == 32
1467 && value
1468 .bytes()
1469 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1470 && value.bytes().any(|byte| byte != b'0')
1471}
1472
1473impl From<ClientConfigError> for ClientError {
1474 fn from(error: ClientConfigError) -> Self {
1475 Self::Config(error)
1476 }
1477}
1478
1479impl Display for ClientError {
1480 fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
1481 formatter.write_str(match self {
1482 Self::Config(_) => "Core client configuration is invalid",
1483 Self::Credential(_) => "Core credential material is unavailable or mismatched",
1484 Self::InvalidCallOptions => "Core call options are invalid",
1485 Self::InvalidEndpoint => "Core endpoint is invalid",
1486 Self::InvalidCollectionIndexRef => "Core request index scope is invalid",
1487 Self::InvalidStateIndex => "Lodestone state index identity is invalid",
1488 Self::InvalidStateActionId => "Lodestone state action identity is invalid",
1489 Self::InvalidStateKey => "Lodestone state key must contain 1 to 254 bytes",
1490 Self::InvalidStateBatch => "Lodestone state key batch is empty or exceeds its bound",
1491 Self::UnexpectedStateResult => "Lodestone state result differs from the requested operation",
1492 Self::CollectionIndexRefRequired => "Core request requires an exact index scope",
1493 Self::CollectionIndexRefMismatch => "Core request and admission index scopes differ",
1494 Self::ProfileNotVerified => "Core mutation is refused until discovery and binding are verified",
1495 Self::ProfileIdentityMismatch => "Core discovery or binding differs from the connection profile",
1496 Self::ProfileStreamMismatch => "Core selected stream differs from the connection profile",
1497 Self::MissingCommandContract => "generated Core command metadata is incomplete",
1498 Self::RequestCodec => "Core request is not a canonical current Lodestone API value",
1499 Self::RequestTooLarge => "Core request exceeds its generated byte bound",
1500 Self::InvalidRequestIdentity => "Core request identity is invalid",
1501 Self::ResponseCodec => "Core response is malformed or unsupported",
1502 Self::ResponseTooLarge => "Core response exceeds its generated byte bound",
1503 Self::NonCanonicalResponse => "Core response encoding is noncanonical",
1504 Self::ResponseIdentityMismatch => "Core response changed the request identity or version",
1505 Self::ResponseCommandMismatch => "Core response returned another command result",
1506 Self::InvalidErrorShape => "Core error detail does not match its generated code",
1507 Self::RandomUnavailable => "secure request identity generation is unavailable",
1508 Self::ClockUnavailable => "absolute Core request deadline cannot be calculated",
1509 Self::InternalStateUnavailable => "Core client verification state is unavailable",
1510 Self::Transport { .. } => "Core transport failed",
1511 Self::Server(_) => "Core returned a typed request error",
1512 })
1513 }
1514}
1515
1516impl Error for ClientError {}
1517
1518#[cfg(test)]
1519mod tests {
1520 use std::error::Error;
1521
1522 use lodestone_wire::{GetIndexRequest, Identity, OpenPitRequest, Resource, U64};
1523
1524 use super::*;
1525 use crate::SecretBytes;
1526
1527 #[test]
1528 fn response_failures_preserve_indeterminate_submission() {
1529 for error in [
1530 ClientError::UnexpectedStateResult,
1531 ClientError::ResponseCodec,
1532 ClientError::ResponseTooLarge,
1533 ClientError::NonCanonicalResponse,
1534 ClientError::ResponseIdentityMismatch,
1535 ClientError::ResponseCommandMismatch,
1536 ClientError::InvalidErrorShape,
1537 ] {
1538 assert_eq!(error.submission_disposition(), SubmissionDisposition::Indeterminate);
1539 }
1540 assert_eq!(
1541 ClientError::RequestCodec.submission_disposition(),
1542 SubmissionDisposition::NotSubmitted
1543 );
1544 }
1545
1546 #[test]
1547 fn response_metadata_requires_exact_echo_and_nonzero_canonical_diagnostic() {
1548 let correlation = [0x12; 16];
1549 let mut meta = lodestone_wire::ResponseMeta {
1550 format_version: u32::from(LODESTONE_API_RESPONSE_META_FORMAT_VERSION),
1551 request_id: "request-1".to_owned(),
1552 correlation_id: Identity {
1553 value: lower_hex(&correlation),
1554 },
1555 diagnostic_id: Identity { value: "34".repeat(16) },
1556 };
1557 assert!(response_meta_matches(&meta, "request-1", &correlation));
1558
1559 meta.correlation_id.value = "56".repeat(16);
1560 assert!(!response_meta_matches(&meta, "request-1", &correlation));
1561 meta.correlation_id.value = lower_hex(&correlation);
1562
1563 meta.diagnostic_id.value = "00".repeat(16);
1564 assert!(!response_meta_matches(&meta, "request-1", &correlation));
1565 meta.diagnostic_id.value = "AB".repeat(16);
1566 assert!(!response_meta_matches(&meta, "request-1", &correlation));
1567 meta.diagnostic_id.value = "34".repeat(16);
1568
1569 meta.request_id = "request-2".to_owned();
1570 assert!(!response_meta_matches(&meta, "request-1", &correlation));
1571 meta.request_id = "request-1".to_owned();
1572 meta.format_version = meta.format_version.saturating_add(1);
1573 assert!(!response_meta_matches(&meta, "request-1", &correlation));
1574 }
1575
1576 fn cache_key(collection: u128, principal: u128, credential: u128) -> VerifiedSessionCacheKey {
1577 VerifiedSessionCacheKey {
1578 collection_id: format!("{collection:032x}"),
1579 principal_id: format!("{principal:032x}"),
1580 profile_generation: "1".to_owned(),
1581 credential_id: format!("{credential:032x}"),
1582 credential_generation: "1".to_owned(),
1583 secret_reference: format!("memory://session/{collection}/{principal}/{credential}"),
1584 }
1585 }
1586
1587 fn verification(collection: u128, principal: u128) -> ProfileVerification {
1588 ProfileVerification {
1589 artifact_version: "development".to_owned(),
1590 principal_id: Identity {
1591 value: format!("{principal:032x}"),
1592 },
1593 collection_id: Identity {
1594 value: format!("{collection:032x}"),
1595 },
1596 policy_revision: U64 { value: "1".to_owned() },
1597 cluster_id: Identity {
1598 value: "00000000000000000000000000000001".to_owned(),
1599 },
1600 configuration_generation: U64 { value: "1".to_owned() },
1601 binding_revision: U64 { value: "1".to_owned() },
1602 schema_id: LODESTONE_API_SCHEMA_ID.to_owned(),
1603 schema_digest: lodestone_wire::Digest {
1604 value: LODESTONE_API_SPEC_SHA256.to_owned(),
1605 },
1606 native_endpoints: vec!["lodestone+tls://core.example:8443".to_owned()],
1607 selected_stream_endpoint: "https://core.example:9443".to_owned(),
1608 }
1609 }
1610
1611 #[test]
1612 fn automatic_retry_uses_only_typed_disposition_and_recovery() {
1613 let transport = |disposition| ClientError::Transport {
1614 kind: TransportErrorKind::Read,
1615 disposition,
1616 };
1617 assert!(transport(SubmissionDisposition::NotSubmitted).automatic_retry_admitted());
1618 assert!(!transport(SubmissionDisposition::Submitted).automatic_retry_admitted());
1619 assert!(!transport(SubmissionDisposition::Indeterminate).automatic_retry_admitted());
1620
1621 let server = |disposition, recovery| {
1622 ClientError::Server(ErrorBody {
1623 code: lodestone_wire::ErrorCode::TemporarilyUnavailable,
1624 message: "typed retry probe".to_owned(),
1625 disposition,
1626 recovery,
1627 details: lodestone_wire::ErrorDetail::TemporarilyUnavailable {
1628 retry_after_millis: None,
1629 },
1630 })
1631 };
1632 assert!(server(ErrorDisposition::NotSubmitted, RecoveryAction::Retry).automatic_retry_admitted());
1633 assert!(server(ErrorDisposition::NotSubmitted, RecoveryAction::RetryAfter).automatic_retry_admitted());
1634 assert!(!server(ErrorDisposition::Indeterminate, RecoveryAction::Resolve).automatic_retry_admitted());
1635 assert!(!server(ErrorDisposition::NotSubmitted, RecoveryAction::Stop).automatic_retry_admitted());
1636 }
1637
1638 #[test]
1639 fn one_deadline_survives_connect_state_and_resolution_clones() -> Result<(), Box<dyn Error>> {
1640 let options = CallOptions::new(Duration::from_secs(5))?;
1641 let absolute = options.absolute_deadline_unix_millis()?;
1642 let local = options.local_deadline()?;
1643
1644 for stage in [
1647 options.clone(),
1648 options.clone(),
1649 options.clone(),
1650 options.clone(),
1651 options.for_resolution(),
1652 ] {
1653 assert_eq!(stage.absolute_deadline_unix_millis()?, absolute);
1654 assert_eq!(stage.local_deadline()?, local);
1655 }
1656
1657 let resolution = options.for_resolution();
1658 options.cancellation().cancel();
1659 assert!(options.cancellation().is_cancelled());
1660 assert!(!resolution.cancellation().is_cancelled());
1661 assert_eq!(resolution.absolute_deadline_unix_millis()?, absolute);
1662 Ok(())
1663 }
1664
1665 #[test]
1666 fn caller_supplied_absolute_deadline_is_preserved_exactly() -> Result<(), Box<dyn Error>> {
1667 let now = SystemTime::now().duration_since(UNIX_EPOCH)?;
1668 let now_millis = u64::try_from(now.as_millis())?;
1669 let absolute = now_millis.checked_add(5_000).ok_or("test deadline overflow")?;
1670 let options = CallOptions::from_absolute_deadline_unix_millis(absolute)?;
1671 assert_eq!(options.absolute_deadline_unix_millis()?, absolute);
1672
1673 let too_far = now_millis
1674 .checked_add(u64::try_from(MAX_CALL_TIMEOUT.as_millis())?)
1675 .and_then(|value| value.checked_add(1_000))
1676 .ok_or("test deadline overflow")?;
1677 assert!(matches!(
1678 CallOptions::from_absolute_deadline_unix_millis(too_far),
1679 Err(ClientError::InvalidCallOptions)
1680 ));
1681 assert_eq!(normalized_timeout(Duration::from_nanos(1))?, Duration::from_millis(1));
1682 Ok(())
1683 }
1684
1685 #[test]
1686 fn private_api_key_encodings_match_the_core_admission_contract() -> Result<(), Box<dyn Error>> {
1687 let material = CredentialMaterial::new(
1688 Identity {
1689 value: "00000000000000000000000000000001".to_owned(),
1690 },
1691 U64 { value: "1".to_owned() },
1692 SecretBytes::from_array([0; 32]),
1693 )?;
1694 assert_eq!(
1695 native_credential(&material)?.as_slice(),
1696 b"1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
1697 );
1698 assert_eq!(
1699 authorization_value(&material)?.as_slice(),
1700 b"ApiKey MTpBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB"
1701 );
1702 Ok(())
1703 }
1704
1705 #[test]
1706 fn typed_refusal_discards_the_connection_before_a_dependent_follow_up() {
1707 let refusal = Err(ClientError::Server(ErrorBody {
1708 code: lodestone_wire::ErrorCode::PermissionDenied,
1709 message: "denied".to_owned(),
1710 disposition: lodestone_wire::ErrorDisposition::NotSubmitted,
1711 recovery: lodestone_wire::RecoveryAction::Stop,
1712 details: lodestone_wire::ErrorDetail::PermissionDenied {
1713 action: None,
1714 workload_class: None,
1715 },
1716 }));
1717 assert!(!response_keeps_connection(&refusal));
1718 let failure = Err(ClientError::Transport {
1719 kind: TransportErrorKind::Read,
1720 disposition: SubmissionDisposition::Indeterminate,
1721 });
1722 assert!(!response_keeps_connection(&failure));
1723 }
1724
1725 #[test]
1726 fn connection_idle_age_is_fenced_below_the_gateway_prelude_timeout() {
1727 let now = tokio::time::Instant::now();
1728 assert!(connection_idle_is_reusable(now - MAX_REUSABLE_CONNECTION_IDLE, now));
1729 assert!(!connection_idle_is_reusable(
1730 now - MAX_REUSABLE_CONNECTION_IDLE - Duration::from_millis(1),
1731 now
1732 ));
1733 }
1734
1735 #[test]
1736 fn verified_session_cache_is_bounded_and_never_crosses_principals() {
1737 let metrics = CellTransportMetrics::default();
1738 let mut cache = VerifiedSessionCache::default();
1739 let first = cache_key(1, 10, 100);
1740 let other_principal = cache_key(1, 11, 100);
1741 assert_ne!(first, other_principal);
1742 cache.insert(first.clone(), verification(1, 10), &metrics);
1743 assert!(cache.get(&other_principal, &metrics).is_none());
1744 assert_eq!(cache.get(&first, &metrics), Some(verification(1, 10)));
1745
1746 for offset in 0..=MAX_VERIFIED_SESSION_METADATA {
1747 let identity = u128::try_from(offset).unwrap_or_default().saturating_add(1_000);
1748 cache.insert(
1749 cache_key(identity, identity, identity),
1750 verification(identity, identity),
1751 &metrics,
1752 );
1753 }
1754 assert_eq!(cache.entries.len(), MAX_VERIFIED_SESSION_METADATA);
1755 assert!(metrics.session_cache_evictions.load(Ordering::Relaxed) > 0);
1756 assert_eq!(
1757 metrics.session_cache_entries.load(Ordering::Relaxed),
1758 MAX_VERIFIED_SESSION_METADATA
1759 );
1760 assert_eq!(metrics.session_cache_hits.load(Ordering::Relaxed), 1);
1761 assert_eq!(metrics.session_cache_misses.load(Ordering::Relaxed), 1);
1762 }
1763
1764 #[test]
1765 fn generated_multi_index_scope_requires_a_nonempty_distinct_set() {
1766 fn index(name: &str, incarnation: &str) -> Resource {
1767 Resource {
1768 kind: "index".to_owned(),
1769 name: name.to_owned(),
1770 incarnation: Identity {
1771 value: incarnation.to_owned(),
1772 },
1773 }
1774 }
1775
1776 fn request(indexes: Vec<Resource>) -> RequestBody {
1777 RequestBody::OpenPit(OpenPitRequest {
1778 indexes,
1779 keep_alive_millis: U64 {
1780 value: "1000".to_owned(),
1781 },
1782 })
1783 }
1784
1785 let first = index("orders", "00000000000000000000000000000001");
1786 let second = index("events", "00000000000000000000000000000002");
1787 assert!(matches!(
1788 resolve_index_name(&request(Vec::new()), None),
1789 Err(ClientError::CollectionIndexRefRequired)
1790 ));
1791 assert!(matches!(
1792 resolve_index_name(&request(vec![first.clone(), second.clone()]), None),
1793 Ok(None)
1794 ));
1795 assert!(matches!(
1796 resolve_index_name(&request(vec![first.clone(), first.clone()]), None),
1797 Err(ClientError::CollectionIndexRefMismatch)
1798 ));
1799 assert!(matches!(
1800 resolve_index_name(&request(vec![second.clone(), first.clone(), first]), None),
1801 Err(ClientError::CollectionIndexRefMismatch)
1802 ));
1803 }
1804
1805 #[test]
1806 fn generated_get_index_is_collection_scoped() {
1807 let request = RequestBody::GetIndex(GetIndexRequest {
1808 name: "events".to_owned(),
1809 expected_incarnation: None,
1810 });
1811
1812 assert!(matches!(resolve_index_name(&request, None), Ok(None)));
1813 assert!(matches!(
1814 resolve_index_name(&request, Some("events")),
1815 Err(ClientError::CollectionIndexRefMismatch)
1816 ));
1817 }
1818}