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.trim().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().trim().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 validate_internal_call_target_method(&self.method)?;
464 let ttl_secs = self.proof_ttl_secs()?;
465 let role = self
466 .caller_role
467 .ok_or_else(|| Error::invalid("CanicCall requires with_caller_role(...)"))?;
468 validate_internal_call_caller_role(&role)?;
469 let request = InternalInvocationProofRequest {
470 subject: IcOps::canister_self(),
471 role,
472 subnet_id: EnvOps::subnet_pid().ok(),
473 audience: self.canister_id,
474 audience_method: self.method.clone(),
475 ttl_secs,
476 metadata: None,
477 };
478 let args = self.args.into_owned();
479 let proof = internal_invocation_proof_for_request(request.clone()).await?;
480
481 let envelope =
482 build_internal_call_envelope(self.canister_id, &self.method, proof, args.clone());
483 let result = execute_internal_call_once(
484 self.wait,
485 self.canister_id,
486 &self.method,
487 self.cycles,
488 envelope,
489 )
490 .await?;
491 if !internal_call_result_is_retryable(&result) {
492 return Ok(result);
493 }
494
495 invalidate_internal_invocation_proof(&request)?;
496 let proof = fresh_internal_invocation_proof_for_request(request).await?;
497 let envelope = build_internal_call_envelope(self.canister_id, &self.method, proof, args);
498 execute_internal_call_once(
499 self.wait,
500 self.canister_id,
501 &self.method,
502 self.cycles,
503 envelope,
504 )
505 .await
506 }
507
508 fn proof_ttl_secs(&self) -> Result<u64, Error> {
509 let requested = self
510 .ttl_secs
511 .unwrap_or(DEFAULT_INTERNAL_CALL_PROOF_TTL_SECS);
512 let max = ConfigOps::role_attestation_config()
513 .map_err(Error::from)?
514 .max_ttl_secs;
515 effective_internal_call_proof_ttl_secs(requested, max)
516 }
517}
518
519fn validate_internal_call_target_method(method: &str) -> Result<(), Error> {
520 if method.trim().is_empty() {
521 return Err(Error::invalid(
522 "CanicCall requires a non-empty target method",
523 ));
524 }
525 Ok(())
526}
527
528fn validate_internal_call_caller_role(role: &CanisterRole) -> Result<(), Error> {
529 if role.as_str().trim().is_empty() {
530 return Err(Error::invalid("CanicCall requires a non-empty caller role"));
531 }
532 Ok(())
533}
534
535fn effective_internal_call_proof_ttl_secs(requested: u64, max: u64) -> Result<u64, Error> {
536 if requested == 0 {
537 return Err(Error::invalid(
538 "CanicCall proof TTL must be greater than zero",
539 ));
540 }
541 let effective = requested.min(max);
542 if effective == 0 {
543 return Err(Error::invalid(
544 "CanicCall proof TTL maximum must be greater than zero",
545 ));
546 }
547 Ok(effective)
548}
549
550async fn execute_internal_call_once(
551 wait: WaitMode,
552 canister_id: Principal,
553 method: &str,
554 cycles: u128,
555 envelope: CanicInternalCallEnvelopeV1,
556) -> Result<CallResult, Error> {
557 let call = match wait {
558 WaitMode::Bounded => Call::bounded_wait(canister_id, method),
559 WaitMode::Unbounded => Call::unbounded_wait(canister_id, method),
560 }
561 .with_cycles(cycles)
562 .with_raw_args(encode_internal_call_envelope_raw(envelope)?);
563
564 call.execute().await
565}
566
567#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
572struct InternalInvocationProofCacheKey {
573 root_pid: Principal,
574 attestation_key_name: String,
575 subject: Principal,
576 role: CanisterRole,
577 subnet_id: Option<Principal>,
578 audience: Principal,
579 audience_method: String,
580 ttl_secs: u64,
581}
582
583async fn internal_invocation_proof_for_request(
584 request: InternalInvocationProofRequest,
585) -> Result<SignedInternalInvocationProofV1, Error> {
586 let cfg = ConfigOps::role_attestation_config().map_err(Error::from)?;
587 let root_pid = EnvOps::root_pid().map_err(Error::from)?;
588 let now_secs = IcOps::now_secs();
589
590 if let Some(proof) = cached_internal_invocation_proof(&request, &cfg, root_pid, now_secs) {
591 return Ok(proof);
592 }
593
594 fresh_internal_invocation_proof_for_request_with_context(request, cfg, root_pid, now_secs).await
595}
596
597async fn fresh_internal_invocation_proof_for_request(
598 request: InternalInvocationProofRequest,
599) -> Result<SignedInternalInvocationProofV1, Error> {
600 let cfg = ConfigOps::role_attestation_config().map_err(Error::from)?;
601 let root_pid = EnvOps::root_pid().map_err(Error::from)?;
602 let now_secs = IcOps::now_secs();
603 fresh_internal_invocation_proof_for_request_with_context(request, cfg, root_pid, now_secs).await
604}
605
606async fn fresh_internal_invocation_proof_for_request_with_context(
607 request: InternalInvocationProofRequest,
608 cfg: RoleAttestationConfig,
609 root_pid: Principal,
610 now_secs: u64,
611) -> Result<SignedInternalInvocationProofV1, Error> {
612 let proof =
613 crate::api::auth::AuthApi::request_internal_invocation_proof(request.clone()).await?;
614 cache_internal_invocation_proof(&request, &cfg, root_pid, now_secs, proof.clone());
615 Ok(proof)
616}
617
618fn internal_invocation_proof_cache_key(
619 request: &InternalInvocationProofRequest,
620 cfg: &RoleAttestationConfig,
621 root_pid: Principal,
622) -> InternalInvocationProofCacheKey {
623 InternalInvocationProofCacheKey {
624 root_pid,
625 attestation_key_name: cfg.ecdsa_key_name.clone(),
626 subject: request.subject,
627 role: request.role.clone(),
628 subnet_id: request.subnet_id,
629 audience: request.audience,
630 audience_method: request.audience_method.clone(),
631 ttl_secs: request.ttl_secs,
632 }
633}
634
635fn cached_internal_invocation_proof(
636 request: &InternalInvocationProofRequest,
637 cfg: &RoleAttestationConfig,
638 root_pid: Principal,
639 now_secs: u64,
640) -> Option<SignedInternalInvocationProofV1> {
641 let key = internal_invocation_proof_cache_key(request, cfg, root_pid);
642 let min_accepted_epoch = cfg
643 .min_accepted_epoch_by_role
644 .get(request.role.as_str())
645 .copied()
646 .unwrap_or(0);
647
648 INTERNAL_INVOCATION_PROOF_CACHE.with_borrow_mut(|cache| {
649 let proof = cache.get(&key)?;
650 if internal_invocation_proof_is_reusable(proof, request, now_secs, min_accepted_epoch) {
651 Some(proof.clone())
652 } else {
653 cache.remove(&key);
654 None
655 }
656 })
657}
658
659fn cache_internal_invocation_proof(
660 request: &InternalInvocationProofRequest,
661 cfg: &RoleAttestationConfig,
662 root_pid: Principal,
663 now_secs: u64,
664 proof: SignedInternalInvocationProofV1,
665) {
666 let min_accepted_epoch = cfg
667 .min_accepted_epoch_by_role
668 .get(request.role.as_str())
669 .copied()
670 .unwrap_or(0);
671 if !internal_invocation_proof_is_reusable(&proof, request, now_secs, min_accepted_epoch) {
672 return;
673 }
674
675 let key = internal_invocation_proof_cache_key(request, cfg, root_pid);
676 INTERNAL_INVOCATION_PROOF_CACHE.with_borrow_mut(|cache| {
677 cache.insert(key, proof);
678 });
679}
680
681fn invalidate_internal_invocation_proof(
682 request: &InternalInvocationProofRequest,
683) -> Result<(), Error> {
684 let cfg = ConfigOps::role_attestation_config().map_err(Error::from)?;
685 let root_pid = EnvOps::root_pid().map_err(Error::from)?;
686 let key = internal_invocation_proof_cache_key(request, &cfg, root_pid);
687 INTERNAL_INVOCATION_PROOF_CACHE.with_borrow_mut(|cache| {
688 cache.remove(&key);
689 });
690 Ok(())
691}
692
693fn internal_invocation_proof_is_reusable(
694 proof: &SignedInternalInvocationProofV1,
695 request: &InternalInvocationProofRequest,
696 now_secs: u64,
697 min_accepted_epoch: u64,
698) -> bool {
699 let payload = &proof.payload;
700 if payload.expires_at <= payload.issued_at || now_secs < payload.issued_at {
701 return false;
702 }
703
704 payload.subject == request.subject
705 && payload.role == request.role
706 && payload.subnet_id == request.subnet_id
707 && payload.audience == request.audience
708 && payload.audience_method == request.audience_method
709 && payload.epoch >= min_accepted_epoch
710 && now_secs.saturating_add(internal_invocation_proof_refresh_margin_secs(proof))
711 < payload.expires_at
712}
713
714fn internal_invocation_proof_refresh_margin_secs(proof: &SignedInternalInvocationProofV1) -> u64 {
715 proof
716 .payload
717 .expires_at
718 .saturating_sub(proof.payload.issued_at)
719 .saturating_div(5)
720 .clamp(1, INTERNAL_CALL_PROOF_REFRESH_MARGIN_MAX_SECS)
721}
722
723fn internal_call_result_is_retryable(result: &CallResult) -> bool {
724 let Ok(Err(err)) = result.candid::<Result<candid::Reserved, Error>>() else {
725 return false;
726 };
727 internal_call_error_is_retryable(&err)
728}
729
730const fn internal_call_error_is_retryable(err: &Error) -> bool {
731 matches!(
732 err.code,
733 ErrorCode::AuthKeyUnknown | ErrorCode::AuthMaterialStale
734 )
735}
736
737#[derive(Clone, Copy, Debug, Eq, PartialEq)]
738enum WaitMode {
739 Bounded,
740 Unbounded,
741}
742
743fn build_internal_call_envelope(
744 target_canister: Principal,
745 target_method: &str,
746 proof: SignedInternalInvocationProofV1,
747 args: Vec<u8>,
748) -> CanicInternalCallEnvelopeV1 {
749 CanicInternalCallEnvelopeV1 {
750 version: 1,
751 header: CanicInternalCallHeaderV1 {
752 target_canister,
753 target_method: target_method.to_string(),
754 },
755 proof,
756 args,
757 }
758}
759
760fn encode_internal_call_envelope_raw(
761 envelope: CanicInternalCallEnvelopeV1,
762) -> Result<Vec<u8>, Error> {
763 encode_one(envelope).map_err(|err| Error::invalid(err.to_string()))
764}
765
766pub struct IntentKey(BoundedString128);
781
782impl IntentKey {
783 pub fn try_new(value: impl Into<String>) -> Result<Self, Error> {
784 BoundedString128::try_new(value)
785 .map(Self)
786 .map_err(Error::invalid)
787 }
788
789 #[must_use]
790 pub fn as_str(&self) -> &str {
791 self.0.as_str()
792 }
793
794 #[must_use]
795 pub fn into_inner(self) -> BoundedString128 {
796 self.0
797 }
798}
799
800impl AsRef<str> for IntentKey {
801 fn as_ref(&self) -> &str {
802 self.0.as_str()
803 }
804}
805
806impl From<IntentKey> for BoundedString128 {
807 fn from(key: IntentKey) -> Self {
808 key.0
809 }
810}
811
812pub struct IntentReservation {
831 key: IntentKey,
832 quantity: u64,
833 ttl_secs: Option<u64>,
834 max_in_flight: Option<u64>,
835}
836
837impl IntentReservation {
838 #[must_use]
839 pub const fn new(key: IntentKey, quantity: u64) -> Self {
840 Self {
841 key,
842 quantity,
843 ttl_secs: None,
844 max_in_flight: None,
845 }
846 }
847
848 #[must_use]
849 pub const fn with_ttl_secs(mut self, ttl_secs: u64) -> Self {
850 self.ttl_secs = Some(ttl_secs);
851 self
852 }
853
854 #[must_use]
855 pub const fn with_max_in_flight(mut self, max_in_flight: u64) -> Self {
856 self.max_in_flight = Some(max_in_flight);
857 self
858 }
859
860 pub(crate) fn into_spec(self) -> WorkflowIntentSpec {
861 WorkflowIntentSpec::new(
862 self.key.into(),
863 self.quantity,
864 self.ttl_secs,
865 self.max_in_flight,
866 )
867 }
868}
869
870pub struct CallBuilder<'a> {
875 inner: WorkflowCallBuilder<'a>,
876}
877
878impl CallBuilder<'_> {
879 pub fn with_arg<A>(self, arg: A) -> Result<Self, Error>
883 where
884 A: CandidType,
885 {
886 Ok(Self {
887 inner: self.inner.with_arg(arg).map_err(Error::from)?,
888 })
889 }
890
891 pub fn with_args<A>(self, args: A) -> Result<Self, Error>
893 where
894 A: ArgumentEncoder,
895 {
896 Ok(Self {
897 inner: self.inner.with_args(args).map_err(Error::from)?,
898 })
899 }
900
901 #[must_use]
903 pub fn with_raw_args<'b>(self, args: impl Into<Cow<'b, [u8]>>) -> CallBuilder<'b> {
904 CallBuilder {
905 inner: self.inner.with_raw_args(args),
906 }
907 }
908
909 #[must_use]
912 pub fn with_cycles(self, cycles: u128) -> Self {
913 Self {
914 inner: self.inner.with_cycles(cycles),
915 }
916 }
917
918 #[must_use]
921 pub fn with_intent(self, intent: IntentReservation) -> Self {
922 Self {
923 inner: self.inner.with_intent(intent.into_spec()),
924 }
925 }
926
927 pub async fn execute(self) -> Result<CallResult, Error> {
930 Ok(CallResult {
931 inner: self.inner.execute().await.map_err(Error::from)?,
932 })
933 }
934}
935
936pub struct CallResult {
948 inner: WorkflowCallResult,
949}
950
951impl CallResult {
952 pub fn candid<R>(&self) -> Result<R, Error>
953 where
954 R: CandidType + DeserializeOwned,
955 {
956 self.inner.candid().map_err(Error::from)
957 }
958
959 pub fn candid_tuple<R>(&self) -> Result<R, Error>
960 where
961 R: for<'de> ArgumentDecoder<'de>,
962 {
963 self.inner.candid_tuple().map_err(Error::from)
964 }
965}
966
967#[cfg(test)]
968mod tests {
969 use super::*;
970 use crate::config::schema::RoleAttestationConfig;
971 use crate::dto::auth::{InternalInvocationProofPayloadV1, SignedInternalInvocationProofV1};
972 use candid::{decode_args, decode_one};
973 use std::collections::BTreeMap;
974
975 fn p(id: u8) -> Principal {
976 Principal::from_slice(&[id; 29])
977 }
978
979 fn proof() -> SignedInternalInvocationProofV1 {
980 SignedInternalInvocationProofV1 {
981 payload: InternalInvocationProofPayloadV1 {
982 subject: p(1),
983 role: CanisterRole::new("project_hub"),
984 subnet_id: None,
985 audience: p(2),
986 audience_method: "system_add_project_to_user".to_string(),
987 issued_at: 10,
988 expires_at: 20,
989 epoch: 3,
990 },
991 signature: vec![1, 2, 3],
992 key_id: 1,
993 }
994 }
995
996 fn request() -> InternalInvocationProofRequest {
997 InternalInvocationProofRequest {
998 subject: p(1),
999 role: CanisterRole::new("project_hub"),
1000 subnet_id: Some(p(9)),
1001 audience: p(2),
1002 audience_method: "system_add_project_to_user".to_string(),
1003 ttl_secs: 120,
1004 metadata: None,
1005 }
1006 }
1007
1008 fn cfg(min_epoch: u64) -> RoleAttestationConfig {
1009 let mut min_accepted_epoch_by_role = BTreeMap::new();
1010 min_accepted_epoch_by_role.insert("project_hub".to_string(), min_epoch);
1011 RoleAttestationConfig {
1012 ecdsa_key_name: "key_1".to_string(),
1013 max_ttl_secs: 900,
1014 min_accepted_epoch_by_role,
1015 }
1016 }
1017
1018 fn clear_internal_invocation_proof_cache() {
1019 INTERNAL_INVOCATION_PROOF_CACHE.with_borrow_mut(BTreeMap::clear);
1020 }
1021
1022 #[test]
1023 fn canic_call_envelope_binds_target_method_and_original_args() {
1024 let args = encode_args((7_u64, "project")).expect("args encode");
1025 let envelope =
1026 build_internal_call_envelope(p(2), "system_add_project_to_user", proof(), args);
1027
1028 assert_eq!(envelope.version, 1);
1029 assert_eq!(envelope.header.target_canister, p(2));
1030 assert_eq!(envelope.header.target_method, "system_add_project_to_user");
1031 assert_eq!(
1032 envelope.proof.payload.audience_method,
1033 "system_add_project_to_user"
1034 );
1035
1036 let decoded: (u64, String) = decode_args(&envelope.args).expect("decode original args");
1037 assert_eq!(decoded, (7, "project".to_string()));
1038 }
1039
1040 #[test]
1041 fn canic_call_encodes_envelope_as_raw_ingress_bytes() {
1042 let args = encode_args((7_u64, "project")).expect("args encode");
1043 let envelope =
1044 build_internal_call_envelope(p(2), "system_add_project_to_user", proof(), args);
1045 let raw =
1046 encode_internal_call_envelope_raw(envelope.clone()).expect("envelope should encode");
1047
1048 let decoded: CanicInternalCallEnvelopeV1 =
1049 decode_one(&raw).expect("raw ingress bytes decode as envelope");
1050
1051 assert_eq!(decoded, envelope);
1052 }
1053
1054 #[test]
1055 fn canic_call_builder_records_role_and_raw_args() {
1056 let raw = vec![9_u8, 8, 7];
1057 let builder = CanicCall::unbounded_wait(p(3), "target")
1058 .with_caller_role(CanisterRole::new("project_hub"))
1059 .with_proof_ttl_secs(30)
1060 .with_cycles(10)
1061 .with_raw_args(raw.clone());
1062
1063 assert_eq!(builder.wait, WaitMode::Unbounded);
1064 assert_eq!(builder.canister_id, p(3));
1065 assert_eq!(builder.method, "target");
1066 assert_eq!(builder.caller_role, Some(CanisterRole::new("project_hub")));
1067 assert_eq!(builder.ttl_secs, Some(30));
1068 assert_eq!(builder.cycles, 10);
1069 assert_eq!(builder.args.as_ref(), raw.as_slice());
1070 }
1071
1072 #[test]
1073 fn canic_call_rejects_empty_target_method_locally() {
1074 let err = validate_internal_call_target_method(" ")
1075 .expect_err("empty protected call method should fail locally");
1076
1077 assert_eq!(err.code, ErrorCode::InvalidInput);
1078 }
1079
1080 #[test]
1081 fn canic_call_rejects_empty_caller_role_locally() {
1082 let err = validate_internal_call_caller_role(&CanisterRole::new(" "))
1083 .expect_err("empty protected call role should fail locally");
1084
1085 assert_eq!(err.code, ErrorCode::InvalidInput);
1086 }
1087
1088 #[test]
1089 fn canic_call_rejects_zero_effective_proof_ttl_locally() {
1090 let zero_requested = effective_internal_call_proof_ttl_secs(0, 900)
1091 .expect_err("zero requested proof ttl should fail locally");
1092 assert_eq!(zero_requested.code, ErrorCode::InvalidInput);
1093
1094 let zero_max = effective_internal_call_proof_ttl_secs(120, 0)
1095 .expect_err("zero configured max proof ttl should fail locally");
1096 assert_eq!(zero_max.code, ErrorCode::InvalidInput);
1097 }
1098
1099 #[test]
1100 fn canic_call_clamps_requested_proof_ttl_to_config_max() {
1101 assert_eq!(
1102 effective_internal_call_proof_ttl_secs(120, 900).expect("ttl"),
1103 120
1104 );
1105 assert_eq!(
1106 effective_internal_call_proof_ttl_secs(1200, 900).expect("ttl"),
1107 900
1108 );
1109 }
1110
1111 #[test]
1112 fn protected_internal_endpoint_descriptor_matches_roles() {
1113 let endpoint = ProtectedInternalEndpoint::new(
1114 "system_add_project_to_user",
1115 [
1116 CanisterRole::new("project_hub"),
1117 CanisterRole::new("admin_hub"),
1118 ],
1119 );
1120
1121 assert_eq!(endpoint.method(), "system_add_project_to_user");
1122 assert!(endpoint.accepts_role(&CanisterRole::new("project_hub")));
1123 assert!(endpoint.accepts_role(&CanisterRole::new("admin_hub")));
1124 assert!(!endpoint.accepts_role(&CanisterRole::new("user_hub")));
1125 assert!(endpoint.single_role().is_none());
1126 }
1127
1128 #[test]
1129 fn protected_internal_endpoint_single_role_is_available_to_generated_clients() {
1130 let endpoint = ProtectedInternalEndpoint::new(
1131 "system_add_project_to_user",
1132 [CanisterRole::new("project_hub")],
1133 );
1134
1135 assert_eq!(
1136 endpoint.single_role(),
1137 Some(&CanisterRole::new("project_hub"))
1138 );
1139 assert_eq!(
1140 endpoint.required_single_role().expect("single role"),
1141 CanisterRole::new("project_hub")
1142 );
1143 }
1144
1145 #[test]
1146 fn protected_internal_endpoint_requires_explicit_role_when_ambiguous() {
1147 let endpoint = ProtectedInternalEndpoint::new(
1148 "system_add_project_to_user",
1149 [
1150 CanisterRole::new("project_hub"),
1151 CanisterRole::new("admin_hub"),
1152 ],
1153 );
1154
1155 let err = endpoint
1156 .required_single_role()
1157 .expect_err("multi-role endpoint should require explicit caller role");
1158 assert_eq!(err.code, ErrorCode::InvalidInput);
1159 }
1160
1161 #[test]
1162 fn protected_internal_endpoint_descriptor_rejects_missing_method() {
1163 let result =
1164 std::panic::catch_unwind(|| ProtectedInternalEndpoint::new("", [CanisterRole::ROOT]));
1165
1166 assert!(result.is_err());
1167 }
1168
1169 #[test]
1170 fn protected_internal_endpoint_descriptor_rejects_blank_method() {
1171 let result = std::panic::catch_unwind(|| {
1172 ProtectedInternalEndpoint::new(" ", [CanisterRole::ROOT])
1173 });
1174
1175 assert!(result.is_err());
1176 }
1177
1178 #[test]
1179 fn protected_internal_endpoint_descriptor_rejects_missing_roles() {
1180 let result = std::panic::catch_unwind(|| {
1181 ProtectedInternalEndpoint::new("system_add_project_to_user", [])
1182 });
1183
1184 assert!(result.is_err());
1185 }
1186
1187 #[test]
1188 fn protected_internal_endpoint_descriptor_rejects_empty_role() {
1189 let result = std::panic::catch_unwind(|| {
1190 ProtectedInternalEndpoint::new("system_add_project_to_user", [CanisterRole::new("")])
1191 });
1192
1193 assert!(result.is_err());
1194 }
1195
1196 #[test]
1197 fn protected_internal_endpoint_descriptor_rejects_blank_role() {
1198 let result = std::panic::catch_unwind(|| {
1199 ProtectedInternalEndpoint::new("system_add_project_to_user", [CanisterRole::new(" ")])
1200 });
1201
1202 assert!(result.is_err());
1203 }
1204
1205 #[test]
1206 fn protected_internal_endpoint_descriptor_rejects_duplicate_roles() {
1207 let result = std::panic::catch_unwind(|| {
1208 ProtectedInternalEndpoint::new(
1209 "system_add_project_to_user",
1210 [
1211 CanisterRole::new("project_hub"),
1212 CanisterRole::new("project_hub"),
1213 ],
1214 )
1215 });
1216
1217 assert!(result.is_err());
1218 }
1219
1220 #[test]
1221 fn internal_client_options_are_chainable() {
1222 let client = CanicInternalClient::new(p(3))
1223 .with_bounded_wait()
1224 .with_cycles(10)
1225 .with_proof_ttl_secs(30);
1226
1227 assert_eq!(client.canister_id, p(3));
1228 assert_eq!(client.options.wait, CanicInternalWaitMode::Bounded);
1229 assert_eq!(client.options.cycles, 10);
1230 assert_eq!(client.options.proof_ttl_secs, Some(30));
1231 }
1232
1233 #[test]
1234 fn internal_client_rejects_unaccepted_explicit_role_locally() {
1235 let client = CanicInternalClient::new(p(3));
1236 let endpoint = ProtectedInternalEndpoint::new(
1237 "system_add_project_to_user",
1238 [CanisterRole::new("project_hub")],
1239 );
1240 let result = futures::executor::block_on(client.call_update(
1241 &endpoint,
1242 CanisterRole::new("admin_hub"),
1243 (),
1244 ));
1245
1246 match result {
1247 Err(err) => assert_eq!(err.code, ErrorCode::InvalidInput),
1248 Ok(_) => panic!("unaccepted caller role should fail before transport"),
1249 }
1250 }
1251
1252 #[test]
1253 fn internal_invocation_proof_cache_reuses_exact_fresh_edge() {
1254 clear_internal_invocation_proof_cache();
1255 let request = request();
1256 let mut proof = proof();
1257 proof.payload.subnet_id = request.subnet_id;
1258 cache_internal_invocation_proof(&request, &cfg(0), p(7), 12, proof.clone());
1259
1260 let cached = cached_internal_invocation_proof(&request, &cfg(0), p(7), 12)
1261 .expect("fresh matching proof should cache-hit");
1262
1263 assert_eq!(cached, proof);
1264 }
1265
1266 #[test]
1267 fn internal_invocation_proof_cache_rejects_near_expiry_entry() {
1268 clear_internal_invocation_proof_cache();
1269 let request = request();
1270 let mut proof = proof();
1271 proof.payload.subnet_id = request.subnet_id;
1272 proof.payload.issued_at = 10;
1273 proof.payload.expires_at = 20;
1274 cache_internal_invocation_proof(&request, &cfg(0), p(7), 18, proof);
1275
1276 assert!(cached_internal_invocation_proof(&request, &cfg(0), p(7), 18).is_none());
1277 }
1278
1279 #[test]
1280 fn internal_invocation_proof_cache_rejects_future_issued_at_entry() {
1281 clear_internal_invocation_proof_cache();
1282 let request = request();
1283 let mut proof = proof();
1284 proof.payload.subnet_id = request.subnet_id;
1285 proof.payload.issued_at = 20;
1286 proof.payload.expires_at = 40;
1287 cache_internal_invocation_proof(&request, &cfg(0), p(7), 12, proof);
1288
1289 assert!(cached_internal_invocation_proof(&request, &cfg(0), p(7), 12).is_none());
1290 }
1291
1292 #[test]
1293 fn internal_invocation_proof_cache_rejects_invalid_time_window() {
1294 clear_internal_invocation_proof_cache();
1295 let request = request();
1296 let mut proof = proof();
1297 proof.payload.subnet_id = request.subnet_id;
1298 proof.payload.issued_at = 20;
1299 proof.payload.expires_at = 20;
1300 cache_internal_invocation_proof(&request, &cfg(0), p(7), 20, proof);
1301
1302 assert!(cached_internal_invocation_proof(&request, &cfg(0), p(7), 20).is_none());
1303 }
1304
1305 #[test]
1306 fn internal_invocation_proof_cache_rejects_epoch_below_local_floor() {
1307 clear_internal_invocation_proof_cache();
1308 let request = request();
1309 let mut proof = proof();
1310 proof.payload.subnet_id = request.subnet_id;
1311 proof.payload.epoch = 3;
1312 cache_internal_invocation_proof(&request, &cfg(0), p(7), 12, proof);
1313
1314 assert!(cached_internal_invocation_proof(&request, &cfg(4), p(7), 12).is_none());
1315 }
1316
1317 #[test]
1318 fn internal_call_retry_classifier_is_limited_to_repairable_auth_material() {
1319 assert!(internal_call_error_is_retryable(&Error::new(
1320 ErrorCode::AuthKeyUnknown,
1321 "unknown key".to_string(),
1322 )));
1323 assert!(internal_call_error_is_retryable(&Error::new(
1324 ErrorCode::AuthMaterialStale,
1325 "stale epoch".to_string(),
1326 )));
1327 assert!(!internal_call_error_is_retryable(&Error::new(
1328 ErrorCode::AuthProofExpired,
1329 "expired".to_string(),
1330 )));
1331 assert!(!internal_call_error_is_retryable(&Error::unauthorized(
1332 "role mismatch"
1333 )));
1334 }
1335}