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 _ => SubmissionDisposition::NotSubmitted,
1368 }
1369 }
1370
1371 pub(crate) fn is_deadline_exceeded(&self) -> bool {
1372 matches!(
1373 self,
1374 Self::Transport {
1375 kind: TransportErrorKind::Deadline,
1376 ..
1377 } | Self::Server(ErrorBody {
1378 code: lodestone_wire::ErrorCode::DeadlineExceeded,
1379 ..
1380 })
1381 )
1382 }
1383}
1384
1385fn server_error_disposition(error: &ErrorBody) -> Option<SubmissionDisposition> {
1386 let contract = LODESTONE_API_ERROR_OUTCOMES
1387 .iter()
1388 .find(|contract| contract.code == error.code)?;
1389 let value = if error.submitted {
1390 contract.when_submitted
1391 } else {
1392 contract.when_not_submitted
1393 }?;
1394 match value {
1395 "not_submitted" => Some(SubmissionDisposition::NotSubmitted),
1396 "submitted" => Some(SubmissionDisposition::Submitted),
1397 "resolved" => Some(SubmissionDisposition::Resolved),
1398 "known_retired" => Some(SubmissionDisposition::KnownRetired),
1399 "indeterminate" => Some(SubmissionDisposition::Indeterminate),
1400 _ => None,
1401 }
1402}
1403
1404impl From<ClientConfigError> for ClientError {
1405 fn from(error: ClientConfigError) -> Self {
1406 Self::Config(error)
1407 }
1408}
1409
1410impl Display for ClientError {
1411 fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
1412 formatter.write_str(match self {
1413 Self::Config(_) => "Core client configuration is invalid",
1414 Self::Credential(_) => "Core credential material is unavailable or mismatched",
1415 Self::InvalidCallOptions => "Core call options are invalid",
1416 Self::InvalidEndpoint => "Core endpoint is invalid",
1417 Self::InvalidCollectionIndexRef => "Core request index scope is invalid",
1418 Self::InvalidStateIndex => "Lodestone state index identity is invalid",
1419 Self::InvalidStateActionId => "Lodestone state action identity is invalid",
1420 Self::InvalidStateKey => "Lodestone state key must contain 1 to 254 bytes",
1421 Self::InvalidStateBatch => "Lodestone state key batch is empty or exceeds its bound",
1422 Self::UnexpectedStateResult => "Lodestone state result differs from the requested operation",
1423 Self::CollectionIndexRefRequired => "Core request requires an exact index scope",
1424 Self::CollectionIndexRefMismatch => "Core request and admission index scopes differ",
1425 Self::ProfileNotVerified => "Core mutation is refused until discovery and binding are verified",
1426 Self::ProfileIdentityMismatch => "Core discovery or binding differs from the connection profile",
1427 Self::ProfileStreamMismatch => "Core selected stream differs from the connection profile",
1428 Self::MissingCommandContract => "generated Core command metadata is incomplete",
1429 Self::RequestCodec => "Core request is not a canonical current Lodestone API value",
1430 Self::RequestTooLarge => "Core request exceeds its generated byte bound",
1431 Self::InvalidRequestIdentity => "Core request identity is invalid",
1432 Self::ResponseCodec => "Core response is malformed or unsupported",
1433 Self::ResponseTooLarge => "Core response exceeds its generated byte bound",
1434 Self::NonCanonicalResponse => "Core response encoding is noncanonical",
1435 Self::ResponseIdentityMismatch => "Core response changed the request identity or version",
1436 Self::ResponseCommandMismatch => "Core response returned another command result",
1437 Self::InvalidErrorShape => "Core error detail does not match its generated code",
1438 Self::RandomUnavailable => "secure request identity generation is unavailable",
1439 Self::ClockUnavailable => "absolute Core request deadline cannot be calculated",
1440 Self::InternalStateUnavailable => "Core client verification state is unavailable",
1441 Self::Transport { .. } => "Core transport failed",
1442 Self::Server(_) => "Core returned a typed request error",
1443 })
1444 }
1445}
1446
1447impl Error for ClientError {}
1448
1449#[cfg(test)]
1450mod tests {
1451 use std::error::Error;
1452
1453 use lodestone_wire::{GetIndexRequest, Identity, OpenPitRequest, Resource, U64};
1454
1455 use super::*;
1456 use crate::SecretBytes;
1457
1458 fn cache_key(collection: u128, principal: u128, credential: u128) -> VerifiedSessionCacheKey {
1459 VerifiedSessionCacheKey {
1460 collection_id: format!("{collection:032x}"),
1461 principal_id: format!("{principal:032x}"),
1462 profile_generation: "1".to_owned(),
1463 credential_id: format!("{credential:032x}"),
1464 credential_generation: "1".to_owned(),
1465 secret_reference: format!("memory://session/{collection}/{principal}/{credential}"),
1466 }
1467 }
1468
1469 fn verification(collection: u128, principal: u128) -> ProfileVerification {
1470 ProfileVerification {
1471 artifact_version: "development".to_owned(),
1472 principal_id: Identity {
1473 value: format!("{principal:032x}"),
1474 },
1475 collection_id: Identity {
1476 value: format!("{collection:032x}"),
1477 },
1478 policy_revision: U64 { value: "1".to_owned() },
1479 cluster_id: Identity {
1480 value: "00000000000000000000000000000001".to_owned(),
1481 },
1482 configuration_generation: U64 { value: "1".to_owned() },
1483 binding_revision: U64 { value: "1".to_owned() },
1484 schema_id: LODESTONE_API_SCHEMA_ID.to_owned(),
1485 schema_digest: lodestone_wire::Digest {
1486 value: LODESTONE_API_SPEC_SHA256.to_owned(),
1487 },
1488 native_endpoints: vec!["lodestone+tls://core.example:8443".to_owned()],
1489 selected_stream_endpoint: "https://core.example:9443".to_owned(),
1490 }
1491 }
1492
1493 #[test]
1494 fn mutation_retry_stops_at_the_submission_boundary() {
1495 assert!(retry_admitted(true, true, SubmissionDisposition::NotSubmitted));
1496 assert!(!retry_admitted(true, true, SubmissionDisposition::Submitted));
1497 assert!(!retry_admitted(true, true, SubmissionDisposition::Indeterminate));
1498 assert!(!retry_admitted(true, false, SubmissionDisposition::NotSubmitted));
1499 assert!(retry_admitted(false, true, SubmissionDisposition::Indeterminate));
1500 }
1501
1502 #[test]
1503 fn one_deadline_survives_connect_state_and_resolution_clones() -> Result<(), Box<dyn Error>> {
1504 let options = CallOptions::new(Duration::from_secs(5))?;
1505 let absolute = options.absolute_deadline_unix_millis()?;
1506 let local = options.local_deadline()?;
1507
1508 for stage in [
1511 options.clone(),
1512 options.clone(),
1513 options.clone(),
1514 options.clone(),
1515 options.for_resolution(),
1516 ] {
1517 assert_eq!(stage.absolute_deadline_unix_millis()?, absolute);
1518 assert_eq!(stage.local_deadline()?, local);
1519 }
1520
1521 let resolution = options.for_resolution();
1522 options.cancellation().cancel();
1523 assert!(options.cancellation().is_cancelled());
1524 assert!(!resolution.cancellation().is_cancelled());
1525 assert_eq!(resolution.absolute_deadline_unix_millis()?, absolute);
1526 Ok(())
1527 }
1528
1529 #[test]
1530 fn caller_supplied_absolute_deadline_is_preserved_exactly() -> Result<(), Box<dyn Error>> {
1531 let now = SystemTime::now().duration_since(UNIX_EPOCH)?;
1532 let now_millis = u64::try_from(now.as_millis())?;
1533 let absolute = now_millis.checked_add(5_000).ok_or("test deadline overflow")?;
1534 let options = CallOptions::from_absolute_deadline_unix_millis(absolute)?;
1535 assert_eq!(options.absolute_deadline_unix_millis()?, absolute);
1536
1537 let too_far = now_millis
1538 .checked_add(u64::try_from(MAX_CALL_TIMEOUT.as_millis())?)
1539 .and_then(|value| value.checked_add(1_000))
1540 .ok_or("test deadline overflow")?;
1541 assert!(matches!(
1542 CallOptions::from_absolute_deadline_unix_millis(too_far),
1543 Err(ClientError::InvalidCallOptions)
1544 ));
1545 assert_eq!(normalized_timeout(Duration::from_nanos(1))?, Duration::from_millis(1));
1546 Ok(())
1547 }
1548
1549 #[test]
1550 fn private_api_key_encodings_match_the_core_admission_contract() -> Result<(), Box<dyn Error>> {
1551 let material = CredentialMaterial::new(
1552 Identity {
1553 value: "00000000000000000000000000000001".to_owned(),
1554 },
1555 U64 { value: "1".to_owned() },
1556 SecretBytes::from_array([0; 32]),
1557 )?;
1558 assert_eq!(
1559 native_credential(&material)?.as_slice(),
1560 b"1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
1561 );
1562 assert_eq!(
1563 authorization_value(&material)?.as_slice(),
1564 b"ApiKey MTpBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB"
1565 );
1566 Ok(())
1567 }
1568
1569 #[test]
1570 fn collection_local_refusal_keeps_the_shared_connection_reusable() {
1571 let refusal = Err(ClientError::Server(ErrorBody {
1572 code: lodestone_wire::ErrorCode::PermissionDenied,
1573 message: "denied".to_owned(),
1574 retry_safe: false,
1575 submitted: false,
1576 details: None,
1577 }));
1578 assert!(response_keeps_connection(&refusal));
1579 let failure = Err(ClientError::Transport {
1580 kind: TransportErrorKind::Read,
1581 disposition: SubmissionDisposition::Indeterminate,
1582 });
1583 assert!(!response_keeps_connection(&failure));
1584 }
1585
1586 #[test]
1587 fn verified_session_cache_is_bounded_and_never_crosses_principals() {
1588 let metrics = CellTransportMetrics::default();
1589 let mut cache = VerifiedSessionCache::default();
1590 let first = cache_key(1, 10, 100);
1591 let other_principal = cache_key(1, 11, 100);
1592 assert_ne!(first, other_principal);
1593 cache.insert(first.clone(), verification(1, 10), &metrics);
1594 assert!(cache.get(&other_principal, &metrics).is_none());
1595 assert_eq!(cache.get(&first, &metrics), Some(verification(1, 10)));
1596
1597 for offset in 0..=MAX_VERIFIED_SESSION_METADATA {
1598 let identity = u128::try_from(offset).unwrap_or_default().saturating_add(1_000);
1599 cache.insert(
1600 cache_key(identity, identity, identity),
1601 verification(identity, identity),
1602 &metrics,
1603 );
1604 }
1605 assert_eq!(cache.entries.len(), MAX_VERIFIED_SESSION_METADATA);
1606 assert!(metrics.session_cache_evictions.load(Ordering::Relaxed) > 0);
1607 assert_eq!(
1608 metrics.session_cache_entries.load(Ordering::Relaxed),
1609 MAX_VERIFIED_SESSION_METADATA
1610 );
1611 assert_eq!(metrics.session_cache_hits.load(Ordering::Relaxed), 1);
1612 assert_eq!(metrics.session_cache_misses.load(Ordering::Relaxed), 1);
1613 }
1614
1615 #[test]
1616 fn generated_multi_index_scope_requires_a_nonempty_distinct_set() {
1617 fn index(name: &str, incarnation: &str) -> Resource {
1618 Resource {
1619 kind: "index".to_owned(),
1620 name: name.to_owned(),
1621 incarnation: Identity {
1622 value: incarnation.to_owned(),
1623 },
1624 }
1625 }
1626
1627 fn request(indexes: Vec<Resource>) -> RequestBody {
1628 RequestBody::OpenPit(OpenPitRequest {
1629 indexes,
1630 keep_alive_millis: U64 {
1631 value: "1000".to_owned(),
1632 },
1633 })
1634 }
1635
1636 let first = index("orders", "00000000000000000000000000000001");
1637 let second = index("events", "00000000000000000000000000000002");
1638 assert!(matches!(
1639 resolve_index_name(&request(Vec::new()), None),
1640 Err(ClientError::CollectionIndexRefRequired)
1641 ));
1642 assert!(matches!(
1643 resolve_index_name(&request(vec![first.clone(), second.clone()]), None),
1644 Ok(None)
1645 ));
1646 assert!(matches!(
1647 resolve_index_name(&request(vec![first.clone(), first.clone()]), None),
1648 Err(ClientError::CollectionIndexRefMismatch)
1649 ));
1650 assert!(matches!(
1651 resolve_index_name(&request(vec![second.clone(), first.clone(), first]), None),
1652 Err(ClientError::CollectionIndexRefMismatch)
1653 ));
1654 }
1655
1656 #[test]
1657 fn generated_get_index_is_collection_scoped() {
1658 let request = RequestBody::GetIndex(GetIndexRequest {
1659 name: "events".to_owned(),
1660 expected_incarnation: None,
1661 });
1662
1663 assert!(matches!(resolve_index_name(&request, None), Ok(None)));
1664 assert!(matches!(
1665 resolve_index_name(&request, Some("events")),
1666 Err(ClientError::CollectionIndexRefMismatch)
1667 ));
1668 }
1669}