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().as_chunks::<2>().0.iter().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    ComponentChildLifecycleInterrupted,
278    ResponseCommitFailed,
279    CostSettlementFailed,
280}
281
282///
283/// ExternalEffectDescriptor
284///
285/// Replay-visible description of an external side effect boundary.
286/// Owned by the replay model and persisted while effects are in flight.
287///
288
289#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
290pub enum ExternalEffectDescriptor {
291    RootCanisterProvision { command_kind: CommandKind },
292    ManagementCall { canister: Principal, method: String },
293    IcpTransfer { operation_id: OperationId },
294}
295
296/// Whether a committed placement-child receipt must remain until its caller acknowledges it.
297#[must_use]
298pub fn placement_receipt_requires_acknowledgement(
299    status: &ReplayReceiptStatus,
300    effect: Option<&ExternalEffectDescriptor>,
301) -> bool {
302    *status == ReplayReceiptStatus::Committed
303        && matches!(
304            effect,
305            Some(ExternalEffectDescriptor::RootCanisterProvision { command_kind })
306                if command_kind.as_str() == PLACEMENT_CHILD_REPLAY_COMMAND_KIND
307        )
308}
309
310///
311/// ReplayPayloadHasher
312///
313/// Deterministic hasher for replay command payloads.
314/// Owned by the replay model and used by workflow replay adapters.
315///
316
317pub struct ReplayPayloadHasher {
318    inner: Sha256,
319}
320
321impl ReplayPayloadHasher {
322    /// Start a deterministic replay payload hash for a command and actor.
323    #[must_use]
324    pub fn new(command_kind: &CommandKind, actor: &ReplayActor) -> Self {
325        let mut inner = Sha256::new();
326        hash_bytes(&mut inner, REPLAY_PAYLOAD_HASH_DOMAIN);
327        hash_u32(&mut inner, REPLAY_PAYLOAD_HASH_SCHEMA_VERSION);
328        hash_str(&mut inner, command_kind.as_str());
329        hash_replay_actor(&mut inner, actor);
330        Self { inner }
331    }
332
333    /// Add a boolean field to the replay payload hash.
334    pub fn hash_bool(&mut self, value: bool) {
335        hash_bool(&mut self.inner, value);
336    }
337
338    /// Add a `u64` field to the replay payload hash.
339    pub fn hash_u64(&mut self, value: u64) {
340        hash_u64(&mut self.inner, value);
341    }
342
343    /// Add byte string data to the replay payload hash.
344    pub fn hash_bytes(&mut self, value: &[u8]) {
345        hash_bytes(&mut self.inner, value);
346    }
347
348    /// Add UTF-8 string data to the replay payload hash.
349    pub fn hash_str(&mut self, value: &str) {
350        hash_str(&mut self.inner, value);
351    }
352
353    /// Add a principal to the replay payload hash.
354    pub fn hash_principal(&mut self, value: &Principal) {
355        hash_principal(&mut self.inner, value);
356    }
357
358    /// Add a canister role to the replay payload hash.
359    pub fn hash_role(&mut self, value: &CanisterRole) {
360        hash_str(&mut self.inner, value.as_str());
361    }
362
363    /// Finish and return the canonical replay payload hash.
364    #[must_use]
365    pub fn finish(self) -> [u8; 32] {
366        self.inner.finalize().into()
367    }
368}
369
370const fn decode_hex_nibble(byte: u8) -> Result<u8, OperationIdParseError> {
371    match byte {
372        b'0'..=b'9' => Ok(byte - b'0'),
373        b'a'..=b'f' => Ok(byte - b'a' + 10),
374        b'A'..=b'F' => Ok(byte - b'A' + 10),
375        _ => Err(OperationIdParseError::InvalidHexCharacter { byte }),
376    }
377}
378
379fn hash_replay_actor(hasher: &mut Sha256, actor: &ReplayActor) {
380    hash_principal(hasher, &actor.effective_principal);
381    hash_str(
382        hasher,
383        match actor.auth_kind {
384            AuthKind::DirectCaller => "DirectCaller",
385            AuthKind::DelegatedToken => "DelegatedToken",
386            AuthKind::RoleAttestation => "RoleAttestation",
387        },
388    );
389}
390
391fn hash_bool(hasher: &mut Sha256, value: bool) {
392    hasher.update([u8::from(value)]);
393}
394
395fn hash_u32(hasher: &mut Sha256, value: u32) {
396    hasher.update(value.to_be_bytes());
397}
398
399fn hash_u64(hasher: &mut Sha256, value: u64) {
400    hasher.update(value.to_be_bytes());
401}
402
403fn hash_bytes(hasher: &mut Sha256, bytes: &[u8]) {
404    hasher.update((bytes.len() as u64).to_be_bytes());
405    hasher.update(bytes);
406}
407
408fn hash_str(hasher: &mut Sha256, value: &str) {
409    hash_bytes(hasher, value.as_bytes());
410}
411
412fn hash_principal(hasher: &mut Sha256, principal: &Principal) {
413    hash_bytes(hasher, principal.as_slice());
414}
415
416// -----------------------------------------------------------------------------
417// Tests
418// -----------------------------------------------------------------------------
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423
424    fn p(id: u8) -> Principal {
425        Principal::from_slice(&[id; 29])
426    }
427
428    #[test]
429    fn operation_id_is_exactly_32_bytes_and_hex_round_trips() {
430        let raw = [0xabu8; 32];
431        let id = OperationId::from_bytes(raw);
432        let text = id.to_string();
433
434        assert_eq!(text.len(), 64);
435        assert_eq!(text.parse::<OperationId>().expect("hex parses"), id);
436        assert_eq!(OperationId::try_from(&raw[..]).expect("bytes parse"), id);
437    }
438
439    #[test]
440    fn operation_id_rejects_wrong_widths() {
441        assert!(matches!(
442            OperationId::try_from(&[1u8; 31][..]),
443            Err(OperationIdParseError::InvalidByteLength { actual: 31 })
444        ));
445        assert!(matches!(
446            "aa".parse::<OperationId>(),
447            Err(OperationIdParseError::InvalidHexLength { actual: 2 })
448        ));
449    }
450
451    #[test]
452    fn operation_id_rejects_invalid_hex() {
453        let text = format!("{}zz", "00".repeat(31));
454
455        assert!(matches!(
456            text.parse::<OperationId>(),
457            Err(OperationIdParseError::InvalidHexCharacter { byte: b'z' })
458        ));
459    }
460
461    #[test]
462    fn command_kind_rejects_empty_and_space_values() {
463        assert_eq!(CommandKind::new(""), Err(CommandKindError::Empty));
464        assert_eq!(
465            CommandKind::new("pool create"),
466            Err(CommandKindError::InvalidCharacter)
467        );
468        assert_eq!(
469            CommandKind::new("proof.issue.v1").expect("kind").as_str(),
470            "proof.issue.v1"
471        );
472    }
473
474    #[test]
475    fn payload_hash_binds_command_kind_actor_and_payload() {
476        let command = CommandKind::new("proof.issue.v1").expect("kind");
477        let actor = ReplayActor::direct_caller(p(1));
478
479        let mut first = ReplayPayloadHasher::new(&command, &actor);
480        first.hash_str("payload");
481        let first = first.finish();
482
483        let mut changed_payload = ReplayPayloadHasher::new(&command, &actor);
484        changed_payload.hash_str("other");
485        assert_ne!(first, changed_payload.finish());
486
487        let other_command = CommandKind::new("proof.issue.v2").expect("kind");
488        let mut changed_command = ReplayPayloadHasher::new(&other_command, &actor);
489        changed_command.hash_str("payload");
490        assert_ne!(first, changed_command.finish());
491
492        let other_actor = ReplayActor::direct_caller(p(2));
493        let mut changed_actor = ReplayPayloadHasher::new(&command, &other_actor);
494        changed_actor.hash_str("payload");
495        assert_ne!(first, changed_actor.finish());
496    }
497}