1use std::collections::BTreeSet;
2use std::error::Error;
3use std::fmt::{self, Display, Formatter};
4use std::sync::Arc;
5use std::time::{Duration, SystemTime, UNIX_EPOCH};
6
7use aws_lc_rs::digest;
8use http::Uri;
9use lodestone_wire::{
10 ConnectionProfile as WireConnectionProfile, LODESTONE_API_CONNECTION_PROFILE_FORMAT_VERSION,
11 LODESTONE_API_SCHEMA_ID, LODESTONE_API_SPEC_SHA256, LODESTONE_API_STORAGE_PROFILE_EXPECTATION_FORMAT_VERSION,
12};
13use rustls::pki_types::ServerName;
14
15use crate::CredentialProvider;
16
17const MAX_PROFILE_BYTES: usize = 64 * 1024;
18const MAX_ENDPOINTS: usize = 8;
19const MAX_ENDPOINT_BYTES: usize = 512;
20const MAX_REFERENCE_BYTES: usize = 512;
21const MAX_TRUST_CERTIFICATES: usize = 16;
22const MAX_TRUST_CERTIFICATE_BYTES: usize = 128 * 1024;
23const TRUST_DIGEST_DOMAIN: &[u8] = b"lodestone/client/tls-trust/v1";
24
25#[derive(Clone, Debug, PartialEq)]
26pub struct ValidatedConnectionProfile(WireConnectionProfile);
27
28impl ValidatedConnectionProfile {
29 pub fn new(profile: WireConnectionProfile) -> Result<Self, ClientConfigError> {
30 validate_profile(&profile)?;
31 Ok(Self(profile))
32 }
33
34 pub fn decode_current(bytes: &[u8]) -> Result<Self, ClientConfigError> {
35 if bytes.is_empty() || bytes.len() > MAX_PROFILE_BYTES {
36 return Err(ClientConfigError::ProfileSize);
37 }
38 let profile: WireConnectionProfile =
39 serde_json::from_slice(bytes).map_err(|_| ClientConfigError::ProfileCodec)?;
40 let value = Self::new(profile)?;
41 let canonical = serde_json::to_vec(value.as_wire()).map_err(|_| ClientConfigError::ProfileCodec)?;
42 if canonical != bytes {
43 return Err(ClientConfigError::NonCanonicalProfile);
44 }
45 Ok(value)
46 }
47
48 pub fn encode_current(&self) -> Result<Vec<u8>, ClientConfigError> {
49 validate_profile(&self.0)?;
50 let bytes = serde_json::to_vec(&self.0).map_err(|_| ClientConfigError::ProfileCodec)?;
51 if bytes.len() > MAX_PROFILE_BYTES {
52 return Err(ClientConfigError::ProfileSize);
53 }
54 Ok(bytes)
55 }
56
57 #[must_use]
58 pub const fn as_wire(&self) -> &WireConnectionProfile {
59 &self.0
60 }
61
62 #[must_use]
63 pub fn profile_generation(&self) -> u64 {
64 parse_u64(&self.0.profile_generation.value).unwrap_or(0)
65 }
66
67 pub fn compare_and_swap(
68 current: Option<&Self>,
69 expected_generation: u64,
70 replacement: &Self,
71 ) -> Result<(), ClientConfigError> {
72 validate_profile(replacement.as_wire())?;
73 match current {
74 None if expected_generation == 0 && replacement.profile_generation() == 1 => Ok(()),
75 Some(current) => {
76 validate_profile(current.as_wire())?;
77 let next = current
78 .profile_generation()
79 .checked_add(1)
80 .ok_or(ClientConfigError::GenerationExhausted)?;
81 let prior = current.as_wire();
82 let next_profile = replacement.as_wire();
83 if expected_generation != current.profile_generation()
84 || replacement.profile_generation() != next
85 || prior.expected_cluster_id != next_profile.expected_cluster_id
86 || prior.expected_collection_id != next_profile.expected_collection_id
87 || prior.expected_principal_id != next_profile.expected_principal_id
88 || prior.storage_profile_expectation != next_profile.storage_profile_expectation
89 {
90 return Err(ClientConfigError::ProfileCasConflict);
91 }
92 Ok(())
93 }
94 None => Err(ClientConfigError::ProfileCasConflict),
95 }
96 }
97}
98
99#[derive(Clone, Debug, Eq, PartialEq)]
100pub struct TlsTrust {
101 certificates_der: Vec<Vec<u8>>,
102 digest: [u8; 32],
103}
104
105impl TlsTrust {
106 pub fn new(certificates_der: Vec<Vec<u8>>) -> Result<Self, ClientConfigError> {
107 if certificates_der.is_empty()
108 || certificates_der.len() > MAX_TRUST_CERTIFICATES
109 || certificates_der
110 .iter()
111 .any(|certificate| certificate.is_empty() || certificate.len() > MAX_TRUST_CERTIFICATE_BYTES)
112 {
113 return Err(ClientConfigError::InvalidTrustMaterial);
114 }
115 let mut hasher = digest::Context::new(&digest::SHA256);
116 hasher.update(TRUST_DIGEST_DOMAIN);
117 for certificate in &certificates_der {
118 let length = u64::try_from(certificate.len()).map_err(|_| ClientConfigError::InvalidTrustMaterial)?;
119 hasher.update(&length.to_be_bytes());
120 hasher.update(certificate);
121 }
122 let observed = hasher.finish();
123 let mut trust_digest = [0; 32];
124 trust_digest.copy_from_slice(observed.as_ref());
125 Ok(Self {
126 certificates_der,
127 digest: trust_digest,
128 })
129 }
130
131 #[must_use]
132 pub const fn digest(&self) -> [u8; 32] {
133 self.digest
134 }
135
136 #[must_use]
137 pub fn certificates_der(&self) -> &[Vec<u8>] {
138 &self.certificates_der
139 }
140}
141
142#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
143pub struct PoolConfig {
144 pub maximum_connections: usize,
145 pub maximum_idle_connections: usize,
146 pub connect_timeout: Duration,
147 pub maximum_read_retries: u8,
148 pub maximum_subscription_reconnects: u8,
149}
150
151impl Default for PoolConfig {
152 fn default() -> Self {
153 Self {
154 maximum_connections: 4,
155 maximum_idle_connections: 4,
156 connect_timeout: Duration::from_secs(5),
157 maximum_read_retries: 2,
158 maximum_subscription_reconnects: 4,
159 }
160 }
161}
162
163impl PoolConfig {
164 fn validate(self) -> Result<Self, ClientConfigError> {
165 if self.maximum_connections == 0
166 || self.maximum_connections > 32
167 || self.maximum_idle_connections > self.maximum_connections
168 || self.connect_timeout.is_zero()
169 || self.connect_timeout > Duration::from_secs(60)
170 || self.maximum_read_retries > 3
171 || self.maximum_subscription_reconnects > 8
172 {
173 return Err(ClientConfigError::InvalidPoolConfig);
174 }
175 Ok(self)
176 }
177}
178
179#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
180pub enum CellOperationClass {
181 Ordinary,
182 CollectionProvisioner,
183 CapacityObserver,
184 RecoveryOperator,
185}
186
187#[derive(Clone, Debug, Eq, Hash, PartialEq)]
188pub struct CellTransportIdentity {
189 native_endpoints: Vec<String>,
190 selected_stream_endpoint: String,
191 tls_server_name: String,
192 tls_trust_sha256: [u8; 32],
193 expected_cluster_id: String,
194 schema_id: String,
195 schema_digest: String,
196 operation_class: CellOperationClass,
197 pool: PoolConfig,
198}
199
200impl CellTransportIdentity {
201 #[must_use]
202 pub fn native_endpoints(&self) -> &[String] {
203 &self.native_endpoints
204 }
205
206 #[must_use]
207 pub fn selected_stream_endpoint(&self) -> &str {
208 &self.selected_stream_endpoint
209 }
210
211 #[must_use]
212 pub fn tls_server_name(&self) -> &str {
213 &self.tls_server_name
214 }
215
216 #[must_use]
217 pub const fn tls_trust_sha256(&self) -> [u8; 32] {
218 self.tls_trust_sha256
219 }
220
221 #[must_use]
222 pub fn expected_cluster_id(&self) -> &str {
223 &self.expected_cluster_id
224 }
225
226 #[must_use]
227 pub fn schema_id(&self) -> &str {
228 &self.schema_id
229 }
230
231 #[must_use]
232 pub fn schema_digest(&self) -> &str {
233 &self.schema_digest
234 }
235
236 #[must_use]
237 pub const fn operation_class(&self) -> CellOperationClass {
238 self.operation_class
239 }
240
241 #[must_use]
242 pub const fn pool(&self) -> PoolConfig {
243 self.pool
244 }
245}
246
247pub struct CellTransportConfig {
248 pub(crate) identity: CellTransportIdentity,
249 pub(crate) trust: TlsTrust,
250 pub(crate) pool: PoolConfig,
251 pub(crate) native_endpoints: Vec<ValidatedEndpoint>,
252 pub(crate) stream_endpoint: ValidatedEndpoint,
253}
254
255impl CellTransportConfig {
256 pub fn new(
257 profile: &ValidatedConnectionProfile,
258 trust: TlsTrust,
259 operation_class: CellOperationClass,
260 pool: PoolConfig,
261 ) -> Result<Self, ClientConfigError> {
262 validate_profile(profile.as_wire())?;
263 let expected_trust = decode_digest(&profile.as_wire().tls_trust_sha256.value)?;
264 if expected_trust != trust.digest() {
265 return Err(ClientConfigError::TrustDigestMismatch);
266 }
267 let pool = pool.validate()?;
268 let native_endpoints = profile
269 .as_wire()
270 .native_endpoints
271 .iter()
272 .map(|endpoint| ValidatedEndpoint::parse(endpoint, EndpointScheme::NativeTls))
273 .collect::<Result<Vec<_>, _>>()?;
274 let stream_endpoint =
275 ValidatedEndpoint::parse(&profile.as_wire().selected_stream_endpoint, EndpointScheme::Https)?;
276 let identity = CellTransportIdentity {
277 native_endpoints: canonical_endpoint_set(&profile.as_wire().native_endpoints),
278 selected_stream_endpoint: profile.as_wire().selected_stream_endpoint.clone(),
279 tls_server_name: profile.as_wire().tls_server_name.clone(),
280 tls_trust_sha256: trust.digest(),
281 expected_cluster_id: profile.as_wire().expected_cluster_id.value.clone(),
282 schema_id: profile.as_wire().schema_id.clone(),
283 schema_digest: profile.as_wire().schema_digest.value.clone(),
284 operation_class,
285 pool,
286 };
287 Ok(Self {
288 identity,
289 trust,
290 pool,
291 native_endpoints,
292 stream_endpoint,
293 })
294 }
295
296 #[must_use]
297 pub const fn identity(&self) -> &CellTransportIdentity {
298 &self.identity
299 }
300}
301
302pub struct CollectionSessionConfig {
303 pub(crate) profile: ValidatedConnectionProfile,
304 pub(crate) credentials: Arc<dyn CredentialProvider>,
305}
306
307impl CollectionSessionConfig {
308 pub fn new(
309 profile: ValidatedConnectionProfile,
310 credentials: Arc<dyn CredentialProvider>,
311 ) -> Result<Self, ClientConfigError> {
312 validate_profile(profile.as_wire())?;
313 if profile
314 .as_wire()
315 .credential_expires_at
316 .as_ref()
317 .is_some_and(|expiry| parse_u64(&expiry.value).is_none_or(|expiry| expiry <= unix_millis()))
318 {
319 return Err(ClientConfigError::CredentialExpired);
320 }
321 Ok(Self { profile, credentials })
322 }
323
324 #[must_use]
325 pub const fn profile(&self) -> &ValidatedConnectionProfile {
326 &self.profile
327 }
328
329 pub(crate) fn validate_transport(&self, transport: &CellTransportConfig) -> Result<(), ClientConfigError> {
330 let profile = self.profile.as_wire();
331 let identity = transport.identity();
332 if identity.operation_class != CellOperationClass::Ordinary
333 || identity.native_endpoints != canonical_endpoint_set(&profile.native_endpoints)
334 || identity.selected_stream_endpoint != profile.selected_stream_endpoint
335 || identity.tls_server_name != profile.tls_server_name
336 || identity.tls_trust_sha256 != decode_digest(&profile.tls_trust_sha256.value)?
337 || identity.expected_cluster_id != profile.expected_cluster_id.value
338 || identity.schema_id != profile.schema_id
339 || identity.schema_digest != profile.schema_digest.value
340 {
341 return Err(ClientConfigError::CellTransportMismatch);
342 }
343 Ok(())
344 }
345}
346
347fn canonical_endpoint_set(endpoints: &[String]) -> Vec<String> {
348 endpoints.iter().cloned().collect::<BTreeSet<_>>().into_iter().collect()
349}
350
351#[derive(Clone, Copy, Debug, Eq, PartialEq)]
352enum EndpointScheme {
353 NativeTls,
354 Https,
355}
356
357impl EndpointScheme {
358 const fn value(self) -> &'static str {
359 match self {
360 Self::NativeTls => "lodestone+tls",
361 Self::Https => "https",
362 }
363 }
364}
365
366#[derive(Clone, Debug, Eq, PartialEq)]
367pub struct ValidatedEndpoint {
368 original: String,
369 host: String,
370 port: u16,
371 scheme: EndpointScheme,
372}
373
374impl ValidatedEndpoint {
375 fn parse(value: &str, scheme: EndpointScheme) -> Result<Self, ClientConfigError> {
376 if value.is_empty()
377 || value.len() > MAX_ENDPOINT_BYTES
378 || value
379 .bytes()
380 .any(|byte| byte.is_ascii_control() || byte.is_ascii_whitespace())
381 || value.contains('@')
382 || value.contains('?')
383 || value.contains('#')
384 {
385 return Err(ClientConfigError::InvalidEndpoint);
386 }
387 let uri: Uri = value.parse().map_err(|_| ClientConfigError::InvalidEndpoint)?;
388 if uri.scheme_str() != Some(scheme.value()) || uri.path() != "" && uri.path() != "/" {
389 return Err(ClientConfigError::InvalidEndpoint);
390 }
391 let host = uri.host().ok_or(ClientConfigError::InvalidEndpoint)?.to_owned();
392 let port = uri.port_u16().ok_or(ClientConfigError::InvalidEndpoint)?;
393 if host.is_empty() || host.len() > 253 || port == 0 {
394 return Err(ClientConfigError::InvalidEndpoint);
395 }
396 Ok(Self {
397 original: value.to_owned(),
398 host,
399 port,
400 scheme,
401 })
402 }
403
404 #[must_use]
405 pub fn as_str(&self) -> &str {
406 &self.original
407 }
408
409 pub(crate) fn host(&self) -> &str {
410 &self.host
411 }
412
413 pub(crate) const fn port(&self) -> u16 {
414 self.port
415 }
416}
417
418fn validate_profile(profile: &WireConnectionProfile) -> Result<(), ClientConfigError> {
419 if profile.format_version != u32::from(LODESTONE_API_CONNECTION_PROFILE_FORMAT_VERSION) {
420 return Err(ClientConfigError::UnsupportedProfileVersion);
421 }
422 if parse_u64(&profile.profile_generation.value).is_none_or(|value| value == 0)
423 || profile.native_endpoints.is_empty()
424 || profile.native_endpoints.len() > MAX_ENDPOINTS
425 || profile.schema_id != LODESTONE_API_SCHEMA_ID
426 || profile.schema_digest.value != LODESTONE_API_SPEC_SHA256
427 || profile.expected_cluster_id.validate().is_err()
428 || profile.expected_collection_id.validate().is_err()
429 || profile.expected_principal_id.validate().is_err()
430 || profile.credential_id.validate().is_err()
431 || parse_u64(&profile.credential_generation.value).is_none_or(|value| value == 0)
432 || profile
433 .credential_expires_at
434 .as_ref()
435 .is_some_and(|value| parse_u64(&value.value).is_none_or(|value| value == 0))
436 || decode_digest(&profile.tls_trust_sha256.value).is_err()
437 || !valid_reference(&profile.secret_reference)
438 {
439 return Err(ClientConfigError::InvalidProfileBinding);
440 }
441 if profile.storage_profile_expectation.as_ref().is_some_and(|expectation| {
442 expectation.format_version != u32::from(LODESTONE_API_STORAGE_PROFILE_EXPECTATION_FORMAT_VERSION)
443 || expectation.required_posture.is_none() && expectation.expected_storage_profile_digest.is_none()
444 || expectation
445 .expected_storage_profile_digest
446 .as_ref()
447 .is_some_and(|digest| digest.validate().is_err())
448 }) {
449 return Err(ClientConfigError::InvalidProfileBinding);
450 }
451 let mut endpoints = BTreeSet::new();
452 for endpoint in &profile.native_endpoints {
453 ValidatedEndpoint::parse(endpoint, EndpointScheme::NativeTls)?;
454 if !endpoints.insert(endpoint) {
455 return Err(ClientConfigError::InvalidEndpoint);
456 }
457 }
458 ValidatedEndpoint::parse(&profile.selected_stream_endpoint, EndpointScheme::Https)?;
459 let tls_server_name = ServerName::try_from(profile.tls_server_name.clone());
460 if profile.tls_server_name.is_empty()
461 || profile.tls_server_name.len() > 253
462 || profile
463 .tls_server_name
464 .bytes()
465 .any(|byte| byte.is_ascii_control() || byte.is_ascii_whitespace())
466 || !matches!(tls_server_name, Ok(ServerName::DnsName(_)))
467 {
468 return Err(ClientConfigError::InvalidServerName);
469 }
470 Ok(())
471}
472
473fn valid_reference(value: &str) -> bool {
474 !value.is_empty()
475 && value.len() <= MAX_REFERENCE_BYTES
476 && value.contains("://")
477 && !value.contains('@')
478 && !value.contains('=')
479 && value
480 .bytes()
481 .all(|byte| byte.is_ascii_graphic() && !matches!(byte, b'?' | b'#'))
482}
483
484pub(crate) fn parse_u64(value: &str) -> Option<u64> {
485 value.parse::<u64>().ok().filter(|parsed| parsed.to_string() == value)
486}
487
488pub(crate) fn parse_identity(value: &str) -> Option<u128> {
489 if value.len() != 32 {
490 return None;
491 }
492 u128::from_str_radix(value, 16).ok().filter(|value| *value != 0)
493}
494
495fn decode_digest(value: &str) -> Result<[u8; 32], ClientConfigError> {
496 if value.len() != 64 {
497 return Err(ClientConfigError::InvalidProfileBinding);
498 }
499 let mut output = [0; 32];
500 for (index, pair) in value.as_bytes().as_chunks::<2>().0.iter().enumerate() {
501 let text = std::str::from_utf8(pair).map_err(|_| ClientConfigError::InvalidProfileBinding)?;
502 output[index] = u8::from_str_radix(text, 16).map_err(|_| ClientConfigError::InvalidProfileBinding)?;
503 }
504 Ok(output)
505}
506
507fn unix_millis() -> u64 {
508 SystemTime::now()
509 .duration_since(UNIX_EPOCH)
510 .ok()
511 .and_then(|duration| u64::try_from(duration.as_millis()).ok())
512 .unwrap_or(u64::MAX)
513}
514
515#[derive(Clone, Copy, Debug, Eq, PartialEq)]
516pub enum ClientConfigError {
517 UnsupportedProfileVersion,
518 InvalidProfileBinding,
519 InvalidEndpoint,
520 InvalidServerName,
521 InvalidTrustMaterial,
522 TrustDigestMismatch,
523 InvalidPoolConfig,
524 CredentialExpired,
525 ProfileSize,
526 ProfileCodec,
527 NonCanonicalProfile,
528 ProfileCasConflict,
529 GenerationExhausted,
530 CellTransportMismatch,
531}
532
533impl Display for ClientConfigError {
534 fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
535 formatter.write_str(match self {
536 Self::UnsupportedProfileVersion => "Core connection profile version is unsupported",
537 Self::InvalidProfileBinding => "Core connection profile binding is invalid",
538 Self::InvalidEndpoint => "Core connection endpoint is invalid",
539 Self::InvalidServerName => "Core TLS server name is invalid",
540 Self::InvalidTrustMaterial => "Core TLS trust material is invalid",
541 Self::TrustDigestMismatch => "Core TLS trust material does not match the connection profile",
542 Self::InvalidPoolConfig => "Core client pool configuration is invalid",
543 Self::CredentialExpired => "Core connection profile credential is expired",
544 Self::ProfileSize => "Core connection profile size is outside the bound",
545 Self::ProfileCodec => "Core connection profile could not be decoded",
546 Self::NonCanonicalProfile => "Core connection profile encoding is noncanonical",
547 Self::ProfileCasConflict => "Core connection profile generation CAS conflicted",
548 Self::GenerationExhausted => "Core connection profile generation is exhausted",
549 Self::CellTransportMismatch => "Core Collection session does not match the selected cell transport",
550 })
551 }
552}
553
554impl Error for ClientConfigError {}
555
556#[cfg(test)]
557mod tests {
558 use std::error::Error;
559 use std::sync::Arc;
560
561 use lodestone_wire::{
562 Digest, Identity, LODESTONE_API_CONNECTION_PROFILE_FORMAT_VERSION, LODESTONE_API_SCHEMA_ID,
563 LODESTONE_API_SPEC_SHA256, U64,
564 };
565
566 use super::*;
567 use crate::FileCredentialProvider;
568
569 fn profile(generation: u64, trust: [u8; 32]) -> Result<ValidatedConnectionProfile, ClientConfigError> {
570 ValidatedConnectionProfile::new(WireConnectionProfile {
571 format_version: u32::from(LODESTONE_API_CONNECTION_PROFILE_FORMAT_VERSION),
572 profile_generation: U64 {
573 value: generation.to_string(),
574 },
575 native_endpoints: vec!["lodestone+tls://core.example:8443".to_owned()],
576 selected_stream_endpoint: "https://core.example:9443".to_owned(),
577 tls_server_name: "core.example".to_owned(),
578 tls_trust_sha256: Digest {
579 value: lower_hex(&trust),
580 },
581 expected_cluster_id: identity(1),
582 expected_collection_id: identity(2),
583 expected_principal_id: identity(4),
584 schema_id: LODESTONE_API_SCHEMA_ID.to_owned(),
585 schema_digest: Digest {
586 value: LODESTONE_API_SPEC_SHA256.to_owned(),
587 },
588 credential_id: identity(3),
589 credential_generation: U64 { value: "1".to_owned() },
590 credential_expires_at: None,
591 secret_reference: "memory://core/admin".to_owned(),
592 storage_profile_expectation: None,
593 })
594 }
595
596 fn identity(value: u128) -> Identity {
597 Identity {
598 value: format!("{value:032x}"),
599 }
600 }
601
602 fn lower_hex(bytes: &[u8]) -> String {
603 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
604 }
605
606 #[test]
607 fn profile_codec_is_canonical_and_generation_cas_is_strict() -> Result<(), Box<dyn Error>> {
608 let trust = TlsTrust::new(vec![vec![1, 2, 3]])?;
609 let first = profile(1, trust.digest())?;
610 let encoded = first.encode_current()?;
611 assert_eq!(ValidatedConnectionProfile::decode_current(&encoded)?, first);
612 let second = profile(2, trust.digest())?;
613 ValidatedConnectionProfile::compare_and_swap(Some(&first), 1, &second)?;
614 assert_eq!(
615 ValidatedConnectionProfile::compare_and_swap(Some(&first), 0, &second),
616 Err(ClientConfigError::ProfileCasConflict)
617 );
618 let mut other_principal_wire = second.as_wire().clone();
619 other_principal_wire.expected_principal_id = identity(99);
620 let other_principal = ValidatedConnectionProfile::new(other_principal_wire)?;
621 assert_eq!(
622 ValidatedConnectionProfile::compare_and_swap(Some(&first), 1, &other_principal),
623 Err(ClientConfigError::ProfileCasConflict)
624 );
625 let mut noncanonical = encoded;
626 noncanonical.push(b' ');
627 assert_eq!(
628 ValidatedConnectionProfile::decode_current(&noncanonical),
629 Err(ClientConfigError::NonCanonicalProfile)
630 );
631 Ok(())
632 }
633
634 #[test]
635 fn endpoints_have_no_downgrade_or_ambient_authority_forms() -> Result<(), Box<dyn Error>> {
636 let trust = TlsTrust::new(vec![vec![1]])?;
637 let mut value = profile(1, trust.digest())?.as_wire().clone();
638 value.native_endpoints[0] = "tcp://core.example:8443".to_owned();
639 assert_eq!(
640 ValidatedConnectionProfile::new(value),
641 Err(ClientConfigError::InvalidEndpoint)
642 );
643 Ok(())
644 }
645
646 #[test]
647 fn explicit_tls_server_name_is_validated_independently_of_endpoint_hosts() -> Result<(), Box<dyn Error>> {
648 let trust = TlsTrust::new(vec![vec![1]])?;
649 let mut value = profile(1, trust.digest())?.as_wire().clone();
650 value.native_endpoints[0] = "lodestone+tls://127.0.0.1:8443".to_owned();
651 value.selected_stream_endpoint = "https://127.0.0.1:9443".to_owned();
652 assert!(ValidatedConnectionProfile::new(value.clone()).is_ok());
653 value.tls_server_name = "bad name".to_owned();
654 assert_eq!(
655 ValidatedConnectionProfile::new(value),
656 Err(ClientConfigError::InvalidServerName)
657 );
658 let mut value = profile(1, trust.digest())?.as_wire().clone();
659 value.tls_server_name = "127.0.0.1".to_owned();
660 assert_eq!(
661 ValidatedConnectionProfile::new(value),
662 Err(ClientConfigError::InvalidServerName)
663 );
664 Ok(())
665 }
666
667 #[test]
668 fn cell_identity_excludes_collection_and_credential_but_sessions_stay_exact() -> Result<(), Box<dyn Error>> {
669 let trust = TlsTrust::new(vec![vec![1, 2, 3]])?;
670 let first = profile(1, trust.digest())?;
671 let mut second_wire = first.as_wire().clone();
672 second_wire.profile_generation = U64 { value: "7".to_owned() };
673 second_wire.expected_collection_id = identity(20);
674 second_wire.expected_principal_id = identity(40);
675 second_wire.credential_id = identity(30);
676 second_wire.credential_generation = U64 { value: "4".to_owned() };
677 second_wire.secret_reference = "memory://core/second".to_owned();
678 let second = ValidatedConnectionProfile::new(second_wire)?;
679
680 let first_transport = CellTransportConfig::new(
681 &first,
682 trust.clone(),
683 CellOperationClass::Ordinary,
684 PoolConfig::default(),
685 )?;
686 let second_transport = CellTransportConfig::new(
687 &second,
688 trust.clone(),
689 CellOperationClass::Ordinary,
690 PoolConfig::default(),
691 )?;
692 assert_eq!(first_transport.identity(), second_transport.identity());
693
694 let second_session = CollectionSessionConfig::new(second, Arc::new(FileCredentialProvider))?;
695 assert!(second_session.validate_transport(&first_transport).is_ok());
696
697 let control_transport = CellTransportConfig::new(
698 &first,
699 trust,
700 CellOperationClass::CollectionProvisioner,
701 PoolConfig::default(),
702 )?;
703 let first_session = CollectionSessionConfig::new(first, Arc::new(FileCredentialProvider))?;
704 assert_eq!(
705 first_session.validate_transport(&control_transport),
706 Err(ClientConfigError::CellTransportMismatch)
707 );
708 Ok(())
709 }
710
711 #[test]
712 fn cluster_or_trust_omission_cannot_alias_a_cell_transport() -> Result<(), Box<dyn Error>> {
713 let trust = TlsTrust::new(vec![vec![1, 2, 3]])?;
714 let first = profile(1, trust.digest())?;
715 let first_transport = CellTransportConfig::new(
716 &first,
717 trust.clone(),
718 CellOperationClass::Ordinary,
719 PoolConfig::default(),
720 )?;
721
722 let mut wrong_cluster_wire = first.as_wire().clone();
723 wrong_cluster_wire.expected_cluster_id = identity(99);
724 let wrong_cluster = ValidatedConnectionProfile::new(wrong_cluster_wire)?;
725 let wrong_cluster_transport = CellTransportConfig::new(
726 &wrong_cluster,
727 trust,
728 CellOperationClass::Ordinary,
729 PoolConfig::default(),
730 )?;
731 assert_ne!(first_transport.identity(), wrong_cluster_transport.identity());
732
733 let other_trust = TlsTrust::new(vec![vec![9, 8, 7]])?;
734 let mut wrong_trust_wire = first.as_wire().clone();
735 wrong_trust_wire.tls_trust_sha256 = Digest {
736 value: lower_hex(&other_trust.digest()),
737 };
738 let wrong_trust = ValidatedConnectionProfile::new(wrong_trust_wire)?;
739 let wrong_trust_transport = CellTransportConfig::new(
740 &wrong_trust,
741 other_trust,
742 CellOperationClass::Ordinary,
743 PoolConfig::default(),
744 )?;
745 assert_ne!(first_transport.identity(), wrong_trust_transport.identity());
746 Ok(())
747 }
748
749 #[test]
750 fn endpoint_order_does_not_split_one_cell_transport_identity() -> Result<(), Box<dyn Error>> {
751 let trust = TlsTrust::new(vec![vec![1, 2, 3]])?;
752 let mut first_wire = profile(1, trust.digest())?.as_wire().clone();
753 first_wire
754 .native_endpoints
755 .push("lodestone+tls://core.example:8444".to_owned());
756 let first = ValidatedConnectionProfile::new(first_wire.clone())?;
757 first_wire.native_endpoints.reverse();
758 first_wire.expected_collection_id = identity(20);
759 first_wire.expected_principal_id = identity(40);
760 first_wire.credential_id = identity(30);
761 first_wire.secret_reference = "memory://core/second".to_owned();
762 let second = ValidatedConnectionProfile::new(first_wire)?;
763
764 let first_transport = CellTransportConfig::new(
765 &first,
766 trust.clone(),
767 CellOperationClass::Ordinary,
768 PoolConfig::default(),
769 )?;
770 let second_transport =
771 CellTransportConfig::new(&second, trust, CellOperationClass::Ordinary, PoolConfig::default())?;
772 assert_eq!(first_transport.identity(), second_transport.identity());
773 Ok(())
774 }
775
776 #[test]
777 fn storage_expectation_is_nonempty_current_and_preserved_by_rotation() -> Result<(), Box<dyn Error>> {
778 let trust = TlsTrust::new(vec![vec![1, 2, 3]])?;
779 let first = profile(1, trust.digest())?;
780 let mut invalid = first.as_wire().clone();
781 invalid.storage_profile_expectation = Some(lodestone_wire::StorageProfileExpectation {
782 format_version: u32::from(LODESTONE_API_STORAGE_PROFILE_EXPECTATION_FORMAT_VERSION),
783 required_posture: None,
784 expected_storage_profile_digest: None,
785 });
786 assert_eq!(
787 ValidatedConnectionProfile::new(invalid),
788 Err(ClientConfigError::InvalidProfileBinding)
789 );
790
791 let mut pinned_wire = first.as_wire().clone();
792 pinned_wire.storage_profile_expectation = Some(lodestone_wire::StorageProfileExpectation {
793 format_version: u32::from(LODESTONE_API_STORAGE_PROFILE_EXPECTATION_FORMAT_VERSION),
794 required_posture: Some(lodestone_wire::StorageOverallPosture::FilesystemDevelopment),
795 expected_storage_profile_digest: None,
796 });
797 let pinned = ValidatedConnectionProfile::new(pinned_wire)?;
798 let mut rotated_wire = pinned.as_wire().clone();
799 rotated_wire.profile_generation = U64 { value: "2".to_owned() };
800 let rotated = ValidatedConnectionProfile::new(rotated_wire)?;
801 ValidatedConnectionProfile::compare_and_swap(Some(&pinned), 1, &rotated)?;
802
803 let mut dropped_wire = rotated.as_wire().clone();
804 dropped_wire.storage_profile_expectation = None;
805 let dropped = ValidatedConnectionProfile::new(dropped_wire)?;
806 assert_eq!(
807 ValidatedConnectionProfile::compare_and_swap(Some(&pinned), 1, &dropped),
808 Err(ClientConfigError::ProfileCasConflict)
809 );
810 Ok(())
811 }
812}