1#![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#[derive(Clone, Copy, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
28pub struct OperationId([u8; 32]);
29
30impl OperationId {
31 #[must_use]
33 pub const fn from_bytes(bytes: [u8; 32]) -> Self {
34 Self(bytes)
35 }
36
37 #[must_use]
39 pub const fn as_bytes(&self) -> &[u8; 32] {
40 &self.0
41 }
42
43 #[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#[derive(Clone, Debug, Eq, PartialEq)]
119pub enum OperationIdParseError {
120 InvalidByteLength { actual: usize },
121 InvalidHexLength { actual: usize },
122 InvalidHexCharacter { byte: u8 },
123}
124
125#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
133pub struct CommandKind(String);
134
135impl CommandKind {
136 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 #[must_use]
153 pub fn as_str(&self) -> &str {
154 &self.0
155 }
156}
157
158#[derive(Clone, Debug, Eq, PartialEq)]
166pub enum CommandKindError {
167 Empty,
168 InvalidCharacter,
169}
170
171#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
179pub enum AuthKind {
180 DirectCaller,
181 DelegatedToken,
182 RoleAttestation,
183}
184
185#[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 #[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#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
217pub struct ReplayReceiptKey {
218 pub command_kind: CommandKind,
219 pub operation_id: OperationId,
220}
221
222#[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#[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#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
276pub enum ReplayTerminalErrorCode {
277 ValidationRejected,
278 ExecutionFailed,
279 ResponseEncodeFailed,
280 Other(String),
281}
282
283#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
291pub enum RecoveryReason {
292 ExternalEffectStatusUnknown,
293 ResponseCommitFailed,
294 Other(String),
295}
296
297#[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#[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
330pub struct ReplayPayloadHasher {
338 inner: Sha256,
339}
340
341impl ReplayPayloadHasher {
342 #[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 pub fn hash_bool(&mut self, value: bool) {
355 hash_bool(&mut self.inner, value);
356 }
357
358 pub fn hash_u64(&mut self, value: u64) {
360 hash_u64(&mut self.inner, value);
361 }
362
363 pub fn hash_u128(&mut self, value: u128) {
365 hash_u128(&mut self.inner, value);
366 }
367
368 pub fn hash_bytes(&mut self, value: &[u8]) {
370 hash_bytes(&mut self.inner, value);
371 }
372
373 pub fn hash_str(&mut self, value: &str) {
375 hash_str(&mut self.inner, value);
376 }
377
378 pub fn hash_principal(&mut self, value: &Principal) {
380 hash_principal(&mut self.inner, value);
381 }
382
383 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 pub fn hash_role(&mut self, value: &CanisterRole) {
393 hash_str(&mut self.inner, value.as_str());
394 }
395
396 #[must_use]
398 pub fn finish(self) -> [u8; 32] {
399 self.inner.finalize().into()
400 }
401}
402
403#[derive(Clone, Debug, Eq, PartialEq)]
411pub struct BoundedTerminalError {
412 pub bytes: Vec<u8>,
413 pub truncated: bool,
414}
415
416#[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 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#[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}