Skip to main content

canic_core/
replay_policy.rs

1//! Replay policy inventory for Canic-owned endpoint surfaces.
2//!
3//! This is Slice A scaffolding for the 0.61 replay-safety work. It records the
4//! intended replay and cost policy for endpoints emitted by Canic macros. Later
5//! slices wire these classifications into shared replay receipts and cost
6//! guards.
7
8///
9/// EndpointKind
10///
11#[derive(Clone, Copy, Debug, Eq, PartialEq)]
12pub enum EndpointKind {
13    Query,
14    Update,
15}
16
17///
18/// ReplayPolicy
19///
20#[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///
43/// CostClass
44///
45#[derive(Clone, Copy, Debug, Eq, PartialEq)]
46pub enum CostClass {
47    None,
48    ThresholdEcdsaSign,
49    ManagementDeployment,
50    ValueTransfer,
51    DurablePublish,
52}
53
54///
55/// ReplayImplementationStatus
56///
57#[derive(Clone, Copy, Debug, Eq, PartialEq)]
58pub enum ReplayImplementationStatus {
59    Implemented,
60    ReleaseBlocker,
61}
62
63///
64/// EndpointReplayPolicy
65///
66#[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///
78/// PoolAdminCommandReplayPolicy
79///
80#[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}