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().as_chunks::<2>().0.iter().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}
281
282#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
290pub enum ExternalEffectDescriptor {
291 RootCanisterProvision { command_kind: CommandKind },
292 ManagementCall { canister: Principal, method: String },
293 IcpTransfer { operation_id: OperationId },
294}
295
296#[must_use]
298pub fn placement_receipt_requires_acknowledgement(
299 status: &ReplayReceiptStatus,
300 effect: Option<&ExternalEffectDescriptor>,
301) -> bool {
302 *status == ReplayReceiptStatus::Committed
303 && matches!(
304 effect,
305 Some(ExternalEffectDescriptor::RootCanisterProvision { command_kind })
306 if command_kind.as_str() == PLACEMENT_CHILD_REPLAY_COMMAND_KIND
307 )
308}
309
310pub struct ReplayPayloadHasher {
318 inner: Sha256,
319}
320
321impl ReplayPayloadHasher {
322 #[must_use]
324 pub fn new(command_kind: &CommandKind, actor: &ReplayActor) -> Self {
325 let mut inner = Sha256::new();
326 hash_bytes(&mut inner, REPLAY_PAYLOAD_HASH_DOMAIN);
327 hash_u32(&mut inner, REPLAY_PAYLOAD_HASH_SCHEMA_VERSION);
328 hash_str(&mut inner, command_kind.as_str());
329 hash_replay_actor(&mut inner, actor);
330 Self { inner }
331 }
332
333 pub fn hash_bool(&mut self, value: bool) {
335 hash_bool(&mut self.inner, value);
336 }
337
338 pub fn hash_u64(&mut self, value: u64) {
340 hash_u64(&mut self.inner, value);
341 }
342
343 pub fn hash_bytes(&mut self, value: &[u8]) {
345 hash_bytes(&mut self.inner, value);
346 }
347
348 pub fn hash_str(&mut self, value: &str) {
350 hash_str(&mut self.inner, value);
351 }
352
353 pub fn hash_principal(&mut self, value: &Principal) {
355 hash_principal(&mut self.inner, value);
356 }
357
358 pub fn hash_role(&mut self, value: &CanisterRole) {
360 hash_str(&mut self.inner, value.as_str());
361 }
362
363 #[must_use]
365 pub fn finish(self) -> [u8; 32] {
366 self.inner.finalize().into()
367 }
368}
369
370const fn decode_hex_nibble(byte: u8) -> Result<u8, OperationIdParseError> {
371 match byte {
372 b'0'..=b'9' => Ok(byte - b'0'),
373 b'a'..=b'f' => Ok(byte - b'a' + 10),
374 b'A'..=b'F' => Ok(byte - b'A' + 10),
375 _ => Err(OperationIdParseError::InvalidHexCharacter { byte }),
376 }
377}
378
379fn hash_replay_actor(hasher: &mut Sha256, actor: &ReplayActor) {
380 hash_principal(hasher, &actor.effective_principal);
381 hash_str(
382 hasher,
383 match actor.auth_kind {
384 AuthKind::DirectCaller => "DirectCaller",
385 AuthKind::DelegatedToken => "DelegatedToken",
386 AuthKind::RoleAttestation => "RoleAttestation",
387 },
388 );
389}
390
391fn hash_bool(hasher: &mut Sha256, value: bool) {
392 hasher.update([u8::from(value)]);
393}
394
395fn hash_u32(hasher: &mut Sha256, value: u32) {
396 hasher.update(value.to_be_bytes());
397}
398
399fn hash_u64(hasher: &mut Sha256, value: u64) {
400 hasher.update(value.to_be_bytes());
401}
402
403fn hash_bytes(hasher: &mut Sha256, bytes: &[u8]) {
404 hasher.update((bytes.len() as u64).to_be_bytes());
405 hasher.update(bytes);
406}
407
408fn hash_str(hasher: &mut Sha256, value: &str) {
409 hash_bytes(hasher, value.as_bytes());
410}
411
412fn hash_principal(hasher: &mut Sha256, principal: &Principal) {
413 hash_bytes(hasher, principal.as_slice());
414}
415
416#[cfg(test)]
421mod tests {
422 use super::*;
423
424 fn p(id: u8) -> Principal {
425 Principal::from_slice(&[id; 29])
426 }
427
428 #[test]
429 fn operation_id_is_exactly_32_bytes_and_hex_round_trips() {
430 let raw = [0xabu8; 32];
431 let id = OperationId::from_bytes(raw);
432 let text = id.to_string();
433
434 assert_eq!(text.len(), 64);
435 assert_eq!(text.parse::<OperationId>().expect("hex parses"), id);
436 assert_eq!(OperationId::try_from(&raw[..]).expect("bytes parse"), id);
437 }
438
439 #[test]
440 fn operation_id_rejects_wrong_widths() {
441 assert!(matches!(
442 OperationId::try_from(&[1u8; 31][..]),
443 Err(OperationIdParseError::InvalidByteLength { actual: 31 })
444 ));
445 assert!(matches!(
446 "aa".parse::<OperationId>(),
447 Err(OperationIdParseError::InvalidHexLength { actual: 2 })
448 ));
449 }
450
451 #[test]
452 fn operation_id_rejects_invalid_hex() {
453 let text = format!("{}zz", "00".repeat(31));
454
455 assert!(matches!(
456 text.parse::<OperationId>(),
457 Err(OperationIdParseError::InvalidHexCharacter { byte: b'z' })
458 ));
459 }
460
461 #[test]
462 fn command_kind_rejects_empty_and_space_values() {
463 assert_eq!(CommandKind::new(""), Err(CommandKindError::Empty));
464 assert_eq!(
465 CommandKind::new("pool create"),
466 Err(CommandKindError::InvalidCharacter)
467 );
468 assert_eq!(
469 CommandKind::new("proof.issue.v1").expect("kind").as_str(),
470 "proof.issue.v1"
471 );
472 }
473
474 #[test]
475 fn payload_hash_binds_command_kind_actor_and_payload() {
476 let command = CommandKind::new("proof.issue.v1").expect("kind");
477 let actor = ReplayActor::direct_caller(p(1));
478
479 let mut first = ReplayPayloadHasher::new(&command, &actor);
480 first.hash_str("payload");
481 let first = first.finish();
482
483 let mut changed_payload = ReplayPayloadHasher::new(&command, &actor);
484 changed_payload.hash_str("other");
485 assert_ne!(first, changed_payload.finish());
486
487 let other_command = CommandKind::new("proof.issue.v2").expect("kind");
488 let mut changed_command = ReplayPayloadHasher::new(&other_command, &actor);
489 changed_command.hash_str("payload");
490 assert_ne!(first, changed_command.finish());
491
492 let other_actor = ReplayActor::direct_caller(p(2));
493 let mut changed_actor = ReplayPayloadHasher::new(&command, &other_actor);
494 changed_actor.hash_str("payload");
495 assert_ne!(first, changed_actor.finish());
496 }
497}