Skip to main content

ic_testkit/pic/
snapshot.rs

1use std::{
2    collections::{BTreeMap, BTreeSet},
3    panic::{AssertUnwindSafe, catch_unwind},
4};
5
6use candid::Principal;
7use pocket_ic::{PocketIc, RejectResponse};
8
9use super::transport;
10
11#[derive(Clone, Debug, Eq, PartialEq)]
12struct ControllerSnapshot {
13    snapshot_id: Vec<u8>,
14    sender: Option<Principal>,
15}
16
17/// Deterministically ordered snapshots retaining each successful capture sender.
18#[derive(Clone, Debug, Eq, PartialEq)]
19pub struct ControllerSnapshots(BTreeMap<Principal, ControllerSnapshot>);
20
21/// One canister and exact management-call sender used for snapshot capture.
22#[derive(Clone, Copy, Debug, Eq, PartialEq)]
23pub struct CanisterSnapshotTarget {
24    canister_id: Principal,
25    sender: Option<Principal>,
26}
27
28/// One rejected sender attempt for a snapshot operation.
29#[derive(Clone, Debug, Eq, PartialEq)]
30pub struct SnapshotAttemptFailure {
31    sender: Option<Principal>,
32    response: RejectResponse,
33}
34
35/// Failure to remove a snapshot while rolling back a partial capture.
36#[derive(Clone, Debug, Eq, PartialEq)]
37pub struct SnapshotCleanupFailure {
38    canister_id: Principal,
39    sender: Option<Principal>,
40    response: Option<Box<RejectResponse>>,
41    panic_message: Option<String>,
42}
43
44/// Caller-selected cycle funding applied immediately before snapshot restore.
45#[non_exhaustive]
46#[derive(Clone, Copy, Debug, Eq, PartialEq)]
47pub enum SnapshotRestoreFunding {
48    /// Do not add cycles before the restore operation.
49    Preserve,
50    /// Add cycles only when needed to reach the given minimum balance.
51    TopUpTo {
52        /// Minimum balance established immediately before each restore attempt.
53        minimum_cycles: u128,
54    },
55}
56
57/// Structured controller-snapshot failure.
58#[non_exhaustive]
59#[derive(Clone, Debug, Eq, PartialEq)]
60pub enum ControllerSnapshotError {
61    /// Input contained the same canister more than once.
62    DuplicateCanisterId {
63        /// Repeated canister id.
64        canister_id: Principal,
65    },
66    /// PocketIC rejected every sender attempted for capture.
67    CaptureFailed {
68        /// Canister whose capture failed.
69        canister_id: Principal,
70        /// Rejected sender attempts in execution order.
71        attempts: Vec<SnapshotAttemptFailure>,
72        /// Failures while deleting snapshots captured earlier in the set.
73        cleanup_failures: Vec<SnapshotCleanupFailure>,
74    },
75    /// PocketIC panicked while capturing a snapshot.
76    CapturePanicked {
77        /// Canister whose capture panicked.
78        canister_id: Principal,
79        /// Captured panic message.
80        message: String,
81        /// Failures while deleting snapshots captured earlier in the set.
82        cleanup_failures: Vec<SnapshotCleanupFailure>,
83    },
84    /// PocketIC rejected every sender attempted for restore.
85    RestoreFailed {
86        /// Canister whose restore failed.
87        canister_id: Principal,
88        /// Rejected sender attempts in execution order.
89        attempts: Vec<SnapshotAttemptFailure>,
90    },
91    /// PocketIC panicked while restoring a snapshot.
92    RestorePanicked {
93        /// Canister whose restore panicked.
94        canister_id: Principal,
95        /// Captured panic message.
96        message: String,
97    },
98}
99
100enum SnapshotCaptureFailure {
101    Rejected(Vec<SnapshotAttemptFailure>),
102    Panicked(String),
103}
104
105/// Controller-aware capture and restore of related canister snapshots.
106pub trait PocketIcSnapshotExt {
107    /// Capture snapshots with one explicit sender per canister.
108    ///
109    /// Unlike [`Self::capture_controller_snapshots`], this performs no
110    /// rejected-sender fallback attempts. Input is duplicate-checked and
111    /// captured in deterministic canister-id order.
112    fn capture_snapshots_with_senders<I>(
113        &self,
114        targets: I,
115    ) -> Result<ControllerSnapshots, ControllerSnapshotError>
116    where
117        I: IntoIterator<Item = CanisterSnapshotTarget>;
118
119    /// Capture one restorable snapshot per unique canister.
120    ///
121    /// Input is validated before capture begins. If a later capture fails,
122    /// snapshots already captured by this operation are deleted before the
123    /// structured error is returned.
124    fn capture_controller_snapshots<I>(
125        &self,
126        controller_id: Principal,
127        canister_ids: I,
128    ) -> Result<ControllerSnapshots, ControllerSnapshotError>
129    where
130        I: IntoIterator<Item = Principal>;
131
132    /// Restore a previously captured snapshot set using the same controller.
133    ///
134    /// This default path never funds the canister before restore. PocketIC may
135    /// still charge cycles as part of the restore operation itself.
136    fn restore_controller_snapshots(
137        &self,
138        controller_id: Principal,
139        snapshots: &ControllerSnapshots,
140    ) -> Result<(), ControllerSnapshotError>;
141
142    /// Restore a snapshot set with an explicit cycle-funding policy.
143    ///
144    /// `TopUpTo` is evaluated immediately before each restore attempt. No
145    /// cycles are removed when the current balance already meets the minimum.
146    fn restore_controller_snapshots_with_funding(
147        &self,
148        controller_id: Principal,
149        snapshots: &ControllerSnapshots,
150        funding: SnapshotRestoreFunding,
151    ) -> Result<(), ControllerSnapshotError>;
152}
153
154/// Exact-sender restoration for snapshots that retained their capture sender.
155pub trait PocketIcCapturedSnapshotExt {
156    /// Restore every snapshot with exactly the sender retained during capture.
157    ///
158    /// This performs no fallback attempts. In particular, a captured
159    /// anonymous sender remains `None` instead of falling back to a supplied
160    /// controller principal.
161    fn restore_snapshots_with_captured_senders(
162        &self,
163        snapshots: &ControllerSnapshots,
164    ) -> Result<(), ControllerSnapshotError>;
165
166    /// Restore with captured senders and an explicit cycle-funding policy.
167    fn restore_snapshots_with_captured_senders_and_funding(
168        &self,
169        snapshots: &ControllerSnapshots,
170        funding: SnapshotRestoreFunding,
171    ) -> Result<(), ControllerSnapshotError>;
172}
173
174impl PocketIcSnapshotExt for PocketIc {
175    fn capture_snapshots_with_senders<I>(
176        &self,
177        targets: I,
178    ) -> Result<ControllerSnapshots, ControllerSnapshotError>
179    where
180        I: IntoIterator<Item = CanisterSnapshotTarget>,
181    {
182        let targets = ordered_unique_snapshot_targets(targets)?;
183        capture_snapshot_set(
184            self,
185            targets.into_iter().map(|target| {
186                (
187                    target.canister_id,
188                    std::iter::once(target.sender).collect::<Vec<_>>(),
189                )
190            }),
191        )
192    }
193
194    fn capture_controller_snapshots<I>(
195        &self,
196        controller_id: Principal,
197        canister_ids: I,
198    ) -> Result<ControllerSnapshots, ControllerSnapshotError>
199    where
200        I: IntoIterator<Item = Principal>,
201    {
202        let canister_ids = ordered_unique_canister_ids(canister_ids)?;
203        capture_snapshot_set(
204            self,
205            canister_ids.into_iter().map(|canister_id| {
206                (
207                    canister_id,
208                    controller_sender_candidates(controller_id, canister_id).to_vec(),
209                )
210            }),
211        )
212    }
213
214    fn restore_controller_snapshots(
215        &self,
216        controller_id: Principal,
217        snapshots: &ControllerSnapshots,
218    ) -> Result<(), ControllerSnapshotError> {
219        self.restore_controller_snapshots_with_funding(
220            controller_id,
221            snapshots,
222            SnapshotRestoreFunding::Preserve,
223        )
224    }
225
226    fn restore_controller_snapshots_with_funding(
227        &self,
228        controller_id: Principal,
229        snapshots: &ControllerSnapshots,
230        funding: SnapshotRestoreFunding,
231    ) -> Result<(), ControllerSnapshotError> {
232        for (canister_id, snapshot_id, sender) in snapshots.iter() {
233            restore_controller_snapshot(
234                self,
235                canister_id,
236                snapshot_id,
237                funding,
238                [
239                    sender,
240                    if sender.is_some() {
241                        None
242                    } else {
243                        Some(controller_id)
244                    },
245                ],
246            )?;
247        }
248        Ok(())
249    }
250}
251
252impl PocketIcCapturedSnapshotExt for PocketIc {
253    fn restore_snapshots_with_captured_senders(
254        &self,
255        snapshots: &ControllerSnapshots,
256    ) -> Result<(), ControllerSnapshotError> {
257        self.restore_snapshots_with_captured_senders_and_funding(
258            snapshots,
259            SnapshotRestoreFunding::Preserve,
260        )
261    }
262
263    fn restore_snapshots_with_captured_senders_and_funding(
264        &self,
265        snapshots: &ControllerSnapshots,
266        funding: SnapshotRestoreFunding,
267    ) -> Result<(), ControllerSnapshotError> {
268        for (canister_id, snapshot_id, sender) in snapshots.iter() {
269            restore_controller_snapshot(
270                self,
271                canister_id,
272                snapshot_id,
273                funding,
274                std::iter::once(sender),
275            )?;
276        }
277        Ok(())
278    }
279}
280
281impl CanisterSnapshotTarget {
282    /// Select one canister and exact sender for snapshot capture.
283    #[must_use]
284    pub const fn new(canister_id: Principal, sender: Option<Principal>) -> Self {
285        Self {
286            canister_id,
287            sender,
288        }
289    }
290
291    /// Canister whose snapshot will be captured.
292    #[must_use]
293    pub const fn canister_id(self) -> Principal {
294        self.canister_id
295    }
296
297    /// Exact management-call sender, including `None` for the default sender.
298    #[must_use]
299    pub const fn sender(self) -> Option<Principal> {
300        self.sender
301    }
302}
303
304fn capture_snapshot_set<I>(
305    pocket_ic: &PocketIc,
306    targets: I,
307) -> Result<ControllerSnapshots, ControllerSnapshotError>
308where
309    I: IntoIterator<Item = (Principal, Vec<Option<Principal>>)>,
310{
311    let mut snapshots = BTreeMap::new();
312    for (canister_id, senders) in targets {
313        match try_take_snapshot(pocket_ic, canister_id, senders) {
314            Ok(snapshot) => {
315                snapshots.insert(canister_id, snapshot);
316            }
317            Err(SnapshotCaptureFailure::Rejected(attempts)) => {
318                let cleanup_failures = cleanup_captured_snapshots(pocket_ic, &snapshots);
319                return Err(ControllerSnapshotError::CaptureFailed {
320                    canister_id,
321                    attempts,
322                    cleanup_failures,
323                });
324            }
325            Err(SnapshotCaptureFailure::Panicked(message)) => {
326                let cleanup_failures = cleanup_captured_snapshots(pocket_ic, &snapshots);
327                return Err(ControllerSnapshotError::CapturePanicked {
328                    canister_id,
329                    message,
330                    cleanup_failures,
331                });
332            }
333        }
334    }
335    Ok(ControllerSnapshots(snapshots))
336}
337
338impl ControllerSnapshots {
339    /// Return the number of captured canisters.
340    #[must_use]
341    pub fn len(&self) -> usize {
342        self.0.len()
343    }
344
345    /// Report whether the set contains no snapshots.
346    #[must_use]
347    pub fn is_empty(&self) -> bool {
348        self.0.is_empty()
349    }
350
351    /// Iterate over captured canister ids in deterministic principal order.
352    pub fn canister_ids(&self) -> impl Iterator<Item = Principal> + '_ {
353        self.0.keys().copied()
354    }
355
356    pub(super) fn iter(&self) -> impl Iterator<Item = (Principal, &[u8], Option<Principal>)> + '_ {
357        self.0.iter().map(|(canister_id, snapshot)| {
358            (
359                *canister_id,
360                snapshot.snapshot_id.as_slice(),
361                snapshot.sender,
362            )
363        })
364    }
365}
366
367impl SnapshotAttemptFailure {
368    /// Read the sender used for this rejected attempt.
369    #[must_use]
370    pub const fn sender(&self) -> Option<Principal> {
371        self.sender
372    }
373
374    /// Read PocketIC's structured rejection.
375    #[must_use]
376    pub const fn response(&self) -> &RejectResponse {
377        &self.response
378    }
379}
380
381impl SnapshotCleanupFailure {
382    /// Read the canister whose captured snapshot could not be removed.
383    #[must_use]
384    pub const fn canister_id(&self) -> Principal {
385        self.canister_id
386    }
387
388    /// Read the sender used for the rejected cleanup.
389    #[must_use]
390    pub const fn sender(&self) -> Option<Principal> {
391        self.sender
392    }
393
394    /// Read PocketIC's structured rejection.
395    #[must_use]
396    pub fn response(&self) -> Option<&RejectResponse> {
397        self.response.as_deref()
398    }
399
400    /// Read a captured PocketIC panic message, when cleanup did not return a rejection.
401    #[must_use]
402    pub fn panic_message(&self) -> Option<&str> {
403        self.panic_message.as_deref()
404    }
405}
406
407impl std::fmt::Display for ControllerSnapshotError {
408    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
409        match self {
410            Self::DuplicateCanisterId { canister_id } => {
411                write!(f, "duplicate canister id in snapshot set: {canister_id}")
412            }
413            Self::CaptureFailed {
414                canister_id,
415                attempts,
416                cleanup_failures,
417            } => write!(
418                f,
419                "failed to capture snapshot for {canister_id} after {} sender attempts; {} partial snapshots could not be cleaned up",
420                attempts.len(),
421                cleanup_failures.len()
422            ),
423            Self::CapturePanicked {
424                canister_id,
425                message,
426                cleanup_failures,
427            } => write!(
428                f,
429                "snapshot capture panicked for {canister_id}: {message}; {} partial snapshots could not be cleaned up",
430                cleanup_failures.len()
431            ),
432            Self::RestoreFailed {
433                canister_id,
434                attempts,
435            } => write!(
436                f,
437                "failed to restore snapshot for {canister_id} after {} sender attempts",
438                attempts.len()
439            ),
440            Self::RestorePanicked {
441                canister_id,
442                message,
443            } => write!(f, "snapshot restore panicked for {canister_id}: {message}"),
444        }
445    }
446}
447
448impl std::error::Error for ControllerSnapshotError {}
449
450fn ordered_unique_canister_ids<I>(
451    canister_ids: I,
452) -> Result<Vec<Principal>, ControllerSnapshotError>
453where
454    I: IntoIterator<Item = Principal>,
455{
456    let mut unique = BTreeSet::new();
457    for canister_id in canister_ids {
458        if !unique.insert(canister_id) {
459            return Err(ControllerSnapshotError::DuplicateCanisterId { canister_id });
460        }
461    }
462    Ok(unique.into_iter().collect())
463}
464
465fn ordered_unique_snapshot_targets<I>(
466    targets: I,
467) -> Result<Vec<CanisterSnapshotTarget>, ControllerSnapshotError>
468where
469    I: IntoIterator<Item = CanisterSnapshotTarget>,
470{
471    let mut unique = BTreeMap::new();
472    for target in targets {
473        if unique.insert(target.canister_id, target).is_some() {
474            return Err(ControllerSnapshotError::DuplicateCanisterId {
475                canister_id: target.canister_id,
476            });
477        }
478    }
479    Ok(unique.into_values().collect())
480}
481
482fn try_take_snapshot(
483    pocket_ic: &PocketIc,
484    canister_id: Principal,
485    candidates: impl IntoIterator<Item = Option<Principal>>,
486) -> Result<ControllerSnapshot, SnapshotCaptureFailure> {
487    let mut attempts = Vec::new();
488
489    for sender in candidates {
490        let capture = catch_unwind(AssertUnwindSafe(|| {
491            pocket_ic.take_canister_snapshot(canister_id, sender, None)
492        }));
493        match capture {
494            Err(payload) => {
495                return Err(SnapshotCaptureFailure::Panicked(
496                    transport::panic_payload_to_string(payload.as_ref()),
497                ));
498            }
499            Ok(snapshot) => match snapshot {
500                Ok(snapshot) => {
501                    return Ok(ControllerSnapshot {
502                        snapshot_id: snapshot.id,
503                        sender,
504                    });
505                }
506                Err(response) => attempts.push(SnapshotAttemptFailure { sender, response }),
507            },
508        }
509    }
510
511    Err(SnapshotCaptureFailure::Rejected(attempts))
512}
513
514fn cleanup_captured_snapshots(
515    pocket_ic: &PocketIc,
516    snapshots: &BTreeMap<Principal, ControllerSnapshot>,
517) -> Vec<SnapshotCleanupFailure> {
518    let mut failures = Vec::new();
519    for (canister_id, snapshot) in snapshots {
520        let cleanup = catch_unwind(AssertUnwindSafe(|| {
521            pocket_ic.delete_canister_snapshot(
522                *canister_id,
523                snapshot.sender,
524                snapshot.snapshot_id.clone(),
525            )
526        }));
527        match cleanup {
528            Ok(Ok(())) => {}
529            Ok(Err(response)) => failures.push(SnapshotCleanupFailure {
530                canister_id: *canister_id,
531                sender: snapshot.sender,
532                response: Some(Box::new(response)),
533                panic_message: None,
534            }),
535            Err(payload) => failures.push(SnapshotCleanupFailure {
536                canister_id: *canister_id,
537                sender: snapshot.sender,
538                response: None,
539                panic_message: Some(transport::panic_payload_to_string(payload.as_ref())),
540            }),
541        }
542    }
543    failures
544}
545
546fn restore_controller_snapshot(
547    pocket_ic: &PocketIc,
548    canister_id: Principal,
549    snapshot_id: &[u8],
550    funding: SnapshotRestoreFunding,
551    candidates: impl IntoIterator<Item = Option<Principal>>,
552) -> Result<(), ControllerSnapshotError> {
553    let mut attempts = Vec::new();
554
555    for sender in candidates {
556        let restore = catch_unwind(AssertUnwindSafe(|| {
557            apply_snapshot_restore_funding(pocket_ic, canister_id, funding);
558            pocket_ic.load_canister_snapshot(canister_id, sender, snapshot_id.to_vec())
559        }));
560        match restore {
561            Err(payload) => {
562                return Err(ControllerSnapshotError::RestorePanicked {
563                    canister_id,
564                    message: transport::panic_payload_to_string(payload.as_ref()),
565                });
566            }
567            Ok(Ok(())) => return Ok(()),
568            Ok(Err(response)) => attempts.push(SnapshotAttemptFailure { sender, response }),
569        }
570    }
571
572    Err(ControllerSnapshotError::RestoreFailed {
573        canister_id,
574        attempts,
575    })
576}
577
578fn apply_snapshot_restore_funding(
579    pocket_ic: &PocketIc,
580    canister_id: Principal,
581    funding: SnapshotRestoreFunding,
582) {
583    if funding == SnapshotRestoreFunding::Preserve {
584        return;
585    }
586
587    let balance = pocket_ic.cycle_balance(canister_id);
588    let top_up = snapshot_restore_top_up(balance, funding);
589    if top_up > 0 {
590        let _ = pocket_ic.add_cycles(canister_id, top_up);
591    }
592}
593
594const fn snapshot_restore_top_up(balance: u128, funding: SnapshotRestoreFunding) -> u128 {
595    match funding {
596        SnapshotRestoreFunding::Preserve => 0,
597        SnapshotRestoreFunding::TopUpTo { minimum_cycles } => {
598            minimum_cycles.saturating_sub(balance)
599        }
600    }
601}
602
603fn controller_sender_candidates(
604    controller_id: Principal,
605    canister_id: Principal,
606) -> [Option<Principal>; 2] {
607    if canister_id == controller_id {
608        [None, Some(controller_id)]
609    } else {
610        [Some(controller_id), None]
611    }
612}
613
614#[cfg(test)]
615mod tests {
616    use candid::Principal;
617
618    use super::{
619        ControllerSnapshotError, SnapshotRestoreFunding, ordered_unique_canister_ids,
620        snapshot_restore_top_up,
621    };
622
623    #[test]
624    fn duplicate_canister_ids_are_rejected_before_capture() {
625        let canister_id = Principal::from_slice(&[1]);
626        let error = ordered_unique_canister_ids([canister_id, canister_id]).unwrap_err();
627
628        assert_eq!(
629            error,
630            ControllerSnapshotError::DuplicateCanisterId { canister_id }
631        );
632    }
633
634    #[test]
635    fn canister_ids_are_sorted_deterministically() {
636        let first = Principal::from_slice(&[1]);
637        let second = Principal::from_slice(&[2]);
638
639        assert_eq!(
640            ordered_unique_canister_ids([second, first]).unwrap(),
641            vec![first, second]
642        );
643    }
644
645    #[test]
646    fn snapshot_restore_funding_is_explicit() {
647        assert_eq!(
648            snapshot_restore_top_up(10, SnapshotRestoreFunding::Preserve),
649            0
650        );
651        assert_eq!(
652            snapshot_restore_top_up(10, SnapshotRestoreFunding::TopUpTo { minimum_cycles: 25 }),
653            15
654        );
655        assert_eq!(
656            snapshot_restore_top_up(30, SnapshotRestoreFunding::TopUpTo { minimum_cycles: 25 }),
657            0
658        );
659    }
660}