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