Skip to main content

ozzy_replication/
configuration_record.rs

1use ozzy_proto::{GroupId, NodeId};
2
3use crate::{Configuration, Digest, QuorumPolicy, ReplicationError};
4
5/// Exact encoded length of a fixed-three-voter durable configuration.
6pub const CONFIGURATION_RECORD_BYTES: usize = 256;
7const HASH_CONTEXT: &str = "ozzy fixed durable replica configuration v1";
8const MAGIC: &[u8; 8] = b"OZYVOTER";
9const DIGEST_START: usize = 192;
10
11/// One persistent voter and its independently authenticated principal binding.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub struct ConfiguredVoter {
14    /// Stable node identity. Its array position participates in primary selection.
15    pub node_id: NodeId,
16    /// Fingerprint of the adapter's canonical authentication-principal binding.
17    /// This is not a socket identity, endpoint hash, or proof of authentication.
18    /// All replicas must use the same fingerprint definition and mapping.
19    pub principal: Digest,
20}
21
22/// Canonical immutable configuration for three authenticated durable voters.
23///
24/// The record fixes confirmation policy, the native wire version, and canonical operation
25/// schema 1. Endpoint addresses and local tuning are deliberately excluded.
26/// Authentication still belongs to the transport adapter: loading this record
27/// does not authenticate a peer or authorize a restarted voter to enter normal.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub struct ConfigurationRecord {
30    configuration: Configuration,
31    voters: [ConfiguredVoter; 3],
32}
33
34impl ConfigurationRecord {
35    /// Construct a complete fixed configuration and derive its scope digest.
36    /// Voter IDs and principal fingerprints must be nonzero and pairwise distinct.
37    pub fn new(
38        group_id: GroupId,
39        configuration_epoch: u64,
40        voters: [ConfiguredVoter; 3],
41    ) -> Result<Self, ConfigurationRecordError> {
42        Self::with_policy(group_id, configuration_epoch, voters, QuorumPolicy::Durable)
43    }
44
45    /// Bind disk or retained-memory confirmation into the immutable group identity.
46    pub fn with_policy(
47        group_id: GroupId,
48        configuration_epoch: u64,
49        voters: [ConfiguredVoter; 3],
50        policy: QuorumPolicy,
51    ) -> Result<Self, ConfigurationRecordError> {
52        if configuration_epoch == 0
53            || voters.iter().any(|voter| voter.principal == Digest::ZERO)
54            || voters[0].principal == voters[1].principal
55            || voters[0].principal == voters[2].principal
56            || voters[1].principal == voters[2].principal
57        {
58            return Err(ConfigurationRecordError::InvalidConfiguration);
59        }
60        let bytes = unsigned_record(group_id, configuration_epoch, &voters, policy);
61        let mut configuration = Configuration::new(
62            group_id,
63            configuration_epoch,
64            record_digest(&bytes),
65            voters.map(|voter| voter.node_id),
66        )?;
67        configuration.policy = policy;
68        Ok(Self {
69            configuration,
70            voters,
71        })
72    }
73
74    /// Verified group, ordered voter identities, and complete configuration digest.
75    pub const fn configuration(self) -> Configuration {
76        self.configuration
77    }
78
79    /// Ordered voter-to-principal bindings the transport adapter must enforce.
80    pub const fn voters(&self) -> &[ConfiguredVoter; 3] {
81        &self.voters
82    }
83
84    /// Encode without allocation. All integer fields use network byte order.
85    pub fn encode(self) -> [u8; CONFIGURATION_RECORD_BYTES] {
86        let scope = self.configuration.scope();
87        let mut bytes = unsigned_record(
88            scope.group_id,
89            scope.configuration_epoch,
90            &self.voters,
91            self.configuration.policy(),
92        );
93        bytes[DIGEST_START..DIGEST_START + 32]
94            .copy_from_slice(scope.configuration_digest.as_bytes());
95        bytes
96    }
97
98    /// Validate exact length, supported policy/versions, canonical fields, and digest.
99    /// Unknown fields and malformed membership are rejected, never normalized.
100    pub fn decode(bytes: &[u8]) -> Result<Self, ConfigurationRecordError> {
101        if bytes.len() != CONFIGURATION_RECORD_BYTES {
102            return Err(ConfigurationRecordError::Length);
103        }
104        if &bytes[..8] != MAGIC
105            || bytes[8..12] != [0, 2, 1, 0]
106            || !matches!(bytes[12], 1 | 2)
107            || bytes[13..16] != [1, ozzy_proto::VERSION, 1]
108            || bytes[40] != 3
109            || bytes[41..48].iter().any(|&byte| byte != 0)
110            || bytes[224..].iter().any(|&byte| byte != 0)
111        {
112            return Err(ConfigurationRecordError::UnsupportedFields);
113        }
114        let stored = Digest::from_bytes(
115            bytes[DIGEST_START..DIGEST_START + 32]
116                .try_into()
117                .expect("fixed digest"),
118        );
119        if record_digest(bytes) != stored {
120            return Err(ConfigurationRecordError::DigestMismatch);
121        }
122        let group = GroupId::from_bytes(bytes[16..32].try_into().expect("fixed group"));
123        let epoch = u64::from_be_bytes(bytes[32..40].try_into().expect("fixed epoch"));
124        let voters = std::array::from_fn(|index| {
125            let start = 48 + index * 48;
126            ConfiguredVoter {
127                node_id: NodeId::from_bytes(
128                    bytes[start..start + 16].try_into().expect("fixed voter"),
129                ),
130                principal: Digest::from_bytes(
131                    bytes[start + 16..start + 48]
132                        .try_into()
133                        .expect("fixed principal"),
134                ),
135            }
136        });
137        let policy = if bytes[12] == 1 {
138            QuorumPolicy::Durable
139        } else {
140            QuorumPolicy::Replicated
141        };
142        let record = Self::with_policy(group, epoch, voters, policy)?;
143        debug_assert_eq!(record.configuration.scope().configuration_digest, stored);
144        Ok(record)
145    }
146}
147
148fn unsigned_record(
149    group: GroupId,
150    epoch: u64,
151    voters: &[ConfiguredVoter; 3],
152    policy: QuorumPolicy,
153) -> [u8; CONFIGURATION_RECORD_BYTES] {
154    let mut bytes = [0; CONFIGURATION_RECORD_BYTES];
155    bytes[..8].copy_from_slice(MAGIC);
156    // Version 2 (XXH3-128 integrity), length 256, disk quorum, external principal binding,
157    // Current native wire version, canonical operation schema 1.
158    bytes[8..16].copy_from_slice(&[0, 2, 1, 0, policy as u8, 1, ozzy_proto::VERSION, 1]);
159    bytes[16..32].copy_from_slice(group.as_bytes());
160    bytes[32..40].copy_from_slice(&epoch.to_be_bytes());
161    bytes[40] = 3;
162    for (index, voter) in voters.iter().enumerate() {
163        let start = 48 + index * 48;
164        bytes[start..start + 16].copy_from_slice(voter.node_id.as_bytes());
165        bytes[start + 16..start + 48].copy_from_slice(voter.principal.as_bytes());
166    }
167    bytes
168}
169
170fn record_digest(bytes: &[u8]) -> Digest {
171    let mut hasher = ozzy_journal::integrity::IntegrityHasher::new(HASH_CONTEXT);
172    hasher.update(&bytes[..DIGEST_START]);
173    hasher.update(&[0; 32]);
174    hasher.update(&bytes[DIGEST_START + 32..]);
175    hasher.finish()
176}
177
178/// Rejected durable configuration. No error permits silently replacing membership.
179#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
180pub enum ConfigurationRecordError {
181    /// Record must have the one exact supported length.
182    #[error("invalid replica configuration record length")]
183    Length,
184    /// Magic, versions, policy, voter count, or reserved fields are unsupported.
185    #[error("unsupported replica configuration record fields")]
186    UnsupportedFields,
187    /// Stored configuration bytes no longer match their domain-separated digest.
188    #[error("replica configuration record digest mismatch")]
189    DigestMismatch,
190    /// Zero epoch/principal or repeated principal binding.
191    #[error("invalid replica configuration principals or epoch")]
192    InvalidConfiguration,
193    /// Group or ordered voter identities violate the replication core's rules.
194    #[error(transparent)]
195    Replication(#[from] ReplicationError),
196}