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 payload.subject == request.subject
701 && payload.role == request.role
702 && payload.subnet_id == request.subnet_id
703 && payload.audience == request.audience
704 && payload.audience_method == request.audience_method
705 && payload.epoch >= min_accepted_epoch
706 && now_secs.saturating_add(internal_invocation_proof_refresh_margin_secs(proof))
707 < payload.expires_at
708}
709
710fn internal_invocation_proof_refresh_margin_secs(proof: &SignedInternalInvocationProofV1) -> u64 {
711 proof
712 .payload
713 .expires_at
714 .saturating_sub(proof.payload.issued_at)
715 .saturating_div(5)
716 .clamp(1, INTERNAL_CALL_PROOF_REFRESH_MARGIN_MAX_SECS)
717}
718
719fn internal_call_result_is_retryable(result: &CallResult) -> bool {
720 let Ok(Err(err)) = result.candid::<Result<candid::Reserved, Error>>() else {
721 return false;
722 };
723 internal_call_error_is_retryable(&err)
724}
725
726const fn internal_call_error_is_retryable(err: &Error) -> bool {
727 matches!(
728 err.code,
729 ErrorCode::AuthKeyUnknown | ErrorCode::AuthMaterialStale
730 )
731}
732
733#[derive(Clone, Copy, Debug, Eq, PartialEq)]
734enum WaitMode {
735 Bounded,
736 Unbounded,
737}
738
739fn build_internal_call_envelope(
740 target_canister: Principal,
741 target_method: &str,
742 proof: SignedInternalInvocationProofV1,
743 args: Vec<u8>,
744) -> CanicInternalCallEnvelopeV1 {
745 CanicInternalCallEnvelopeV1 {
746 version: 1,
747 header: CanicInternalCallHeaderV1 {
748 target_canister,
749 target_method: target_method.to_string(),
750 },
751 proof,
752 args,
753 }
754}
755
756fn encode_internal_call_envelope_raw(
757 envelope: CanicInternalCallEnvelopeV1,
758) -> Result<Vec<u8>, Error> {
759 encode_one(envelope).map_err(|err| Error::invalid(err.to_string()))
760}
761
762pub struct IntentKey(BoundedString128);
777
778impl IntentKey {
779 pub fn try_new(value: impl Into<String>) -> Result<Self, Error> {
780 BoundedString128::try_new(value)
781 .map(Self)
782 .map_err(Error::invalid)
783 }
784
785 #[must_use]
786 pub fn as_str(&self) -> &str {
787 self.0.as_str()
788 }
789
790 #[must_use]
791 pub fn into_inner(self) -> BoundedString128 {
792 self.0
793 }
794}
795
796impl AsRef<str> for IntentKey {
797 fn as_ref(&self) -> &str {
798 self.0.as_str()
799 }
800}
801
802impl From<IntentKey> for BoundedString128 {
803 fn from(key: IntentKey) -> Self {
804 key.0
805 }
806}
807
808pub struct IntentReservation {
827 key: IntentKey,
828 quantity: u64,
829 ttl_secs: Option<u64>,
830 max_in_flight: Option<u64>,
831}
832
833impl IntentReservation {
834 #[must_use]
835 pub const fn new(key: IntentKey, quantity: u64) -> Self {
836 Self {
837 key,
838 quantity,
839 ttl_secs: None,
840 max_in_flight: None,
841 }
842 }
843
844 #[must_use]
845 pub const fn with_ttl_secs(mut self, ttl_secs: u64) -> Self {
846 self.ttl_secs = Some(ttl_secs);
847 self
848 }
849
850 #[must_use]
851 pub const fn with_max_in_flight(mut self, max_in_flight: u64) -> Self {
852 self.max_in_flight = Some(max_in_flight);
853 self
854 }
855
856 pub(crate) fn into_spec(self) -> WorkflowIntentSpec {
857 WorkflowIntentSpec::new(
858 self.key.into(),
859 self.quantity,
860 self.ttl_secs,
861 self.max_in_flight,
862 )
863 }
864}
865
866pub struct CallBuilder<'a> {
871 inner: WorkflowCallBuilder<'a>,
872}
873
874impl CallBuilder<'_> {
875 pub fn with_arg<A>(self, arg: A) -> Result<Self, Error>
879 where
880 A: CandidType,
881 {
882 Ok(Self {
883 inner: self.inner.with_arg(arg).map_err(Error::from)?,
884 })
885 }
886
887 pub fn with_args<A>(self, args: A) -> Result<Self, Error>
889 where
890 A: ArgumentEncoder,
891 {
892 Ok(Self {
893 inner: self.inner.with_args(args).map_err(Error::from)?,
894 })
895 }
896
897 #[must_use]
899 pub fn with_raw_args<'b>(self, args: impl Into<Cow<'b, [u8]>>) -> CallBuilder<'b> {
900 CallBuilder {
901 inner: self.inner.with_raw_args(args),
902 }
903 }
904
905 #[must_use]
908 pub fn with_cycles(self, cycles: u128) -> Self {
909 Self {
910 inner: self.inner.with_cycles(cycles),
911 }
912 }
913
914 #[must_use]
917 pub fn with_intent(self, intent: IntentReservation) -> Self {
918 Self {
919 inner: self.inner.with_intent(intent.into_spec()),
920 }
921 }
922
923 pub async fn execute(self) -> Result<CallResult, Error> {
926 Ok(CallResult {
927 inner: self.inner.execute().await.map_err(Error::from)?,
928 })
929 }
930}
931
932pub struct CallResult {
944 inner: WorkflowCallResult,
945}
946
947impl CallResult {
948 pub fn candid<R>(&self) -> Result<R, Error>
949 where
950 R: CandidType + DeserializeOwned,
951 {
952 self.inner.candid().map_err(Error::from)
953 }
954
955 pub fn candid_tuple<R>(&self) -> Result<R, Error>
956 where
957 R: for<'de> ArgumentDecoder<'de>,
958 {
959 self.inner.candid_tuple().map_err(Error::from)
960 }
961}
962
963#[cfg(test)]
964mod tests {
965 use super::*;
966 use crate::config::schema::RoleAttestationConfig;
967 use crate::dto::auth::{InternalInvocationProofPayloadV1, SignedInternalInvocationProofV1};
968 use candid::{decode_args, decode_one};
969 use std::collections::BTreeMap;
970
971 fn p(id: u8) -> Principal {
972 Principal::from_slice(&[id; 29])
973 }
974
975 fn proof() -> SignedInternalInvocationProofV1 {
976 SignedInternalInvocationProofV1 {
977 payload: InternalInvocationProofPayloadV1 {
978 subject: p(1),
979 role: CanisterRole::new("project_hub"),
980 subnet_id: None,
981 audience: p(2),
982 audience_method: "system_add_project_to_user".to_string(),
983 issued_at: 10,
984 expires_at: 20,
985 epoch: 3,
986 },
987 signature: vec![1, 2, 3],
988 key_id: 1,
989 }
990 }
991
992 fn request() -> InternalInvocationProofRequest {
993 InternalInvocationProofRequest {
994 subject: p(1),
995 role: CanisterRole::new("project_hub"),
996 subnet_id: Some(p(9)),
997 audience: p(2),
998 audience_method: "system_add_project_to_user".to_string(),
999 ttl_secs: 120,
1000 metadata: None,
1001 }
1002 }
1003
1004 fn cfg(min_epoch: u64) -> RoleAttestationConfig {
1005 let mut min_accepted_epoch_by_role = BTreeMap::new();
1006 min_accepted_epoch_by_role.insert("project_hub".to_string(), min_epoch);
1007 RoleAttestationConfig {
1008 ecdsa_key_name: "key_1".to_string(),
1009 max_ttl_secs: 900,
1010 min_accepted_epoch_by_role,
1011 }
1012 }
1013
1014 fn clear_internal_invocation_proof_cache() {
1015 INTERNAL_INVOCATION_PROOF_CACHE.with_borrow_mut(BTreeMap::clear);
1016 }
1017
1018 #[test]
1019 fn canic_call_envelope_binds_target_method_and_original_args() {
1020 let args = encode_args((7_u64, "project")).expect("args encode");
1021 let envelope =
1022 build_internal_call_envelope(p(2), "system_add_project_to_user", proof(), args);
1023
1024 assert_eq!(envelope.version, 1);
1025 assert_eq!(envelope.header.target_canister, p(2));
1026 assert_eq!(envelope.header.target_method, "system_add_project_to_user");
1027 assert_eq!(
1028 envelope.proof.payload.audience_method,
1029 "system_add_project_to_user"
1030 );
1031
1032 let decoded: (u64, String) = decode_args(&envelope.args).expect("decode original args");
1033 assert_eq!(decoded, (7, "project".to_string()));
1034 }
1035
1036 #[test]
1037 fn canic_call_encodes_envelope_as_raw_ingress_bytes() {
1038 let args = encode_args((7_u64, "project")).expect("args encode");
1039 let envelope =
1040 build_internal_call_envelope(p(2), "system_add_project_to_user", proof(), args);
1041 let raw =
1042 encode_internal_call_envelope_raw(envelope.clone()).expect("envelope should encode");
1043
1044 let decoded: CanicInternalCallEnvelopeV1 =
1045 decode_one(&raw).expect("raw ingress bytes decode as envelope");
1046
1047 assert_eq!(decoded, envelope);
1048 }
1049
1050 #[test]
1051 fn canic_call_builder_records_role_and_raw_args() {
1052 let raw = vec![9_u8, 8, 7];
1053 let builder = CanicCall::unbounded_wait(p(3), "target")
1054 .with_caller_role(CanisterRole::new("project_hub"))
1055 .with_proof_ttl_secs(30)
1056 .with_cycles(10)
1057 .with_raw_args(raw.clone());
1058
1059 assert_eq!(builder.wait, WaitMode::Unbounded);
1060 assert_eq!(builder.canister_id, p(3));
1061 assert_eq!(builder.method, "target");
1062 assert_eq!(builder.caller_role, Some(CanisterRole::new("project_hub")));
1063 assert_eq!(builder.ttl_secs, Some(30));
1064 assert_eq!(builder.cycles, 10);
1065 assert_eq!(builder.args.as_ref(), raw.as_slice());
1066 }
1067
1068 #[test]
1069 fn canic_call_rejects_empty_target_method_locally() {
1070 let err = validate_internal_call_target_method(" ")
1071 .expect_err("empty protected call method should fail locally");
1072
1073 assert_eq!(err.code, ErrorCode::InvalidInput);
1074 }
1075
1076 #[test]
1077 fn canic_call_rejects_empty_caller_role_locally() {
1078 let err = validate_internal_call_caller_role(&CanisterRole::new(" "))
1079 .expect_err("empty protected call role should fail locally");
1080
1081 assert_eq!(err.code, ErrorCode::InvalidInput);
1082 }
1083
1084 #[test]
1085 fn canic_call_rejects_zero_effective_proof_ttl_locally() {
1086 let zero_requested = effective_internal_call_proof_ttl_secs(0, 900)
1087 .expect_err("zero requested proof ttl should fail locally");
1088 assert_eq!(zero_requested.code, ErrorCode::InvalidInput);
1089
1090 let zero_max = effective_internal_call_proof_ttl_secs(120, 0)
1091 .expect_err("zero configured max proof ttl should fail locally");
1092 assert_eq!(zero_max.code, ErrorCode::InvalidInput);
1093 }
1094
1095 #[test]
1096 fn canic_call_clamps_requested_proof_ttl_to_config_max() {
1097 assert_eq!(
1098 effective_internal_call_proof_ttl_secs(120, 900).expect("ttl"),
1099 120
1100 );
1101 assert_eq!(
1102 effective_internal_call_proof_ttl_secs(1200, 900).expect("ttl"),
1103 900
1104 );
1105 }
1106
1107 #[test]
1108 fn protected_internal_endpoint_descriptor_matches_roles() {
1109 let endpoint = ProtectedInternalEndpoint::new(
1110 "system_add_project_to_user",
1111 [
1112 CanisterRole::new("project_hub"),
1113 CanisterRole::new("admin_hub"),
1114 ],
1115 );
1116
1117 assert_eq!(endpoint.method(), "system_add_project_to_user");
1118 assert!(endpoint.accepts_role(&CanisterRole::new("project_hub")));
1119 assert!(endpoint.accepts_role(&CanisterRole::new("admin_hub")));
1120 assert!(!endpoint.accepts_role(&CanisterRole::new("user_hub")));
1121 assert!(endpoint.single_role().is_none());
1122 }
1123
1124 #[test]
1125 fn protected_internal_endpoint_single_role_is_available_to_generated_clients() {
1126 let endpoint = ProtectedInternalEndpoint::new(
1127 "system_add_project_to_user",
1128 [CanisterRole::new("project_hub")],
1129 );
1130
1131 assert_eq!(
1132 endpoint.single_role(),
1133 Some(&CanisterRole::new("project_hub"))
1134 );
1135 assert_eq!(
1136 endpoint.required_single_role().expect("single role"),
1137 CanisterRole::new("project_hub")
1138 );
1139 }
1140
1141 #[test]
1142 fn protected_internal_endpoint_requires_explicit_role_when_ambiguous() {
1143 let endpoint = ProtectedInternalEndpoint::new(
1144 "system_add_project_to_user",
1145 [
1146 CanisterRole::new("project_hub"),
1147 CanisterRole::new("admin_hub"),
1148 ],
1149 );
1150
1151 let err = endpoint
1152 .required_single_role()
1153 .expect_err("multi-role endpoint should require explicit caller role");
1154 assert_eq!(err.code, ErrorCode::InvalidInput);
1155 }
1156
1157 #[test]
1158 fn protected_internal_endpoint_descriptor_rejects_missing_method() {
1159 let result =
1160 std::panic::catch_unwind(|| ProtectedInternalEndpoint::new("", [CanisterRole::ROOT]));
1161
1162 assert!(result.is_err());
1163 }
1164
1165 #[test]
1166 fn protected_internal_endpoint_descriptor_rejects_blank_method() {
1167 let result = std::panic::catch_unwind(|| {
1168 ProtectedInternalEndpoint::new(" ", [CanisterRole::ROOT])
1169 });
1170
1171 assert!(result.is_err());
1172 }
1173
1174 #[test]
1175 fn protected_internal_endpoint_descriptor_rejects_missing_roles() {
1176 let result = std::panic::catch_unwind(|| {
1177 ProtectedInternalEndpoint::new("system_add_project_to_user", [])
1178 });
1179
1180 assert!(result.is_err());
1181 }
1182
1183 #[test]
1184 fn protected_internal_endpoint_descriptor_rejects_empty_role() {
1185 let result = std::panic::catch_unwind(|| {
1186 ProtectedInternalEndpoint::new("system_add_project_to_user", [CanisterRole::new("")])
1187 });
1188
1189 assert!(result.is_err());
1190 }
1191
1192 #[test]
1193 fn protected_internal_endpoint_descriptor_rejects_blank_role() {
1194 let result = std::panic::catch_unwind(|| {
1195 ProtectedInternalEndpoint::new("system_add_project_to_user", [CanisterRole::new(" ")])
1196 });
1197
1198 assert!(result.is_err());
1199 }
1200
1201 #[test]
1202 fn protected_internal_endpoint_descriptor_rejects_duplicate_roles() {
1203 let result = std::panic::catch_unwind(|| {
1204 ProtectedInternalEndpoint::new(
1205 "system_add_project_to_user",
1206 [
1207 CanisterRole::new("project_hub"),
1208 CanisterRole::new("project_hub"),
1209 ],
1210 )
1211 });
1212
1213 assert!(result.is_err());
1214 }
1215
1216 #[test]
1217 fn internal_client_options_are_chainable() {
1218 let client = CanicInternalClient::new(p(3))
1219 .with_bounded_wait()
1220 .with_cycles(10)
1221 .with_proof_ttl_secs(30);
1222
1223 assert_eq!(client.canister_id, p(3));
1224 assert_eq!(client.options.wait, CanicInternalWaitMode::Bounded);
1225 assert_eq!(client.options.cycles, 10);
1226 assert_eq!(client.options.proof_ttl_secs, Some(30));
1227 }
1228
1229 #[test]
1230 fn internal_client_rejects_unaccepted_explicit_role_locally() {
1231 let client = CanicInternalClient::new(p(3));
1232 let endpoint = ProtectedInternalEndpoint::new(
1233 "system_add_project_to_user",
1234 [CanisterRole::new("project_hub")],
1235 );
1236 let result = futures::executor::block_on(client.call_update(
1237 &endpoint,
1238 CanisterRole::new("admin_hub"),
1239 (),
1240 ));
1241
1242 match result {
1243 Err(err) => assert_eq!(err.code, ErrorCode::InvalidInput),
1244 Ok(_) => panic!("unaccepted caller role should fail before transport"),
1245 }
1246 }
1247
1248 #[test]
1249 fn internal_invocation_proof_cache_reuses_exact_fresh_edge() {
1250 clear_internal_invocation_proof_cache();
1251 let request = request();
1252 let mut proof = proof();
1253 proof.payload.subnet_id = request.subnet_id;
1254 cache_internal_invocation_proof(&request, &cfg(0), p(7), 12, proof.clone());
1255
1256 let cached = cached_internal_invocation_proof(&request, &cfg(0), p(7), 12)
1257 .expect("fresh matching proof should cache-hit");
1258
1259 assert_eq!(cached, proof);
1260 }
1261
1262 #[test]
1263 fn internal_invocation_proof_cache_rejects_near_expiry_entry() {
1264 clear_internal_invocation_proof_cache();
1265 let request = request();
1266 let mut proof = proof();
1267 proof.payload.subnet_id = request.subnet_id;
1268 proof.payload.issued_at = 10;
1269 proof.payload.expires_at = 20;
1270 cache_internal_invocation_proof(&request, &cfg(0), p(7), 18, proof);
1271
1272 assert!(cached_internal_invocation_proof(&request, &cfg(0), p(7), 18).is_none());
1273 }
1274
1275 #[test]
1276 fn internal_invocation_proof_cache_rejects_epoch_below_local_floor() {
1277 clear_internal_invocation_proof_cache();
1278 let request = request();
1279 let mut proof = proof();
1280 proof.payload.subnet_id = request.subnet_id;
1281 proof.payload.epoch = 3;
1282 cache_internal_invocation_proof(&request, &cfg(0), p(7), 12, proof);
1283
1284 assert!(cached_internal_invocation_proof(&request, &cfg(4), p(7), 12).is_none());
1285 }
1286
1287 #[test]
1288 fn internal_call_retry_classifier_is_limited_to_repairable_auth_material() {
1289 assert!(internal_call_error_is_retryable(&Error::new(
1290 ErrorCode::AuthKeyUnknown,
1291 "unknown key".to_string(),
1292 )));
1293 assert!(internal_call_error_is_retryable(&Error::new(
1294 ErrorCode::AuthMaterialStale,
1295 "stale epoch".to_string(),
1296 )));
1297 assert!(!internal_call_error_is_retryable(&Error::new(
1298 ErrorCode::AuthProofExpired,
1299 "expired".to_string(),
1300 )));
1301 assert!(!internal_call_error_is_retryable(&Error::unauthorized(
1302 "role mismatch"
1303 )));
1304 }
1305}