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