Skip to main content

lodestone_client/
config.rs

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}