1use crate::{
21 cdk::{
22 candid::{CandidType, encode_one},
23 types::{BoundedString128, Principal},
24 },
25 config::schema::RoleAttestationConfig,
26 dto::{
27 auth::{
28 CanicInternalCallEnvelopeV1, CanicInternalCallHeaderV1, InternalInvocationProofRequest,
29 SignedInternalInvocationProofV1,
30 },
31 error::Error,
32 error::ErrorCode,
33 },
34 ids::CanisterRole,
35 ops::{config::ConfigOps, ic::IcOps, runtime::env::EnvOps},
36 workflow::ic::call::{
37 CallBuilder as WorkflowCallBuilder, CallResult as WorkflowCallResult, CallWorkflow,
38 IntentSpec as WorkflowIntentSpec,
39 },
40};
41use candid::{
42 encode_args,
43 utils::{ArgumentDecoder, ArgumentEncoder},
44};
45use serde::de::DeserializeOwned;
46use std::{borrow::Cow, cell::RefCell, collections::BTreeMap};
47
48const DEFAULT_INTERNAL_CALL_PROOF_TTL_SECS: u64 = 120;
49const INTERNAL_CALL_PROOF_REFRESH_MARGIN_MAX_SECS: u64 = 30;
50
51thread_local! {
52 static INTERNAL_INVOCATION_PROOF_CACHE:
53 RefCell<BTreeMap<InternalInvocationProofCacheKey, SignedInternalInvocationProofV1>> =
54 const { RefCell::new(BTreeMap::new()) };
55}
56
57pub struct Call;
69
70impl Call {
71 #[must_use]
72 pub fn bounded_wait(canister_id: impl Into<Principal>, method: &str) -> CallBuilder<'static> {
73 CallBuilder {
74 inner: CallWorkflow::bounded_wait(canister_id, method),
75 }
76 }
77
78 #[must_use]
79 pub fn unbounded_wait(canister_id: impl Into<Principal>, method: &str) -> CallBuilder<'static> {
80 CallBuilder {
81 inner: CallWorkflow::unbounded_wait(canister_id, method),
82 }
83 }
84}
85
86pub struct CanicCall;
98
99impl CanicCall {
100 #[must_use]
101 pub fn bounded_wait(
102 canister_id: impl Into<Principal>,
103 method: &str,
104 ) -> CanicCallBuilder<'static> {
105 CanicCallBuilder::new(WaitMode::Bounded, canister_id.into(), method)
106 }
107
108 #[must_use]
109 pub fn unbounded_wait(
110 canister_id: impl Into<Principal>,
111 method: &str,
112 ) -> CanicCallBuilder<'static> {
113 CanicCallBuilder::new(WaitMode::Unbounded, canister_id.into(), method)
114 }
115}
116
117#[derive(Clone, Debug)]
128pub struct ProtectedInternalEndpoint {
129 method: &'static str,
130 accepted_roles: Vec<CanisterRole>,
131}
132
133impl ProtectedInternalEndpoint {
134 #[must_use]
135 #[track_caller]
136 pub fn new(method: &'static str, roles: impl IntoIterator<Item = CanisterRole>) -> Self {
137 assert!(
138 !method.is_empty(),
139 "protected internal endpoint descriptor method must not be empty"
140 );
141 let accepted_roles = roles.into_iter().collect::<Vec<_>>();
142 assert!(
143 !accepted_roles.is_empty(),
144 "protected internal endpoint descriptor '{method}' must accept at least one caller role"
145 );
146 for (index, role) in accepted_roles.iter().enumerate() {
147 assert!(
148 !role.as_str().is_empty(),
149 "protected internal endpoint descriptor '{method}' has an empty caller role at index {index}"
150 );
151 assert!(
152 !accepted_roles[..index].iter().any(|prior| prior == role),
153 "protected internal endpoint descriptor '{method}' contains duplicate caller role '{role}'"
154 );
155 }
156 Self {
157 method,
158 accepted_roles,
159 }
160 }
161
162 #[must_use]
163 pub const fn method(&self) -> &'static str {
164 self.method
165 }
166
167 #[must_use]
168 pub fn accepted_roles(&self) -> &[CanisterRole] {
169 &self.accepted_roles
170 }
171
172 #[must_use]
173 pub fn accepts_role(&self, role: &CanisterRole) -> bool {
174 self.accepted_roles.iter().any(|accepted| accepted == role)
175 }
176
177 #[must_use]
178 pub fn single_role(&self) -> Option<&CanisterRole> {
179 match self.accepted_roles.as_slice() {
180 [role] => Some(role),
181 _ => None,
182 }
183 }
184
185 pub fn required_single_role(&self) -> Result<CanisterRole, Error> {
186 self.single_role().cloned().ok_or_else(|| {
187 Error::invalid(format!(
188 "protected internal endpoint '{}' accepts {} roles; choose a caller role explicitly",
189 self.method(),
190 self.accepted_roles.len()
191 ))
192 })
193 }
194}
195
196#[derive(Clone, Copy, Debug)]
203pub struct CanicInternalClient {
204 canister_id: Principal,
205 options: CanicInternalCallOptions,
206}
207
208impl CanicInternalClient {
209 #[must_use]
210 pub const fn new(canister_id: Principal) -> Self {
211 Self {
212 canister_id,
213 options: CanicInternalCallOptions::new(),
214 }
215 }
216
217 #[must_use]
218 pub const fn with_options(mut self, options: CanicInternalCallOptions) -> Self {
219 self.options = options;
220 self
221 }
222
223 #[must_use]
224 pub const fn with_bounded_wait(mut self) -> Self {
225 self.options = self.options.with_bounded_wait();
226 self
227 }
228
229 #[must_use]
230 pub const fn with_unbounded_wait(mut self) -> Self {
231 self.options = self.options.with_unbounded_wait();
232 self
233 }
234
235 #[must_use]
236 pub const fn with_cycles(mut self, cycles: u128) -> Self {
237 self.options = self.options.with_cycles(cycles);
238 self
239 }
240
241 #[must_use]
242 pub const fn with_proof_ttl_secs(mut self, ttl_secs: u64) -> Self {
243 self.options = self.options.with_proof_ttl_secs(ttl_secs);
244 self
245 }
246
247 pub async fn call_update<A>(
248 &self,
249 endpoint: &ProtectedInternalEndpoint,
250 caller_role: CanisterRole,
251 args: A,
252 ) -> Result<CallResult, Error>
253 where
254 A: ArgumentEncoder,
255 {
256 if !endpoint.accepts_role(&caller_role) {
257 return Err(Error::invalid(format!(
258 "caller role '{caller_role}' is not accepted by protected internal endpoint '{}'",
259 endpoint.method()
260 )));
261 }
262
263 let builder = match self.options.wait {
264 CanicInternalWaitMode::Bounded => {
265 CanicCall::bounded_wait(self.canister_id, endpoint.method())
266 }
267 CanicInternalWaitMode::Unbounded => {
268 CanicCall::unbounded_wait(self.canister_id, endpoint.method())
269 }
270 };
271 let builder = builder
272 .with_caller_role(caller_role)
273 .with_cycles(self.options.cycles);
274 let builder = if let Some(ttl_secs) = self.options.proof_ttl_secs {
275 builder.with_proof_ttl_secs(ttl_secs)
276 } else {
277 builder
278 };
279
280 builder.with_args(args)?.execute().await
281 }
282
283 pub async fn call_update_with_single_role<A>(
284 &self,
285 endpoint: &ProtectedInternalEndpoint,
286 args: A,
287 ) -> Result<CallResult, Error>
288 where
289 A: ArgumentEncoder,
290 {
291 let role = endpoint.required_single_role()?;
292 self.call_update(endpoint, role, args).await
293 }
294
295 pub async fn call_update_result<T, A>(
296 &self,
297 endpoint: &ProtectedInternalEndpoint,
298 caller_role: CanisterRole,
299 args: A,
300 ) -> Result<T, Error>
301 where
302 T: CandidType + DeserializeOwned,
303 A: ArgumentEncoder,
304 {
305 let call = self.call_update(endpoint, caller_role, args).await?;
306 let result: Result<T, Error> = call.candid()?;
307 result
308 }
309
310 pub async fn call_update_result_with_single_role<T, A>(
311 &self,
312 endpoint: &ProtectedInternalEndpoint,
313 args: A,
314 ) -> Result<T, Error>
315 where
316 T: CandidType + DeserializeOwned,
317 A: ArgumentEncoder,
318 {
319 let role = endpoint.required_single_role()?;
320 self.call_update_result(endpoint, role, args).await
321 }
322}
323
324#[derive(Clone, Copy, Debug, Eq, PartialEq)]
331pub struct CanicInternalCallOptions {
332 wait: CanicInternalWaitMode,
333 cycles: u128,
334 proof_ttl_secs: Option<u64>,
335}
336
337impl CanicInternalCallOptions {
338 #[must_use]
339 pub const fn new() -> Self {
340 Self {
341 wait: CanicInternalWaitMode::Unbounded,
342 cycles: 0,
343 proof_ttl_secs: None,
344 }
345 }
346
347 #[must_use]
348 pub const fn with_bounded_wait(mut self) -> Self {
349 self.wait = CanicInternalWaitMode::Bounded;
350 self
351 }
352
353 #[must_use]
354 pub const fn with_unbounded_wait(mut self) -> Self {
355 self.wait = CanicInternalWaitMode::Unbounded;
356 self
357 }
358
359 #[must_use]
360 pub const fn with_cycles(mut self, cycles: u128) -> Self {
361 self.cycles = cycles;
362 self
363 }
364
365 #[must_use]
366 pub const fn with_proof_ttl_secs(mut self, ttl_secs: u64) -> Self {
367 self.proof_ttl_secs = Some(ttl_secs);
368 self
369 }
370}
371
372impl Default for CanicInternalCallOptions {
373 fn default() -> Self {
374 Self::new()
375 }
376}
377
378#[derive(Clone, Copy, Debug, Eq, PartialEq)]
383pub enum CanicInternalWaitMode {
384 Bounded,
385 Unbounded,
386}
387
388pub struct CanicCallBuilder<'a> {
393 wait: WaitMode,
394 canister_id: Principal,
395 method: String,
396 caller_role: Option<CanisterRole>,
397 ttl_secs: Option<u64>,
398 cycles: u128,
399 args: Cow<'a, [u8]>,
400}
401
402impl CanicCallBuilder<'_> {
403 fn new(wait: WaitMode, canister_id: Principal, method: &str) -> Self {
404 Self {
405 wait,
406 canister_id,
407 method: method.to_string(),
408 caller_role: None,
409 ttl_secs: None,
410 cycles: 0,
411 args: Cow::Owned(encode_args(()).expect("empty Candid tuple must encode")),
412 }
413 }
414
415 #[must_use]
416 pub fn with_caller_role(mut self, role: CanisterRole) -> Self {
417 self.caller_role = Some(role);
418 self
419 }
420
421 #[must_use]
422 pub const fn with_proof_ttl_secs(mut self, ttl_secs: u64) -> Self {
423 self.ttl_secs = Some(ttl_secs);
424 self
425 }
426
427 pub fn with_arg<A>(mut self, arg: A) -> Result<Self, Error>
428 where
429 A: CandidType,
430 {
431 self.args = Cow::Owned(encode_one(arg).map_err(|err| Error::invalid(err.to_string()))?);
432 Ok(self)
433 }
434
435 pub fn with_args<A>(mut self, args: A) -> Result<Self, Error>
436 where
437 A: ArgumentEncoder,
438 {
439 self.args = Cow::Owned(encode_args(args).map_err(|err| Error::invalid(err.to_string()))?);
440 Ok(self)
441 }
442
443 #[must_use]
444 pub fn with_raw_args<'b>(self, args: impl Into<Cow<'b, [u8]>>) -> CanicCallBuilder<'b> {
445 CanicCallBuilder {
446 wait: self.wait,
447 canister_id: self.canister_id,
448 method: self.method,
449 caller_role: self.caller_role,
450 ttl_secs: self.ttl_secs,
451 cycles: self.cycles,
452 args: args.into(),
453 }
454 }
455
456 #[must_use]
457 pub const fn with_cycles(mut self, cycles: u128) -> Self {
458 self.cycles = cycles;
459 self
460 }
461
462 pub async fn execute(self) -> Result<CallResult, Error> {
463 let ttl_secs = self.proof_ttl_secs()?;
464 let role = self
465 .caller_role
466 .ok_or_else(|| Error::invalid("CanicCall requires with_caller_role(...)"))?;
467 let request = InternalInvocationProofRequest {
468 subject: IcOps::canister_self(),
469 role,
470 subnet_id: EnvOps::subnet_pid().ok(),
471 audience: self.canister_id,
472 audience_method: self.method.clone(),
473 ttl_secs,
474 metadata: None,
475 };
476 let args = self.args.into_owned();
477 let proof = internal_invocation_proof_for_request(request.clone()).await?;
478
479 let envelope =
480 build_internal_call_envelope(self.canister_id, &self.method, proof, args.clone());
481 let result = execute_internal_call_once(
482 self.wait,
483 self.canister_id,
484 &self.method,
485 self.cycles,
486 envelope,
487 )
488 .await?;
489 if !internal_call_result_is_retryable(&result) {
490 return Ok(result);
491 }
492
493 invalidate_internal_invocation_proof(&request)?;
494 let proof = fresh_internal_invocation_proof_for_request(request).await?;
495 let envelope = build_internal_call_envelope(self.canister_id, &self.method, proof, args);
496 execute_internal_call_once(
497 self.wait,
498 self.canister_id,
499 &self.method,
500 self.cycles,
501 envelope,
502 )
503 .await
504 }
505
506 fn proof_ttl_secs(&self) -> Result<u64, Error> {
507 let requested = self
508 .ttl_secs
509 .unwrap_or(DEFAULT_INTERNAL_CALL_PROOF_TTL_SECS);
510 let max = ConfigOps::role_attestation_config()
511 .map_err(Error::from)?
512 .max_ttl_secs;
513 Ok(requested.min(max))
514 }
515}
516
517async fn execute_internal_call_once(
518 wait: WaitMode,
519 canister_id: Principal,
520 method: &str,
521 cycles: u128,
522 envelope: CanicInternalCallEnvelopeV1,
523) -> Result<CallResult, Error> {
524 let call = match wait {
525 WaitMode::Bounded => Call::bounded_wait(canister_id, method),
526 WaitMode::Unbounded => Call::unbounded_wait(canister_id, method),
527 }
528 .with_cycles(cycles)
529 .with_arg(envelope)?;
530
531 call.execute().await
532}
533
534#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
539struct InternalInvocationProofCacheKey {
540 root_pid: Principal,
541 attestation_key_name: String,
542 subject: Principal,
543 role: CanisterRole,
544 subnet_id: Option<Principal>,
545 audience: Principal,
546 audience_method: String,
547 ttl_secs: u64,
548}
549
550async fn internal_invocation_proof_for_request(
551 request: InternalInvocationProofRequest,
552) -> Result<SignedInternalInvocationProofV1, Error> {
553 let cfg = ConfigOps::role_attestation_config().map_err(Error::from)?;
554 let root_pid = EnvOps::root_pid().map_err(Error::from)?;
555 let now_secs = IcOps::now_secs();
556
557 if let Some(proof) = cached_internal_invocation_proof(&request, &cfg, root_pid, now_secs) {
558 return Ok(proof);
559 }
560
561 fresh_internal_invocation_proof_for_request_with_context(request, cfg, root_pid, now_secs).await
562}
563
564async fn fresh_internal_invocation_proof_for_request(
565 request: InternalInvocationProofRequest,
566) -> Result<SignedInternalInvocationProofV1, Error> {
567 let cfg = ConfigOps::role_attestation_config().map_err(Error::from)?;
568 let root_pid = EnvOps::root_pid().map_err(Error::from)?;
569 let now_secs = IcOps::now_secs();
570 fresh_internal_invocation_proof_for_request_with_context(request, cfg, root_pid, now_secs).await
571}
572
573async fn fresh_internal_invocation_proof_for_request_with_context(
574 request: InternalInvocationProofRequest,
575 cfg: RoleAttestationConfig,
576 root_pid: Principal,
577 now_secs: u64,
578) -> Result<SignedInternalInvocationProofV1, Error> {
579 let proof =
580 crate::api::auth::AuthApi::request_internal_invocation_proof(request.clone()).await?;
581 cache_internal_invocation_proof(&request, &cfg, root_pid, now_secs, proof.clone());
582 Ok(proof)
583}
584
585fn internal_invocation_proof_cache_key(
586 request: &InternalInvocationProofRequest,
587 cfg: &RoleAttestationConfig,
588 root_pid: Principal,
589) -> InternalInvocationProofCacheKey {
590 InternalInvocationProofCacheKey {
591 root_pid,
592 attestation_key_name: cfg.ecdsa_key_name.clone(),
593 subject: request.subject,
594 role: request.role.clone(),
595 subnet_id: request.subnet_id,
596 audience: request.audience,
597 audience_method: request.audience_method.clone(),
598 ttl_secs: request.ttl_secs,
599 }
600}
601
602fn cached_internal_invocation_proof(
603 request: &InternalInvocationProofRequest,
604 cfg: &RoleAttestationConfig,
605 root_pid: Principal,
606 now_secs: u64,
607) -> Option<SignedInternalInvocationProofV1> {
608 let key = internal_invocation_proof_cache_key(request, cfg, root_pid);
609 let min_accepted_epoch = cfg
610 .min_accepted_epoch_by_role
611 .get(request.role.as_str())
612 .copied()
613 .unwrap_or(0);
614
615 INTERNAL_INVOCATION_PROOF_CACHE.with_borrow_mut(|cache| {
616 let proof = cache.get(&key)?;
617 if internal_invocation_proof_is_reusable(proof, request, now_secs, min_accepted_epoch) {
618 Some(proof.clone())
619 } else {
620 cache.remove(&key);
621 None
622 }
623 })
624}
625
626fn cache_internal_invocation_proof(
627 request: &InternalInvocationProofRequest,
628 cfg: &RoleAttestationConfig,
629 root_pid: Principal,
630 now_secs: u64,
631 proof: SignedInternalInvocationProofV1,
632) {
633 let min_accepted_epoch = cfg
634 .min_accepted_epoch_by_role
635 .get(request.role.as_str())
636 .copied()
637 .unwrap_or(0);
638 if !internal_invocation_proof_is_reusable(&proof, request, now_secs, min_accepted_epoch) {
639 return;
640 }
641
642 let key = internal_invocation_proof_cache_key(request, cfg, root_pid);
643 INTERNAL_INVOCATION_PROOF_CACHE.with_borrow_mut(|cache| {
644 cache.insert(key, proof);
645 });
646}
647
648fn invalidate_internal_invocation_proof(
649 request: &InternalInvocationProofRequest,
650) -> Result<(), Error> {
651 let cfg = ConfigOps::role_attestation_config().map_err(Error::from)?;
652 let root_pid = EnvOps::root_pid().map_err(Error::from)?;
653 let key = internal_invocation_proof_cache_key(request, &cfg, root_pid);
654 INTERNAL_INVOCATION_PROOF_CACHE.with_borrow_mut(|cache| {
655 cache.remove(&key);
656 });
657 Ok(())
658}
659
660fn internal_invocation_proof_is_reusable(
661 proof: &SignedInternalInvocationProofV1,
662 request: &InternalInvocationProofRequest,
663 now_secs: u64,
664 min_accepted_epoch: u64,
665) -> bool {
666 let payload = &proof.payload;
667 payload.subject == request.subject
668 && payload.role == request.role
669 && payload.subnet_id == request.subnet_id
670 && payload.audience == request.audience
671 && payload.audience_method == request.audience_method
672 && payload.epoch >= min_accepted_epoch
673 && now_secs.saturating_add(internal_invocation_proof_refresh_margin_secs(proof))
674 < payload.expires_at
675}
676
677fn internal_invocation_proof_refresh_margin_secs(proof: &SignedInternalInvocationProofV1) -> u64 {
678 proof
679 .payload
680 .expires_at
681 .saturating_sub(proof.payload.issued_at)
682 .saturating_div(5)
683 .clamp(1, INTERNAL_CALL_PROOF_REFRESH_MARGIN_MAX_SECS)
684}
685
686fn internal_call_result_is_retryable(result: &CallResult) -> bool {
687 let Ok(Err(err)) = result.candid::<Result<candid::Reserved, Error>>() else {
688 return false;
689 };
690 internal_call_error_is_retryable(&err)
691}
692
693const fn internal_call_error_is_retryable(err: &Error) -> bool {
694 matches!(
695 err.code,
696 ErrorCode::AuthKeyUnknown | ErrorCode::AuthMaterialStale
697 )
698}
699
700#[derive(Clone, Copy, Debug, Eq, PartialEq)]
701enum WaitMode {
702 Bounded,
703 Unbounded,
704}
705
706fn build_internal_call_envelope(
707 target_canister: Principal,
708 target_method: &str,
709 proof: SignedInternalInvocationProofV1,
710 args: Vec<u8>,
711) -> CanicInternalCallEnvelopeV1 {
712 CanicInternalCallEnvelopeV1 {
713 version: 1,
714 header: CanicInternalCallHeaderV1 {
715 target_canister,
716 target_method: target_method.to_string(),
717 },
718 proof,
719 args,
720 }
721}
722
723pub struct IntentKey(BoundedString128);
738
739impl IntentKey {
740 pub fn try_new(value: impl Into<String>) -> Result<Self, Error> {
741 BoundedString128::try_new(value)
742 .map(Self)
743 .map_err(Error::invalid)
744 }
745
746 #[must_use]
747 pub fn as_str(&self) -> &str {
748 self.0.as_str()
749 }
750
751 #[must_use]
752 pub fn into_inner(self) -> BoundedString128 {
753 self.0
754 }
755}
756
757impl AsRef<str> for IntentKey {
758 fn as_ref(&self) -> &str {
759 self.0.as_str()
760 }
761}
762
763impl From<IntentKey> for BoundedString128 {
764 fn from(key: IntentKey) -> Self {
765 key.0
766 }
767}
768
769pub struct IntentReservation {
788 key: IntentKey,
789 quantity: u64,
790 ttl_secs: Option<u64>,
791 max_in_flight: Option<u64>,
792}
793
794impl IntentReservation {
795 #[must_use]
796 pub const fn new(key: IntentKey, quantity: u64) -> Self {
797 Self {
798 key,
799 quantity,
800 ttl_secs: None,
801 max_in_flight: None,
802 }
803 }
804
805 #[must_use]
806 pub const fn with_ttl_secs(mut self, ttl_secs: u64) -> Self {
807 self.ttl_secs = Some(ttl_secs);
808 self
809 }
810
811 #[must_use]
812 pub const fn with_max_in_flight(mut self, max_in_flight: u64) -> Self {
813 self.max_in_flight = Some(max_in_flight);
814 self
815 }
816
817 pub(crate) fn into_spec(self) -> WorkflowIntentSpec {
818 WorkflowIntentSpec::new(
819 self.key.into(),
820 self.quantity,
821 self.ttl_secs,
822 self.max_in_flight,
823 )
824 }
825}
826
827pub struct CallBuilder<'a> {
832 inner: WorkflowCallBuilder<'a>,
833}
834
835impl CallBuilder<'_> {
836 pub fn with_arg<A>(self, arg: A) -> Result<Self, Error>
840 where
841 A: CandidType,
842 {
843 Ok(Self {
844 inner: self.inner.with_arg(arg).map_err(Error::from)?,
845 })
846 }
847
848 pub fn with_args<A>(self, args: A) -> Result<Self, Error>
850 where
851 A: ArgumentEncoder,
852 {
853 Ok(Self {
854 inner: self.inner.with_args(args).map_err(Error::from)?,
855 })
856 }
857
858 #[must_use]
860 pub fn with_raw_args<'b>(self, args: impl Into<Cow<'b, [u8]>>) -> CallBuilder<'b> {
861 CallBuilder {
862 inner: self.inner.with_raw_args(args),
863 }
864 }
865
866 #[must_use]
869 pub fn with_cycles(self, cycles: u128) -> Self {
870 Self {
871 inner: self.inner.with_cycles(cycles),
872 }
873 }
874
875 #[must_use]
878 pub fn with_intent(self, intent: IntentReservation) -> Self {
879 Self {
880 inner: self.inner.with_intent(intent.into_spec()),
881 }
882 }
883
884 pub async fn execute(self) -> Result<CallResult, Error> {
887 Ok(CallResult {
888 inner: self.inner.execute().await.map_err(Error::from)?,
889 })
890 }
891}
892
893pub struct CallResult {
905 inner: WorkflowCallResult,
906}
907
908impl CallResult {
909 pub fn candid<R>(&self) -> Result<R, Error>
910 where
911 R: CandidType + DeserializeOwned,
912 {
913 self.inner.candid().map_err(Error::from)
914 }
915
916 pub fn candid_tuple<R>(&self) -> Result<R, Error>
917 where
918 R: for<'de> ArgumentDecoder<'de>,
919 {
920 self.inner.candid_tuple().map_err(Error::from)
921 }
922}
923
924#[cfg(test)]
925mod tests {
926 use super::*;
927 use crate::config::schema::RoleAttestationConfig;
928 use crate::dto::auth::{InternalInvocationProofPayloadV1, SignedInternalInvocationProofV1};
929 use candid::decode_args;
930 use std::collections::BTreeMap;
931
932 fn p(id: u8) -> Principal {
933 Principal::from_slice(&[id; 29])
934 }
935
936 fn proof() -> SignedInternalInvocationProofV1 {
937 SignedInternalInvocationProofV1 {
938 payload: InternalInvocationProofPayloadV1 {
939 subject: p(1),
940 role: CanisterRole::new("project_hub"),
941 subnet_id: None,
942 audience: p(2),
943 audience_method: "system_add_project_to_user".to_string(),
944 issued_at: 10,
945 expires_at: 20,
946 epoch: 3,
947 },
948 signature: vec![1, 2, 3],
949 key_id: 1,
950 }
951 }
952
953 fn request() -> InternalInvocationProofRequest {
954 InternalInvocationProofRequest {
955 subject: p(1),
956 role: CanisterRole::new("project_hub"),
957 subnet_id: Some(p(9)),
958 audience: p(2),
959 audience_method: "system_add_project_to_user".to_string(),
960 ttl_secs: 120,
961 metadata: None,
962 }
963 }
964
965 fn cfg(min_epoch: u64) -> RoleAttestationConfig {
966 let mut min_accepted_epoch_by_role = BTreeMap::new();
967 min_accepted_epoch_by_role.insert("project_hub".to_string(), min_epoch);
968 RoleAttestationConfig {
969 ecdsa_key_name: "key_1".to_string(),
970 max_ttl_secs: 900,
971 min_accepted_epoch_by_role,
972 }
973 }
974
975 fn clear_internal_invocation_proof_cache() {
976 INTERNAL_INVOCATION_PROOF_CACHE.with_borrow_mut(BTreeMap::clear);
977 }
978
979 #[test]
980 fn canic_call_envelope_binds_target_method_and_original_args() {
981 let args = encode_args((7_u64, "project")).expect("args encode");
982 let envelope =
983 build_internal_call_envelope(p(2), "system_add_project_to_user", proof(), args);
984
985 assert_eq!(envelope.version, 1);
986 assert_eq!(envelope.header.target_canister, p(2));
987 assert_eq!(envelope.header.target_method, "system_add_project_to_user");
988 assert_eq!(
989 envelope.proof.payload.audience_method,
990 "system_add_project_to_user"
991 );
992
993 let decoded: (u64, String) = decode_args(&envelope.args).expect("decode original args");
994 assert_eq!(decoded, (7, "project".to_string()));
995 }
996
997 #[test]
998 fn canic_call_builder_records_role_and_raw_args() {
999 let raw = vec![9_u8, 8, 7];
1000 let builder = CanicCall::unbounded_wait(p(3), "target")
1001 .with_caller_role(CanisterRole::new("project_hub"))
1002 .with_proof_ttl_secs(30)
1003 .with_cycles(10)
1004 .with_raw_args(raw.clone());
1005
1006 assert_eq!(builder.wait, WaitMode::Unbounded);
1007 assert_eq!(builder.canister_id, p(3));
1008 assert_eq!(builder.method, "target");
1009 assert_eq!(builder.caller_role, Some(CanisterRole::new("project_hub")));
1010 assert_eq!(builder.ttl_secs, Some(30));
1011 assert_eq!(builder.cycles, 10);
1012 assert_eq!(builder.args.as_ref(), raw.as_slice());
1013 }
1014
1015 #[test]
1016 fn protected_internal_endpoint_descriptor_matches_roles() {
1017 let endpoint = ProtectedInternalEndpoint::new(
1018 "system_add_project_to_user",
1019 [
1020 CanisterRole::new("project_hub"),
1021 CanisterRole::new("admin_hub"),
1022 ],
1023 );
1024
1025 assert_eq!(endpoint.method(), "system_add_project_to_user");
1026 assert!(endpoint.accepts_role(&CanisterRole::new("project_hub")));
1027 assert!(endpoint.accepts_role(&CanisterRole::new("admin_hub")));
1028 assert!(!endpoint.accepts_role(&CanisterRole::new("user_hub")));
1029 assert!(endpoint.single_role().is_none());
1030 }
1031
1032 #[test]
1033 fn protected_internal_endpoint_single_role_is_available_to_generated_clients() {
1034 let endpoint = ProtectedInternalEndpoint::new(
1035 "system_add_project_to_user",
1036 [CanisterRole::new("project_hub")],
1037 );
1038
1039 assert_eq!(
1040 endpoint.single_role(),
1041 Some(&CanisterRole::new("project_hub"))
1042 );
1043 assert_eq!(
1044 endpoint.required_single_role().expect("single role"),
1045 CanisterRole::new("project_hub")
1046 );
1047 }
1048
1049 #[test]
1050 fn protected_internal_endpoint_requires_explicit_role_when_ambiguous() {
1051 let endpoint = ProtectedInternalEndpoint::new(
1052 "system_add_project_to_user",
1053 [
1054 CanisterRole::new("project_hub"),
1055 CanisterRole::new("admin_hub"),
1056 ],
1057 );
1058
1059 let err = endpoint
1060 .required_single_role()
1061 .expect_err("multi-role endpoint should require explicit caller role");
1062 assert_eq!(err.code, ErrorCode::InvalidInput);
1063 }
1064
1065 #[test]
1066 fn protected_internal_endpoint_descriptor_rejects_missing_method() {
1067 let result =
1068 std::panic::catch_unwind(|| ProtectedInternalEndpoint::new("", [CanisterRole::ROOT]));
1069
1070 assert!(result.is_err());
1071 }
1072
1073 #[test]
1074 fn protected_internal_endpoint_descriptor_rejects_missing_roles() {
1075 let result = std::panic::catch_unwind(|| {
1076 ProtectedInternalEndpoint::new("system_add_project_to_user", [])
1077 });
1078
1079 assert!(result.is_err());
1080 }
1081
1082 #[test]
1083 fn protected_internal_endpoint_descriptor_rejects_empty_role() {
1084 let result = std::panic::catch_unwind(|| {
1085 ProtectedInternalEndpoint::new("system_add_project_to_user", [CanisterRole::new("")])
1086 });
1087
1088 assert!(result.is_err());
1089 }
1090
1091 #[test]
1092 fn protected_internal_endpoint_descriptor_rejects_duplicate_roles() {
1093 let result = std::panic::catch_unwind(|| {
1094 ProtectedInternalEndpoint::new(
1095 "system_add_project_to_user",
1096 [
1097 CanisterRole::new("project_hub"),
1098 CanisterRole::new("project_hub"),
1099 ],
1100 )
1101 });
1102
1103 assert!(result.is_err());
1104 }
1105
1106 #[test]
1107 fn internal_client_options_are_chainable() {
1108 let client = CanicInternalClient::new(p(3))
1109 .with_bounded_wait()
1110 .with_cycles(10)
1111 .with_proof_ttl_secs(30);
1112
1113 assert_eq!(client.canister_id, p(3));
1114 assert_eq!(client.options.wait, CanicInternalWaitMode::Bounded);
1115 assert_eq!(client.options.cycles, 10);
1116 assert_eq!(client.options.proof_ttl_secs, Some(30));
1117 }
1118
1119 #[test]
1120 fn internal_invocation_proof_cache_reuses_exact_fresh_edge() {
1121 clear_internal_invocation_proof_cache();
1122 let request = request();
1123 let mut proof = proof();
1124 proof.payload.subnet_id = request.subnet_id;
1125 cache_internal_invocation_proof(&request, &cfg(0), p(7), 12, proof.clone());
1126
1127 let cached = cached_internal_invocation_proof(&request, &cfg(0), p(7), 12)
1128 .expect("fresh matching proof should cache-hit");
1129
1130 assert_eq!(cached, proof);
1131 }
1132
1133 #[test]
1134 fn internal_invocation_proof_cache_rejects_near_expiry_entry() {
1135 clear_internal_invocation_proof_cache();
1136 let request = request();
1137 let mut proof = proof();
1138 proof.payload.subnet_id = request.subnet_id;
1139 proof.payload.issued_at = 10;
1140 proof.payload.expires_at = 20;
1141 cache_internal_invocation_proof(&request, &cfg(0), p(7), 18, proof);
1142
1143 assert!(cached_internal_invocation_proof(&request, &cfg(0), p(7), 18).is_none());
1144 }
1145
1146 #[test]
1147 fn internal_invocation_proof_cache_rejects_epoch_below_local_floor() {
1148 clear_internal_invocation_proof_cache();
1149 let request = request();
1150 let mut proof = proof();
1151 proof.payload.subnet_id = request.subnet_id;
1152 proof.payload.epoch = 3;
1153 cache_internal_invocation_proof(&request, &cfg(0), p(7), 12, proof);
1154
1155 assert!(cached_internal_invocation_proof(&request, &cfg(4), p(7), 12).is_none());
1156 }
1157
1158 #[test]
1159 fn internal_call_retry_classifier_is_limited_to_repairable_auth_material() {
1160 assert!(internal_call_error_is_retryable(&Error::new(
1161 ErrorCode::AuthKeyUnknown,
1162 "unknown key".to_string(),
1163 )));
1164 assert!(internal_call_error_is_retryable(&Error::new(
1165 ErrorCode::AuthMaterialStale,
1166 "stale epoch".to_string(),
1167 )));
1168 assert!(!internal_call_error_is_retryable(&Error::new(
1169 ErrorCode::AuthProofExpired,
1170 "expired".to_string(),
1171 )));
1172 assert!(!internal_call_error_is_retryable(&Error::unauthorized(
1173 "role mismatch"
1174 )));
1175 }
1176}