1#[derive(Clone, Copy, Debug, Eq, PartialEq)]
12pub enum EndpointKind {
13 Query,
14 Update,
15}
16
17#[derive(Clone, Copy, Debug, Eq, PartialEq)]
21pub enum ReplayPolicy {
22 QueryOrReadOnly,
23 ResponseIdempotent {
24 command_kind: &'static str,
25 },
26 ReplayProtected {
27 command_kind: &'static str,
28 requires_operation_id: bool,
29 },
30 MonotonicTransition {
31 command_kind: &'static str,
32 },
33 SnapshotConvergent {
34 command_kind: &'static str,
35 },
36 IntentionallyNonIdempotent {
37 command_kind: &'static str,
38 reason: &'static str,
39 },
40}
41
42#[derive(Clone, Copy, Debug, Eq, PartialEq)]
46pub enum CostClass {
47 None,
48 ThresholdEcdsaSign,
49 ManagementDeployment,
50 ValueTransfer,
51 DurablePublish,
52}
53
54#[derive(Clone, Copy, Debug, Eq, PartialEq)]
58pub enum ReplayImplementationStatus {
59 Implemented,
60 ReleaseBlocker,
61}
62
63#[derive(Clone, Copy, Debug, Eq, PartialEq)]
67pub struct EndpointReplayPolicy {
68 pub endpoint: &'static str,
69 pub endpoint_kind: EndpointKind,
70 pub replay_policy: ReplayPolicy,
71 pub implementation_status: ReplayImplementationStatus,
72 pub cost_class: CostClass,
73 pub quota_policy: Option<&'static str>,
74 pub cycle_reserve_policy: Option<&'static str>,
75}
76
77#[derive(Clone, Copy, Debug, Eq, PartialEq)]
81pub struct PoolAdminCommandReplayPolicy {
82 pub variant: &'static str,
83 pub replay_policy: ReplayPolicy,
84 pub implementation_status: ReplayImplementationStatus,
85 pub cost_class: CostClass,
86 pub quota_policy: Option<&'static str>,
87 pub cycle_reserve_policy: Option<&'static str>,
88}
89
90const SIGNING_QUOTA_V1: &str = "signing.quota.v1";
91const SIGNING_RESERVE_V1: &str = "signing.cycle_reserve.v1";
92const DEPLOYMENT_QUOTA_V1: &str = "deployment.quota.v1";
93const DEPLOYMENT_RESERVE_V1: &str = "deployment.cycle_reserve.v1";
94const VALUE_TRANSFER_QUOTA_V1: &str = "value_transfer.quota.v1";
95const VALUE_TRANSFER_RESERVE_V1: &str = "value_transfer.cycle_reserve.v1";
96const DURABLE_PUBLISH_QUOTA_V1: &str = "durable_publish.quota.v1";
97const DURABLE_PUBLISH_RESERVE_V1: &str = "durable_publish.cycle_reserve.v1";
98
99pub const ENDPOINT_REPLAY_POLICY_MANIFEST: &[EndpointReplayPolicy] = &[
100 update_response_idempotent("canic_app", "app.command.v1"),
101 update_snapshot_convergent("canic_attestation_key_set", "auth.attestation_key_set.v1"),
102 update_read_only("canic_canister_status"),
103 update_replay_blocker(
104 "canic_canister_upgrade",
105 "management.canister_upgrade.v1",
106 CostClass::ManagementDeployment,
107 Some(DEPLOYMENT_QUOTA_V1),
108 Some(DEPLOYMENT_RESERVE_V1),
109 ),
110 update_replay_blocker(
111 "canic_icp_refill",
112 "icp.refill.v1",
113 CostClass::ValueTransfer,
114 Some(VALUE_TRANSFER_QUOTA_V1),
115 Some(VALUE_TRANSFER_RESERVE_V1),
116 ),
117 update_replay_blocker(
118 "canic_pool_admin",
119 "pool.admin.v1",
120 CostClass::ManagementDeployment,
121 Some(DEPLOYMENT_QUOTA_V1),
122 Some(DEPLOYMENT_RESERVE_V1),
123 ),
124 update_replay_protected(
125 "canic_request_delegation",
126 "auth.issue_delegation_proof.v1",
127 ReplayImplementationStatus::Implemented,
128 CostClass::ThresholdEcdsaSign,
129 Some(SIGNING_QUOTA_V1),
130 Some(SIGNING_RESERVE_V1),
131 ),
132 update_replay_protected(
133 "canic_request_internal_invocation_proof",
134 "auth.issue_internal_invocation_proof.v1",
135 ReplayImplementationStatus::Implemented,
136 CostClass::ThresholdEcdsaSign,
137 Some(SIGNING_QUOTA_V1),
138 Some(SIGNING_RESERVE_V1),
139 ),
140 update_replay_protected(
141 "canic_request_role_attestation",
142 "auth.issue_role_attestation.v1",
143 ReplayImplementationStatus::Implemented,
144 CostClass::ThresholdEcdsaSign,
145 Some(SIGNING_QUOTA_V1),
146 Some(SIGNING_RESERVE_V1),
147 ),
148 update_replay_blocker(
149 "canic_response_capability_v1",
150 "root.capability_rpc.v1",
151 CostClass::ManagementDeployment,
152 Some(DEPLOYMENT_QUOTA_V1),
153 Some(DEPLOYMENT_RESERVE_V1),
154 ),
155 update_snapshot_convergent("canic_sync_state", "cascade.sync_state.v1"),
156 update_snapshot_convergent("canic_sync_topology", "cascade.sync_topology.v1"),
157 update_monotonic_publish(
158 "canic_template_prepare_admin",
159 "wasm_store.template_prepare_admin.v1",
160 ),
161 update_monotonic_publish(
162 "canic_template_publish_chunk_admin",
163 "wasm_store.template_publish_chunk_admin.v1",
164 ),
165 update_monotonic_publish(
166 "canic_template_stage_manifest_admin",
167 "wasm_store.template_stage_manifest_admin.v1",
168 ),
169 update_response_idempotent(
170 "canic_wasm_store_bootstrap_resume_root_admin",
171 "wasm_store.bootstrap_resume.ensure_v1",
172 ),
173 update_monotonic_publish("canic_wasm_store_admin", "wasm_store.admin.v1"),
174 update_monotonic_publish("canic_wasm_store_begin_gc", "wasm_store.begin_gc.v1"),
175 update_monotonic_publish("canic_wasm_store_chunk", "wasm_store.chunk.v1"),
176 update_monotonic_publish("canic_wasm_store_complete_gc", "wasm_store.complete_gc.v1"),
177 update_monotonic_publish("canic_wasm_store_info", "wasm_store.info.v1"),
178 update_monotonic_publish("canic_wasm_store_prepare", "wasm_store.prepare.v1"),
179 update_monotonic_publish("canic_wasm_store_prepare_gc", "wasm_store.prepare_gc.v1"),
180 update_monotonic_publish(
181 "canic_wasm_store_publish_chunk",
182 "wasm_store.publish_chunk.v1",
183 ),
184 update_monotonic_publish(
185 "canic_wasm_store_stage_manifest",
186 "wasm_store.stage_manifest.v1",
187 ),
188];
189
190pub const POOL_ADMIN_COMMAND_REPLAY_POLICY_MANIFEST: &[PoolAdminCommandReplayPolicy] = &[
191 pool_admin_replay_protected(
192 "CreateEmpty",
193 "pool.create_empty.v1",
194 ReplayImplementationStatus::Implemented,
195 CostClass::ManagementDeployment,
196 Some(DEPLOYMENT_QUOTA_V1),
197 Some(DEPLOYMENT_RESERVE_V1),
198 ),
199 pool_admin_response_idempotent(
200 "Recycle",
201 "pool.recycle.ensure_v1",
202 ReplayImplementationStatus::ReleaseBlocker,
203 CostClass::ManagementDeployment,
204 Some(DEPLOYMENT_QUOTA_V1),
205 Some(DEPLOYMENT_RESERVE_V1),
206 ),
207 pool_admin_response_idempotent(
208 "ImportImmediate",
209 "pool.import_immediate.ensure_v1",
210 ReplayImplementationStatus::ReleaseBlocker,
211 CostClass::ManagementDeployment,
212 Some(DEPLOYMENT_QUOTA_V1),
213 Some(DEPLOYMENT_RESERVE_V1),
214 ),
215 pool_admin_snapshot_convergent(
216 "ImportQueued",
217 "pool.import_queued.ensure_v1",
218 ReplayImplementationStatus::Implemented,
219 CostClass::None,
220 None,
221 None,
222 ),
223];
224
225#[must_use]
226pub const fn endpoint_replay_policy_manifest() -> &'static [EndpointReplayPolicy] {
227 ENDPOINT_REPLAY_POLICY_MANIFEST
228}
229
230#[must_use]
231pub const fn pool_admin_command_replay_policy_manifest() -> &'static [PoolAdminCommandReplayPolicy]
232{
233 POOL_ADMIN_COMMAND_REPLAY_POLICY_MANIFEST
234}
235
236const fn update_response_idempotent(
237 endpoint: &'static str,
238 command_kind: &'static str,
239) -> EndpointReplayPolicy {
240 EndpointReplayPolicy {
241 endpoint,
242 endpoint_kind: EndpointKind::Update,
243 replay_policy: ReplayPolicy::ResponseIdempotent { command_kind },
244 implementation_status: ReplayImplementationStatus::Implemented,
245 cost_class: CostClass::None,
246 quota_policy: None,
247 cycle_reserve_policy: None,
248 }
249}
250
251const fn update_read_only(endpoint: &'static str) -> EndpointReplayPolicy {
252 EndpointReplayPolicy {
253 endpoint,
254 endpoint_kind: EndpointKind::Update,
255 replay_policy: ReplayPolicy::QueryOrReadOnly,
256 implementation_status: ReplayImplementationStatus::Implemented,
257 cost_class: CostClass::None,
258 quota_policy: None,
259 cycle_reserve_policy: None,
260 }
261}
262
263const fn update_replay_blocker(
264 endpoint: &'static str,
265 command_kind: &'static str,
266 cost_class: CostClass,
267 quota_policy: Option<&'static str>,
268 cycle_reserve_policy: Option<&'static str>,
269) -> EndpointReplayPolicy {
270 update_replay_protected(
271 endpoint,
272 command_kind,
273 ReplayImplementationStatus::ReleaseBlocker,
274 cost_class,
275 quota_policy,
276 cycle_reserve_policy,
277 )
278}
279
280const fn update_replay_protected(
281 endpoint: &'static str,
282 command_kind: &'static str,
283 implementation_status: ReplayImplementationStatus,
284 cost_class: CostClass,
285 quota_policy: Option<&'static str>,
286 cycle_reserve_policy: Option<&'static str>,
287) -> EndpointReplayPolicy {
288 EndpointReplayPolicy {
289 endpoint,
290 endpoint_kind: EndpointKind::Update,
291 replay_policy: ReplayPolicy::ReplayProtected {
292 command_kind,
293 requires_operation_id: true,
294 },
295 implementation_status,
296 cost_class,
297 quota_policy,
298 cycle_reserve_policy,
299 }
300}
301
302const fn update_monotonic_publish(
303 endpoint: &'static str,
304 command_kind: &'static str,
305) -> EndpointReplayPolicy {
306 EndpointReplayPolicy {
307 endpoint,
308 endpoint_kind: EndpointKind::Update,
309 replay_policy: ReplayPolicy::MonotonicTransition { command_kind },
310 implementation_status: ReplayImplementationStatus::Implemented,
311 cost_class: CostClass::DurablePublish,
312 quota_policy: Some(DURABLE_PUBLISH_QUOTA_V1),
313 cycle_reserve_policy: Some(DURABLE_PUBLISH_RESERVE_V1),
314 }
315}
316
317const fn update_snapshot_convergent(
318 endpoint: &'static str,
319 command_kind: &'static str,
320) -> EndpointReplayPolicy {
321 EndpointReplayPolicy {
322 endpoint,
323 endpoint_kind: EndpointKind::Update,
324 replay_policy: ReplayPolicy::SnapshotConvergent { command_kind },
325 implementation_status: ReplayImplementationStatus::Implemented,
326 cost_class: CostClass::None,
327 quota_policy: None,
328 cycle_reserve_policy: None,
329 }
330}
331
332const fn pool_admin_response_idempotent(
333 variant: &'static str,
334 command_kind: &'static str,
335 implementation_status: ReplayImplementationStatus,
336 cost_class: CostClass,
337 quota_policy: Option<&'static str>,
338 cycle_reserve_policy: Option<&'static str>,
339) -> PoolAdminCommandReplayPolicy {
340 PoolAdminCommandReplayPolicy {
341 variant,
342 replay_policy: ReplayPolicy::ResponseIdempotent { command_kind },
343 implementation_status,
344 cost_class,
345 quota_policy,
346 cycle_reserve_policy,
347 }
348}
349
350const fn pool_admin_replay_protected(
351 variant: &'static str,
352 command_kind: &'static str,
353 implementation_status: ReplayImplementationStatus,
354 cost_class: CostClass,
355 quota_policy: Option<&'static str>,
356 cycle_reserve_policy: Option<&'static str>,
357) -> PoolAdminCommandReplayPolicy {
358 PoolAdminCommandReplayPolicy {
359 variant,
360 replay_policy: ReplayPolicy::ReplayProtected {
361 command_kind,
362 requires_operation_id: true,
363 },
364 implementation_status,
365 cost_class,
366 quota_policy,
367 cycle_reserve_policy,
368 }
369}
370
371const fn pool_admin_snapshot_convergent(
372 variant: &'static str,
373 command_kind: &'static str,
374 implementation_status: ReplayImplementationStatus,
375 cost_class: CostClass,
376 quota_policy: Option<&'static str>,
377 cycle_reserve_policy: Option<&'static str>,
378) -> PoolAdminCommandReplayPolicy {
379 PoolAdminCommandReplayPolicy {
380 variant,
381 replay_policy: ReplayPolicy::SnapshotConvergent { command_kind },
382 implementation_status,
383 cost_class,
384 quota_policy,
385 cycle_reserve_policy,
386 }
387}
388
389#[cfg(test)]
390mod tests {
391 use super::*;
392 use std::collections::BTreeSet;
393
394 #[test]
395 fn endpoint_manifest_entries_are_unique() {
396 let mut seen = BTreeSet::new();
397 for entry in ENDPOINT_REPLAY_POLICY_MANIFEST {
398 assert!(
399 seen.insert(entry.endpoint),
400 "duplicate replay policy entry for {}",
401 entry.endpoint
402 );
403 }
404 }
405
406 #[test]
407 fn emitted_canic_update_endpoints_have_replay_policy_entries() {
408 let emitted = emitted_update_endpoint_names();
409 let manifest = ENDPOINT_REPLAY_POLICY_MANIFEST
410 .iter()
411 .filter(|entry| entry.endpoint_kind == EndpointKind::Update)
412 .map(|entry| entry.endpoint)
413 .collect::<BTreeSet<_>>();
414
415 let missing = emitted.difference(&manifest).copied().collect::<Vec<_>>();
416
417 assert!(
418 missing.is_empty(),
419 "missing replay policy entries for update endpoints: {missing:?}"
420 );
421 }
422
423 #[test]
424 fn costed_manifest_entries_declare_guards() {
425 for entry in ENDPOINT_REPLAY_POLICY_MANIFEST {
426 if entry.cost_class == CostClass::None {
427 continue;
428 }
429 assert!(
430 entry.quota_policy.is_some(),
431 "costed entry {} missing quota policy",
432 entry.endpoint
433 );
434 assert!(
435 entry.cycle_reserve_policy.is_some(),
436 "costed entry {} missing cycle-reserve policy",
437 entry.endpoint
438 );
439 }
440 }
441
442 #[test]
443 fn costed_pool_admin_command_entries_declare_guards() {
444 for entry in POOL_ADMIN_COMMAND_REPLAY_POLICY_MANIFEST {
445 if entry.cost_class == CostClass::None {
446 continue;
447 }
448 assert!(
449 entry.quota_policy.is_some(),
450 "costed pool admin command {} missing quota policy",
451 entry.variant
452 );
453 assert!(
454 entry.cycle_reserve_policy.is_some(),
455 "costed pool admin command {} missing cycle-reserve policy",
456 entry.variant
457 );
458 }
459 }
460
461 #[test]
462 fn delegation_proof_issuance_is_manifested_as_implemented() {
463 let entry = ENDPOINT_REPLAY_POLICY_MANIFEST
464 .iter()
465 .find(|entry| entry.endpoint == "canic_request_delegation")
466 .expect("delegation endpoint policy entry");
467
468 assert_eq!(
469 entry.implementation_status,
470 ReplayImplementationStatus::Implemented
471 );
472 assert_eq!(entry.cost_class, CostClass::ThresholdEcdsaSign);
473 assert_eq!(
474 entry.replay_policy,
475 ReplayPolicy::ReplayProtected {
476 command_kind: "auth.issue_delegation_proof.v1",
477 requires_operation_id: true,
478 }
479 );
480 }
481
482 #[test]
483 fn root_auth_material_issuance_is_manifested_as_implemented() {
484 for (endpoint, command_kind) in [
485 (
486 "canic_request_role_attestation",
487 "auth.issue_role_attestation.v1",
488 ),
489 (
490 "canic_request_internal_invocation_proof",
491 "auth.issue_internal_invocation_proof.v1",
492 ),
493 ] {
494 let entry = ENDPOINT_REPLAY_POLICY_MANIFEST
495 .iter()
496 .find(|entry| entry.endpoint == endpoint)
497 .expect("root auth-material policy entry");
498
499 assert_eq!(
500 entry.implementation_status,
501 ReplayImplementationStatus::Implemented
502 );
503 assert_eq!(entry.cost_class, CostClass::ThresholdEcdsaSign);
504 assert_eq!(
505 entry.replay_policy,
506 ReplayPolicy::ReplayProtected {
507 command_kind,
508 requires_operation_id: true,
509 }
510 );
511 }
512 }
513
514 #[test]
515 fn canister_status_is_manifested_as_read_only() {
516 let entry = ENDPOINT_REPLAY_POLICY_MANIFEST
517 .iter()
518 .find(|entry| entry.endpoint == "canic_canister_status")
519 .expect("canister status policy entry");
520
521 assert_eq!(
522 entry.implementation_status,
523 ReplayImplementationStatus::Implemented
524 );
525 assert_eq!(entry.replay_policy, ReplayPolicy::QueryOrReadOnly);
526 assert_eq!(entry.cost_class, CostClass::None);
527 assert_eq!(entry.quota_policy, None);
528 assert_eq!(entry.cycle_reserve_policy, None);
529 }
530
531 #[test]
532 fn attestation_key_set_is_manifested_as_snapshot_convergent() {
533 let entry = ENDPOINT_REPLAY_POLICY_MANIFEST
534 .iter()
535 .find(|entry| entry.endpoint == "canic_attestation_key_set")
536 .expect("attestation key set policy entry");
537
538 assert_eq!(
539 entry.implementation_status,
540 ReplayImplementationStatus::Implemented
541 );
542 assert_eq!(
543 entry.replay_policy,
544 ReplayPolicy::SnapshotConvergent {
545 command_kind: "auth.attestation_key_set.v1",
546 }
547 );
548 assert_eq!(entry.cost_class, CostClass::None);
549 assert_eq!(entry.quota_policy, None);
550 assert_eq!(entry.cycle_reserve_policy, None);
551 }
552
553 #[test]
554 fn pool_admin_command_variants_have_replay_policy_entries() {
555 let variants = pool_admin_command_variant_names();
556 let manifest = POOL_ADMIN_COMMAND_REPLAY_POLICY_MANIFEST
557 .iter()
558 .map(|entry| entry.variant)
559 .collect::<BTreeSet<_>>();
560
561 assert_eq!(manifest, variants);
562 }
563
564 #[test]
565 fn pool_create_empty_command_is_manifested_as_implemented() {
566 let entry = POOL_ADMIN_COMMAND_REPLAY_POLICY_MANIFEST
567 .iter()
568 .find(|entry| entry.variant == "CreateEmpty")
569 .expect("CreateEmpty command policy entry");
570
571 assert_eq!(
572 entry.implementation_status,
573 ReplayImplementationStatus::Implemented
574 );
575 assert_eq!(
576 entry.replay_policy,
577 ReplayPolicy::ReplayProtected {
578 command_kind: "pool.create_empty.v1",
579 requires_operation_id: true,
580 }
581 );
582 }
583
584 #[test]
585 fn pool_import_queued_command_is_manifested_as_implemented_convergent() {
586 let entry = POOL_ADMIN_COMMAND_REPLAY_POLICY_MANIFEST
587 .iter()
588 .find(|entry| entry.variant == "ImportQueued")
589 .expect("ImportQueued command policy entry");
590
591 assert_eq!(
592 entry.implementation_status,
593 ReplayImplementationStatus::Implemented
594 );
595 assert_eq!(entry.cost_class, CostClass::None);
596 assert_eq!(
597 entry.replay_policy,
598 ReplayPolicy::SnapshotConvergent {
599 command_kind: "pool.import_queued.ensure_v1",
600 }
601 );
602 }
603
604 #[test]
605 fn pool_admin_non_create_variants_remain_explicit_release_blockers() {
606 for variant in ["Recycle", "ImportImmediate"] {
607 let entry = POOL_ADMIN_COMMAND_REPLAY_POLICY_MANIFEST
608 .iter()
609 .find(|entry| entry.variant == variant)
610 .expect("pool admin command policy entry");
611 assert_eq!(
612 entry.implementation_status,
613 ReplayImplementationStatus::ReleaseBlocker
614 );
615 assert!(
616 matches!(entry.replay_policy, ReplayPolicy::ResponseIdempotent { .. }),
617 "{variant} must declare its chosen replay class"
618 );
619 }
620 }
621
622 #[test]
623 fn intentionally_non_idempotent_entries_must_state_reason() {
624 for entry in ENDPOINT_REPLAY_POLICY_MANIFEST {
625 if let ReplayPolicy::IntentionallyNonIdempotent { reason, .. } = entry.replay_policy {
626 assert!(
627 !reason.trim().is_empty(),
628 "non-idempotent entry {} must state a reason",
629 entry.endpoint
630 );
631 }
632 }
633 }
634
635 fn pool_admin_command_variant_names() -> BTreeSet<&'static str> {
636 let source = include_str!("dto/pool.rs");
637 let marker = "pub enum PoolAdminCommand";
638 let start = source
639 .find(marker)
640 .expect("PoolAdminCommand enum exists in pool DTO");
641 let body_start = source[start..]
642 .find('{')
643 .map(|offset| start + offset + 1)
644 .expect("PoolAdminCommand enum has body");
645 let body_end = source[body_start..]
646 .find("\n}")
647 .map(|offset| body_start + offset)
648 .expect("PoolAdminCommand enum body closes");
649
650 source[body_start..body_end]
651 .lines()
652 .filter_map(pool_admin_command_variant_name_from_line)
653 .collect()
654 }
655
656 fn pool_admin_command_variant_name_from_line(line: &'static str) -> Option<&'static str> {
657 let line = line.trim();
658 let first = line.as_bytes().first().copied()?;
659 if !first.is_ascii_uppercase() {
660 return None;
661 }
662 let end = line
663 .find(|ch: char| ch == '(' || ch == '{' || ch == ',' || ch.is_whitespace())
664 .unwrap_or(line.len());
665 Some(&line[..end])
666 }
667
668 fn emitted_update_endpoint_names() -> BTreeSet<&'static str> {
669 [
670 include_str!("../../canic/src/macros/endpoints/root.rs"),
671 include_str!("../../canic/src/macros/endpoints/shared.rs"),
672 include_str!("../../canic/src/macros/endpoints/wasm_store.rs"),
673 include_str!("../../canic/src/macros/endpoints/nonroot.rs"),
674 include_str!("../../canic/src/macros/endpoints/icp_refill.rs"),
675 ]
676 .into_iter()
677 .flat_map(update_endpoint_names_from_source)
678 .collect()
679 }
680
681 fn update_endpoint_names_from_source(source: &'static str) -> Vec<&'static str> {
682 let lines = source.lines().collect::<Vec<_>>();
683 let mut names = Vec::new();
684 for (index, line) in lines.iter().enumerate() {
685 if !line.contains("#[$crate::canic_update") {
686 continue;
687 }
688 let Some(name) = lines
689 .iter()
690 .skip(index + 1)
691 .take(6)
692 .find_map(|candidate| endpoint_name_from_fn_line(candidate))
693 else {
694 panic!("canic_update endpoint attribute without following function");
695 };
696 names.push(name);
697 }
698 names
699 }
700
701 fn endpoint_name_from_fn_line(line: &'static str) -> Option<&'static str> {
702 let marker = "fn ";
703 let start = line.find(marker)? + marker.len();
704 let rest = &line[start..];
705 let end = rest.find('(')?;
706 Some(&rest[..end])
707 }
708}