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.
6#![expect(dead_code)]
7
8use crate::{cdk::types::Principal, ids::CanisterRole};
9use std::{fmt, str::FromStr};
10
11use serde::{Deserialize, Serialize};
12use sha2::{Digest, Sha256};
13
14pub const REPLAY_RECEIPT_SCHEMA_VERSION: u32 = 1;
15pub const REPLAY_PAYLOAD_HASH_SCHEMA_VERSION: u32 = 1;
16pub const MAX_REPLAY_TERMINAL_ERROR_BYTES: usize = 4096;
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/// ReplayReceiptKey
211///
212/// Logical replay receipt key before storage-specific hashing.
213/// Owned by the replay model and used by replay storage adapters.
214///
215
216#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
217pub struct ReplayReceiptKey {
218    pub command_kind: CommandKind,
219    pub operation_id: OperationId,
220}
221
222///
223/// ReplayReceipt
224///
225/// Canonical replay receipt state independent of stable-memory encoding.
226/// Owned by the replay model and persisted through storage record adapters.
227///
228
229#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
230pub struct ReplayReceipt {
231    pub schema_version: u32,
232    pub command_kind: CommandKind,
233    pub operation_id: OperationId,
234    pub actor: ReplayActor,
235    pub payload_hash_schema_version: u32,
236    pub payload_hash: [u8; 32],
237    pub status: ReplayReceiptStatus,
238    pub created_at_ns: u64,
239    pub updated_at_ns: u64,
240    pub expires_at_ns: Option<u64>,
241    pub response_schema_version: Option<u32>,
242    pub response_bytes: Option<Vec<u8>>,
243    pub effect: Option<ExternalEffectDescriptor>,
244}
245
246///
247/// ReplayReceiptStatus
248///
249/// Lifecycle state for a replay receipt.
250/// Owned by the replay model and interpreted by replay guards.
251///
252
253#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
254pub enum ReplayReceiptStatus {
255    Reserved,
256    ExternalEffectInFlight,
257    Committed,
258    TerminalFailed {
259        error_code: ReplayTerminalErrorCode,
260        error_bytes: Vec<u8>,
261        error_bytes_truncated: bool,
262    },
263    RecoveryRequired {
264        reason: RecoveryReason,
265    },
266}
267
268///
269/// ReplayTerminalErrorCode
270///
271/// Stable terminal replay failure classification.
272/// Owned by the replay model and stored with bounded error bytes.
273///
274
275#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
276pub enum ReplayTerminalErrorCode {
277    ValidationRejected,
278    ExecutionFailed,
279    ResponseEncodeFailed,
280    Other(String),
281}
282
283///
284/// RecoveryReason
285///
286/// Stable replay recovery reason after uncertain external effects.
287/// Owned by the replay model and exposed through replay decisions.
288///
289
290#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
291pub enum RecoveryReason {
292    ExternalEffectStatusUnknown,
293    ResponseCommitFailed,
294    Other(String),
295}
296
297///
298/// ExternalEffectDescriptor
299///
300/// Replay-visible description of an external side effect boundary.
301/// Owned by the replay model and persisted while effects are in flight.
302///
303
304#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
305pub enum ExternalEffectDescriptor {
306    ManagementCreateCanister { command_kind: CommandKind },
307    ManagementCall { canister: Principal, method: String },
308    IcpTransfer { operation_id: OperationId },
309}
310
311///
312/// ReplayError
313///
314/// Shared replay-domain error classification.
315/// Owned by the replay model and used by higher-level replay workflows.
316///
317
318#[derive(Clone, Debug, Eq, PartialEq)]
319pub enum ReplayError {
320    OperationIdRequired,
321    OperationAlreadyCommittedPayloadMismatch,
322    OperationAlreadyCommittedActorMismatch,
323    OperationInProgress,
324    OperationRecoveryRequired,
325    OperationIdInvalid,
326    ReceiptDecodeFailed,
327    ReceiptSchemaUnsupported,
328}
329
330///
331/// ReplayPayloadHasher
332///
333/// Deterministic hasher for replay command payloads.
334/// Owned by the replay model and used by workflow replay adapters.
335///
336
337pub struct ReplayPayloadHasher {
338    inner: Sha256,
339}
340
341impl ReplayPayloadHasher {
342    /// Start a deterministic replay payload hash for a command and actor.
343    #[must_use]
344    pub fn new(command_kind: &CommandKind, actor: &ReplayActor) -> Self {
345        let mut inner = Sha256::new();
346        hash_bytes(&mut inner, REPLAY_PAYLOAD_HASH_DOMAIN);
347        hash_u32(&mut inner, REPLAY_PAYLOAD_HASH_SCHEMA_VERSION);
348        hash_str(&mut inner, command_kind.as_str());
349        hash_replay_actor(&mut inner, actor);
350        Self { inner }
351    }
352
353    /// Add a boolean field to the replay payload hash.
354    pub fn hash_bool(&mut self, value: bool) {
355        hash_bool(&mut self.inner, value);
356    }
357
358    /// Add a `u64` field to the replay payload hash.
359    pub fn hash_u64(&mut self, value: u64) {
360        hash_u64(&mut self.inner, value);
361    }
362
363    /// Add a `u128` field to the replay payload hash.
364    pub fn hash_u128(&mut self, value: u128) {
365        hash_u128(&mut self.inner, value);
366    }
367
368    /// Add byte string data to the replay payload hash.
369    pub fn hash_bytes(&mut self, value: &[u8]) {
370        hash_bytes(&mut self.inner, value);
371    }
372
373    /// Add UTF-8 string data to the replay payload hash.
374    pub fn hash_str(&mut self, value: &str) {
375        hash_str(&mut self.inner, value);
376    }
377
378    /// Add a principal to the replay payload hash.
379    pub fn hash_principal(&mut self, value: &Principal) {
380        hash_principal(&mut self.inner, value);
381    }
382
383    /// Add an optional principal to the replay payload hash.
384    pub fn hash_optional_principal(&mut self, value: Option<Principal>) {
385        hash_bool(&mut self.inner, value.is_some());
386        if let Some(value) = value {
387            hash_principal(&mut self.inner, &value);
388        }
389    }
390
391    /// Add a canister role to the replay payload hash.
392    pub fn hash_role(&mut self, value: &CanisterRole) {
393        hash_str(&mut self.inner, value.as_str());
394    }
395
396    /// Finish and return the canonical replay payload hash.
397    #[must_use]
398    pub fn finish(self) -> [u8; 32] {
399        self.inner.finalize().into()
400    }
401}
402
403///
404/// BoundedTerminalError
405///
406/// Bounded terminal replay error bytes plus truncation metadata.
407/// Owned by the replay model and produced before receipt persistence.
408///
409
410#[derive(Clone, Debug, Eq, PartialEq)]
411pub struct BoundedTerminalError {
412    pub bytes: Vec<u8>,
413    pub truncated: bool,
414}
415
416/// Bound terminal replay error bytes to the stable receipt limit.
417#[must_use]
418pub fn bounded_terminal_error_bytes(bytes: &[u8]) -> BoundedTerminalError {
419    if bytes.len() <= MAX_REPLAY_TERMINAL_ERROR_BYTES {
420        return BoundedTerminalError {
421            bytes: bytes.to_vec(),
422            truncated: false,
423        };
424    }
425
426    BoundedTerminalError {
427        bytes: bytes[..MAX_REPLAY_TERMINAL_ERROR_BYTES].to_vec(),
428        truncated: true,
429    }
430}
431
432const fn decode_hex_nibble(byte: u8) -> Result<u8, OperationIdParseError> {
433    match byte {
434        b'0'..=b'9' => Ok(byte - b'0'),
435        b'a'..=b'f' => Ok(byte - b'a' + 10),
436        b'A'..=b'F' => Ok(byte - b'A' + 10),
437        _ => Err(OperationIdParseError::InvalidHexCharacter { byte }),
438    }
439}
440
441fn hash_replay_actor(hasher: &mut Sha256, actor: &ReplayActor) {
442    hash_principal(hasher, &actor.effective_principal);
443    hash_str(
444        hasher,
445        match actor.auth_kind {
446            AuthKind::DirectCaller => "DirectCaller",
447            AuthKind::DelegatedToken => "DelegatedToken",
448            AuthKind::RoleAttestation => "RoleAttestation",
449        },
450    );
451    // Preserve the retired actor-extension marker in the payload-hash layout.
452    hash_bool(hasher, false);
453}
454
455fn hash_bool(hasher: &mut Sha256, value: bool) {
456    hasher.update([u8::from(value)]);
457}
458
459fn hash_u32(hasher: &mut Sha256, value: u32) {
460    hasher.update(value.to_be_bytes());
461}
462
463fn hash_u64(hasher: &mut Sha256, value: u64) {
464    hasher.update(value.to_be_bytes());
465}
466
467fn hash_u128(hasher: &mut Sha256, value: u128) {
468    hasher.update(value.to_be_bytes());
469}
470
471fn hash_bytes(hasher: &mut Sha256, bytes: &[u8]) {
472    hasher.update((bytes.len() as u64).to_be_bytes());
473    hasher.update(bytes);
474}
475
476fn hash_str(hasher: &mut Sha256, value: &str) {
477    hash_bytes(hasher, value.as_bytes());
478}
479
480fn hash_principal(hasher: &mut Sha256, principal: &Principal) {
481    hash_bytes(hasher, principal.as_slice());
482}
483
484// -----------------------------------------------------------------------------
485// Tests
486// -----------------------------------------------------------------------------
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491
492    fn p(id: u8) -> Principal {
493        Principal::from_slice(&[id; 29])
494    }
495
496    #[test]
497    fn operation_id_is_exactly_32_bytes_and_hex_round_trips() {
498        let raw = [0xabu8; 32];
499        let id = OperationId::from_bytes(raw);
500        let text = id.to_string();
501
502        assert_eq!(text.len(), 64);
503        assert_eq!(text.parse::<OperationId>().expect("hex parses"), id);
504        assert_eq!(OperationId::try_from(&raw[..]).expect("bytes parse"), id);
505    }
506
507    #[test]
508    fn operation_id_rejects_wrong_widths() {
509        assert!(matches!(
510            OperationId::try_from(&[1u8; 31][..]),
511            Err(OperationIdParseError::InvalidByteLength { actual: 31 })
512        ));
513        assert!(matches!(
514            "aa".parse::<OperationId>(),
515            Err(OperationIdParseError::InvalidHexLength { actual: 2 })
516        ));
517    }
518
519    #[test]
520    fn operation_id_rejects_invalid_hex() {
521        let text = format!("{}zz", "00".repeat(31));
522
523        assert!(matches!(
524            text.parse::<OperationId>(),
525            Err(OperationIdParseError::InvalidHexCharacter { byte: b'z' })
526        ));
527    }
528
529    #[test]
530    fn command_kind_rejects_empty_and_space_values() {
531        assert_eq!(CommandKind::new(""), Err(CommandKindError::Empty));
532        assert_eq!(
533            CommandKind::new("pool create"),
534            Err(CommandKindError::InvalidCharacter)
535        );
536        assert_eq!(
537            CommandKind::new("pool.create_empty.v1")
538                .expect("kind")
539                .as_str(),
540            "pool.create_empty.v1"
541        );
542    }
543
544    #[test]
545    fn payload_hash_binds_command_kind_actor_and_payload() {
546        let command = CommandKind::new("proof.issue.v1").expect("kind");
547        let actor = ReplayActor::direct_caller(p(1));
548
549        let mut first = ReplayPayloadHasher::new(&command, &actor);
550        first.hash_str("payload");
551        let first = first.finish();
552
553        let mut changed_payload = ReplayPayloadHasher::new(&command, &actor);
554        changed_payload.hash_str("other");
555        assert_ne!(first, changed_payload.finish());
556
557        let other_command = CommandKind::new("proof.issue.v2").expect("kind");
558        let mut changed_command = ReplayPayloadHasher::new(&other_command, &actor);
559        changed_command.hash_str("payload");
560        assert_ne!(first, changed_command.finish());
561
562        let other_actor = ReplayActor::direct_caller(p(2));
563        let mut changed_actor = ReplayPayloadHasher::new(&command, &other_actor);
564        changed_actor.hash_str("payload");
565        assert_ne!(first, changed_actor.finish());
566    }
567
568    #[test]
569    fn bounded_terminal_error_bytes_caps_large_payloads() {
570        let small = bounded_terminal_error_bytes(b"error");
571        assert_eq!(small.bytes, b"error");
572        assert!(!small.truncated);
573
574        let large = vec![7u8; MAX_REPLAY_TERMINAL_ERROR_BYTES + 12];
575        let bounded = bounded_terminal_error_bytes(&large);
576        assert_eq!(bounded.bytes.len(), MAX_REPLAY_TERMINAL_ERROR_BYTES);
577        assert!(bounded.truncated);
578    }
579}