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