Skip to main content

canic_core/api/ic/
canic.rs

1//! Protected Canic-to-Canic internal call API.
2//!
3//! This module owns the native Canic internal RPC path: method-scoped root
4//! proofs, protected endpoint descriptors, generated-client transport options,
5//! envelope construction, proof caching, and the narrow auth-material repair
6//! retry.
7
8use super::call::{Call, CallResult};
9use crate::{
10    cdk::{
11        candid::{CandidType, encode_one},
12        types::Principal,
13    },
14    config::schema::RoleAttestationConfig,
15    dto::{
16        auth::{
17            CanicInternalCallEnvelopeV1, CanicInternalCallHeaderV1, InternalInvocationProofRequest,
18            SignedInternalInvocationProofV1,
19        },
20        error::{Error, ErrorCode},
21    },
22    ids::CanisterRole,
23    ops::{config::ConfigOps, ic::IcOps, runtime::env::EnvOps},
24};
25use candid::{encode_args, utils::ArgumentEncoder};
26use serde::de::DeserializeOwned;
27use std::{borrow::Cow, cell::RefCell, collections::BTreeMap};
28
29const DEFAULT_INTERNAL_CALL_PROOF_TTL_SECS: u64 = 120;
30const INTERNAL_CALL_PROOF_REFRESH_MARGIN_MAX_SECS: u64 = 30;
31
32thread_local! {
33    static INTERNAL_INVOCATION_PROOF_CACHE:
34        RefCell<BTreeMap<InternalInvocationProofCacheKey, SignedInternalInvocationProofV1>> =
35        const { RefCell::new(BTreeMap::new()) };
36}
37
38///
39/// CanicCall
40///
41/// Low-level protected Canic internal-call primitive.
42///
43/// Unlike `Call`, this API is only for Canic-to-Canic protected internal
44/// endpoints. It obtains a root-signed method-scoped invocation proof, wraps
45/// the original Candid arguments in `CanicInternalCallEnvelopeV1`, encodes that
46/// envelope as raw ingress bytes, and dispatches through the raw call path.
47///
48
49pub struct CanicCall;
50
51impl CanicCall {
52    #[must_use]
53    pub fn bounded_wait(
54        canister_id: impl Into<Principal>,
55        method: &str,
56    ) -> CanicCallBuilder<'static> {
57        CanicCallBuilder::new(WaitMode::Bounded, canister_id.into(), method)
58    }
59
60    #[must_use]
61    pub fn unbounded_wait(
62        canister_id: impl Into<Principal>,
63        method: &str,
64    ) -> CanicCallBuilder<'static> {
65        CanicCallBuilder::new(WaitMode::Unbounded, canister_id.into(), method)
66    }
67}
68
69///
70/// ProtectedInternalEndpoint
71///
72/// Generated metadata for one protected Canic internal endpoint.
73///
74/// Endpoint macros emit this descriptor next to protected internal endpoints.
75/// Callers should pass it to `CanicInternalClient` instead of repeating method
76/// names and accepted-role metadata by hand.
77///
78
79#[derive(Clone, Debug)]
80pub struct ProtectedInternalEndpoint {
81    method: &'static str,
82    accepted_roles: Vec<CanisterRole>,
83}
84
85impl ProtectedInternalEndpoint {
86    #[must_use]
87    #[track_caller]
88    pub fn new(method: &'static str, roles: impl IntoIterator<Item = CanisterRole>) -> Self {
89        assert!(
90            !method.trim().is_empty(),
91            "protected internal endpoint descriptor method must not be empty"
92        );
93        let accepted_roles = roles.into_iter().collect::<Vec<_>>();
94        assert!(
95            !accepted_roles.is_empty(),
96            "protected internal endpoint descriptor '{method}' must accept at least one caller role"
97        );
98        for (index, role) in accepted_roles.iter().enumerate() {
99            assert!(
100                !role.as_str().trim().is_empty(),
101                "protected internal endpoint descriptor '{method}' has an empty caller role at index {index}"
102            );
103            assert!(
104                !accepted_roles[..index].iter().any(|prior| prior == role),
105                "protected internal endpoint descriptor '{method}' contains duplicate caller role '{role}'"
106            );
107        }
108        Self {
109            method,
110            accepted_roles,
111        }
112    }
113
114    #[must_use]
115    pub const fn method(&self) -> &'static str {
116        self.method
117    }
118
119    #[must_use]
120    pub fn accepted_roles(&self) -> &[CanisterRole] {
121        &self.accepted_roles
122    }
123
124    #[must_use]
125    pub fn accepts_role(&self, role: &CanisterRole) -> bool {
126        self.accepted_roles.iter().any(|accepted| accepted == role)
127    }
128
129    #[must_use]
130    pub fn single_role(&self) -> Option<&CanisterRole> {
131        match self.accepted_roles.as_slice() {
132            [role] => Some(role),
133            _ => None,
134        }
135    }
136
137    pub fn required_single_role(&self) -> Result<CanisterRole, Error> {
138        self.single_role().cloned().ok_or_else(|| {
139            Error::invalid(format!(
140                "protected internal endpoint '{}' accepts {} roles; choose a caller role explicitly",
141                self.method(),
142                self.accepted_roles.len()
143            ))
144        })
145    }
146}
147
148///
149/// CanicInternalClient
150///
151/// Generic protected internal client over generated endpoint descriptors.
152///
153
154#[derive(Clone, Copy, Debug)]
155pub struct CanicInternalClient {
156    canister_id: Principal,
157    options: CanicInternalCallOptions,
158}
159
160impl CanicInternalClient {
161    #[must_use]
162    pub const fn new(canister_id: Principal) -> Self {
163        Self {
164            canister_id,
165            options: CanicInternalCallOptions::new(),
166        }
167    }
168
169    #[must_use]
170    pub const fn with_options(mut self, options: CanicInternalCallOptions) -> Self {
171        self.options = options;
172        self
173    }
174
175    #[must_use]
176    pub const fn with_bounded_wait(mut self) -> Self {
177        self.options = self.options.with_bounded_wait();
178        self
179    }
180
181    #[must_use]
182    pub const fn with_unbounded_wait(mut self) -> Self {
183        self.options = self.options.with_unbounded_wait();
184        self
185    }
186
187    #[must_use]
188    pub const fn with_cycles(mut self, cycles: u128) -> Self {
189        self.options = self.options.with_cycles(cycles);
190        self
191    }
192
193    #[must_use]
194    pub const fn with_proof_ttl_secs(mut self, ttl_secs: u64) -> Self {
195        self.options = self.options.with_proof_ttl_secs(ttl_secs);
196        self
197    }
198
199    pub async fn call_update<A>(
200        &self,
201        endpoint: &ProtectedInternalEndpoint,
202        caller_role: CanisterRole,
203        args: A,
204    ) -> Result<CallResult, Error>
205    where
206        A: ArgumentEncoder,
207    {
208        if !endpoint.accepts_role(&caller_role) {
209            return Err(Error::invalid(format!(
210                "caller role '{caller_role}' is not accepted by protected internal endpoint '{}'",
211                endpoint.method()
212            )));
213        }
214
215        let builder = match self.options.wait {
216            CanicInternalWaitMode::Bounded => {
217                CanicCall::bounded_wait(self.canister_id, endpoint.method())
218            }
219            CanicInternalWaitMode::Unbounded => {
220                CanicCall::unbounded_wait(self.canister_id, endpoint.method())
221            }
222        };
223        let builder = builder
224            .with_caller_role(caller_role)
225            .with_cycles(self.options.cycles);
226        let builder = if let Some(ttl_secs) = self.options.proof_ttl_secs {
227            builder.with_proof_ttl_secs(ttl_secs)
228        } else {
229            builder
230        };
231
232        builder.with_args(args)?.execute().await
233    }
234
235    pub async fn call_update_with_single_role<A>(
236        &self,
237        endpoint: &ProtectedInternalEndpoint,
238        args: A,
239    ) -> Result<CallResult, Error>
240    where
241        A: ArgumentEncoder,
242    {
243        let role = endpoint.required_single_role()?;
244        self.call_update(endpoint, role, args).await
245    }
246
247    pub async fn call_update_result<T, A>(
248        &self,
249        endpoint: &ProtectedInternalEndpoint,
250        caller_role: CanisterRole,
251        args: A,
252    ) -> Result<T, Error>
253    where
254        T: CandidType + DeserializeOwned,
255        A: ArgumentEncoder,
256    {
257        let call = self.call_update(endpoint, caller_role, args).await?;
258        let result: Result<T, Error> = call.candid()?;
259        result
260    }
261
262    pub async fn call_update_result_with_single_role<T, A>(
263        &self,
264        endpoint: &ProtectedInternalEndpoint,
265        args: A,
266    ) -> Result<T, Error>
267    where
268        T: CandidType + DeserializeOwned,
269        A: ArgumentEncoder,
270    {
271        let role = endpoint.required_single_role()?;
272        self.call_update_result(endpoint, role, args).await
273    }
274}
275
276///
277/// CanicInternalCallOptions
278///
279/// Transport options shared by generated protected internal clients.
280///
281
282#[derive(Clone, Copy, Debug, Eq, PartialEq)]
283pub struct CanicInternalCallOptions {
284    wait: CanicInternalWaitMode,
285    cycles: u128,
286    proof_ttl_secs: Option<u64>,
287}
288
289impl CanicInternalCallOptions {
290    #[must_use]
291    pub const fn new() -> Self {
292        Self {
293            wait: CanicInternalWaitMode::Unbounded,
294            cycles: 0,
295            proof_ttl_secs: None,
296        }
297    }
298
299    #[must_use]
300    pub const fn with_bounded_wait(mut self) -> Self {
301        self.wait = CanicInternalWaitMode::Bounded;
302        self
303    }
304
305    #[must_use]
306    pub const fn with_unbounded_wait(mut self) -> Self {
307        self.wait = CanicInternalWaitMode::Unbounded;
308        self
309    }
310
311    #[must_use]
312    pub const fn with_cycles(mut self, cycles: u128) -> Self {
313        self.cycles = cycles;
314        self
315    }
316
317    #[must_use]
318    pub const fn with_proof_ttl_secs(mut self, ttl_secs: u64) -> Self {
319        self.proof_ttl_secs = Some(ttl_secs);
320        self
321    }
322}
323
324impl Default for CanicInternalCallOptions {
325    fn default() -> Self {
326        Self::new()
327    }
328}
329
330///
331/// CanicInternalWaitMode
332///
333
334#[derive(Clone, Copy, Debug, Eq, PartialEq)]
335pub enum CanicInternalWaitMode {
336    Bounded,
337    Unbounded,
338}
339
340///
341/// CanicCallBuilder
342///
343
344pub struct CanicCallBuilder<'a> {
345    wait: WaitMode,
346    canister_id: Principal,
347    method: String,
348    caller_role: Option<CanisterRole>,
349    ttl_secs: Option<u64>,
350    cycles: u128,
351    args: Cow<'a, [u8]>,
352}
353
354impl CanicCallBuilder<'_> {
355    fn new(wait: WaitMode, canister_id: Principal, method: &str) -> Self {
356        Self {
357            wait,
358            canister_id,
359            method: method.to_string(),
360            caller_role: None,
361            ttl_secs: None,
362            cycles: 0,
363            args: Cow::Owned(encode_args(()).expect("empty Candid tuple must encode")),
364        }
365    }
366
367    #[must_use]
368    pub fn with_caller_role(mut self, role: CanisterRole) -> Self {
369        self.caller_role = Some(role);
370        self
371    }
372
373    #[must_use]
374    pub const fn with_proof_ttl_secs(mut self, ttl_secs: u64) -> Self {
375        self.ttl_secs = Some(ttl_secs);
376        self
377    }
378
379    pub fn with_arg<A>(mut self, arg: A) -> Result<Self, Error>
380    where
381        A: CandidType,
382    {
383        self.args = Cow::Owned(encode_one(arg).map_err(|err| Error::invalid(err.to_string()))?);
384        Ok(self)
385    }
386
387    pub fn with_args<A>(mut self, args: A) -> Result<Self, Error>
388    where
389        A: ArgumentEncoder,
390    {
391        self.args = Cow::Owned(encode_args(args).map_err(|err| Error::invalid(err.to_string()))?);
392        Ok(self)
393    }
394
395    #[must_use]
396    pub fn with_raw_args<'b>(self, args: impl Into<Cow<'b, [u8]>>) -> CanicCallBuilder<'b> {
397        CanicCallBuilder {
398            wait: self.wait,
399            canister_id: self.canister_id,
400            method: self.method,
401            caller_role: self.caller_role,
402            ttl_secs: self.ttl_secs,
403            cycles: self.cycles,
404            args: args.into(),
405        }
406    }
407
408    #[must_use]
409    pub const fn with_cycles(mut self, cycles: u128) -> Self {
410        self.cycles = cycles;
411        self
412    }
413
414    pub async fn execute(self) -> Result<CallResult, Error> {
415        validate_internal_call_target_method(&self.method)?;
416        let ttl_secs = self.proof_ttl_secs()?;
417        let role = self
418            .caller_role
419            .ok_or_else(|| Error::invalid("CanicCall requires with_caller_role(...)"))?;
420        validate_internal_call_caller_role(&role)?;
421        let request = InternalInvocationProofRequest {
422            subject: IcOps::canister_self(),
423            role,
424            subnet_id: EnvOps::subnet_pid().ok(),
425            audience: self.canister_id,
426            audience_method: self.method.clone(),
427            ttl_secs,
428            metadata: None,
429        };
430        let args = self.args.into_owned();
431        let proof = internal_invocation_proof_for_request(request.clone()).await?;
432
433        let envelope =
434            build_internal_call_envelope(self.canister_id, &self.method, proof, args.clone());
435        let result = execute_internal_call_once(
436            self.wait,
437            self.canister_id,
438            &self.method,
439            self.cycles,
440            envelope,
441        )
442        .await?;
443        if !internal_call_result_is_retryable(&result) {
444            return Ok(result);
445        }
446
447        invalidate_internal_invocation_proof(&request)?;
448        let proof = fresh_internal_invocation_proof_for_request(request).await?;
449        let envelope = build_internal_call_envelope(self.canister_id, &self.method, proof, args);
450        execute_internal_call_once(
451            self.wait,
452            self.canister_id,
453            &self.method,
454            self.cycles,
455            envelope,
456        )
457        .await
458    }
459
460    fn proof_ttl_secs(&self) -> Result<u64, Error> {
461        let requested = self
462            .ttl_secs
463            .unwrap_or(DEFAULT_INTERNAL_CALL_PROOF_TTL_SECS);
464        let max = ConfigOps::role_attestation_config()
465            .map_err(Error::from)?
466            .max_ttl_secs;
467        effective_internal_call_proof_ttl_secs(requested, max)
468    }
469}
470
471fn validate_internal_call_target_method(method: &str) -> Result<(), Error> {
472    if method.trim().is_empty() {
473        return Err(Error::invalid(
474            "CanicCall requires a non-empty target method",
475        ));
476    }
477    Ok(())
478}
479
480fn validate_internal_call_caller_role(role: &CanisterRole) -> Result<(), Error> {
481    if role.as_str().trim().is_empty() {
482        return Err(Error::invalid("CanicCall requires a non-empty caller role"));
483    }
484    Ok(())
485}
486
487fn effective_internal_call_proof_ttl_secs(requested: u64, max: u64) -> Result<u64, Error> {
488    if requested == 0 {
489        return Err(Error::invalid(
490            "CanicCall proof TTL must be greater than zero",
491        ));
492    }
493    let effective = requested.min(max);
494    if effective == 0 {
495        return Err(Error::invalid(
496            "CanicCall proof TTL maximum must be greater than zero",
497        ));
498    }
499    Ok(effective)
500}
501
502async fn execute_internal_call_once(
503    wait: WaitMode,
504    canister_id: Principal,
505    method: &str,
506    cycles: u128,
507    envelope: CanicInternalCallEnvelopeV1,
508) -> Result<CallResult, Error> {
509    let call = match wait {
510        WaitMode::Bounded => Call::bounded_wait(canister_id, method),
511        WaitMode::Unbounded => Call::unbounded_wait(canister_id, method),
512    }
513    .with_cycles(cycles)
514    .with_raw_args(encode_internal_call_envelope_raw(envelope)?);
515
516    call.execute().await
517}
518
519///
520/// InternalInvocationProofCacheKey
521///
522
523#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
524struct InternalInvocationProofCacheKey {
525    root_pid: Principal,
526    attestation_key_name: String,
527    subject: Principal,
528    role: CanisterRole,
529    subnet_id: Option<Principal>,
530    audience: Principal,
531    audience_method: String,
532    ttl_secs: u64,
533}
534
535async fn internal_invocation_proof_for_request(
536    request: InternalInvocationProofRequest,
537) -> Result<SignedInternalInvocationProofV1, Error> {
538    let cfg = ConfigOps::role_attestation_config().map_err(Error::from)?;
539    let root_pid = EnvOps::root_pid().map_err(Error::from)?;
540    let now_secs = IcOps::now_secs();
541
542    if let Some(proof) = cached_internal_invocation_proof(&request, &cfg, root_pid, now_secs) {
543        return Ok(proof);
544    }
545
546    fresh_internal_invocation_proof_for_request_with_context(request, cfg, root_pid, now_secs).await
547}
548
549async fn fresh_internal_invocation_proof_for_request(
550    request: InternalInvocationProofRequest,
551) -> Result<SignedInternalInvocationProofV1, Error> {
552    let cfg = ConfigOps::role_attestation_config().map_err(Error::from)?;
553    let root_pid = EnvOps::root_pid().map_err(Error::from)?;
554    let now_secs = IcOps::now_secs();
555    fresh_internal_invocation_proof_for_request_with_context(request, cfg, root_pid, now_secs).await
556}
557
558async fn fresh_internal_invocation_proof_for_request_with_context(
559    request: InternalInvocationProofRequest,
560    cfg: RoleAttestationConfig,
561    root_pid: Principal,
562    now_secs: u64,
563) -> Result<SignedInternalInvocationProofV1, Error> {
564    let proof =
565        crate::api::auth::AuthApi::request_internal_invocation_proof(request.clone()).await?;
566    cache_internal_invocation_proof(&request, &cfg, root_pid, now_secs, proof.clone());
567    Ok(proof)
568}
569
570fn internal_invocation_proof_cache_key(
571    request: &InternalInvocationProofRequest,
572    cfg: &RoleAttestationConfig,
573    root_pid: Principal,
574) -> InternalInvocationProofCacheKey {
575    InternalInvocationProofCacheKey {
576        root_pid,
577        attestation_key_name: cfg.ecdsa_key_name.clone(),
578        subject: request.subject,
579        role: request.role.clone(),
580        subnet_id: request.subnet_id,
581        audience: request.audience,
582        audience_method: request.audience_method.clone(),
583        ttl_secs: request.ttl_secs,
584    }
585}
586
587fn cached_internal_invocation_proof(
588    request: &InternalInvocationProofRequest,
589    cfg: &RoleAttestationConfig,
590    root_pid: Principal,
591    now_secs: u64,
592) -> Option<SignedInternalInvocationProofV1> {
593    let key = internal_invocation_proof_cache_key(request, cfg, root_pid);
594    let min_accepted_epoch = cfg
595        .min_accepted_epoch_by_role
596        .get(request.role.as_str())
597        .copied()
598        .unwrap_or(0);
599
600    INTERNAL_INVOCATION_PROOF_CACHE.with_borrow_mut(|cache| {
601        let proof = cache.get(&key)?;
602        if internal_invocation_proof_is_reusable(proof, request, now_secs, min_accepted_epoch) {
603            Some(proof.clone())
604        } else {
605            cache.remove(&key);
606            None
607        }
608    })
609}
610
611fn cache_internal_invocation_proof(
612    request: &InternalInvocationProofRequest,
613    cfg: &RoleAttestationConfig,
614    root_pid: Principal,
615    now_secs: u64,
616    proof: SignedInternalInvocationProofV1,
617) {
618    let min_accepted_epoch = cfg
619        .min_accepted_epoch_by_role
620        .get(request.role.as_str())
621        .copied()
622        .unwrap_or(0);
623    if !internal_invocation_proof_is_reusable(&proof, request, now_secs, min_accepted_epoch) {
624        return;
625    }
626
627    let key = internal_invocation_proof_cache_key(request, cfg, root_pid);
628    INTERNAL_INVOCATION_PROOF_CACHE.with_borrow_mut(|cache| {
629        cache.insert(key, proof);
630    });
631}
632
633fn invalidate_internal_invocation_proof(
634    request: &InternalInvocationProofRequest,
635) -> Result<(), Error> {
636    let cfg = ConfigOps::role_attestation_config().map_err(Error::from)?;
637    let root_pid = EnvOps::root_pid().map_err(Error::from)?;
638    let key = internal_invocation_proof_cache_key(request, &cfg, root_pid);
639    INTERNAL_INVOCATION_PROOF_CACHE.with_borrow_mut(|cache| {
640        cache.remove(&key);
641    });
642    Ok(())
643}
644
645fn internal_invocation_proof_is_reusable(
646    proof: &SignedInternalInvocationProofV1,
647    request: &InternalInvocationProofRequest,
648    now_secs: u64,
649    min_accepted_epoch: u64,
650) -> bool {
651    let payload = &proof.payload;
652    if payload.expires_at <= payload.issued_at || now_secs < payload.issued_at {
653        return false;
654    }
655
656    payload.subject == request.subject
657        && payload.role == request.role
658        && payload.subnet_id == request.subnet_id
659        && payload.audience == request.audience
660        && payload.audience_method == request.audience_method
661        && payload.epoch >= min_accepted_epoch
662        && now_secs.saturating_add(internal_invocation_proof_refresh_margin_secs(proof))
663            < payload.expires_at
664}
665
666fn internal_invocation_proof_refresh_margin_secs(proof: &SignedInternalInvocationProofV1) -> u64 {
667    proof
668        .payload
669        .expires_at
670        .saturating_sub(proof.payload.issued_at)
671        .saturating_div(5)
672        .clamp(1, INTERNAL_CALL_PROOF_REFRESH_MARGIN_MAX_SECS)
673}
674
675fn internal_call_result_is_retryable(result: &CallResult) -> bool {
676    let Ok(Err(err)) = result.candid::<Result<candid::Reserved, Error>>() else {
677        return false;
678    };
679    internal_call_error_is_retryable(&err)
680}
681
682const fn internal_call_error_is_retryable(err: &Error) -> bool {
683    matches!(
684        err.code,
685        ErrorCode::AuthKeyUnknown | ErrorCode::AuthMaterialStale
686    )
687}
688
689#[derive(Clone, Copy, Debug, Eq, PartialEq)]
690enum WaitMode {
691    Bounded,
692    Unbounded,
693}
694
695fn build_internal_call_envelope(
696    target_canister: Principal,
697    target_method: &str,
698    proof: SignedInternalInvocationProofV1,
699    args: Vec<u8>,
700) -> CanicInternalCallEnvelopeV1 {
701    CanicInternalCallEnvelopeV1 {
702        version: 1,
703        header: CanicInternalCallHeaderV1 {
704            target_canister,
705            target_method: target_method.to_string(),
706        },
707        proof,
708        args,
709    }
710}
711
712fn encode_internal_call_envelope_raw(
713    envelope: CanicInternalCallEnvelopeV1,
714) -> Result<Vec<u8>, Error> {
715    encode_one(envelope).map_err(|err| Error::invalid(err.to_string()))
716}
717
718#[cfg(test)]
719mod tests {
720    use super::*;
721    use crate::dto::auth::InternalInvocationProofPayloadV1;
722    use candid::{decode_args, decode_one};
723    use std::collections::BTreeMap;
724
725    fn p(id: u8) -> Principal {
726        Principal::from_slice(&[id; 29])
727    }
728
729    fn proof() -> SignedInternalInvocationProofV1 {
730        SignedInternalInvocationProofV1 {
731            payload: InternalInvocationProofPayloadV1 {
732                subject: p(1),
733                role: CanisterRole::new("project_hub"),
734                subnet_id: None,
735                audience: p(2),
736                audience_method: "system_add_project_to_user".to_string(),
737                issued_at: 10,
738                expires_at: 20,
739                epoch: 3,
740            },
741            signature: vec![1, 2, 3],
742            key_id: 1,
743        }
744    }
745
746    fn request() -> InternalInvocationProofRequest {
747        InternalInvocationProofRequest {
748            subject: p(1),
749            role: CanisterRole::new("project_hub"),
750            subnet_id: Some(p(9)),
751            audience: p(2),
752            audience_method: "system_add_project_to_user".to_string(),
753            ttl_secs: 120,
754            metadata: None,
755        }
756    }
757
758    fn cfg(min_epoch: u64) -> RoleAttestationConfig {
759        let mut min_accepted_epoch_by_role = BTreeMap::new();
760        min_accepted_epoch_by_role.insert("project_hub".to_string(), min_epoch);
761        RoleAttestationConfig {
762            ecdsa_key_name: "key_1".to_string(),
763            max_ttl_secs: 900,
764            min_accepted_epoch_by_role,
765        }
766    }
767
768    fn clear_internal_invocation_proof_cache() {
769        INTERNAL_INVOCATION_PROOF_CACHE.with_borrow_mut(BTreeMap::clear);
770    }
771
772    #[test]
773    fn canic_call_envelope_binds_target_method_and_original_args() {
774        let args = encode_args((7_u64, "project")).expect("args encode");
775        let envelope =
776            build_internal_call_envelope(p(2), "system_add_project_to_user", proof(), args);
777
778        assert_eq!(envelope.version, 1);
779        assert_eq!(envelope.header.target_canister, p(2));
780        assert_eq!(envelope.header.target_method, "system_add_project_to_user");
781        assert_eq!(
782            envelope.proof.payload.audience_method,
783            "system_add_project_to_user"
784        );
785
786        let decoded: (u64, String) = decode_args(&envelope.args).expect("decode original args");
787        assert_eq!(decoded, (7, "project".to_string()));
788    }
789
790    #[test]
791    fn canic_call_encodes_envelope_as_raw_ingress_bytes() {
792        let args = encode_args((7_u64, "project")).expect("args encode");
793        let envelope =
794            build_internal_call_envelope(p(2), "system_add_project_to_user", proof(), args);
795        let raw =
796            encode_internal_call_envelope_raw(envelope.clone()).expect("envelope should encode");
797
798        let decoded: CanicInternalCallEnvelopeV1 =
799            decode_one(&raw).expect("raw ingress bytes decode as envelope");
800
801        assert_eq!(decoded, envelope);
802    }
803
804    #[test]
805    fn canic_call_builder_records_role_and_raw_args() {
806        let raw = vec![9_u8, 8, 7];
807        let builder = CanicCall::unbounded_wait(p(3), "target")
808            .with_caller_role(CanisterRole::new("project_hub"))
809            .with_proof_ttl_secs(30)
810            .with_cycles(10)
811            .with_raw_args(raw.clone());
812
813        assert_eq!(builder.wait, WaitMode::Unbounded);
814        assert_eq!(builder.canister_id, p(3));
815        assert_eq!(builder.method, "target");
816        assert_eq!(builder.caller_role, Some(CanisterRole::new("project_hub")));
817        assert_eq!(builder.ttl_secs, Some(30));
818        assert_eq!(builder.cycles, 10);
819        assert_eq!(builder.args.as_ref(), raw.as_slice());
820    }
821
822    #[test]
823    fn canic_call_rejects_empty_target_method_locally() {
824        let err = validate_internal_call_target_method("   ")
825            .expect_err("empty protected call method should fail locally");
826
827        assert_eq!(err.code, ErrorCode::InvalidInput);
828    }
829
830    #[test]
831    fn canic_call_rejects_empty_caller_role_locally() {
832        let err = validate_internal_call_caller_role(&CanisterRole::new("   "))
833            .expect_err("empty protected call role should fail locally");
834
835        assert_eq!(err.code, ErrorCode::InvalidInput);
836    }
837
838    #[test]
839    fn canic_call_rejects_zero_effective_proof_ttl_locally() {
840        let zero_requested = effective_internal_call_proof_ttl_secs(0, 900)
841            .expect_err("zero requested proof ttl should fail locally");
842        assert_eq!(zero_requested.code, ErrorCode::InvalidInput);
843
844        let zero_max = effective_internal_call_proof_ttl_secs(120, 0)
845            .expect_err("zero configured max proof ttl should fail locally");
846        assert_eq!(zero_max.code, ErrorCode::InvalidInput);
847    }
848
849    #[test]
850    fn canic_call_clamps_requested_proof_ttl_to_config_max() {
851        assert_eq!(
852            effective_internal_call_proof_ttl_secs(120, 900).expect("ttl"),
853            120
854        );
855        assert_eq!(
856            effective_internal_call_proof_ttl_secs(1200, 900).expect("ttl"),
857            900
858        );
859    }
860
861    #[test]
862    fn protected_internal_endpoint_descriptor_matches_roles() {
863        let endpoint = ProtectedInternalEndpoint::new(
864            "system_add_project_to_user",
865            [
866                CanisterRole::new("project_hub"),
867                CanisterRole::new("admin_hub"),
868            ],
869        );
870
871        assert_eq!(endpoint.method(), "system_add_project_to_user");
872        assert!(endpoint.accepts_role(&CanisterRole::new("project_hub")));
873        assert!(endpoint.accepts_role(&CanisterRole::new("admin_hub")));
874        assert!(!endpoint.accepts_role(&CanisterRole::new("user_hub")));
875        assert!(endpoint.single_role().is_none());
876    }
877
878    #[test]
879    fn protected_internal_endpoint_single_role_is_available_to_generated_clients() {
880        let endpoint = ProtectedInternalEndpoint::new(
881            "system_add_project_to_user",
882            [CanisterRole::new("project_hub")],
883        );
884
885        assert_eq!(
886            endpoint.single_role(),
887            Some(&CanisterRole::new("project_hub"))
888        );
889        assert_eq!(
890            endpoint.required_single_role().expect("single role"),
891            CanisterRole::new("project_hub")
892        );
893    }
894
895    #[test]
896    fn protected_internal_endpoint_requires_explicit_role_when_ambiguous() {
897        let endpoint = ProtectedInternalEndpoint::new(
898            "system_add_project_to_user",
899            [
900                CanisterRole::new("project_hub"),
901                CanisterRole::new("admin_hub"),
902            ],
903        );
904
905        let err = endpoint
906            .required_single_role()
907            .expect_err("multi-role endpoint should require explicit caller role");
908        assert_eq!(err.code, ErrorCode::InvalidInput);
909    }
910
911    #[test]
912    fn protected_internal_endpoint_descriptor_rejects_missing_method() {
913        let result =
914            std::panic::catch_unwind(|| ProtectedInternalEndpoint::new("", [CanisterRole::ROOT]));
915
916        assert!(result.is_err());
917    }
918
919    #[test]
920    fn protected_internal_endpoint_descriptor_rejects_blank_method() {
921        let result = std::panic::catch_unwind(|| {
922            ProtectedInternalEndpoint::new("   ", [CanisterRole::ROOT])
923        });
924
925        assert!(result.is_err());
926    }
927
928    #[test]
929    fn protected_internal_endpoint_descriptor_rejects_missing_roles() {
930        let result = std::panic::catch_unwind(|| {
931            ProtectedInternalEndpoint::new("system_add_project_to_user", [])
932        });
933
934        assert!(result.is_err());
935    }
936
937    #[test]
938    fn protected_internal_endpoint_descriptor_rejects_empty_role() {
939        let result = std::panic::catch_unwind(|| {
940            ProtectedInternalEndpoint::new("system_add_project_to_user", [CanisterRole::new("")])
941        });
942
943        assert!(result.is_err());
944    }
945
946    #[test]
947    fn protected_internal_endpoint_descriptor_rejects_blank_role() {
948        let result = std::panic::catch_unwind(|| {
949            ProtectedInternalEndpoint::new("system_add_project_to_user", [CanisterRole::new("   ")])
950        });
951
952        assert!(result.is_err());
953    }
954
955    #[test]
956    fn protected_internal_endpoint_descriptor_rejects_duplicate_roles() {
957        let result = std::panic::catch_unwind(|| {
958            ProtectedInternalEndpoint::new(
959                "system_add_project_to_user",
960                [
961                    CanisterRole::new("project_hub"),
962                    CanisterRole::new("project_hub"),
963                ],
964            )
965        });
966
967        assert!(result.is_err());
968    }
969
970    #[test]
971    fn internal_client_options_are_chainable() {
972        let client = CanicInternalClient::new(p(3))
973            .with_bounded_wait()
974            .with_cycles(10)
975            .with_proof_ttl_secs(30);
976
977        assert_eq!(client.canister_id, p(3));
978        assert_eq!(client.options.wait, CanicInternalWaitMode::Bounded);
979        assert_eq!(client.options.cycles, 10);
980        assert_eq!(client.options.proof_ttl_secs, Some(30));
981    }
982
983    #[test]
984    fn internal_client_rejects_unaccepted_explicit_role_locally() {
985        let client = CanicInternalClient::new(p(3));
986        let endpoint = ProtectedInternalEndpoint::new(
987            "system_add_project_to_user",
988            [CanisterRole::new("project_hub")],
989        );
990        let result = futures::executor::block_on(client.call_update(
991            &endpoint,
992            CanisterRole::new("admin_hub"),
993            (),
994        ));
995
996        match result {
997            Err(err) => assert_eq!(err.code, ErrorCode::InvalidInput),
998            Ok(_) => panic!("unaccepted caller role should fail before transport"),
999        }
1000    }
1001
1002    #[test]
1003    fn internal_invocation_proof_cache_reuses_exact_fresh_edge() {
1004        clear_internal_invocation_proof_cache();
1005        let request = request();
1006        let mut proof = proof();
1007        proof.payload.subnet_id = request.subnet_id;
1008        cache_internal_invocation_proof(&request, &cfg(0), p(7), 12, proof.clone());
1009
1010        let cached = cached_internal_invocation_proof(&request, &cfg(0), p(7), 12)
1011            .expect("fresh matching proof should cache-hit");
1012
1013        assert_eq!(cached, proof);
1014    }
1015
1016    #[test]
1017    fn internal_invocation_proof_cache_rejects_near_expiry_entry() {
1018        clear_internal_invocation_proof_cache();
1019        let request = request();
1020        let mut proof = proof();
1021        proof.payload.subnet_id = request.subnet_id;
1022        proof.payload.issued_at = 10;
1023        proof.payload.expires_at = 20;
1024        cache_internal_invocation_proof(&request, &cfg(0), p(7), 18, proof);
1025
1026        assert!(cached_internal_invocation_proof(&request, &cfg(0), p(7), 18).is_none());
1027    }
1028
1029    #[test]
1030    fn internal_invocation_proof_cache_rejects_future_issued_at_entry() {
1031        clear_internal_invocation_proof_cache();
1032        let request = request();
1033        let mut proof = proof();
1034        proof.payload.subnet_id = request.subnet_id;
1035        proof.payload.issued_at = 20;
1036        proof.payload.expires_at = 40;
1037        cache_internal_invocation_proof(&request, &cfg(0), p(7), 12, proof);
1038
1039        assert!(cached_internal_invocation_proof(&request, &cfg(0), p(7), 12).is_none());
1040    }
1041
1042    #[test]
1043    fn internal_invocation_proof_cache_rejects_invalid_time_window() {
1044        clear_internal_invocation_proof_cache();
1045        let request = request();
1046        let mut proof = proof();
1047        proof.payload.subnet_id = request.subnet_id;
1048        proof.payload.issued_at = 20;
1049        proof.payload.expires_at = 20;
1050        cache_internal_invocation_proof(&request, &cfg(0), p(7), 20, proof);
1051
1052        assert!(cached_internal_invocation_proof(&request, &cfg(0), p(7), 20).is_none());
1053    }
1054
1055    #[test]
1056    fn internal_invocation_proof_cache_rejects_epoch_below_local_floor() {
1057        clear_internal_invocation_proof_cache();
1058        let request = request();
1059        let mut proof = proof();
1060        proof.payload.subnet_id = request.subnet_id;
1061        proof.payload.epoch = 3;
1062        cache_internal_invocation_proof(&request, &cfg(0), p(7), 12, proof);
1063
1064        assert!(cached_internal_invocation_proof(&request, &cfg(4), p(7), 12).is_none());
1065    }
1066
1067    #[test]
1068    fn internal_call_retry_classifier_is_limited_to_repairable_auth_material() {
1069        assert!(internal_call_error_is_retryable(&Error::new(
1070            ErrorCode::AuthKeyUnknown,
1071            "unknown key".to_string(),
1072        )));
1073        assert!(internal_call_error_is_retryable(&Error::new(
1074            ErrorCode::AuthMaterialStale,
1075            "stale epoch".to_string(),
1076        )));
1077        assert!(!internal_call_error_is_retryable(&Error::new(
1078            ErrorCode::AuthProofExpired,
1079            "expired".to_string(),
1080        )));
1081        assert!(!internal_call_error_is_retryable(&Error::unauthorized(
1082            "role mismatch"
1083        )));
1084    }
1085}