Skip to main content

canic_core/model/replay/
mod.rs

1//! Module: model::replay
2//!
3//! Responsibility: define pure shared replay receipt identifiers and state.
4//! Does not own: storage mutation, replay reservation, or command execution.
5//! Boundary: consumed by replay ops and stable replay storage records.
6use crate::{
7    cdk::types::Principal,
8    ids::{CanisterRole, IntentId},
9};
10use std::{fmt, str::FromStr};
11
12use serde::{Deserialize, Serialize};
13use sha2::{Digest, Sha256};
14
15pub const REPLAY_RECEIPT_SCHEMA_VERSION: u32 = 1;
16pub const REPLAY_PAYLOAD_HASH_SCHEMA_VERSION: u32 = 1;
17pub const PLACEMENT_CHILD_REPLAY_COMMAND_KIND: &str = "root.allocate_placement_child";
18pub const ROOT_PROVISION_REPLAY_COMMAND_KIND: &str = "root.provision";
19
20const REPLAY_PAYLOAD_HASH_DOMAIN: &[u8] = b"canic-replay-payload-hash:v1";
21
22///
23/// OperationId
24///
25/// Stable operation identifier shared by replay-protected commands.
26/// Owned by the replay model and serialized into stable replay receipts.
27///
28
29#[derive(Clone, Copy, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
30pub struct OperationId([u8; 32]);
31
32impl OperationId {
33    /// Build an operation id from its canonical 32-byte representation.
34    #[must_use]
35    pub const fn from_bytes(bytes: [u8; 32]) -> Self {
36        Self(bytes)
37    }
38
39    /// Return the canonical 32-byte operation id representation.
40    #[must_use]
41    pub const fn as_bytes(&self) -> &[u8; 32] {
42        &self.0
43    }
44
45    /// Consume the operation id and return its canonical bytes.
46    #[must_use]
47    pub const fn into_bytes(self) -> [u8; 32] {
48        self.0
49    }
50}
51
52impl fmt::Debug for OperationId {
53    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54        write!(f, "OperationId({self})")
55    }
56}
57
58impl fmt::Display for OperationId {
59    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60        for byte in self.0 {
61            write!(f, "{byte:02x}")?;
62        }
63        Ok(())
64    }
65}
66
67impl From<[u8; 32]> for OperationId {
68    fn from(value: [u8; 32]) -> Self {
69        Self::from_bytes(value)
70    }
71}
72
73impl From<OperationId> for [u8; 32] {
74    fn from(value: OperationId) -> Self {
75        value.into_bytes()
76    }
77}
78
79impl TryFrom<&[u8]> for OperationId {
80    type Error = OperationIdParseError;
81
82    fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
83        let bytes: [u8; 32] =
84            value
85                .try_into()
86                .map_err(|_| OperationIdParseError::InvalidByteLength {
87                    actual: value.len(),
88                })?;
89        Ok(Self(bytes))
90    }
91}
92
93impl FromStr for OperationId {
94    type Err = OperationIdParseError;
95
96    fn from_str(value: &str) -> Result<Self, Self::Err> {
97        if value.len() != 64 {
98            return Err(OperationIdParseError::InvalidHexLength {
99                actual: value.len(),
100            });
101        }
102
103        let mut bytes = [0u8; 32];
104        for (index, chunk) in value.as_bytes().chunks_exact(2).enumerate() {
105            let high = decode_hex_nibble(chunk[0])?;
106            let low = decode_hex_nibble(chunk[1])?;
107            bytes[index] = (high << 4) | low;
108        }
109        Ok(Self(bytes))
110    }
111}
112
113///
114/// OperationIdParseError
115///
116/// Typed parse failure for operation id byte and hex conversions.
117/// Owned by the replay model and returned by `OperationId` constructors.
118///
119
120#[derive(Clone, Debug, Eq, PartialEq)]
121pub enum OperationIdParseError {
122    InvalidByteLength { actual: usize },
123    InvalidHexLength { actual: usize },
124    InvalidHexCharacter { byte: u8 },
125}
126
127///
128/// CommandKind
129///
130/// Validated replay command namespace.
131/// Owned by the replay model and used to partition replay receipts.
132///
133
134#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
135pub struct CommandKind(String);
136
137impl CommandKind {
138    /// Validate and create a replay command namespace.
139    pub fn new(value: impl Into<String>) -> Result<Self, CommandKindError> {
140        let value = value.into();
141        if value.is_empty() {
142            return Err(CommandKindError::Empty);
143        }
144        if !value
145            .bytes()
146            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b':'))
147        {
148            return Err(CommandKindError::InvalidCharacter);
149        }
150        Ok(Self(value))
151    }
152
153    /// Return the validated command namespace as text.
154    #[must_use]
155    pub fn as_str(&self) -> &str {
156        &self.0
157    }
158}
159
160///
161/// CommandKindError
162///
163/// Typed validation failure for replay command namespaces.
164/// Owned by the replay model and returned by `CommandKind::new`.
165///
166
167#[derive(Clone, Debug, Eq, PartialEq)]
168pub enum CommandKindError {
169    Empty,
170    InvalidCharacter,
171}
172
173///
174/// AuthKind
175///
176/// Authentication class bound into replay actor identity.
177/// Owned by the replay model and included in payload hashes.
178///
179
180#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
181pub enum AuthKind {
182    DirectCaller,
183    DelegatedToken,
184    RoleAttestation,
185}
186
187///
188/// ReplayActor
189///
190/// Effective actor identity bound to replay receipts and payload hashes.
191/// Owned by the replay model and consumed by replay guards.
192///
193
194#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
195pub struct ReplayActor {
196    pub effective_principal: Principal,
197    pub auth_kind: AuthKind,
198}
199
200impl ReplayActor {
201    /// Build a replay actor for direct caller authentication.
202    #[must_use]
203    pub const fn direct_caller(caller: Principal) -> Self {
204        Self {
205            effective_principal: caller,
206            auth_kind: AuthKind::DirectCaller,
207        }
208    }
209}
210
211///
212/// ReplayReceipt
213///
214/// Canonical replay receipt state independent of stable-memory encoding.
215/// Owned by the replay model and persisted through storage record adapters.
216///
217
218#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
219pub struct ReplayReceipt {
220    pub schema_version: u32,
221    pub command_kind: CommandKind,
222    pub operation_id: OperationId,
223    pub actor: ReplayActor,
224    pub payload_hash_schema_version: u32,
225    pub payload_hash: [u8; 32],
226    pub status: ReplayReceiptStatus,
227    pub created_at_ns: u64,
228    pub updated_at_ns: u64,
229    pub expires_at_ns: Option<u64>,
230    pub response_schema_version: Option<u32>,
231    pub response_bytes: Option<Vec<u8>>,
232    pub staged_response_schema_version: Option<u32>,
233    pub staged_response_bytes: Option<Vec<u8>>,
234    pub cost_guard_settlement: Option<ReplayCostGuardSettlement>,
235    pub effect: Option<ExternalEffectDescriptor>,
236}
237
238///
239/// ReplayCostGuardSettlement
240///
241/// Durable cost-intent identity required to finish accounting without repeating
242/// an external effect.
243/// Owned by the replay model and persisted with the protected replay receipt.
244///
245
246#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
247pub struct ReplayCostGuardSettlement {
248    pub quota_intent_id: IntentId,
249    pub reservation_intent_id: IntentId,
250}
251
252///
253/// ReplayReceiptStatus
254///
255/// Lifecycle state for a replay receipt.
256/// Owned by the replay model and interpreted by replay guards.
257///
258
259#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
260pub enum ReplayReceiptStatus {
261    Reserved,
262    ExternalEffectInFlight,
263    Committed,
264    RecoveryRequired { reason: RecoveryReason },
265}
266
267///
268/// RecoveryReason
269///
270/// Stable replay recovery reason after uncertain external effects.
271/// Owned by the replay model and exposed through replay decisions.
272///
273
274#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
275pub enum RecoveryReason {
276    ExternalEffectStatusUnknown,
277    ResponseCommitFailed,
278    CostSettlementFailed,
279    StateProjectionFailed,
280    Other(String),
281}
282
283///
284/// ExternalEffectDescriptor
285///
286/// Replay-visible description of an external side effect boundary.
287/// Owned by the replay model and persisted while effects are in flight.
288///
289
290#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
291pub enum ExternalEffectDescriptor {
292    ManagementCreateCanister { command_kind: CommandKind },
293    ManagementCall { canister: Principal, method: String },
294    IcpTransfer { operation_id: OperationId },
295}
296
297/// Whether a committed placement-child receipt must remain until its caller acknowledges it.
298#[must_use]
299pub fn placement_receipt_requires_acknowledgement(
300    status: &ReplayReceiptStatus,
301    effect: Option<&ExternalEffectDescriptor>,
302) -> bool {
303    *status == ReplayReceiptStatus::Committed
304        && matches!(
305            effect,
306            Some(ExternalEffectDescriptor::ManagementCreateCanister { command_kind })
307                if command_kind.as_str() == PLACEMENT_CHILD_REPLAY_COMMAND_KIND
308        )
309}
310
311///
312/// ReplayPayloadHasher
313///
314/// Deterministic hasher for replay command payloads.
315/// Owned by the replay model and used by workflow replay adapters.
316///
317
318pub struct ReplayPayloadHasher {
319    inner: Sha256,
320}
321
322impl ReplayPayloadHasher {
323    /// Start a deterministic replay payload hash for a command and actor.
324    #[must_use]
325    pub fn new(command_kind: &CommandKind, actor: &ReplayActor) -> Self {
326        let mut inner = Sha256::new();
327        hash_bytes(&mut inner, REPLAY_PAYLOAD_HASH_DOMAIN);
328        hash_u32(&mut inner, REPLAY_PAYLOAD_HASH_SCHEMA_VERSION);
329        hash_str(&mut inner, command_kind.as_str());
330        hash_replay_actor(&mut inner, actor);
331        Self { inner }
332    }
333
334    /// Add a boolean field to the replay payload hash.
335    pub fn hash_bool(&mut self, value: bool) {
336        hash_bool(&mut self.inner, value);
337    }
338
339    /// Add a `u64` field to the replay payload hash.
340    pub fn hash_u64(&mut self, value: u64) {
341        hash_u64(&mut self.inner, value);
342    }
343
344    /// Add byte string data to the replay payload hash.
345    pub fn hash_bytes(&mut self, value: &[u8]) {
346        hash_bytes(&mut self.inner, value);
347    }
348
349    /// Add UTF-8 string data to the replay payload hash.
350    pub fn hash_str(&mut self, value: &str) {
351        hash_str(&mut self.inner, value);
352    }
353
354    /// Add a principal to the replay payload hash.
355    pub fn hash_principal(&mut self, value: &Principal) {
356        hash_principal(&mut self.inner, value);
357    }
358
359    /// Add a canister role to the replay payload hash.
360    pub fn hash_role(&mut self, value: &CanisterRole) {
361        hash_str(&mut self.inner, value.as_str());
362    }
363
364    /// Finish and return the canonical replay payload hash.
365    #[must_use]
366    pub fn finish(self) -> [u8; 32] {
367        self.inner.finalize().into()
368    }
369}
370
371const fn decode_hex_nibble(byte: u8) -> Result<u8, OperationIdParseError> {
372    match byte {
373        b'0'..=b'9' => Ok(byte - b'0'),
374        b'a'..=b'f' => Ok(byte - b'a' + 10),
375        b'A'..=b'F' => Ok(byte - b'A' + 10),
376        _ => Err(OperationIdParseError::InvalidHexCharacter { byte }),
377    }
378}
379
380fn hash_replay_actor(hasher: &mut Sha256, actor: &ReplayActor) {
381    hash_principal(hasher, &actor.effective_principal);
382    hash_str(
383        hasher,
384        match actor.auth_kind {
385            AuthKind::DirectCaller => "DirectCaller",
386            AuthKind::DelegatedToken => "DelegatedToken",
387            AuthKind::RoleAttestation => "RoleAttestation",
388        },
389    );
390    // Preserve the retired actor-extension marker in the payload-hash layout.
391    hash_bool(hasher, false);
392}
393
394fn hash_bool(hasher: &mut Sha256, value: bool) {
395    hasher.update([u8::from(value)]);
396}
397
398fn hash_u32(hasher: &mut Sha256, value: u32) {
399    hasher.update(value.to_be_bytes());
400}
401
402fn hash_u64(hasher: &mut Sha256, value: u64) {
403    hasher.update(value.to_be_bytes());
404}
405
406fn hash_bytes(hasher: &mut Sha256, bytes: &[u8]) {
407    hasher.update((bytes.len() as u64).to_be_bytes());
408    hasher.update(bytes);
409}
410
411fn hash_str(hasher: &mut Sha256, value: &str) {
412    hash_bytes(hasher, value.as_bytes());
413}
414
415fn hash_principal(hasher: &mut Sha256, principal: &Principal) {
416    hash_bytes(hasher, principal.as_slice());
417}
418
419// -----------------------------------------------------------------------------
420// Tests
421// -----------------------------------------------------------------------------
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426
427    fn p(id: u8) -> Principal {
428        Principal::from_slice(&[id; 29])
429    }
430
431    #[test]
432    fn operation_id_is_exactly_32_bytes_and_hex_round_trips() {
433        let raw = [0xabu8; 32];
434        let id = OperationId::from_bytes(raw);
435        let text = id.to_string();
436
437        assert_eq!(text.len(), 64);
438        assert_eq!(text.parse::<OperationId>().expect("hex parses"), id);
439        assert_eq!(OperationId::try_from(&raw[..]).expect("bytes parse"), id);
440    }
441
442    #[test]
443    fn operation_id_rejects_wrong_widths() {
444        assert!(matches!(
445            OperationId::try_from(&[1u8; 31][..]),
446            Err(OperationIdParseError::InvalidByteLength { actual: 31 })
447        ));
448        assert!(matches!(
449            "aa".parse::<OperationId>(),
450            Err(OperationIdParseError::InvalidHexLength { actual: 2 })
451        ));
452    }
453
454    #[test]
455    fn operation_id_rejects_invalid_hex() {
456        let text = format!("{}zz", "00".repeat(31));
457
458        assert!(matches!(
459            text.parse::<OperationId>(),
460            Err(OperationIdParseError::InvalidHexCharacter { byte: b'z' })
461        ));
462    }
463
464    #[test]
465    fn command_kind_rejects_empty_and_space_values() {
466        assert_eq!(CommandKind::new(""), Err(CommandKindError::Empty));
467        assert_eq!(
468            CommandKind::new("pool create"),
469            Err(CommandKindError::InvalidCharacter)
470        );
471        assert_eq!(
472            CommandKind::new("pool.create_empty.v1")
473                .expect("kind")
474                .as_str(),
475            "pool.create_empty.v1"
476        );
477    }
478
479    #[test]
480    fn payload_hash_binds_command_kind_actor_and_payload() {
481        let command = CommandKind::new("proof.issue.v1").expect("kind");
482        let actor = ReplayActor::direct_caller(p(1));
483
484        let mut first = ReplayPayloadHasher::new(&command, &actor);
485        first.hash_str("payload");
486        let first = first.finish();
487
488        let mut changed_payload = ReplayPayloadHasher::new(&command, &actor);
489        changed_payload.hash_str("other");
490        assert_ne!(first, changed_payload.finish());
491
492        let other_command = CommandKind::new("proof.issue.v2").expect("kind");
493        let mut changed_command = ReplayPayloadHasher::new(&other_command, &actor);
494        changed_command.hash_str("payload");
495        assert_ne!(first, changed_command.finish());
496
497        let other_actor = ReplayActor::direct_caller(p(2));
498        let mut changed_actor = ReplayPayloadHasher::new(&command, &other_actor);
499        changed_actor.hash_str("payload");
500        assert_ne!(first, changed_actor.finish());
501    }
502}