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