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