1use 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#[derive(Clone, Copy, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
30pub struct OperationId([u8; 32]);
31
32impl OperationId {
33 #[must_use]
35 pub const fn from_bytes(bytes: [u8; 32]) -> Self {
36 Self(bytes)
37 }
38
39 #[must_use]
41 pub const fn as_bytes(&self) -> &[u8; 32] {
42 &self.0
43 }
44
45 #[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().chunks_exact(2).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#[derive(Clone, Debug, Eq, PartialEq)]
121pub enum OperationIdParseError {
122 InvalidByteLength { actual: usize },
123 InvalidHexLength { actual: usize },
124 InvalidHexCharacter { byte: u8 },
125}
126
127#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
135pub struct CommandKind(String);
136
137impl CommandKind {
138 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 #[must_use]
155 pub fn as_str(&self) -> &str {
156 &self.0
157 }
158}
159
160#[derive(Clone, Debug, Eq, PartialEq)]
168pub enum CommandKindError {
169 Empty,
170 InvalidCharacter,
171}
172
173#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
181pub enum AuthKind {
182 DirectCaller,
183 DelegatedToken,
184 RoleAttestation,
185}
186
187#[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 #[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#[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#[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#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
260pub enum ReplayReceiptStatus {
261 Reserved,
262 ExternalEffectInFlight,
263 Committed,
264 RecoveryRequired { reason: RecoveryReason },
265}
266
267#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
275pub enum RecoveryReason {
276 ExternalEffectStatusUnknown,
277 ComponentChildLifecycleInterrupted,
278 ResponseCommitFailed,
279 CostSettlementFailed,
280 StateProjectionFailed,
281 Other(String),
282}
283
284#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
292pub enum ExternalEffectDescriptor {
293 ManagementCreateCanister { command_kind: CommandKind },
294 ManagementCall { canister: Principal, method: String },
295 IcpTransfer { operation_id: OperationId },
296}
297
298#[must_use]
300pub fn placement_receipt_requires_acknowledgement(
301 status: &ReplayReceiptStatus,
302 effect: Option<&ExternalEffectDescriptor>,
303) -> bool {
304 *status == ReplayReceiptStatus::Committed
305 && matches!(
306 effect,
307 Some(ExternalEffectDescriptor::ManagementCreateCanister { command_kind })
308 if command_kind.as_str() == PLACEMENT_CHILD_REPLAY_COMMAND_KIND
309 )
310}
311
312pub struct ReplayPayloadHasher {
320 inner: Sha256,
321}
322
323impl ReplayPayloadHasher {
324 #[must_use]
326 pub fn new(command_kind: &CommandKind, actor: &ReplayActor) -> Self {
327 let mut inner = Sha256::new();
328 hash_bytes(&mut inner, REPLAY_PAYLOAD_HASH_DOMAIN);
329 hash_u32(&mut inner, REPLAY_PAYLOAD_HASH_SCHEMA_VERSION);
330 hash_str(&mut inner, command_kind.as_str());
331 hash_replay_actor(&mut inner, actor);
332 Self { inner }
333 }
334
335 pub fn hash_bool(&mut self, value: bool) {
337 hash_bool(&mut self.inner, value);
338 }
339
340 pub fn hash_u64(&mut self, value: u64) {
342 hash_u64(&mut self.inner, value);
343 }
344
345 pub fn hash_bytes(&mut self, value: &[u8]) {
347 hash_bytes(&mut self.inner, value);
348 }
349
350 pub fn hash_str(&mut self, value: &str) {
352 hash_str(&mut self.inner, value);
353 }
354
355 pub fn hash_principal(&mut self, value: &Principal) {
357 hash_principal(&mut self.inner, value);
358 }
359
360 pub fn hash_role(&mut self, value: &CanisterRole) {
362 hash_str(&mut self.inner, value.as_str());
363 }
364
365 #[must_use]
367 pub fn finish(self) -> [u8; 32] {
368 self.inner.finalize().into()
369 }
370}
371
372const fn decode_hex_nibble(byte: u8) -> Result<u8, OperationIdParseError> {
373 match byte {
374 b'0'..=b'9' => Ok(byte - b'0'),
375 b'a'..=b'f' => Ok(byte - b'a' + 10),
376 b'A'..=b'F' => Ok(byte - b'A' + 10),
377 _ => Err(OperationIdParseError::InvalidHexCharacter { byte }),
378 }
379}
380
381fn hash_replay_actor(hasher: &mut Sha256, actor: &ReplayActor) {
382 hash_principal(hasher, &actor.effective_principal);
383 hash_str(
384 hasher,
385 match actor.auth_kind {
386 AuthKind::DirectCaller => "DirectCaller",
387 AuthKind::DelegatedToken => "DelegatedToken",
388 AuthKind::RoleAttestation => "RoleAttestation",
389 },
390 );
391}
392
393fn hash_bool(hasher: &mut Sha256, value: bool) {
394 hasher.update([u8::from(value)]);
395}
396
397fn hash_u32(hasher: &mut Sha256, value: u32) {
398 hasher.update(value.to_be_bytes());
399}
400
401fn hash_u64(hasher: &mut Sha256, value: u64) {
402 hasher.update(value.to_be_bytes());
403}
404
405fn hash_bytes(hasher: &mut Sha256, bytes: &[u8]) {
406 hasher.update((bytes.len() as u64).to_be_bytes());
407 hasher.update(bytes);
408}
409
410fn hash_str(hasher: &mut Sha256, value: &str) {
411 hash_bytes(hasher, value.as_bytes());
412}
413
414fn hash_principal(hasher: &mut Sha256, principal: &Principal) {
415 hash_bytes(hasher, principal.as_slice());
416}
417
418#[cfg(test)]
423mod tests {
424 use super::*;
425
426 fn p(id: u8) -> Principal {
427 Principal::from_slice(&[id; 29])
428 }
429
430 #[test]
431 fn operation_id_is_exactly_32_bytes_and_hex_round_trips() {
432 let raw = [0xabu8; 32];
433 let id = OperationId::from_bytes(raw);
434 let text = id.to_string();
435
436 assert_eq!(text.len(), 64);
437 assert_eq!(text.parse::<OperationId>().expect("hex parses"), id);
438 assert_eq!(OperationId::try_from(&raw[..]).expect("bytes parse"), id);
439 }
440
441 #[test]
442 fn operation_id_rejects_wrong_widths() {
443 assert!(matches!(
444 OperationId::try_from(&[1u8; 31][..]),
445 Err(OperationIdParseError::InvalidByteLength { actual: 31 })
446 ));
447 assert!(matches!(
448 "aa".parse::<OperationId>(),
449 Err(OperationIdParseError::InvalidHexLength { actual: 2 })
450 ));
451 }
452
453 #[test]
454 fn operation_id_rejects_invalid_hex() {
455 let text = format!("{}zz", "00".repeat(31));
456
457 assert!(matches!(
458 text.parse::<OperationId>(),
459 Err(OperationIdParseError::InvalidHexCharacter { byte: b'z' })
460 ));
461 }
462
463 #[test]
464 fn command_kind_rejects_empty_and_space_values() {
465 assert_eq!(CommandKind::new(""), Err(CommandKindError::Empty));
466 assert_eq!(
467 CommandKind::new("pool create"),
468 Err(CommandKindError::InvalidCharacter)
469 );
470 assert_eq!(
471 CommandKind::new("proof.issue.v1").expect("kind").as_str(),
472 "proof.issue.v1"
473 );
474 }
475
476 #[test]
477 fn payload_hash_binds_command_kind_actor_and_payload() {
478 let command = CommandKind::new("proof.issue.v1").expect("kind");
479 let actor = ReplayActor::direct_caller(p(1));
480
481 let mut first = ReplayPayloadHasher::new(&command, &actor);
482 first.hash_str("payload");
483 let first = first.finish();
484
485 let mut changed_payload = ReplayPayloadHasher::new(&command, &actor);
486 changed_payload.hash_str("other");
487 assert_ne!(first, changed_payload.finish());
488
489 let other_command = CommandKind::new("proof.issue.v2").expect("kind");
490 let mut changed_command = ReplayPayloadHasher::new(&other_command, &actor);
491 changed_command.hash_str("payload");
492 assert_ne!(first, changed_command.finish());
493
494 let other_actor = ReplayActor::direct_caller(p(2));
495 let mut changed_actor = ReplayPayloadHasher::new(&command, &other_actor);
496 changed_actor.hash_str("payload");
497 assert_ne!(first, changed_actor.finish());
498 }
499}