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_replay_blocker(
102 "canic_attestation_key_set",
103 "auth.attestation_key_set.v1",
104 CostClass::ThresholdEcdsaSign,
105 Some(SIGNING_QUOTA_V1),
106 Some(SIGNING_RESERVE_V1),
107 ),
108 update_replay_blocker(
109 "canic_canister_status",
110 "management.canister_status.v1",
111 CostClass::ManagementDeployment,
112 Some(DEPLOYMENT_QUOTA_V1),
113 Some(DEPLOYMENT_RESERVE_V1),
114 ),
115 update_replay_blocker(
116 "canic_canister_upgrade",
117 "management.canister_upgrade.v1",
118 CostClass::ManagementDeployment,
119 Some(DEPLOYMENT_QUOTA_V1),
120 Some(DEPLOYMENT_RESERVE_V1),
121 ),
122 update_replay_blocker(
123 "canic_icp_refill",
124 "icp.refill.v1",
125 CostClass::ValueTransfer,
126 Some(VALUE_TRANSFER_QUOTA_V1),
127 Some(VALUE_TRANSFER_RESERVE_V1),
128 ),
129 update_replay_blocker(
130 "canic_pool_admin",
131 "pool.admin.v1",
132 CostClass::ManagementDeployment,
133 Some(DEPLOYMENT_QUOTA_V1),
134 Some(DEPLOYMENT_RESERVE_V1),
135 ),
136 update_replay_protected(
137 "canic_request_delegation",
138 "auth.issue_delegation_proof.v1",
139 ReplayImplementationStatus::Implemented,
140 CostClass::ThresholdEcdsaSign,
141 Some(SIGNING_QUOTA_V1),
142 Some(SIGNING_RESERVE_V1),
143 ),
144 update_replay_blocker(
145 "canic_request_internal_invocation_proof",
146 "auth.issue_internal_invocation_proof.v1",
147 CostClass::ThresholdEcdsaSign,
148 Some(SIGNING_QUOTA_V1),
149 Some(SIGNING_RESERVE_V1),
150 ),
151 update_replay_blocker(
152 "canic_request_role_attestation",
153 "auth.issue_role_attestation.v1",
154 CostClass::ThresholdEcdsaSign,
155 Some(SIGNING_QUOTA_V1),
156 Some(SIGNING_RESERVE_V1),
157 ),
158 update_replay_blocker(
159 "canic_response_capability_v1",
160 "root.capability_rpc.v1",
161 CostClass::ManagementDeployment,
162 Some(DEPLOYMENT_QUOTA_V1),
163 Some(DEPLOYMENT_RESERVE_V1),
164 ),
165 update_snapshot_convergent("canic_sync_state", "cascade.sync_state.v1"),
166 update_snapshot_convergent("canic_sync_topology", "cascade.sync_topology.v1"),
167 update_monotonic_publish(
168 "canic_template_prepare_admin",
169 "wasm_store.template_prepare_admin.v1",
170 ),
171 update_monotonic_publish(
172 "canic_template_publish_chunk_admin",
173 "wasm_store.template_publish_chunk_admin.v1",
174 ),
175 update_monotonic_publish(
176 "canic_template_stage_manifest_admin",
177 "wasm_store.template_stage_manifest_admin.v1",
178 ),
179 update_response_idempotent(
180 "canic_wasm_store_bootstrap_resume_root_admin",
181 "wasm_store.bootstrap_resume.ensure_v1",
182 ),
183 update_monotonic_publish("canic_wasm_store_admin", "wasm_store.admin.v1"),
184 update_monotonic_publish("canic_wasm_store_begin_gc", "wasm_store.begin_gc.v1"),
185 update_monotonic_publish("canic_wasm_store_chunk", "wasm_store.chunk.v1"),
186 update_monotonic_publish("canic_wasm_store_complete_gc", "wasm_store.complete_gc.v1"),
187 update_monotonic_publish("canic_wasm_store_info", "wasm_store.info.v1"),
188 update_monotonic_publish("canic_wasm_store_prepare", "wasm_store.prepare.v1"),
189 update_monotonic_publish("canic_wasm_store_prepare_gc", "wasm_store.prepare_gc.v1"),
190 update_monotonic_publish(
191 "canic_wasm_store_publish_chunk",
192 "wasm_store.publish_chunk.v1",
193 ),
194 update_monotonic_publish(
195 "canic_wasm_store_stage_manifest",
196 "wasm_store.stage_manifest.v1",
197 ),
198];
199
200pub const POOL_ADMIN_COMMAND_REPLAY_POLICY_MANIFEST: &[PoolAdminCommandReplayPolicy] = &[
201 pool_admin_replay_protected(
202 "CreateEmpty",
203 "pool.create_empty.v1",
204 ReplayImplementationStatus::Implemented,
205 CostClass::ManagementDeployment,
206 Some(DEPLOYMENT_QUOTA_V1),
207 Some(DEPLOYMENT_RESERVE_V1),
208 ),
209 pool_admin_response_idempotent(
210 "Recycle",
211 "pool.recycle.ensure_v1",
212 ReplayImplementationStatus::ReleaseBlocker,
213 CostClass::ManagementDeployment,
214 Some(DEPLOYMENT_QUOTA_V1),
215 Some(DEPLOYMENT_RESERVE_V1),
216 ),
217 pool_admin_response_idempotent(
218 "ImportImmediate",
219 "pool.import_immediate.ensure_v1",
220 ReplayImplementationStatus::ReleaseBlocker,
221 CostClass::ManagementDeployment,
222 Some(DEPLOYMENT_QUOTA_V1),
223 Some(DEPLOYMENT_RESERVE_V1),
224 ),
225 pool_admin_response_idempotent(
226 "ImportQueued",
227 "pool.import_queued.ensure_v1",
228 ReplayImplementationStatus::ReleaseBlocker,
229 CostClass::None,
230 None,
231 None,
232 ),
233];
234
235#[must_use]
236pub const fn endpoint_replay_policy_manifest() -> &'static [EndpointReplayPolicy] {
237 ENDPOINT_REPLAY_POLICY_MANIFEST
238}
239
240#[must_use]
241pub const fn pool_admin_command_replay_policy_manifest() -> &'static [PoolAdminCommandReplayPolicy]
242{
243 POOL_ADMIN_COMMAND_REPLAY_POLICY_MANIFEST
244}
245
246const fn update_response_idempotent(
247 endpoint: &'static str,
248 command_kind: &'static str,
249) -> EndpointReplayPolicy {
250 EndpointReplayPolicy {
251 endpoint,
252 endpoint_kind: EndpointKind::Update,
253 replay_policy: ReplayPolicy::ResponseIdempotent { command_kind },
254 implementation_status: ReplayImplementationStatus::Implemented,
255 cost_class: CostClass::None,
256 quota_policy: None,
257 cycle_reserve_policy: None,
258 }
259}
260
261const fn update_replay_blocker(
262 endpoint: &'static str,
263 command_kind: &'static str,
264 cost_class: CostClass,
265 quota_policy: Option<&'static str>,
266 cycle_reserve_policy: Option<&'static str>,
267) -> EndpointReplayPolicy {
268 update_replay_protected(
269 endpoint,
270 command_kind,
271 ReplayImplementationStatus::ReleaseBlocker,
272 cost_class,
273 quota_policy,
274 cycle_reserve_policy,
275 )
276}
277
278const fn update_replay_protected(
279 endpoint: &'static str,
280 command_kind: &'static str,
281 implementation_status: ReplayImplementationStatus,
282 cost_class: CostClass,
283 quota_policy: Option<&'static str>,
284 cycle_reserve_policy: Option<&'static str>,
285) -> EndpointReplayPolicy {
286 EndpointReplayPolicy {
287 endpoint,
288 endpoint_kind: EndpointKind::Update,
289 replay_policy: ReplayPolicy::ReplayProtected {
290 command_kind,
291 requires_operation_id: true,
292 },
293 implementation_status,
294 cost_class,
295 quota_policy,
296 cycle_reserve_policy,
297 }
298}
299
300const fn update_monotonic_publish(
301 endpoint: &'static str,
302 command_kind: &'static str,
303) -> EndpointReplayPolicy {
304 EndpointReplayPolicy {
305 endpoint,
306 endpoint_kind: EndpointKind::Update,
307 replay_policy: ReplayPolicy::MonotonicTransition { command_kind },
308 implementation_status: ReplayImplementationStatus::Implemented,
309 cost_class: CostClass::DurablePublish,
310 quota_policy: Some(DURABLE_PUBLISH_QUOTA_V1),
311 cycle_reserve_policy: Some(DURABLE_PUBLISH_RESERVE_V1),
312 }
313}
314
315const fn update_snapshot_convergent(
316 endpoint: &'static str,
317 command_kind: &'static str,
318) -> EndpointReplayPolicy {
319 EndpointReplayPolicy {
320 endpoint,
321 endpoint_kind: EndpointKind::Update,
322 replay_policy: ReplayPolicy::SnapshotConvergent { command_kind },
323 implementation_status: ReplayImplementationStatus::Implemented,
324 cost_class: CostClass::None,
325 quota_policy: None,
326 cycle_reserve_policy: None,
327 }
328}
329
330const fn pool_admin_response_idempotent(
331 variant: &'static str,
332 command_kind: &'static str,
333 implementation_status: ReplayImplementationStatus,
334 cost_class: CostClass,
335 quota_policy: Option<&'static str>,
336 cycle_reserve_policy: Option<&'static str>,
337) -> PoolAdminCommandReplayPolicy {
338 PoolAdminCommandReplayPolicy {
339 variant,
340 replay_policy: ReplayPolicy::ResponseIdempotent { command_kind },
341 implementation_status,
342 cost_class,
343 quota_policy,
344 cycle_reserve_policy,
345 }
346}
347
348const fn pool_admin_replay_protected(
349 variant: &'static str,
350 command_kind: &'static str,
351 implementation_status: ReplayImplementationStatus,
352 cost_class: CostClass,
353 quota_policy: Option<&'static str>,
354 cycle_reserve_policy: Option<&'static str>,
355) -> PoolAdminCommandReplayPolicy {
356 PoolAdminCommandReplayPolicy {
357 variant,
358 replay_policy: ReplayPolicy::ReplayProtected {
359 command_kind,
360 requires_operation_id: true,
361 },
362 implementation_status,
363 cost_class,
364 quota_policy,
365 cycle_reserve_policy,
366 }
367}
368
369#[cfg(test)]
370mod tests {
371 use super::*;
372 use std::collections::BTreeSet;
373
374 #[test]
375 fn endpoint_manifest_entries_are_unique() {
376 let mut seen = BTreeSet::new();
377 for entry in ENDPOINT_REPLAY_POLICY_MANIFEST {
378 assert!(
379 seen.insert(entry.endpoint),
380 "duplicate replay policy entry for {}",
381 entry.endpoint
382 );
383 }
384 }
385
386 #[test]
387 fn emitted_canic_update_endpoints_have_replay_policy_entries() {
388 let emitted = emitted_update_endpoint_names();
389 let manifest = ENDPOINT_REPLAY_POLICY_MANIFEST
390 .iter()
391 .filter(|entry| entry.endpoint_kind == EndpointKind::Update)
392 .map(|entry| entry.endpoint)
393 .collect::<BTreeSet<_>>();
394
395 let missing = emitted.difference(&manifest).copied().collect::<Vec<_>>();
396
397 assert!(
398 missing.is_empty(),
399 "missing replay policy entries for update endpoints: {missing:?}"
400 );
401 }
402
403 #[test]
404 fn costed_manifest_entries_declare_guards() {
405 for entry in ENDPOINT_REPLAY_POLICY_MANIFEST {
406 if entry.cost_class == CostClass::None {
407 continue;
408 }
409 assert!(
410 entry.quota_policy.is_some(),
411 "costed entry {} missing quota policy",
412 entry.endpoint
413 );
414 assert!(
415 entry.cycle_reserve_policy.is_some(),
416 "costed entry {} missing cycle-reserve policy",
417 entry.endpoint
418 );
419 }
420 }
421
422 #[test]
423 fn costed_pool_admin_command_entries_declare_guards() {
424 for entry in POOL_ADMIN_COMMAND_REPLAY_POLICY_MANIFEST {
425 if entry.cost_class == CostClass::None {
426 continue;
427 }
428 assert!(
429 entry.quota_policy.is_some(),
430 "costed pool admin command {} missing quota policy",
431 entry.variant
432 );
433 assert!(
434 entry.cycle_reserve_policy.is_some(),
435 "costed pool admin command {} missing cycle-reserve policy",
436 entry.variant
437 );
438 }
439 }
440
441 #[test]
442 fn delegation_proof_issuance_is_manifested_as_implemented() {
443 let entry = ENDPOINT_REPLAY_POLICY_MANIFEST
444 .iter()
445 .find(|entry| entry.endpoint == "canic_request_delegation")
446 .expect("delegation endpoint policy entry");
447
448 assert_eq!(
449 entry.implementation_status,
450 ReplayImplementationStatus::Implemented
451 );
452 assert_eq!(entry.cost_class, CostClass::ThresholdEcdsaSign);
453 assert_eq!(
454 entry.replay_policy,
455 ReplayPolicy::ReplayProtected {
456 command_kind: "auth.issue_delegation_proof.v1",
457 requires_operation_id: true,
458 }
459 );
460 }
461
462 #[test]
463 fn pool_admin_command_variants_have_replay_policy_entries() {
464 let variants = pool_admin_command_variant_names();
465 let manifest = POOL_ADMIN_COMMAND_REPLAY_POLICY_MANIFEST
466 .iter()
467 .map(|entry| entry.variant)
468 .collect::<BTreeSet<_>>();
469
470 assert_eq!(manifest, variants);
471 }
472
473 #[test]
474 fn pool_create_empty_command_is_manifested_as_implemented() {
475 let entry = POOL_ADMIN_COMMAND_REPLAY_POLICY_MANIFEST
476 .iter()
477 .find(|entry| entry.variant == "CreateEmpty")
478 .expect("CreateEmpty command policy entry");
479
480 assert_eq!(
481 entry.implementation_status,
482 ReplayImplementationStatus::Implemented
483 );
484 assert_eq!(
485 entry.replay_policy,
486 ReplayPolicy::ReplayProtected {
487 command_kind: "pool.create_empty.v1",
488 requires_operation_id: true,
489 }
490 );
491 }
492
493 #[test]
494 fn pool_admin_non_create_variants_remain_explicit_release_blockers() {
495 for variant in ["Recycle", "ImportImmediate", "ImportQueued"] {
496 let entry = POOL_ADMIN_COMMAND_REPLAY_POLICY_MANIFEST
497 .iter()
498 .find(|entry| entry.variant == variant)
499 .expect("pool admin command policy entry");
500 assert_eq!(
501 entry.implementation_status,
502 ReplayImplementationStatus::ReleaseBlocker
503 );
504 assert!(
505 matches!(entry.replay_policy, ReplayPolicy::ResponseIdempotent { .. }),
506 "{variant} must declare its chosen replay class"
507 );
508 }
509 }
510
511 #[test]
512 fn intentionally_non_idempotent_entries_must_state_reason() {
513 for entry in ENDPOINT_REPLAY_POLICY_MANIFEST {
514 if let ReplayPolicy::IntentionallyNonIdempotent { reason, .. } = entry.replay_policy {
515 assert!(
516 !reason.trim().is_empty(),
517 "non-idempotent entry {} must state a reason",
518 entry.endpoint
519 );
520 }
521 }
522 }
523
524 fn pool_admin_command_variant_names() -> BTreeSet<&'static str> {
525 let source = include_str!("dto/pool.rs");
526 let marker = "pub enum PoolAdminCommand";
527 let start = source
528 .find(marker)
529 .expect("PoolAdminCommand enum exists in pool DTO");
530 let body_start = source[start..]
531 .find('{')
532 .map(|offset| start + offset + 1)
533 .expect("PoolAdminCommand enum has body");
534 let body_end = source[body_start..]
535 .find("\n}")
536 .map(|offset| body_start + offset)
537 .expect("PoolAdminCommand enum body closes");
538
539 source[body_start..body_end]
540 .lines()
541 .filter_map(pool_admin_command_variant_name_from_line)
542 .collect()
543 }
544
545 fn pool_admin_command_variant_name_from_line(line: &'static str) -> Option<&'static str> {
546 let line = line.trim();
547 let first = line.as_bytes().first().copied()?;
548 if !first.is_ascii_uppercase() {
549 return None;
550 }
551 let end = line
552 .find(|ch: char| ch == '(' || ch == '{' || ch == ',' || ch.is_whitespace())
553 .unwrap_or(line.len());
554 Some(&line[..end])
555 }
556
557 fn emitted_update_endpoint_names() -> BTreeSet<&'static str> {
558 [
559 include_str!("../../canic/src/macros/endpoints/root.rs"),
560 include_str!("../../canic/src/macros/endpoints/shared.rs"),
561 include_str!("../../canic/src/macros/endpoints/wasm_store.rs"),
562 include_str!("../../canic/src/macros/endpoints/nonroot.rs"),
563 include_str!("../../canic/src/macros/endpoints/icp_refill.rs"),
564 ]
565 .into_iter()
566 .flat_map(update_endpoint_names_from_source)
567 .collect()
568 }
569
570 fn update_endpoint_names_from_source(source: &'static str) -> Vec<&'static str> {
571 let lines = source.lines().collect::<Vec<_>>();
572 let mut names = Vec::new();
573 for (index, line) in lines.iter().enumerate() {
574 if !line.contains("#[$crate::canic_update") {
575 continue;
576 }
577 let Some(name) = lines
578 .iter()
579 .skip(index + 1)
580 .take(6)
581 .find_map(|candidate| endpoint_name_from_fn_line(candidate))
582 else {
583 panic!("canic_update endpoint attribute without following function");
584 };
585 names.push(name);
586 }
587 names
588 }
589
590 fn endpoint_name_from_fn_line(line: &'static str) -> Option<&'static str> {
591 let marker = "fn ";
592 let start = line.find(marker)? + marker.len();
593 let rest = &line[start..];
594 let end = rest.find('(')?;
595 Some(&rest[..end])
596 }
597}