Skip to main content

lodestone_proto/
cluster_control_protocol.rs

1//! Strict cluster-control session wire values shared by servers and external clients.
2//!
3//! Cluster control is deliberately disjoint from [`crate::PublicSessionV1`].
4//! A cluster-control credential has no Collection grant, and the action enum
5//! makes each credential namespace's closed capability set explicit before a
6//! listener or durable credential authority is allowed to accept it.
7
8use std::error::Error;
9use std::fmt::{self, Display, Formatter};
10
11use serde::{Deserialize, Serialize};
12
13pub use lodestone_types::{CollectionId, CollectionSecurityGeneration, LogicalMillis};
14
15pub const CLUSTER_CONTROL_SESSION_FORMAT_VERSION: u16 = 2;
16pub const MAX_CLUSTER_CONTROL_SESSION_BYTES: usize = 1_024;
17pub const MAX_CLUSTER_CONTROL_CREDENTIAL_BYTES: usize = 512;
18pub const CLUSTER_CONTROL_CREDENTIAL_PREFIX: &[u8] = b"lcc1_";
19pub const CLUSTER_CONTROL_CREDENTIAL_ENCODED_BYTES: usize = 48;
20pub const CLUSTER_CONTROL_REQUEST_FORMAT_VERSION: u16 = 2;
21pub const CLUSTER_CONTROL_RESPONSE_FORMAT_VERSION: u16 = 2;
22pub const MAX_CLUSTER_CONTROL_FRAME_BYTES: usize = 2 * 1024 * 1024;
23pub const MAX_CLUSTER_CONTROL_REQUEST_ID_BYTES: usize = 128;
24pub const MAX_CLUSTER_CONTROL_ERROR_CODE_BYTES: usize = 96;
25pub const COLLECTION_RETIREMENT_REQUEST_FORMAT_VERSION: u16 = 1;
26pub const COLLECTION_RETIREMENT_STATUS_FORMAT_VERSION: u16 = 1;
27pub const COLLECTION_RETIREMENT_RECEIPT_FORMAT_VERSION: u16 = 1;
28pub const MAX_COLLECTION_RETIREMENT_CURSOR_BYTES: usize = 8 * 1024;
29pub const MAX_COLLECTION_RETIREMENT_BLOCKERS: usize = 32;
30
31const COLLECTION_RETIREMENT_REQUEST_DIGEST_DOMAIN: &[u8] =
32    b"lodestone/cluster-control/collection-retirement-request/v1\0";
33const COLLECTION_RETIREMENT_CLOSURE_DIGEST_DOMAIN: &[u8] =
34    b"lodestone/cluster-control/collection-retirement-closure/v1\0";
35
36/// Policy admitted by the Collection provisioner. Protection and retention
37/// authorities remain blockers; this policy never grants permission to
38/// override them.
39#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
40#[serde(rename_all = "snake_case")]
41pub enum CollectionRetirementClosurePolicyV1 {
42    RetireOwnedChildren,
43}
44
45#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
46#[serde(deny_unknown_fields)]
47pub struct CollectionRetirementRequestV1 {
48    pub format_version: u16,
49    pub operation_id: [u8; 32],
50    pub collection_id: CollectionId,
51    pub expected_security_generation: CollectionSecurityGeneration,
52    pub closure_policy: CollectionRetirementClosurePolicyV1,
53}
54
55impl CollectionRetirementRequestV1 {
56    pub fn validate(&self) -> Result<(), CollectionRetirementContractError> {
57        if self.format_version != COLLECTION_RETIREMENT_REQUEST_FORMAT_VERSION
58            || self.operation_id == [0; 32]
59            || self.expected_security_generation.get() == 0
60        {
61            return Err(CollectionRetirementContractError::InvalidRequest);
62        }
63        Ok(())
64    }
65
66    pub fn canonical_request_digest(&self) -> Result<[u8; 32], CollectionRetirementContractError> {
67        self.validate()?;
68        canonical_collection_retirement_digest(COLLECTION_RETIREMENT_REQUEST_DIGEST_DOMAIN, self)
69    }
70}
71
72#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
73#[serde(rename_all = "snake_case")]
74pub enum CollectionRetirementOwnerV1 {
75    Admission,
76    Administration,
77    Index,
78    Security,
79    Operation,
80    Portability,
81    Retention,
82    Protection,
83    Usage,
84    Task,
85    ResidentRoute,
86}
87
88#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
89#[serde(rename_all = "snake_case")]
90pub enum CollectionRetirementBlockerKindV1 {
91    OwnerBusy,
92    ActiveAuthority,
93    PolicyRefusal,
94    RetainedEvidence,
95    PendingOperation,
96}
97
98#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
99#[serde(deny_unknown_fields)]
100pub struct CollectionRetirementBlockerV1 {
101    pub owner: CollectionRetirementOwnerV1,
102    pub kind: CollectionRetirementBlockerKindV1,
103}
104
105#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
106#[serde(deny_unknown_fields)]
107pub struct CollectionRetirementCursorV1 {
108    pub owner: CollectionRetirementOwnerV1,
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub after: Option<Vec<u8>>,
111}
112
113impl CollectionRetirementCursorV1 {
114    fn validate(&self) -> Result<(), CollectionRetirementContractError> {
115        if self
116            .after
117            .as_ref()
118            .is_some_and(|after| after.is_empty() || after.len() > MAX_COLLECTION_RETIREMENT_CURSOR_BYTES)
119        {
120            return Err(CollectionRetirementContractError::InvalidStatus);
121        }
122        Ok(())
123    }
124}
125
126#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
127#[serde(deny_unknown_fields)]
128pub struct CollectionRetirementChildSummaryV1 {
129    pub administration_resources: u64,
130    pub indexes: u64,
131    pub security_resources: u64,
132    pub operations: u64,
133}
134
135#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
136#[serde(deny_unknown_fields)]
137pub struct CollectionRetirementReceiptV1 {
138    pub format_version: u16,
139    pub operation_id: [u8; 32],
140    pub request_digest: [u8; 32],
141    pub collection_id: CollectionId,
142    pub admitted_security_generation: CollectionSecurityGeneration,
143    pub retired_security_generation: CollectionSecurityGeneration,
144    pub admitted_at: LogicalMillis,
145    pub retired_at: LogicalMillis,
146    pub final_root_digest: [u8; 32],
147    pub closure_digest: [u8; 32],
148    pub child_summary: CollectionRetirementChildSummaryV1,
149}
150
151impl CollectionRetirementReceiptV1 {
152    pub fn validate(&self) -> Result<(), CollectionRetirementContractError> {
153        if self.format_version != COLLECTION_RETIREMENT_RECEIPT_FORMAT_VERSION
154            || self.operation_id == [0; 32]
155            || self.request_digest == [0; 32]
156            || self.admitted_security_generation.get() == 0
157            || self.retired_security_generation.get() <= self.admitted_security_generation.get()
158            || self.admitted_at.get() == 0
159            || self.retired_at < self.admitted_at
160            || self.final_root_digest == [0; 32]
161            || self.closure_digest == [0; 32]
162        {
163            return Err(CollectionRetirementContractError::InvalidReceipt);
164        }
165        if self.canonical_closure_digest()? != self.closure_digest {
166            return Err(CollectionRetirementContractError::InvalidReceipt);
167        }
168        Ok(())
169    }
170
171    pub fn canonical_closure_digest(&self) -> Result<[u8; 32], CollectionRetirementContractError> {
172        canonical_collection_retirement_digest(
173            COLLECTION_RETIREMENT_CLOSURE_DIGEST_DOMAIN,
174            &(
175                self.operation_id,
176                self.request_digest,
177                self.collection_id,
178                self.admitted_security_generation,
179                self.retired_security_generation,
180                self.admitted_at,
181                self.retired_at,
182                self.final_root_digest,
183                self.child_summary,
184            ),
185        )
186    }
187}
188
189#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
190#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
191pub enum CollectionRetirementOutcomeV1 {
192    NotSubmitted {
193        format_version: u16,
194        request_digest: [u8; 32],
195    },
196    Blocked {
197        format_version: u16,
198        request_digest: [u8; 32],
199        admitted_security_generation: CollectionSecurityGeneration,
200        cursor: CollectionRetirementCursorV1,
201        child_summary: CollectionRetirementChildSummaryV1,
202        blockers: Vec<CollectionRetirementBlockerV1>,
203    },
204    InProgress {
205        format_version: u16,
206        request_digest: [u8; 32],
207        admitted_security_generation: CollectionSecurityGeneration,
208        cursor: CollectionRetirementCursorV1,
209        child_summary: CollectionRetirementChildSummaryV1,
210    },
211    /// Client-only projection used when a call may have submitted but no
212    /// authoritative response was received. The exact request is retained for
213    /// resolver replay and is never safe to replace with a fresh identity.
214    Indeterminate {
215        format_version: u16,
216        request: Box<CollectionRetirementRequestV1>,
217    },
218    Resolved {
219        format_version: u16,
220        receipt: Box<CollectionRetirementReceiptV1>,
221    },
222    KnownRetired {
223        format_version: u16,
224        request_digest: [u8; 32],
225        collection_id: CollectionId,
226        retired_security_generation: CollectionSecurityGeneration,
227        retired_at: LogicalMillis,
228    },
229    ExpiredUnknown {
230        format_version: u16,
231        request_digest: [u8; 32],
232        collection_id: CollectionId,
233        operation_id: [u8; 32],
234    },
235}
236
237impl CollectionRetirementOutcomeV1 {
238    pub fn validate(&self) -> Result<(), CollectionRetirementContractError> {
239        match self {
240            Self::NotSubmitted {
241                format_version,
242                request_digest,
243            } if *format_version == COLLECTION_RETIREMENT_STATUS_FORMAT_VERSION && *request_digest != [0; 32] => Ok(()),
244            Self::Blocked {
245                format_version,
246                request_digest,
247                admitted_security_generation,
248                cursor,
249                blockers,
250                ..
251            } if *format_version == COLLECTION_RETIREMENT_STATUS_FORMAT_VERSION
252                && *request_digest != [0; 32]
253                && admitted_security_generation.get() > 0
254                && !blockers.is_empty()
255                && blockers.len() <= MAX_COLLECTION_RETIREMENT_BLOCKERS
256                && blockers.windows(2).all(|pair| pair[0] < pair[1]) =>
257            {
258                cursor.validate()
259            }
260            Self::InProgress {
261                format_version,
262                request_digest,
263                admitted_security_generation,
264                cursor,
265                ..
266            } if *format_version == COLLECTION_RETIREMENT_STATUS_FORMAT_VERSION
267                && *request_digest != [0; 32]
268                && admitted_security_generation.get() > 0 =>
269            {
270                cursor.validate()
271            }
272            Self::Indeterminate {
273                format_version,
274                request,
275            } if *format_version == COLLECTION_RETIREMENT_STATUS_FORMAT_VERSION => request.validate(),
276            Self::Resolved {
277                format_version,
278                receipt,
279            } if *format_version == COLLECTION_RETIREMENT_STATUS_FORMAT_VERSION => receipt.validate(),
280            Self::KnownRetired {
281                format_version,
282                request_digest,
283                retired_security_generation,
284                retired_at,
285                ..
286            } if *format_version == COLLECTION_RETIREMENT_STATUS_FORMAT_VERSION
287                && *request_digest != [0; 32]
288                && retired_security_generation.get() > 0
289                && retired_at.get() > 0 =>
290            {
291                Ok(())
292            }
293            Self::ExpiredUnknown {
294                format_version,
295                request_digest,
296                operation_id,
297                ..
298            } if *format_version == COLLECTION_RETIREMENT_STATUS_FORMAT_VERSION
299                && *request_digest != [0; 32]
300                && *operation_id != [0; 32] =>
301            {
302                Ok(())
303            }
304            Self::NotSubmitted { .. }
305            | Self::Blocked { .. }
306            | Self::InProgress { .. }
307            | Self::Indeterminate { .. }
308            | Self::Resolved { .. }
309            | Self::KnownRetired { .. }
310            | Self::ExpiredUnknown { .. } => Err(CollectionRetirementContractError::InvalidStatus),
311        }
312    }
313}
314
315fn canonical_collection_retirement_digest(
316    domain: &[u8],
317    value: &impl Serialize,
318) -> Result<[u8; 32], CollectionRetirementContractError> {
319    let encoded = serde_json::to_vec(value).map_err(|_| CollectionRetirementContractError::Codec)?;
320    let mut hasher = blake3::Hasher::new();
321    hasher.update(domain);
322    hasher.update(&encoded);
323    Ok(*hasher.finalize().as_bytes())
324}
325
326#[derive(Clone, Copy, Debug, Eq, PartialEq)]
327pub enum CollectionRetirementContractError {
328    InvalidRequest,
329    InvalidStatus,
330    InvalidReceipt,
331    Codec,
332}
333
334impl Display for CollectionRetirementContractError {
335    fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
336        formatter.write_str(match self {
337            Self::InvalidRequest => "Collection-retirement request is invalid",
338            Self::InvalidStatus => "Collection-retirement status is invalid",
339            Self::InvalidReceipt => "Collection-retirement receipt is invalid",
340            Self::Codec => "Collection-retirement canonical encoding failed",
341        })
342    }
343}
344
345impl Error for CollectionRetirementContractError {}
346
347#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
348#[serde(rename_all = "snake_case")]
349pub enum ClusterControlCredentialKindV1 {
350    CollectionProvisioner,
351    CapacityObserver,
352    RecoveryOperator,
353}
354
355#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
356#[serde(rename_all = "snake_case")]
357pub enum ClusterControlActionV2 {
358    InitializeCollectionAuthority,
359    ActivateCollectionAuthority,
360    ResolveCollectionAuthority,
361    InspectCollectionRetirement,
362    RetireCollectionAuthority,
363    ObserveClusterCapacity,
364    ObserveResourceDiagnostics,
365    ReserveClusterControlCredentialRotation,
366    ActivateClusterControlCredentialRotation,
367    RevokeClusterControlCredential,
368    ResolveClusterControlCredential,
369    ListClusterBackups,
370    GetClusterBackup,
371    CreateClusterBackup,
372    ResolveClusterBackup,
373    DeleteClusterBackup,
374    ResolveClusterBackupDeletion,
375    GetClusterRestoreReadiness,
376    PrepareOfflineRecovery,
377}
378
379impl ClusterControlCredentialKindV1 {
380    #[must_use]
381    pub const fn admits(self, action: ClusterControlActionV2) -> bool {
382        match self {
383            Self::CollectionProvisioner => matches!(
384                action,
385                ClusterControlActionV2::InitializeCollectionAuthority
386                    | ClusterControlActionV2::ActivateCollectionAuthority
387                    | ClusterControlActionV2::ResolveCollectionAuthority
388                    | ClusterControlActionV2::InspectCollectionRetirement
389                    | ClusterControlActionV2::RetireCollectionAuthority
390                    | ClusterControlActionV2::ReserveClusterControlCredentialRotation
391                    | ClusterControlActionV2::ActivateClusterControlCredentialRotation
392                    | ClusterControlActionV2::RevokeClusterControlCredential
393                    | ClusterControlActionV2::ResolveClusterControlCredential
394            ),
395            Self::CapacityObserver => matches!(
396                action,
397                ClusterControlActionV2::ObserveClusterCapacity | ClusterControlActionV2::ObserveResourceDiagnostics
398            ),
399            Self::RecoveryOperator => matches!(
400                action,
401                ClusterControlActionV2::ReserveClusterControlCredentialRotation
402                    | ClusterControlActionV2::ActivateClusterControlCredentialRotation
403                    | ClusterControlActionV2::RevokeClusterControlCredential
404                    | ClusterControlActionV2::ResolveClusterControlCredential
405                    | ClusterControlActionV2::ListClusterBackups
406                    | ClusterControlActionV2::GetClusterBackup
407                    | ClusterControlActionV2::CreateClusterBackup
408                    | ClusterControlActionV2::ResolveClusterBackup
409                    | ClusterControlActionV2::DeleteClusterBackup
410                    | ClusterControlActionV2::ResolveClusterBackupDeletion
411                    | ClusterControlActionV2::GetClusterRestoreReadiness
412                    | ClusterControlActionV2::PrepareOfflineRecovery
413            ),
414        }
415    }
416}
417
418#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)]
419#[serde(deny_unknown_fields)]
420pub struct ClusterControlSessionV2 {
421    pub format_version: u16,
422    pub credential_kind: ClusterControlCredentialKindV1,
423    pub credential: Vec<u8>,
424    pub request_id: String,
425    pub action: ClusterControlActionV2,
426    pub request_correlation_id: [u8; 16],
427}
428
429impl fmt::Debug for ClusterControlSessionV2 {
430    fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
431        formatter
432            .debug_struct("ClusterControlSessionV2")
433            .field("format_version", &self.format_version)
434            .field("credential_kind", &self.credential_kind)
435            .field("credential", &"[REDACTED]")
436            .field("request_id", &self.request_id)
437            .field("action", &self.action)
438            .field("request_correlation_id", &self.request_correlation_id)
439            .finish()
440    }
441}
442
443impl ClusterControlSessionV2 {
444    pub fn validate(&self) -> Result<(), ClusterControlWireError> {
445        if self.format_version != CLUSTER_CONTROL_SESSION_FORMAT_VERSION {
446            return Err(ClusterControlWireError::UnsupportedVersion(self.format_version));
447        }
448        validate_cluster_control_credential_v1(&self.credential)?;
449        if self.request_id.is_empty()
450            || self.request_id.len() > MAX_CLUSTER_CONTROL_REQUEST_ID_BYTES
451            || self.request_id.bytes().any(|byte| byte.is_ascii_control())
452        {
453            return Err(ClusterControlWireError::InvalidRequestIdentity);
454        }
455        if !self.credential_kind.admits(self.action) {
456            return Err(ClusterControlWireError::ActionDenied);
457        }
458        Ok(())
459    }
460}
461
462pub fn validate_cluster_control_credential_v1(credential: &[u8]) -> Result<(), ClusterControlWireError> {
463    if credential.len() != CLUSTER_CONTROL_CREDENTIAL_ENCODED_BYTES
464        || !credential.starts_with(CLUSTER_CONTROL_CREDENTIAL_PREFIX)
465        || !credential[CLUSTER_CONTROL_CREDENTIAL_PREFIX.len()..]
466            .iter()
467            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
468    {
469        return Err(ClusterControlWireError::InvalidCredentialShape);
470    }
471    Ok(())
472}
473
474#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
475#[serde(deny_unknown_fields)]
476pub struct ClusterControlRequestV2 {
477    pub format_version: u16,
478    pub session: ClusterControlSessionV2,
479    pub body: serde_json::Value,
480}
481
482impl ClusterControlRequestV2 {
483    pub fn validate(&self) -> Result<(), ClusterControlWireError> {
484        if self.format_version != CLUSTER_CONTROL_REQUEST_FORMAT_VERSION {
485            return Err(ClusterControlWireError::UnsupportedVersion(self.format_version));
486        }
487        self.session.validate()?;
488        if !self.body.is_object() {
489            return Err(ClusterControlWireError::InvalidBody);
490        }
491        Ok(())
492    }
493}
494
495#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
496#[serde(rename_all = "snake_case")]
497pub enum ClusterControlResponseDispositionV2 {
498    RetrySameRequest,
499    ResolveOperation,
500    CorrectRequest,
501    Reauthenticate,
502    Terminal,
503}
504
505#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
506#[serde(tag = "outcome", rename_all = "snake_case", deny_unknown_fields)]
507pub enum ClusterControlResponseOutcomeV2 {
508    Succeeded {
509        body: serde_json::Value,
510    },
511    Refused {
512        code: String,
513        disposition: ClusterControlResponseDispositionV2,
514    },
515}
516
517#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
518#[serde(deny_unknown_fields)]
519pub struct ClusterControlResponseV2 {
520    pub format_version: u16,
521    pub request_id: String,
522    pub request_correlation_id: [u8; 16],
523    pub outcome: ClusterControlResponseOutcomeV2,
524}
525
526impl ClusterControlResponseV2 {
527    pub fn validate(&self) -> Result<(), ClusterControlWireError> {
528        if self.format_version != CLUSTER_CONTROL_RESPONSE_FORMAT_VERSION {
529            return Err(ClusterControlWireError::UnsupportedVersion(self.format_version));
530        }
531        validate_request_id(&self.request_id)?;
532        match &self.outcome {
533            ClusterControlResponseOutcomeV2::Succeeded { body } if body.is_object() => Ok(()),
534            ClusterControlResponseOutcomeV2::Succeeded { .. } => Err(ClusterControlWireError::InvalidBody),
535            ClusterControlResponseOutcomeV2::Refused { code, .. }
536                if !code.is_empty()
537                    && code.len() <= MAX_CLUSTER_CONTROL_ERROR_CODE_BYTES
538                    && code
539                        .bytes()
540                        .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') =>
541            {
542                Ok(())
543            }
544            ClusterControlResponseOutcomeV2::Refused { .. } => Err(ClusterControlWireError::InvalidErrorCode),
545        }
546    }
547}
548
549pub fn encode_cluster_control_request_v2(
550    request: &ClusterControlRequestV2,
551) -> Result<Vec<u8>, ClusterControlWireError> {
552    request.validate()?;
553    encode_frame(request)
554}
555
556pub fn decode_cluster_control_request_v2(bytes: &[u8]) -> Result<ClusterControlRequestV2, ClusterControlWireError> {
557    let request: ClusterControlRequestV2 = decode_frame(bytes)?;
558    request.validate()?;
559    Ok(request)
560}
561
562pub fn encode_cluster_control_response_v2(
563    response: &ClusterControlResponseV2,
564) -> Result<Vec<u8>, ClusterControlWireError> {
565    response.validate()?;
566    encode_frame(response)
567}
568
569pub fn decode_cluster_control_response_v2(bytes: &[u8]) -> Result<ClusterControlResponseV2, ClusterControlWireError> {
570    let response: ClusterControlResponseV2 = decode_frame(bytes)?;
571    response.validate()?;
572    Ok(response)
573}
574
575fn encode_frame(value: &impl Serialize) -> Result<Vec<u8>, ClusterControlWireError> {
576    let bytes = serde_json::to_vec(value).map_err(|error| ClusterControlWireError::Codec(error.to_string()))?;
577    if bytes.is_empty() || bytes.len() > MAX_CLUSTER_CONTROL_FRAME_BYTES {
578        return Err(ClusterControlWireError::FrameTooLarge(bytes.len()));
579    }
580    Ok(bytes)
581}
582
583fn decode_frame<T: for<'de> Deserialize<'de> + Serialize>(bytes: &[u8]) -> Result<T, ClusterControlWireError> {
584    if bytes.is_empty() || bytes.len() > MAX_CLUSTER_CONTROL_FRAME_BYTES {
585        return Err(ClusterControlWireError::FrameTooLarge(bytes.len()));
586    }
587    let value = serde_json::from_slice(bytes).map_err(|error| ClusterControlWireError::Codec(error.to_string()))?;
588    if serde_json::to_vec(&value).map_err(|error| ClusterControlWireError::Codec(error.to_string()))? != bytes {
589        return Err(ClusterControlWireError::NonCanonical);
590    }
591    Ok(value)
592}
593
594fn validate_request_id(request_id: &str) -> Result<(), ClusterControlWireError> {
595    if request_id.is_empty()
596        || request_id.len() > MAX_CLUSTER_CONTROL_REQUEST_ID_BYTES
597        || request_id.bytes().any(|byte| byte.is_ascii_control())
598    {
599        return Err(ClusterControlWireError::InvalidRequestIdentity);
600    }
601    Ok(())
602}
603
604pub fn encode_cluster_control_session_v2(
605    session: &ClusterControlSessionV2,
606) -> Result<Vec<u8>, ClusterControlWireError> {
607    session.validate()?;
608    let bytes = serde_json::to_vec(session).map_err(|error| ClusterControlWireError::Codec(error.to_string()))?;
609    if bytes.len() > MAX_CLUSTER_CONTROL_SESSION_BYTES {
610        return Err(ClusterControlWireError::FrameTooLarge(bytes.len()));
611    }
612    Ok(bytes)
613}
614
615pub fn decode_cluster_control_session_v2(bytes: &[u8]) -> Result<ClusterControlSessionV2, ClusterControlWireError> {
616    if bytes.is_empty() || bytes.len() > MAX_CLUSTER_CONTROL_SESSION_BYTES {
617        return Err(ClusterControlWireError::FrameTooLarge(bytes.len()));
618    }
619    let session: ClusterControlSessionV2 =
620        serde_json::from_slice(bytes).map_err(|error| ClusterControlWireError::Codec(error.to_string()))?;
621    session.validate()?;
622    if serde_json::to_vec(&session).map_err(|error| ClusterControlWireError::Codec(error.to_string()))? != bytes {
623        return Err(ClusterControlWireError::NonCanonical);
624    }
625    Ok(session)
626}
627
628#[derive(Clone, Debug, Eq, PartialEq)]
629pub enum ClusterControlWireError {
630    UnsupportedVersion(u16),
631    InvalidCredentialShape,
632    InvalidRequestIdentity,
633    InvalidBody,
634    InvalidErrorCode,
635    ActionDenied,
636    FrameTooLarge(usize),
637    NonCanonical,
638    Codec(String),
639}
640
641impl Display for ClusterControlWireError {
642    fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
643        match self {
644            Self::UnsupportedVersion(version) => {
645                write!(formatter, "unsupported cluster-control wire version {version}")
646            }
647            Self::InvalidCredentialShape => formatter.write_str("invalid cluster-control credential shape"),
648            Self::InvalidRequestIdentity => formatter.write_str("invalid cluster-control request identity"),
649            Self::InvalidBody => formatter.write_str("invalid cluster-control request or response body"),
650            Self::InvalidErrorCode => formatter.write_str("invalid cluster-control error code"),
651            Self::ActionDenied => formatter.write_str("cluster-control credential kind does not admit the action"),
652            Self::FrameTooLarge(size) => write!(formatter, "cluster-control frame size {size} exceeds the bound"),
653            Self::NonCanonical => formatter.write_str("cluster-control frame is not canonical"),
654            Self::Codec(error) => write!(formatter, "cluster-control frame codec failed: {error}"),
655        }
656    }
657}
658
659impl Error for ClusterControlWireError {}
660
661#[cfg(test)]
662mod tests {
663    use super::*;
664
665    fn retirement_request() -> Result<CollectionRetirementRequestV1, Box<dyn Error>> {
666        Ok(CollectionRetirementRequestV1 {
667            format_version: COLLECTION_RETIREMENT_REQUEST_FORMAT_VERSION,
668            operation_id: [11; 32],
669            collection_id: CollectionId::new(7)?,
670            expected_security_generation: CollectionSecurityGeneration::new(4),
671            closure_policy: CollectionRetirementClosurePolicyV1::RetireOwnedChildren,
672        })
673    }
674
675    fn session(
676        credential_kind: ClusterControlCredentialKindV1,
677        action: ClusterControlActionV2,
678    ) -> ClusterControlSessionV2 {
679        ClusterControlSessionV2 {
680            format_version: CLUSTER_CONTROL_SESSION_FORMAT_VERSION,
681            credential_kind,
682            credential: b"lcc1_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_vec(),
683            request_id: "capacity-sample-1".to_owned(),
684            action,
685            request_correlation_id: [7; 16],
686        }
687    }
688
689    #[test]
690    fn credential_namespaces_are_disjoint_and_canonical() -> Result<(), Box<dyn Error>> {
691        let observer = session(
692            ClusterControlCredentialKindV1::CapacityObserver,
693            ClusterControlActionV2::ObserveClusterCapacity,
694        );
695        let bytes = encode_cluster_control_session_v2(&observer)?;
696        assert_eq!(decode_cluster_control_session_v2(&bytes)?, observer);
697
698        let provisioner_backup = session(
699            ClusterControlCredentialKindV1::CollectionProvisioner,
700            ClusterControlActionV2::GetClusterBackup,
701        );
702        assert_eq!(
703            provisioner_backup.validate(),
704            Err(ClusterControlWireError::ActionDenied)
705        );
706
707        let observer_mutation = session(
708            ClusterControlCredentialKindV1::CapacityObserver,
709            ClusterControlActionV2::InitializeCollectionAuthority,
710        );
711        assert_eq!(observer_mutation.validate(), Err(ClusterControlWireError::ActionDenied));
712
713        let recovery_capacity = session(
714            ClusterControlCredentialKindV1::RecoveryOperator,
715            ClusterControlActionV2::ObserveClusterCapacity,
716        );
717        assert_eq!(recovery_capacity.validate(), Err(ClusterControlWireError::ActionDenied));
718
719        let recovery_delete = session(
720            ClusterControlCredentialKindV1::RecoveryOperator,
721            ClusterControlActionV2::DeleteClusterBackup,
722        );
723        assert!(recovery_delete.validate().is_ok());
724        let provisioner_delete = session(
725            ClusterControlCredentialKindV1::CollectionProvisioner,
726            ClusterControlActionV2::DeleteClusterBackup,
727        );
728        assert_eq!(
729            provisioner_delete.validate(),
730            Err(ClusterControlWireError::ActionDenied)
731        );
732
733        let mut public_shape = observer;
734        public_shape.credential = b"1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_vec();
735        assert_eq!(
736            public_shape.validate(),
737            Err(ClusterControlWireError::InvalidCredentialShape)
738        );
739        Ok(())
740    }
741
742    #[test]
743    fn decoder_refuses_predecessor_newer_unknown_and_malformed_frames() -> Result<(), Box<dyn Error>> {
744        let observer = session(
745            ClusterControlCredentialKindV1::CapacityObserver,
746            ClusterControlActionV2::ObserveClusterCapacity,
747        );
748        let predecessor = String::from_utf8(encode_cluster_control_session_v2(&observer)?)?.replacen(
749            &format!("\"format_version\":{CLUSTER_CONTROL_SESSION_FORMAT_VERSION}"),
750            &format!("\"format_version\":{}", CLUSTER_CONTROL_SESSION_FORMAT_VERSION - 1),
751            1,
752        );
753        assert_eq!(
754            decode_cluster_control_session_v2(predecessor.as_bytes()),
755            Err(ClusterControlWireError::UnsupportedVersion(1))
756        );
757        let mut newer = serde_json::to_value(&observer)?;
758        newer["format_version"] = serde_json::json!(CLUSTER_CONTROL_SESSION_FORMAT_VERSION + 1);
759        assert!(matches!(
760            decode_cluster_control_session_v2(&serde_json::to_vec(&newer)?),
761            Err(ClusterControlWireError::UnsupportedVersion(_))
762        ));
763
764        let mut unknown = serde_json::to_value(observer)?;
765        unknown["collection_id"] = serde_json::json!(1);
766        assert!(decode_cluster_control_session_v2(&serde_json::to_vec(&unknown)?).is_err());
767
768        for malformed in [
769            Vec::new(),
770            vec![0xff],
771            vec![b'x'; MAX_CLUSTER_CONTROL_SESSION_BYTES + 1],
772        ] {
773            assert!(decode_cluster_control_session_v2(&malformed).is_err());
774        }
775        Ok(())
776    }
777
778    #[test]
779    fn request_and_response_envelopes_are_strict_bounded_and_correlated() -> Result<(), Box<dyn Error>> {
780        let request = ClusterControlRequestV2 {
781            format_version: CLUSTER_CONTROL_REQUEST_FORMAT_VERSION,
782            session: session(
783                ClusterControlCredentialKindV1::CapacityObserver,
784                ClusterControlActionV2::ObserveClusterCapacity,
785            ),
786            body: serde_json::json!({}),
787        };
788        let encoded = encode_cluster_control_request_v2(&request)?;
789        assert_eq!(decode_cluster_control_request_v2(&encoded)?, request);
790
791        let predecessor_request = String::from_utf8(encoded.clone())?.replacen(
792            &format!("\"format_version\":{CLUSTER_CONTROL_REQUEST_FORMAT_VERSION}"),
793            &format!("\"format_version\":{}", CLUSTER_CONTROL_REQUEST_FORMAT_VERSION - 1),
794            1,
795        );
796        assert_eq!(
797            decode_cluster_control_request_v2(predecessor_request.as_bytes()),
798            Err(ClusterControlWireError::UnsupportedVersion(1))
799        );
800
801        let response = ClusterControlResponseV2 {
802            format_version: CLUSTER_CONTROL_RESPONSE_FORMAT_VERSION,
803            request_id: request.session.request_id.clone(),
804            request_correlation_id: request.session.request_correlation_id,
805            outcome: ClusterControlResponseOutcomeV2::Succeeded {
806                body: serde_json::json!({"observation_id": "sample-1"}),
807            },
808        };
809        let encoded = encode_cluster_control_response_v2(&response)?;
810        assert_eq!(decode_cluster_control_response_v2(&encoded)?, response);
811
812        let predecessor_response = String::from_utf8(encoded)?.replacen(
813            &format!("\"format_version\":{CLUSTER_CONTROL_RESPONSE_FORMAT_VERSION}"),
814            &format!("\"format_version\":{}", CLUSTER_CONTROL_RESPONSE_FORMAT_VERSION - 1),
815            1,
816        );
817        assert_eq!(
818            decode_cluster_control_response_v2(predecessor_response.as_bytes()),
819            Err(ClusterControlWireError::UnsupportedVersion(1))
820        );
821
822        let mut unknown = serde_json::to_value(&request)?;
823        unknown["unexpected"] = serde_json::json!(true);
824        assert!(decode_cluster_control_request_v2(&serde_json::to_vec(&unknown)?).is_err());
825        assert!(matches!(
826            decode_cluster_control_request_v2(&vec![b'x'; MAX_CLUSTER_CONTROL_FRAME_BYTES + 1]),
827            Err(ClusterControlWireError::FrameTooLarge(_))
828        ));
829        Ok(())
830    }
831
832    #[test]
833    fn collection_retirement_identity_binds_every_admitted_semantic() -> Result<(), Box<dyn Error>> {
834        let request = retirement_request()?;
835        let digest = request.canonical_request_digest()?;
836        let mut changed = request.clone();
837        changed.expected_security_generation = CollectionSecurityGeneration::new(5);
838        assert_ne!(changed.canonical_request_digest()?, digest);
839
840        let mut invalid = request;
841        invalid.operation_id = [0; 32];
842        assert_eq!(
843            invalid.validate(),
844            Err(CollectionRetirementContractError::InvalidRequest)
845        );
846        Ok(())
847    }
848
849    #[test]
850    fn collection_retirement_receipt_and_recovery_statuses_fail_closed() -> Result<(), Box<dyn Error>> {
851        let request = retirement_request()?;
852        let mut receipt = CollectionRetirementReceiptV1 {
853            format_version: COLLECTION_RETIREMENT_RECEIPT_FORMAT_VERSION,
854            operation_id: request.operation_id,
855            request_digest: request.canonical_request_digest()?,
856            collection_id: request.collection_id,
857            admitted_security_generation: CollectionSecurityGeneration::new(5),
858            retired_security_generation: CollectionSecurityGeneration::new(6),
859            admitted_at: LogicalMillis::new(12),
860            retired_at: LogicalMillis::new(18),
861            final_root_digest: [21; 32],
862            closure_digest: [0; 32],
863            child_summary: CollectionRetirementChildSummaryV1 {
864                administration_resources: 2,
865                indexes: 3,
866                security_resources: 4,
867                operations: 0,
868            },
869        };
870        receipt.closure_digest = receipt.canonical_closure_digest()?;
871        receipt.validate()?;
872        let closure_digest = receipt.closure_digest;
873        receipt.child_summary.indexes += 1;
874        assert_eq!(
875            receipt.validate(),
876            Err(CollectionRetirementContractError::InvalidReceipt)
877        );
878        assert_ne!(receipt.canonical_closure_digest()?, closure_digest);
879
880        let blocked = CollectionRetirementOutcomeV1::Blocked {
881            format_version: COLLECTION_RETIREMENT_STATUS_FORMAT_VERSION,
882            request_digest: request.canonical_request_digest()?,
883            admitted_security_generation: CollectionSecurityGeneration::new(5),
884            cursor: CollectionRetirementCursorV1 {
885                owner: CollectionRetirementOwnerV1::Protection,
886                after: None,
887            },
888            child_summary: CollectionRetirementChildSummaryV1::default(),
889            blockers: vec![CollectionRetirementBlockerV1 {
890                owner: CollectionRetirementOwnerV1::Protection,
891                kind: CollectionRetirementBlockerKindV1::PolicyRefusal,
892            }],
893        };
894        blocked.validate()?;
895        let indeterminate = CollectionRetirementOutcomeV1::Indeterminate {
896            format_version: COLLECTION_RETIREMENT_STATUS_FORMAT_VERSION,
897            request: Box::new(request),
898        };
899        indeterminate.validate()?;
900        Ok(())
901    }
902}