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}
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 validate_discovery(&self.inner.config, discovered)?;
540 validate_binding(&self.inner.config, &binding.binding, binding.derived, &binding.revision)?;
541 let verified = ProfileVerification {
542 artifact_version: discovered.artifact_version.clone(),
543 principal_id: discovered.principal_id.clone(),
544 collection_id: discovered.collection_id.clone(),
545 policy_revision: discovered.policy_revision.clone(),
546 cluster_id: binding.binding.cluster_id.clone(),
547 configuration_generation: binding.binding.configuration_generation.clone(),
548 binding_revision: binding.revision.clone(),
549 schema_id: binding.binding.schema_id.clone(),
550 schema_digest: binding.binding.schema_digest.clone(),
551 native_endpoints: binding.binding.native_endpoints.clone(),
552 selected_stream_endpoint: binding.binding.selected_stream_endpoint.clone(),
553 };
554 let mut slot = self
555 .inner
556 .verified
557 .lock()
558 .map_err(|_| ClientError::InternalStateUnavailable)?;
559 *slot = Some(verified.clone());
560 self.inner
561 .transport
562 .inner
563 .verified_sessions
564 .lock()
565 .map_err(|_| ClientError::InternalStateUnavailable)?
566 .insert(
567 VerifiedSessionCacheKey::from_config(&self.inner.config),
568 verified.clone(),
569 &self.inner.transport.inner.metrics,
570 );
571 Ok(verified)
572 }
573
574 pub async fn call(&self, request: RequestBody, options: CallOptions) -> Result<ResultBody, ClientError> {
581 let local_deadline = options.local_deadline()?;
582 if tokio::time::Instant::now() >= local_deadline {
583 return Err(ClientError::Transport {
584 kind: TransportErrorKind::Deadline,
585 disposition: SubmissionDisposition::NotSubmitted,
586 });
587 }
588 let command = request_command(&request);
589 let command_contract = LODESTONE_API_COMMAND_CONTRACTS
590 .iter()
591 .find(|contract| contract.command == command)
592 .ok_or(ClientError::MissingCommandContract)?;
593 let outcome_contract = LODESTONE_API_COMMAND_OUTCOMES
594 .iter()
595 .find(|contract| contract.command == command)
596 .ok_or(ClientError::MissingCommandContract)?;
597 let mutation = outcome_contract.possible_submission_states.contains(&"submitted");
598 if mutation && self.profile_verification().is_none() {
599 return Err(ClientError::ProfileNotVerified);
600 }
601 let index_name = resolve_index_name(&request, options.index_name.as_deref())?;
602 let correlation = random_identity()?;
603 let deadline = options.absolute_deadline_unix_millis()?;
604 let request_id = format!("r-{}", lower_hex(&correlation));
605 let workload_class = options
606 .workload_class
607 .unwrap_or_else(|| default_workload_class(command_contract.action));
608 let envelope = RequestEnvelope {
609 meta: RequestMeta {
610 format_version: LODESTONE_API_FORMAT_VERSION,
611 request_id: request_id.clone(),
612 correlation_id: Identity {
613 value: lower_hex(&correlation),
614 },
615 absolute_deadline_unix_millis: U64 {
616 value: deadline.to_string(),
617 },
618 workload_class,
619 },
620 request,
621 };
622 let payload = canonical_request(&envelope)?;
623 let started = Arc::new(AtomicBool::new(false));
624 let maximum_attempts = usize::from(self.pool_config().maximum_read_retries).saturating_add(1);
625 let mut attempt = 0_usize;
626 loop {
627 attempt = attempt.saturating_add(1);
628 let result = tokio::select! {
629 result = tokio::time::timeout_at(
630 local_deadline,
631 self.transport_attempt(
632 &payload,
633 &request_id,
634 command,
635 command_contract.action,
636 workload_class,
637 index_name.as_deref(),
638 correlation,
639 Arc::clone(&started),
640 ),
641 ) => match result {
642 Ok(result) => result,
643 Err(_) => Err(ClientError::Transport {
644 kind: TransportErrorKind::Deadline,
645 disposition: started_disposition(&started),
646 }),
647 },
648 () = options.cancellation.cancelled() => Err(ClientError::Transport {
649 kind: TransportErrorKind::Cancelled,
650 disposition: started_disposition(&started),
651 }),
652 };
653 match result {
654 Err(ClientError::Transport { disposition, .. })
655 if attempt < maximum_attempts
656 && !options.cancellation.is_cancelled()
657 && retry_admitted(mutation, outcome_contract.pre_submit_retry_safe, disposition) =>
658 {
659 continue;
660 }
661 result => return result,
662 }
663 }
664 }
665
666 #[allow(clippy::too_many_arguments)]
667 async fn transport_attempt(
668 &self,
669 payload: &[u8],
670 request_id: &str,
671 command: Command,
672 action: Action,
673 workload_class: WorkloadClass,
674 index_name: Option<&str>,
675 correlation: [u8; 16],
676 started: Arc<AtomicBool>,
677 ) -> Result<ResultBody, ClientError> {
678 let credential = self.credential().await?;
682 let native_credential = native_credential(&credential)?;
683 let session = PublicSession {
684 format_version: PUBLIC_SESSION_FORMAT_VERSION,
685 api_key_credential: native_credential.as_slice(),
686 collection_id: &self.inner.config.profile.as_wire().expected_collection_id.value,
687 request_id,
688 action: action_name(action),
689 workload_class,
690 index_name,
691 request_correlation_id: correlation,
692 };
693 let session = SensitiveBuffer(serde_json::to_vec(&session).map_err(|_| ClientError::RequestCodec)?);
694 if session.is_empty() || session.len() > MAX_PUBLIC_SESSION_BYTES {
695 return Err(ClientError::RequestTooLarge);
696 }
697 let mut wire = SensitiveBuffer::new();
698 wire.extend_u32(session.len())?;
699 wire.extend(session.as_slice());
700 wire.extend_u32(payload.len())?;
701 wire.extend(payload);
702
703 if self.inner.transport.inner.connection_slots.available_permits() == 0 {
704 self.inner
705 .transport
706 .inner
707 .metrics
708 .bulkhead_saturation
709 .fetch_add(1, Ordering::Relaxed);
710 }
711 let _permit = self
712 .inner
713 .transport
714 .inner
715 .connection_slots
716 .clone()
717 .acquire_owned()
718 .await
719 .map_err(|_| ClientError::Transport {
720 kind: TransportErrorKind::PoolClosed,
721 disposition: SubmissionDisposition::NotSubmitted,
722 })?;
723 let endpoint_index = self.inner.transport.inner.next_endpoint.fetch_add(1, Ordering::Relaxed)
724 % self.inner.transport.inner.config.native_endpoints.len();
725 let mut connection = {
726 let mut pool = self.inner.transport.inner.pool.lock().await;
727 let connection = pool.pop();
731 self.inner
732 .transport
733 .inner
734 .metrics
735 .idle_connections
736 .store(pool.len(), Ordering::Relaxed);
737 connection
738 };
739 if connection.is_none() {
740 connection = Some(self.connect_native(endpoint_index).await?);
741 }
742 let mut connection = connection.ok_or(ClientError::Transport {
743 kind: TransportErrorKind::Connect,
744 disposition: SubmissionDisposition::NotSubmitted,
745 })?;
746 started.store(true, Ordering::Release);
747 if connection.stream.write_all(wire.as_slice()).await.is_err() || connection.stream.flush().await.is_err() {
748 return Err(ClientError::Transport {
749 kind: TransportErrorKind::Write,
750 disposition: SubmissionDisposition::Indeterminate,
751 });
752 }
753 let response_length = connection.stream.read_u32().await.map_err(|_| ClientError::Transport {
754 kind: TransportErrorKind::Read,
755 disposition: SubmissionDisposition::Indeterminate,
756 })?;
757 let response_length = usize::try_from(response_length).map_err(|_| ClientError::ResponseTooLarge)?;
758 if response_length == 0 || response_length > MAX_NATIVE_RESPONSE_BYTES {
759 return Err(ClientError::ResponseTooLarge);
760 }
761 let mut response = vec![0; response_length];
762 connection
763 .stream
764 .read_exact(&mut response)
765 .await
766 .map_err(|_| ClientError::Transport {
767 kind: TransportErrorKind::Read,
768 disposition: SubmissionDisposition::Indeterminate,
769 })?;
770 let decoded: ResponseEnvelope = serde_json::from_slice(&response).map_err(|_| ClientError::ResponseCodec)?;
771 if serde_json::to_vec(&decoded).map_err(|_| ClientError::ResponseCodec)? != response {
772 return Err(ClientError::NonCanonicalResponse);
773 }
774 let result = match decoded {
775 ResponseEnvelope::Success {
776 format_version,
777 request_id: observed,
778 result,
779 } if format_version == LODESTONE_API_FORMAT_VERSION && observed == request_id => {
780 if command_for_result(&result) != command {
781 Err(ClientError::ResponseCommandMismatch)
782 } else {
783 Ok(*result)
784 }
785 }
786 ResponseEnvelope::Success { .. } => Err(ClientError::ResponseIdentityMismatch),
787 ResponseEnvelope::Error {
788 format_version,
789 request_id: observed,
790 error,
791 } => {
792 if format_version != LODESTONE_API_FORMAT_VERSION || observed != request_id {
793 return Err(ClientError::ResponseIdentityMismatch);
794 }
795 validate_error_body(&error).map_err(|_| ClientError::InvalidErrorShape)?;
796 let semantic = LODESTONE_API_COMMAND_SEMANTICS
797 .iter()
798 .find(|contract| contract.command == command)
799 .ok_or(ClientError::MissingCommandContract)?;
800 if !semantic.admissible_errors.contains(&error.code) || server_error_disposition(&error).is_none() {
801 return Err(ClientError::InvalidErrorShape);
802 }
803 Err(ClientError::Server(error))
804 }
805 };
806 if response_keeps_connection(&result) {
814 let mut pool = self.inner.transport.inner.pool.lock().await;
815 if pool.len() < self.pool_config().maximum_idle_connections {
816 pool.push(connection);
817 self.inner
818 .transport
819 .inner
820 .metrics
821 .idle_connections
822 .store(pool.len(), Ordering::Relaxed);
823 }
824 }
825 result
826 }
827
828 async fn connect_native(&self, endpoint_index: usize) -> Result<NativeConnection, ClientError> {
829 let endpoint = self
830 .inner
831 .transport
832 .inner
833 .config
834 .native_endpoints
835 .get(endpoint_index)
836 .ok_or(ClientError::InvalidEndpoint)?;
837 let stream = tokio::time::timeout(
838 self.pool_config().connect_timeout,
839 TcpStream::connect((endpoint.host(), endpoint.port())),
840 )
841 .await
842 .map_err(|_| ClientError::Transport {
843 kind: TransportErrorKind::Connect,
844 disposition: SubmissionDisposition::NotSubmitted,
845 })?
846 .map_err(|_| ClientError::Transport {
847 kind: TransportErrorKind::Connect,
848 disposition: SubmissionDisposition::NotSubmitted,
849 })?;
850 let server_name = ServerName::try_from(self.inner.transport.inner.config.identity.tls_server_name().to_owned())
851 .map_err(|_| ClientError::Config(ClientConfigError::InvalidServerName))?;
852 let connector = TlsConnector::from(Arc::clone(&self.inner.transport.inner.tls));
853 let stream = tokio::time::timeout(
854 self.pool_config().connect_timeout,
855 connector.connect(server_name, stream),
856 )
857 .await
858 .map_err(|_| ClientError::Transport {
859 kind: TransportErrorKind::Tls,
860 disposition: SubmissionDisposition::NotSubmitted,
861 })?
862 .map_err(|_| ClientError::Transport {
863 kind: TransportErrorKind::Tls,
864 disposition: SubmissionDisposition::NotSubmitted,
865 })?;
866 if stream.get_ref().1.alpn_protocol() != Some(LODESTONE_API_ALPN.as_bytes()) {
867 return Err(ClientError::Transport {
868 kind: TransportErrorKind::Tls,
869 disposition: SubmissionDisposition::NotSubmitted,
870 });
871 }
872 self.inner
873 .transport
874 .inner
875 .metrics
876 .live_connections
877 .fetch_add(1, Ordering::Relaxed);
878 Ok(NativeConnection {
879 stream,
880 metrics: Arc::clone(&self.inner.transport.inner.metrics),
881 })
882 }
883
884 pub(crate) async fn credential(&self) -> Result<CredentialMaterial, ClientError> {
885 let profile = self.inner.config.profile.as_wire();
886 self.inner
887 .transport
888 .inner
889 .metrics
890 .credential_fetches
891 .fetch_add(1, Ordering::Relaxed);
892 let material = self
893 .inner
894 .config
895 .credentials
896 .provide(&profile.secret_reference)
897 .await
898 .map_err(ClientError::Credential)?;
899 if material.api_key_id() != &profile.credential_id || material.generation() != &profile.credential_generation {
900 return Err(ClientError::Credential(CredentialProviderError::InvalidMaterial));
901 }
902 Ok(material)
903 }
904
905 pub(crate) fn transport_config(&self) -> &CellTransportConfig {
906 &self.inner.transport.inner.config
907 }
908
909 pub(crate) fn pool_config(&self) -> PoolConfig {
910 self.inner.transport.pool_config()
911 }
912}
913
914fn response_keeps_connection(result: &Result<ResultBody, ClientError>) -> bool {
915 result.is_ok()
916}
917
918const fn default_workload_class(action: Action) -> WorkloadClass {
919 match action {
920 Action::DataRead | Action::SubscriptionRead => WorkloadClass::InteractiveSearch,
921 Action::DataWrite | Action::Append | Action::ResolveAppend => WorkloadClass::StreamingIngest,
922 Action::IdentityDiscovery
923 | Action::IndexAdmin
924 | Action::CatalogRead
925 | Action::TaskRead
926 | Action::DiagnosticsRead
927 | Action::DiagnosticsCreate
928 | Action::BackupRead
929 | Action::BackupAdmin
930 | Action::SecurityRead
931 | Action::SecurityAdmin
932 | Action::AuditRead
933 | Action::QuotaRead
934 | Action::QuotaAdmin
935 | Action::BindingRead
936 | Action::LifecycleAdmin
937 | Action::AdmissionRead
938 | Action::AdmissionAdmin
939 | Action::UsageRead
940 | Action::OperationRead
941 | Action::OperationCancel => WorkloadClass::BatchAnalytics,
942 }
943}
944
945const fn retry_admitted(mutation: bool, pre_submit_retry_safe: bool, disposition: SubmissionDisposition) -> bool {
946 pre_submit_retry_safe && (!mutation || matches!(disposition, SubmissionDisposition::NotSubmitted))
947}
948
949#[derive(serde::Serialize)]
950struct PublicSession<'a> {
951 format_version: u16,
952 api_key_credential: &'a [u8],
953 collection_id: &'a str,
954 request_id: &'a str,
955 action: &'static str,
956 workload_class: WorkloadClass,
957 #[serde(skip_serializing_if = "Option::is_none")]
958 index_name: Option<&'a str>,
959 request_correlation_id: [u8; 16],
960}
961
962fn canonical_request(envelope: &RequestEnvelope) -> Result<Vec<u8>, ClientError> {
963 if envelope.meta.request_id.is_empty()
964 || envelope.meta.request_id.len() > MAX_REQUEST_ID_BYTES
965 || envelope.meta.request_id.bytes().any(|byte| byte.is_ascii_control())
966 {
967 return Err(ClientError::InvalidRequestIdentity);
968 }
969 let payload = serde_json::to_vec(envelope).map_err(|_| ClientError::RequestCodec)?;
970 if payload.is_empty() || payload.len() > MAX_NATIVE_REQUEST_BYTES {
971 return Err(ClientError::RequestTooLarge);
972 }
973 let decoded: RequestEnvelope = serde_json::from_slice(&payload).map_err(|_| ClientError::RequestCodec)?;
974 if decoded != *envelope {
975 return Err(ClientError::RequestCodec);
976 }
977 Ok(payload)
978}
979
980fn build_tls_config(config: &CellTransportConfig) -> Result<rustls::ClientConfig, ClientError> {
981 let mut roots = rustls::RootCertStore::empty();
982 for certificate in config.trust.certificates_der() {
983 roots
984 .add(CertificateDer::from(certificate.clone()))
985 .map_err(|_| ClientError::Config(ClientConfigError::InvalidTrustMaterial))?;
986 }
987 let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
988 let mut tls = rustls::ClientConfig::builder_with_provider(provider)
989 .with_protocol_versions(&[&rustls::version::TLS13])
990 .map_err(|_| ClientError::Config(ClientConfigError::InvalidTrustMaterial))?
991 .with_root_certificates(roots)
992 .with_no_client_auth();
993 tls.alpn_protocols = vec![LODESTONE_API_ALPN.as_bytes().to_vec()];
994 Ok(tls)
995}
996
997fn validate_discovery(config: &CollectionSessionConfig, discovered: &DiscoverResult) -> Result<(), ClientError> {
998 let profile = config.profile.as_wire();
999 if discovered.collection_id != profile.expected_collection_id
1000 || discovered.principal_id != profile.expected_principal_id
1001 || discovered.schema_id != LODESTONE_API_SCHEMA_ID
1002 || discovered.schema_digest.value != LODESTONE_API_SPEC_SHA256
1003 {
1004 return Err(ClientError::ProfileIdentityMismatch);
1005 }
1006 let expected = LODESTONE_API_STREAM_CONTRACTS
1007 .first()
1008 .ok_or(ClientError::MissingCommandContract)?;
1009 if discovered.streams.len() != 1
1010 || discovered.streams.first().is_none_or(|stream| {
1011 stream.name != expected.name
1012 || stream.transport != expected.transport
1013 || stream.service != expected.service
1014 || stream.method != expected.method
1015 || stream.endpoint != profile.selected_stream_endpoint
1016 || stream.auth_metadata != expected.auth_metadata
1017 || stream.auth_scheme != expected.auth_scheme
1018 || stream.semantic_format_version != u32::from(expected.semantic_format_version)
1019 || stream.schema_digest.value != LODESTONE_API_SPEC_SHA256
1020 })
1021 {
1022 return Err(ClientError::ProfileStreamMismatch);
1023 }
1024 Ok(())
1025}
1026
1027fn validate_binding(
1028 config: &CollectionSessionConfig,
1029 binding: &CoreBinding,
1030 derived: bool,
1031 revision: &U64,
1032) -> Result<(), ClientError> {
1033 let profile = config.profile.as_wire();
1034 if !derived
1035 || binding.cluster_id != profile.expected_cluster_id
1036 || binding.collection_id != profile.expected_collection_id
1037 || binding.native_endpoints != profile.native_endpoints
1038 || binding.selected_stream_endpoint != profile.selected_stream_endpoint
1039 || binding.schema_id != profile.schema_id
1040 || binding.schema_digest != profile.schema_digest
1041 || binding
1042 .configuration_generation
1043 .value
1044 .parse::<u64>()
1045 .ok()
1046 .is_none_or(|generation| generation == 0)
1047 || revision.value.parse::<u64>().ok().is_none_or(|revision| revision == 0)
1048 {
1049 return Err(ClientError::ProfileIdentityMismatch);
1050 }
1051 Ok(())
1052}
1053
1054fn validate_cached_verification(
1055 config: &CollectionSessionConfig,
1056 verification: &ProfileVerification,
1057) -> Result<(), ClientError> {
1058 let profile = config.profile.as_wire();
1059 if verification.collection_id != profile.expected_collection_id
1060 || verification.principal_id != profile.expected_principal_id
1061 || verification.cluster_id != profile.expected_cluster_id
1062 || verification.schema_id != profile.schema_id
1063 || verification.schema_digest != profile.schema_digest
1064 || verification.native_endpoints != profile.native_endpoints
1065 || verification.selected_stream_endpoint != profile.selected_stream_endpoint
1066 || verification
1067 .configuration_generation
1068 .value
1069 .parse::<u64>()
1070 .ok()
1071 .is_none_or(|generation| generation == 0)
1072 || verification
1073 .binding_revision
1074 .value
1075 .parse::<u64>()
1076 .ok()
1077 .is_none_or(|revision| revision == 0)
1078 {
1079 return Err(ClientError::ProfileIdentityMismatch);
1080 }
1081 Ok(())
1082}
1083
1084fn resolve_index_name(request: &RequestBody, supplied: Option<&str>) -> Result<Option<String>, ClientError> {
1085 let scope = request_collection_index_ref(request)?;
1086 let inferred = match &scope {
1087 GeneratedCollectionIndexRef::Exact(name) => Some(name.clone()),
1088 GeneratedCollectionIndexRef::None | GeneratedCollectionIndexRef::Caller => None,
1089 };
1090 if let Some(supplied) = supplied {
1091 validate_index_name(supplied)?;
1092 if inferred.as_deref().is_some_and(|inferred| inferred != supplied) {
1093 return Err(ClientError::CollectionIndexRefMismatch);
1094 }
1095 }
1096 let resolved = supplied.map(str::to_owned).or(inferred);
1097 if let Some(name) = &resolved {
1098 validate_index_name(name)?;
1099 }
1100 match scope {
1101 GeneratedCollectionIndexRef::Caller | GeneratedCollectionIndexRef::Exact(_) if resolved.is_none() => {
1102 return Err(ClientError::CollectionIndexRefRequired);
1103 }
1104 GeneratedCollectionIndexRef::None if resolved.is_some() => return Err(ClientError::CollectionIndexRefMismatch),
1105 GeneratedCollectionIndexRef::None
1106 | GeneratedCollectionIndexRef::Caller
1107 | GeneratedCollectionIndexRef::Exact(_) => {}
1108 }
1109 Ok(resolved)
1110}
1111
1112const fn action_name(action: Action) -> &'static str {
1113 match action {
1114 Action::IdentityDiscovery => "identity_discovery",
1115 Action::DataRead => "data_read",
1116 Action::DataWrite => "data_write",
1117 Action::Append => "append",
1118 Action::ResolveAppend => "resolve_append",
1119 Action::IndexAdmin => "index_admin",
1120 Action::CatalogRead => "catalog_read",
1121 Action::TaskRead => "task_read",
1122 Action::SubscriptionRead => "subscription_read",
1123 Action::DiagnosticsRead => "diagnostics_read",
1124 Action::DiagnosticsCreate => "diagnostics_create",
1125 Action::BackupRead => "backup_read",
1126 Action::BackupAdmin => "backup_admin",
1127 Action::SecurityRead => "security_read",
1128 Action::SecurityAdmin => "security_admin",
1129 Action::AuditRead => "audit_read",
1130 Action::QuotaRead => "quota_read",
1131 Action::QuotaAdmin => "quota_admin",
1132 Action::BindingRead => "binding_read",
1133 Action::LifecycleAdmin => "lifecycle_admin",
1134 Action::AdmissionRead => "admission_read",
1135 Action::AdmissionAdmin => "admission_admin",
1136 Action::UsageRead => "usage_read",
1137 Action::OperationRead => "operation_read",
1138 Action::OperationCancel => "operation_cancel",
1139 }
1140}
1141
1142fn native_credential(material: &CredentialMaterial) -> Result<SensitiveBuffer, ClientError> {
1143 let identifier = parse_identity(&material.api_key_id().value)
1144 .ok_or(ClientError::Credential(CredentialProviderError::InvalidMaterial))?;
1145 let mut output = SensitiveBuffer::new();
1146 output.extend(identifier.to_string().as_bytes());
1147 output.extend(b":");
1148 output.extend(&base64_url_no_pad(material.secret().expose()));
1149 Ok(output)
1150}
1151
1152pub(crate) fn authorization_value(material: &CredentialMaterial) -> Result<SensitiveBuffer, ClientError> {
1153 let native = native_credential(material)?;
1154 let mut output = SensitiveBuffer::new();
1155 output.extend(b"ApiKey ");
1156 output.extend(&base64_standard(native.as_slice()));
1157 Ok(output)
1158}
1159
1160fn base64_url_no_pad(bytes: &[u8]) -> Vec<u8> {
1161 base64_encode(
1162 bytes,
1163 b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_",
1164 false,
1165 )
1166}
1167
1168fn base64_standard(bytes: &[u8]) -> Vec<u8> {
1169 base64_encode(
1170 bytes,
1171 b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",
1172 true,
1173 )
1174}
1175
1176pub fn raw_json_text(text: &str) -> Result<RawJson, ClientError> {
1179 let bytes_base64 = String::from_utf8(base64_standard(text.as_bytes())).map_err(|_| ClientError::RequestCodec)?;
1180 Ok(RawJson { bytes_base64 })
1181}
1182
1183fn base64_encode(bytes: &[u8], alphabet: &[u8; 64], padded: bool) -> Vec<u8> {
1184 let mut output = Vec::with_capacity(bytes.len().div_ceil(3).saturating_mul(4));
1185 for chunk in bytes.chunks(3) {
1186 let first = chunk[0];
1187 let second = chunk.get(1).copied().unwrap_or(0);
1188 let third = chunk.get(2).copied().unwrap_or(0);
1189 output.push(alphabet[usize::from(first >> 2)]);
1190 output.push(alphabet[usize::from(((first & 0x03) << 4) | (second >> 4))]);
1191 if chunk.len() > 1 {
1192 output.push(alphabet[usize::from(((second & 0x0f) << 2) | (third >> 6))]);
1193 } else if padded {
1194 output.push(b'=');
1195 }
1196 if chunk.len() > 2 {
1197 output.push(alphabet[usize::from(third & 0x3f)]);
1198 } else if padded {
1199 output.push(b'=');
1200 }
1201 }
1202 output
1203}
1204
1205fn random_identity() -> Result<[u8; 16], ClientError> {
1206 let mut output = [0; 16];
1207 getrandom::fill(&mut output).map_err(|_| ClientError::RandomUnavailable)?;
1208 if output == [0; 16] {
1209 return Err(ClientError::RandomUnavailable);
1210 }
1211 Ok(output)
1212}
1213
1214fn normalized_timeout(timeout: Duration) -> Result<Duration, ClientError> {
1215 if timeout.is_zero() || timeout > MAX_CALL_TIMEOUT {
1216 return Err(ClientError::InvalidCallOptions);
1217 }
1218 let mut millis = timeout.as_millis();
1219 if !timeout.subsec_nanos().is_multiple_of(1_000_000) {
1220 millis = millis.checked_add(1).ok_or(ClientError::InvalidCallOptions)?;
1221 }
1222 let millis = u64::try_from(millis).map_err(|_| ClientError::InvalidCallOptions)?;
1223 let normalized = Duration::from_millis(millis);
1224 if normalized > MAX_CALL_TIMEOUT {
1225 return Err(ClientError::InvalidCallOptions);
1226 }
1227 Ok(normalized)
1228}
1229
1230fn duration_millis_ceil(duration: Duration) -> Result<u64, ClientError> {
1231 let mut millis = duration.as_millis();
1232 if !duration.subsec_nanos().is_multiple_of(1_000_000) {
1233 millis = millis.checked_add(1).ok_or(ClientError::ClockUnavailable)?;
1234 }
1235 u64::try_from(millis).map_err(|_| ClientError::ClockUnavailable)
1236}
1237
1238pub(crate) fn validate_index_name(value: &str) -> Result<(), ClientError> {
1239 if value.is_empty()
1240 || value.len() > 255
1241 || value
1242 .bytes()
1243 .any(|byte| byte.is_ascii_control() || byte.is_ascii_whitespace())
1244 {
1245 return Err(ClientError::InvalidCollectionIndexRef);
1246 }
1247 Ok(())
1248}
1249
1250fn lower_hex(bytes: &[u8]) -> String {
1251 const HEX: &[u8; 16] = b"0123456789abcdef";
1252 let mut output = String::with_capacity(bytes.len().saturating_mul(2));
1253 for byte in bytes {
1254 output.push(char::from(HEX[usize::from(byte >> 4)]));
1255 output.push(char::from(HEX[usize::from(byte & 0x0f)]));
1256 }
1257 output
1258}
1259
1260pub(crate) struct SensitiveBuffer(Vec<u8>);
1261
1262impl SensitiveBuffer {
1263 fn new() -> Self {
1264 Self(Vec::new())
1265 }
1266
1267 fn extend(&mut self, bytes: &[u8]) {
1268 self.0.extend_from_slice(bytes);
1269 }
1270
1271 fn extend_u32(&mut self, value: usize) -> Result<(), ClientError> {
1272 let value = u32::try_from(value).map_err(|_| ClientError::RequestTooLarge)?;
1273 self.extend(&value.to_be_bytes());
1274 Ok(())
1275 }
1276
1277 fn is_empty(&self) -> bool {
1278 self.0.is_empty()
1279 }
1280
1281 fn len(&self) -> usize {
1282 self.0.len()
1283 }
1284
1285 pub(crate) fn as_slice(&self) -> &[u8] {
1286 &self.0
1287 }
1288}
1289
1290impl Drop for SensitiveBuffer {
1291 fn drop(&mut self) {
1292 self.0.fill(0);
1293 std::hint::black_box(&mut self.0);
1294 }
1295}
1296
1297fn started_disposition(started: &AtomicBool) -> SubmissionDisposition {
1298 if started.load(Ordering::Acquire) {
1299 SubmissionDisposition::Indeterminate
1300 } else {
1301 SubmissionDisposition::NotSubmitted
1302 }
1303}
1304
1305#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1306pub enum SubmissionDisposition {
1307 NotSubmitted,
1308 Submitted,
1309 Resolved,
1310 KnownRetired,
1311 Indeterminate,
1312}
1313
1314#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1315pub enum TransportErrorKind {
1316 PoolClosed,
1317 Connect,
1318 Tls,
1319 Write,
1320 Read,
1321 Deadline,
1322 Cancelled,
1323}
1324
1325#[derive(Debug)]
1326pub enum ClientError {
1327 Config(ClientConfigError),
1328 Credential(CredentialProviderError),
1329 InvalidCallOptions,
1330 InvalidEndpoint,
1331 InvalidCollectionIndexRef,
1332 InvalidStateIndex,
1333 InvalidStateActionId,
1334 InvalidStateKey,
1335 InvalidStateBatch,
1336 UnexpectedStateResult,
1337 CollectionIndexRefRequired,
1338 CollectionIndexRefMismatch,
1339 ProfileNotVerified,
1340 ProfileIdentityMismatch,
1341 ProfileStreamMismatch,
1342 MissingCommandContract,
1343 RequestCodec,
1344 RequestTooLarge,
1345 InvalidRequestIdentity,
1346 ResponseCodec,
1347 ResponseTooLarge,
1348 NonCanonicalResponse,
1349 ResponseIdentityMismatch,
1350 ResponseCommandMismatch,
1351 InvalidErrorShape,
1352 RandomUnavailable,
1353 ClockUnavailable,
1354 InternalStateUnavailable,
1355 Transport {
1356 kind: TransportErrorKind,
1357 disposition: SubmissionDisposition,
1358 },
1359 Server(ErrorBody),
1360}
1361
1362impl ClientError {
1363 #[must_use]
1364 pub fn submission_disposition(&self) -> SubmissionDisposition {
1365 match self {
1366 Self::Transport { disposition, .. } => *disposition,
1367 Self::Server(error) => match server_error_disposition(error) {
1368 Some(disposition) => disposition,
1369 None => SubmissionDisposition::Indeterminate,
1370 },
1371 Self::UnexpectedStateResult
1372 | Self::ResponseCodec
1373 | Self::ResponseTooLarge
1374 | Self::NonCanonicalResponse
1375 | Self::ResponseIdentityMismatch
1376 | Self::ResponseCommandMismatch
1377 | Self::InvalidErrorShape => SubmissionDisposition::Indeterminate,
1378 _ => SubmissionDisposition::NotSubmitted,
1379 }
1380 }
1381
1382 pub(crate) fn is_deadline_exceeded(&self) -> bool {
1383 matches!(
1384 self,
1385 Self::Transport {
1386 kind: TransportErrorKind::Deadline,
1387 ..
1388 } | Self::Server(ErrorBody {
1389 code: lodestone_wire::ErrorCode::DeadlineExceeded,
1390 ..
1391 })
1392 )
1393 }
1394}
1395
1396fn server_error_disposition(error: &ErrorBody) -> Option<SubmissionDisposition> {
1397 let contract = LODESTONE_API_ERROR_OUTCOMES
1398 .iter()
1399 .find(|contract| contract.code == error.code)?;
1400 let value = if error.submitted {
1401 contract.when_submitted
1402 } else {
1403 contract.when_not_submitted
1404 }?;
1405 match value {
1406 "not_submitted" => Some(SubmissionDisposition::NotSubmitted),
1407 "submitted" => Some(SubmissionDisposition::Submitted),
1408 "resolved" => Some(SubmissionDisposition::Resolved),
1409 "known_retired" => Some(SubmissionDisposition::KnownRetired),
1410 "indeterminate" => Some(SubmissionDisposition::Indeterminate),
1411 _ => None,
1412 }
1413}
1414
1415impl From<ClientConfigError> for ClientError {
1416 fn from(error: ClientConfigError) -> Self {
1417 Self::Config(error)
1418 }
1419}
1420
1421impl Display for ClientError {
1422 fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
1423 formatter.write_str(match self {
1424 Self::Config(_) => "Core client configuration is invalid",
1425 Self::Credential(_) => "Core credential material is unavailable or mismatched",
1426 Self::InvalidCallOptions => "Core call options are invalid",
1427 Self::InvalidEndpoint => "Core endpoint is invalid",
1428 Self::InvalidCollectionIndexRef => "Core request index scope is invalid",
1429 Self::InvalidStateIndex => "Lodestone state index identity is invalid",
1430 Self::InvalidStateActionId => "Lodestone state action identity is invalid",
1431 Self::InvalidStateKey => "Lodestone state key must contain 1 to 254 bytes",
1432 Self::InvalidStateBatch => "Lodestone state key batch is empty or exceeds its bound",
1433 Self::UnexpectedStateResult => "Lodestone state result differs from the requested operation",
1434 Self::CollectionIndexRefRequired => "Core request requires an exact index scope",
1435 Self::CollectionIndexRefMismatch => "Core request and admission index scopes differ",
1436 Self::ProfileNotVerified => "Core mutation is refused until discovery and binding are verified",
1437 Self::ProfileIdentityMismatch => "Core discovery or binding differs from the connection profile",
1438 Self::ProfileStreamMismatch => "Core selected stream differs from the connection profile",
1439 Self::MissingCommandContract => "generated Core command metadata is incomplete",
1440 Self::RequestCodec => "Core request is not a canonical current Lodestone API value",
1441 Self::RequestTooLarge => "Core request exceeds its generated byte bound",
1442 Self::InvalidRequestIdentity => "Core request identity is invalid",
1443 Self::ResponseCodec => "Core response is malformed or unsupported",
1444 Self::ResponseTooLarge => "Core response exceeds its generated byte bound",
1445 Self::NonCanonicalResponse => "Core response encoding is noncanonical",
1446 Self::ResponseIdentityMismatch => "Core response changed the request identity or version",
1447 Self::ResponseCommandMismatch => "Core response returned another command result",
1448 Self::InvalidErrorShape => "Core error detail does not match its generated code",
1449 Self::RandomUnavailable => "secure request identity generation is unavailable",
1450 Self::ClockUnavailable => "absolute Core request deadline cannot be calculated",
1451 Self::InternalStateUnavailable => "Core client verification state is unavailable",
1452 Self::Transport { .. } => "Core transport failed",
1453 Self::Server(_) => "Core returned a typed request error",
1454 })
1455 }
1456}
1457
1458impl Error for ClientError {}
1459
1460#[cfg(test)]
1461mod tests {
1462 use std::error::Error;
1463
1464 use lodestone_wire::{GetIndexRequest, Identity, OpenPitRequest, Resource, U64};
1465
1466 use super::*;
1467 use crate::SecretBytes;
1468
1469 #[test]
1470 fn response_failures_preserve_indeterminate_submission() {
1471 for error in [
1472 ClientError::UnexpectedStateResult,
1473 ClientError::ResponseCodec,
1474 ClientError::ResponseTooLarge,
1475 ClientError::NonCanonicalResponse,
1476 ClientError::ResponseIdentityMismatch,
1477 ClientError::ResponseCommandMismatch,
1478 ClientError::InvalidErrorShape,
1479 ] {
1480 assert_eq!(error.submission_disposition(), SubmissionDisposition::Indeterminate);
1481 }
1482 assert_eq!(
1483 ClientError::RequestCodec.submission_disposition(),
1484 SubmissionDisposition::NotSubmitted
1485 );
1486 }
1487
1488 fn cache_key(collection: u128, principal: u128, credential: u128) -> VerifiedSessionCacheKey {
1489 VerifiedSessionCacheKey {
1490 collection_id: format!("{collection:032x}"),
1491 principal_id: format!("{principal:032x}"),
1492 profile_generation: "1".to_owned(),
1493 credential_id: format!("{credential:032x}"),
1494 credential_generation: "1".to_owned(),
1495 secret_reference: format!("memory://session/{collection}/{principal}/{credential}"),
1496 }
1497 }
1498
1499 fn verification(collection: u128, principal: u128) -> ProfileVerification {
1500 ProfileVerification {
1501 artifact_version: "development".to_owned(),
1502 principal_id: Identity {
1503 value: format!("{principal:032x}"),
1504 },
1505 collection_id: Identity {
1506 value: format!("{collection:032x}"),
1507 },
1508 policy_revision: U64 { value: "1".to_owned() },
1509 cluster_id: Identity {
1510 value: "00000000000000000000000000000001".to_owned(),
1511 },
1512 configuration_generation: U64 { value: "1".to_owned() },
1513 binding_revision: U64 { value: "1".to_owned() },
1514 schema_id: LODESTONE_API_SCHEMA_ID.to_owned(),
1515 schema_digest: lodestone_wire::Digest {
1516 value: LODESTONE_API_SPEC_SHA256.to_owned(),
1517 },
1518 native_endpoints: vec!["lodestone+tls://core.example:8443".to_owned()],
1519 selected_stream_endpoint: "https://core.example:9443".to_owned(),
1520 }
1521 }
1522
1523 #[test]
1524 fn mutation_retry_stops_at_the_submission_boundary() {
1525 assert!(retry_admitted(true, true, SubmissionDisposition::NotSubmitted));
1526 assert!(!retry_admitted(true, true, SubmissionDisposition::Submitted));
1527 assert!(!retry_admitted(true, true, SubmissionDisposition::Indeterminate));
1528 assert!(!retry_admitted(true, false, SubmissionDisposition::NotSubmitted));
1529 assert!(retry_admitted(false, true, SubmissionDisposition::Indeterminate));
1530 }
1531
1532 #[test]
1533 fn one_deadline_survives_connect_state_and_resolution_clones() -> Result<(), Box<dyn Error>> {
1534 let options = CallOptions::new(Duration::from_secs(5))?;
1535 let absolute = options.absolute_deadline_unix_millis()?;
1536 let local = options.local_deadline()?;
1537
1538 for stage in [
1541 options.clone(),
1542 options.clone(),
1543 options.clone(),
1544 options.clone(),
1545 options.for_resolution(),
1546 ] {
1547 assert_eq!(stage.absolute_deadline_unix_millis()?, absolute);
1548 assert_eq!(stage.local_deadline()?, local);
1549 }
1550
1551 let resolution = options.for_resolution();
1552 options.cancellation().cancel();
1553 assert!(options.cancellation().is_cancelled());
1554 assert!(!resolution.cancellation().is_cancelled());
1555 assert_eq!(resolution.absolute_deadline_unix_millis()?, absolute);
1556 Ok(())
1557 }
1558
1559 #[test]
1560 fn caller_supplied_absolute_deadline_is_preserved_exactly() -> Result<(), Box<dyn Error>> {
1561 let now = SystemTime::now().duration_since(UNIX_EPOCH)?;
1562 let now_millis = u64::try_from(now.as_millis())?;
1563 let absolute = now_millis.checked_add(5_000).ok_or("test deadline overflow")?;
1564 let options = CallOptions::from_absolute_deadline_unix_millis(absolute)?;
1565 assert_eq!(options.absolute_deadline_unix_millis()?, absolute);
1566
1567 let too_far = now_millis
1568 .checked_add(u64::try_from(MAX_CALL_TIMEOUT.as_millis())?)
1569 .and_then(|value| value.checked_add(1_000))
1570 .ok_or("test deadline overflow")?;
1571 assert!(matches!(
1572 CallOptions::from_absolute_deadline_unix_millis(too_far),
1573 Err(ClientError::InvalidCallOptions)
1574 ));
1575 assert_eq!(normalized_timeout(Duration::from_nanos(1))?, Duration::from_millis(1));
1576 Ok(())
1577 }
1578
1579 #[test]
1580 fn private_api_key_encodings_match_the_core_admission_contract() -> Result<(), Box<dyn Error>> {
1581 let material = CredentialMaterial::new(
1582 Identity {
1583 value: "00000000000000000000000000000001".to_owned(),
1584 },
1585 U64 { value: "1".to_owned() },
1586 SecretBytes::from_array([0; 32]),
1587 )?;
1588 assert_eq!(
1589 native_credential(&material)?.as_slice(),
1590 b"1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
1591 );
1592 assert_eq!(
1593 authorization_value(&material)?.as_slice(),
1594 b"ApiKey MTpBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB"
1595 );
1596 Ok(())
1597 }
1598
1599 #[test]
1600 fn typed_refusal_discards_the_connection_before_a_dependent_follow_up() {
1601 let refusal = Err(ClientError::Server(ErrorBody {
1602 code: lodestone_wire::ErrorCode::PermissionDenied,
1603 message: "denied".to_owned(),
1604 retry_safe: false,
1605 submitted: false,
1606 details: None,
1607 }));
1608 assert!(!response_keeps_connection(&refusal));
1609 let failure = Err(ClientError::Transport {
1610 kind: TransportErrorKind::Read,
1611 disposition: SubmissionDisposition::Indeterminate,
1612 });
1613 assert!(!response_keeps_connection(&failure));
1614 }
1615
1616 #[test]
1617 fn verified_session_cache_is_bounded_and_never_crosses_principals() {
1618 let metrics = CellTransportMetrics::default();
1619 let mut cache = VerifiedSessionCache::default();
1620 let first = cache_key(1, 10, 100);
1621 let other_principal = cache_key(1, 11, 100);
1622 assert_ne!(first, other_principal);
1623 cache.insert(first.clone(), verification(1, 10), &metrics);
1624 assert!(cache.get(&other_principal, &metrics).is_none());
1625 assert_eq!(cache.get(&first, &metrics), Some(verification(1, 10)));
1626
1627 for offset in 0..=MAX_VERIFIED_SESSION_METADATA {
1628 let identity = u128::try_from(offset).unwrap_or_default().saturating_add(1_000);
1629 cache.insert(
1630 cache_key(identity, identity, identity),
1631 verification(identity, identity),
1632 &metrics,
1633 );
1634 }
1635 assert_eq!(cache.entries.len(), MAX_VERIFIED_SESSION_METADATA);
1636 assert!(metrics.session_cache_evictions.load(Ordering::Relaxed) > 0);
1637 assert_eq!(
1638 metrics.session_cache_entries.load(Ordering::Relaxed),
1639 MAX_VERIFIED_SESSION_METADATA
1640 );
1641 assert_eq!(metrics.session_cache_hits.load(Ordering::Relaxed), 1);
1642 assert_eq!(metrics.session_cache_misses.load(Ordering::Relaxed), 1);
1643 }
1644
1645 #[test]
1646 fn generated_multi_index_scope_requires_a_nonempty_distinct_set() {
1647 fn index(name: &str, incarnation: &str) -> Resource {
1648 Resource {
1649 kind: "index".to_owned(),
1650 name: name.to_owned(),
1651 incarnation: Identity {
1652 value: incarnation.to_owned(),
1653 },
1654 }
1655 }
1656
1657 fn request(indexes: Vec<Resource>) -> RequestBody {
1658 RequestBody::OpenPit(OpenPitRequest {
1659 indexes,
1660 keep_alive_millis: U64 {
1661 value: "1000".to_owned(),
1662 },
1663 })
1664 }
1665
1666 let first = index("orders", "00000000000000000000000000000001");
1667 let second = index("events", "00000000000000000000000000000002");
1668 assert!(matches!(
1669 resolve_index_name(&request(Vec::new()), None),
1670 Err(ClientError::CollectionIndexRefRequired)
1671 ));
1672 assert!(matches!(
1673 resolve_index_name(&request(vec![first.clone(), second.clone()]), None),
1674 Ok(None)
1675 ));
1676 assert!(matches!(
1677 resolve_index_name(&request(vec![first.clone(), first.clone()]), None),
1678 Err(ClientError::CollectionIndexRefMismatch)
1679 ));
1680 assert!(matches!(
1681 resolve_index_name(&request(vec![second.clone(), first.clone(), first]), None),
1682 Err(ClientError::CollectionIndexRefMismatch)
1683 ));
1684 }
1685
1686 #[test]
1687 fn generated_get_index_is_collection_scoped() {
1688 let request = RequestBody::GetIndex(GetIndexRequest {
1689 name: "events".to_owned(),
1690 expected_incarnation: None,
1691 });
1692
1693 assert!(matches!(resolve_index_name(&request, None), Ok(None)));
1694 assert!(matches!(
1695 resolve_index_name(&request, Some("events")),
1696 Err(ClientError::CollectionIndexRefMismatch)
1697 ));
1698 }
1699}