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#[derive(Clone, Debug, Eq, PartialEq)]
19pub struct ControllerSnapshots(BTreeMap<Principal, ControllerSnapshot>);
20
21#[derive(Clone, Copy, Debug, Eq, PartialEq)]
23pub struct CanisterSnapshotTarget {
24 canister_id: Principal,
25 sender: Option<Principal>,
26}
27
28#[derive(Clone, Debug, Eq, PartialEq)]
30pub struct SnapshotAttemptFailure {
31 sender: Option<Principal>,
32 response: RejectResponse,
33}
34
35#[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#[non_exhaustive]
46#[derive(Clone, Copy, Debug, Eq, PartialEq)]
47pub enum SnapshotRestoreFunding {
48 Preserve,
50 TopUpTo {
52 minimum_cycles: u128,
54 },
55}
56
57#[non_exhaustive]
59#[derive(Clone, Debug, Eq, PartialEq)]
60pub enum ControllerSnapshotError {
61 DuplicateCanisterId {
63 canister_id: Principal,
65 },
66 CaptureFailed {
68 canister_id: Principal,
70 attempts: Vec<SnapshotAttemptFailure>,
72 cleanup_failures: Vec<SnapshotCleanupFailure>,
74 },
75 CapturePanicked {
77 canister_id: Principal,
79 message: String,
81 cleanup_failures: Vec<SnapshotCleanupFailure>,
83 },
84 RestoreFailed {
86 canister_id: Principal,
88 attempts: Vec<SnapshotAttemptFailure>,
90 },
91 RestorePanicked {
93 canister_id: Principal,
95 message: String,
97 },
98}
99
100enum SnapshotCaptureFailure {
101 Rejected(Vec<SnapshotAttemptFailure>),
102 Panicked(String),
103}
104
105pub trait PocketIcSnapshotExt {
107 fn capture_snapshots_with_senders<I>(
113 &self,
114 targets: I,
115 ) -> Result<ControllerSnapshots, ControllerSnapshotError>
116 where
117 I: IntoIterator<Item = CanisterSnapshotTarget>;
118
119 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 fn restore_controller_snapshots(
137 &self,
138 controller_id: Principal,
139 snapshots: &ControllerSnapshots,
140 ) -> Result<(), ControllerSnapshotError>;
141
142 fn restore_controller_snapshots_with_funding(
147 &self,
148 controller_id: Principal,
149 snapshots: &ControllerSnapshots,
150 funding: SnapshotRestoreFunding,
151 ) -> Result<(), ControllerSnapshotError>;
152}
153
154impl PocketIcSnapshotExt for PocketIc {
155 fn capture_snapshots_with_senders<I>(
156 &self,
157 targets: I,
158 ) -> Result<ControllerSnapshots, ControllerSnapshotError>
159 where
160 I: IntoIterator<Item = CanisterSnapshotTarget>,
161 {
162 let targets = ordered_unique_snapshot_targets(targets)?;
163 capture_snapshot_set(
164 self,
165 targets.into_iter().map(|target| {
166 (
167 target.canister_id,
168 std::iter::once(target.sender).collect::<Vec<_>>(),
169 )
170 }),
171 )
172 }
173
174 fn capture_controller_snapshots<I>(
175 &self,
176 controller_id: Principal,
177 canister_ids: I,
178 ) -> Result<ControllerSnapshots, ControllerSnapshotError>
179 where
180 I: IntoIterator<Item = Principal>,
181 {
182 let canister_ids = ordered_unique_canister_ids(canister_ids)?;
183 capture_snapshot_set(
184 self,
185 canister_ids.into_iter().map(|canister_id| {
186 (
187 canister_id,
188 controller_sender_candidates(controller_id, canister_id).to_vec(),
189 )
190 }),
191 )
192 }
193
194 fn restore_controller_snapshots(
195 &self,
196 controller_id: Principal,
197 snapshots: &ControllerSnapshots,
198 ) -> Result<(), ControllerSnapshotError> {
199 self.restore_controller_snapshots_with_funding(
200 controller_id,
201 snapshots,
202 SnapshotRestoreFunding::Preserve,
203 )
204 }
205
206 fn restore_controller_snapshots_with_funding(
207 &self,
208 controller_id: Principal,
209 snapshots: &ControllerSnapshots,
210 funding: SnapshotRestoreFunding,
211 ) -> Result<(), ControllerSnapshotError> {
212 for (canister_id, snapshot_id, sender) in snapshots.iter() {
213 restore_controller_snapshot(
214 self,
215 controller_id,
216 canister_id,
217 sender,
218 snapshot_id,
219 funding,
220 )?;
221 }
222 Ok(())
223 }
224}
225
226impl CanisterSnapshotTarget {
227 #[must_use]
229 pub const fn new(canister_id: Principal, sender: Option<Principal>) -> Self {
230 Self {
231 canister_id,
232 sender,
233 }
234 }
235
236 #[must_use]
238 pub const fn canister_id(self) -> Principal {
239 self.canister_id
240 }
241
242 #[must_use]
244 pub const fn sender(self) -> Option<Principal> {
245 self.sender
246 }
247}
248
249fn capture_snapshot_set<I>(
250 pocket_ic: &PocketIc,
251 targets: I,
252) -> Result<ControllerSnapshots, ControllerSnapshotError>
253where
254 I: IntoIterator<Item = (Principal, Vec<Option<Principal>>)>,
255{
256 let mut snapshots = BTreeMap::new();
257 for (canister_id, senders) in targets {
258 match try_take_snapshot(pocket_ic, canister_id, senders) {
259 Ok(snapshot) => {
260 snapshots.insert(canister_id, snapshot);
261 }
262 Err(SnapshotCaptureFailure::Rejected(attempts)) => {
263 let cleanup_failures = cleanup_captured_snapshots(pocket_ic, &snapshots);
264 return Err(ControllerSnapshotError::CaptureFailed {
265 canister_id,
266 attempts,
267 cleanup_failures,
268 });
269 }
270 Err(SnapshotCaptureFailure::Panicked(message)) => {
271 let cleanup_failures = cleanup_captured_snapshots(pocket_ic, &snapshots);
272 return Err(ControllerSnapshotError::CapturePanicked {
273 canister_id,
274 message,
275 cleanup_failures,
276 });
277 }
278 }
279 }
280 Ok(ControllerSnapshots(snapshots))
281}
282
283impl ControllerSnapshots {
284 #[must_use]
286 pub fn len(&self) -> usize {
287 self.0.len()
288 }
289
290 #[must_use]
292 pub fn is_empty(&self) -> bool {
293 self.0.is_empty()
294 }
295
296 pub fn canister_ids(&self) -> impl Iterator<Item = Principal> + '_ {
298 self.0.keys().copied()
299 }
300
301 pub(super) fn iter(&self) -> impl Iterator<Item = (Principal, &[u8], Option<Principal>)> + '_ {
302 self.0.iter().map(|(canister_id, snapshot)| {
303 (
304 *canister_id,
305 snapshot.snapshot_id.as_slice(),
306 snapshot.sender,
307 )
308 })
309 }
310}
311
312impl SnapshotAttemptFailure {
313 #[must_use]
315 pub const fn sender(&self) -> Option<Principal> {
316 self.sender
317 }
318
319 #[must_use]
321 pub const fn response(&self) -> &RejectResponse {
322 &self.response
323 }
324}
325
326impl SnapshotCleanupFailure {
327 #[must_use]
329 pub const fn canister_id(&self) -> Principal {
330 self.canister_id
331 }
332
333 #[must_use]
335 pub const fn sender(&self) -> Option<Principal> {
336 self.sender
337 }
338
339 #[must_use]
341 pub fn response(&self) -> Option<&RejectResponse> {
342 self.response.as_deref()
343 }
344
345 #[must_use]
347 pub fn panic_message(&self) -> Option<&str> {
348 self.panic_message.as_deref()
349 }
350}
351
352impl std::fmt::Display for ControllerSnapshotError {
353 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
354 match self {
355 Self::DuplicateCanisterId { canister_id } => {
356 write!(f, "duplicate canister id in snapshot set: {canister_id}")
357 }
358 Self::CaptureFailed {
359 canister_id,
360 attempts,
361 cleanup_failures,
362 } => write!(
363 f,
364 "failed to capture snapshot for {canister_id} after {} sender attempts; {} partial snapshots could not be cleaned up",
365 attempts.len(),
366 cleanup_failures.len()
367 ),
368 Self::CapturePanicked {
369 canister_id,
370 message,
371 cleanup_failures,
372 } => write!(
373 f,
374 "snapshot capture panicked for {canister_id}: {message}; {} partial snapshots could not be cleaned up",
375 cleanup_failures.len()
376 ),
377 Self::RestoreFailed {
378 canister_id,
379 attempts,
380 } => write!(
381 f,
382 "failed to restore snapshot for {canister_id} after {} sender attempts",
383 attempts.len()
384 ),
385 Self::RestorePanicked {
386 canister_id,
387 message,
388 } => write!(f, "snapshot restore panicked for {canister_id}: {message}"),
389 }
390 }
391}
392
393impl std::error::Error for ControllerSnapshotError {}
394
395fn ordered_unique_canister_ids<I>(
396 canister_ids: I,
397) -> Result<Vec<Principal>, ControllerSnapshotError>
398where
399 I: IntoIterator<Item = Principal>,
400{
401 let mut unique = BTreeSet::new();
402 for canister_id in canister_ids {
403 if !unique.insert(canister_id) {
404 return Err(ControllerSnapshotError::DuplicateCanisterId { canister_id });
405 }
406 }
407 Ok(unique.into_iter().collect())
408}
409
410fn ordered_unique_snapshot_targets<I>(
411 targets: I,
412) -> Result<Vec<CanisterSnapshotTarget>, ControllerSnapshotError>
413where
414 I: IntoIterator<Item = CanisterSnapshotTarget>,
415{
416 let mut unique = BTreeMap::new();
417 for target in targets {
418 if unique.insert(target.canister_id, target).is_some() {
419 return Err(ControllerSnapshotError::DuplicateCanisterId {
420 canister_id: target.canister_id,
421 });
422 }
423 }
424 Ok(unique.into_values().collect())
425}
426
427fn try_take_snapshot(
428 pocket_ic: &PocketIc,
429 canister_id: Principal,
430 candidates: impl IntoIterator<Item = Option<Principal>>,
431) -> Result<ControllerSnapshot, SnapshotCaptureFailure> {
432 let mut attempts = Vec::new();
433
434 for sender in candidates {
435 let capture = catch_unwind(AssertUnwindSafe(|| {
436 pocket_ic.take_canister_snapshot(canister_id, sender, None)
437 }));
438 match capture {
439 Err(payload) => {
440 return Err(SnapshotCaptureFailure::Panicked(
441 transport::panic_payload_to_string(payload.as_ref()),
442 ));
443 }
444 Ok(snapshot) => match snapshot {
445 Ok(snapshot) => {
446 return Ok(ControllerSnapshot {
447 snapshot_id: snapshot.id,
448 sender,
449 });
450 }
451 Err(response) => attempts.push(SnapshotAttemptFailure { sender, response }),
452 },
453 }
454 }
455
456 Err(SnapshotCaptureFailure::Rejected(attempts))
457}
458
459fn cleanup_captured_snapshots(
460 pocket_ic: &PocketIc,
461 snapshots: &BTreeMap<Principal, ControllerSnapshot>,
462) -> Vec<SnapshotCleanupFailure> {
463 let mut failures = Vec::new();
464 for (canister_id, snapshot) in snapshots {
465 let cleanup = catch_unwind(AssertUnwindSafe(|| {
466 pocket_ic.delete_canister_snapshot(
467 *canister_id,
468 snapshot.sender,
469 snapshot.snapshot_id.clone(),
470 )
471 }));
472 match cleanup {
473 Ok(Ok(())) => {}
474 Ok(Err(response)) => failures.push(SnapshotCleanupFailure {
475 canister_id: *canister_id,
476 sender: snapshot.sender,
477 response: Some(Box::new(response)),
478 panic_message: None,
479 }),
480 Err(payload) => failures.push(SnapshotCleanupFailure {
481 canister_id: *canister_id,
482 sender: snapshot.sender,
483 response: None,
484 panic_message: Some(transport::panic_payload_to_string(payload.as_ref())),
485 }),
486 }
487 }
488 failures
489}
490
491fn restore_controller_snapshot(
492 pocket_ic: &PocketIc,
493 controller_id: Principal,
494 canister_id: Principal,
495 snapshot_sender: Option<Principal>,
496 snapshot_id: &[u8],
497 funding: SnapshotRestoreFunding,
498) -> Result<(), ControllerSnapshotError> {
499 let fallback_sender = if snapshot_sender.is_some() {
500 None
501 } else {
502 Some(controller_id)
503 };
504 let candidates = [snapshot_sender, fallback_sender];
505 let mut attempts = Vec::new();
506
507 for sender in candidates {
508 let restore = catch_unwind(AssertUnwindSafe(|| {
509 apply_snapshot_restore_funding(pocket_ic, canister_id, funding);
510 pocket_ic.load_canister_snapshot(canister_id, sender, snapshot_id.to_vec())
511 }));
512 match restore {
513 Err(payload) => {
514 return Err(ControllerSnapshotError::RestorePanicked {
515 canister_id,
516 message: transport::panic_payload_to_string(payload.as_ref()),
517 });
518 }
519 Ok(Ok(())) => return Ok(()),
520 Ok(Err(response)) => attempts.push(SnapshotAttemptFailure { sender, response }),
521 }
522 }
523
524 Err(ControllerSnapshotError::RestoreFailed {
525 canister_id,
526 attempts,
527 })
528}
529
530fn apply_snapshot_restore_funding(
531 pocket_ic: &PocketIc,
532 canister_id: Principal,
533 funding: SnapshotRestoreFunding,
534) {
535 if funding == SnapshotRestoreFunding::Preserve {
536 return;
537 }
538
539 let balance = pocket_ic.cycle_balance(canister_id);
540 let top_up = snapshot_restore_top_up(balance, funding);
541 if top_up > 0 {
542 let _ = pocket_ic.add_cycles(canister_id, top_up);
543 }
544}
545
546const fn snapshot_restore_top_up(balance: u128, funding: SnapshotRestoreFunding) -> u128 {
547 match funding {
548 SnapshotRestoreFunding::Preserve => 0,
549 SnapshotRestoreFunding::TopUpTo { minimum_cycles } => {
550 minimum_cycles.saturating_sub(balance)
551 }
552 }
553}
554
555fn controller_sender_candidates(
556 controller_id: Principal,
557 canister_id: Principal,
558) -> [Option<Principal>; 2] {
559 if canister_id == controller_id {
560 [None, Some(controller_id)]
561 } else {
562 [Some(controller_id), None]
563 }
564}
565
566#[cfg(test)]
567mod tests {
568 use candid::Principal;
569
570 use super::{
571 ControllerSnapshotError, SnapshotRestoreFunding, ordered_unique_canister_ids,
572 snapshot_restore_top_up,
573 };
574
575 #[test]
576 fn duplicate_canister_ids_are_rejected_before_capture() {
577 let canister_id = Principal::from_slice(&[1]);
578 let error = ordered_unique_canister_ids([canister_id, canister_id]).unwrap_err();
579
580 assert_eq!(
581 error,
582 ControllerSnapshotError::DuplicateCanisterId { canister_id }
583 );
584 }
585
586 #[test]
587 fn canister_ids_are_sorted_deterministically() {
588 let first = Principal::from_slice(&[1]);
589 let second = Principal::from_slice(&[2]);
590
591 assert_eq!(
592 ordered_unique_canister_ids([second, first]).unwrap(),
593 vec![first, second]
594 );
595 }
596
597 #[test]
598 fn snapshot_restore_funding_is_explicit() {
599 assert_eq!(
600 snapshot_restore_top_up(10, SnapshotRestoreFunding::Preserve),
601 0
602 );
603 assert_eq!(
604 snapshot_restore_top_up(10, SnapshotRestoreFunding::TopUpTo { minimum_cycles: 25 }),
605 15
606 );
607 assert_eq!(
608 snapshot_restore_top_up(30, SnapshotRestoreFunding::TopUpTo { minimum_cycles: 25 }),
609 0
610 );
611 }
612}