Skip to main content

ic_testkit/pocket_ic/
mod.rs

1#![cfg(not(target_family = "wasm"))]
2#![expect(
3    missing_docs,
4    clippy::too_many_arguments,
5    clippy::pedantic,
6    clippy::nursery,
7    reason = "initial upstream source transfer preserves public signatures and protocol names; see upstream/NOTICE.md"
8)]
9// Copyright DFINITY Foundation. Licensed under Apache-2.0.
10//! Maintained PocketIC 16.1 client implementation; see ../upstream/NOTICE.md.
11//! The client is async-only and uses the caller's Tokio runtime.
12//! Build with an explicit server URL and await operations. Explicitly await
13//! `PocketIc::drop` before releasing a borrowed server; Rust Drop performs no I/O.
14use self::common::rest::{
15    AutoProgressConfig, IcpFeatures, InitialTime, InstanceConfig, InstanceHttpGatewayConfig,
16    RawSubnetBlockmakers, RawTickConfigs, RawTime, SubnetId,
17};
18use candid::Principal;
19pub use ic_management_canister_types::{
20    CanisterId, CanisterInstallMode, CanisterLogRecord, CanisterSettings, CanisterStatusResult,
21    EnvironmentVariable, Snapshot,
22};
23pub use ic_platform::ic_protocol::SubnetMetrics;
24use reqwest::Url;
25use serde::{Deserialize, Serialize};
26use std::{
27    path::PathBuf,
28    time::{Duration, SystemTime, UNIX_EPOCH},
29};
30
31mod error;
32mod http;
33pub use error::{ClientError, Operation, RequestContext};
34pub mod common;
35mod nonblocking;
36pub use nonblocking::{
37    PocketIc, call_candid, call_candid_as, get_default_effective_canister_id, query_candid,
38    query_candid_as, update_candid, update_candid_as, with_candid,
39};
40pub mod snapshot;
41
42/// Server version associated with the imported PocketIC 16.1 client.
43pub const LATEST_SERVER_VERSION: &str = "16.1.0";
44
45// the default timeout of a PocketIC operation
46const DEFAULT_MAX_REQUEST_TIME_MS: u64 = 300_000;
47
48/// Select the supported instance configuration and an admitted server endpoint.
49/// Persistent state paths remain caller-owned; this builder creates no directories.
50pub struct PocketIcBuilder {
51    config: InstanceConfig,
52    server_url: Option<Url>,
53    max_request_time_ms: Option<u64>,
54}
55
56#[expect(
57    clippy::new_without_default,
58    reason = "preserve the imported PocketIC contract without an unrelated API change"
59)]
60impl PocketIcBuilder {
61    pub fn new() -> Self {
62        Self {
63            config: InstanceConfig {
64                icp_features: Some(IcpFeatures::default()),
65                ..InstanceConfig::default()
66            },
67            server_url: None,
68            max_request_time_ms: Some(DEFAULT_MAX_REQUEST_TIME_MS),
69        }
70    }
71
72    pub async fn build(self) -> Result<PocketIc, ClientError> {
73        PocketIc::from_config(
74            self.server_url.ok_or(ClientError::MissingServerUrl)?,
75            self.max_request_time_ms,
76            self.config,
77        )
78        .await
79    }
80
81    /// Use an already running PocketIC server.
82    pub fn with_server_url(mut self, server_url: Url) -> Self {
83        self.server_url = Some(server_url);
84        self
85    }
86
87    pub fn with_max_request_time_ms(mut self, max_request_time_ms: Option<u64>) -> Self {
88        self.max_request_time_ms = max_request_time_ms;
89        self
90    }
91
92    /// Select a caller-owned persistent instance directory accessible to the server.
93    /// Instance deletion and client Drop never remove this directory.
94    pub fn with_state_dir(mut self, state_dir: PathBuf) -> Self {
95        self.config.state_dir = Some(state_dir);
96        self
97    }
98
99    /// Add the unique NNS subnet.
100    pub fn with_nns_subnet(mut self) -> Self {
101        self.config.subnet_config_set.nns.get_or_insert_default();
102        self
103    }
104
105    /// Add the unique Internet Identity subnet.
106    pub fn with_ii_subnet(mut self) -> Self {
107        self.config.subnet_config_set.ii.get_or_insert_default();
108        self
109    }
110
111    pub fn with_system_subnet(mut self) -> Self {
112        self.config
113            .subnet_config_set
114            .system
115            .push(Default::default());
116        self
117    }
118
119    pub fn with_application_subnet(mut self) -> Self {
120        self.config
121            .subnet_config_set
122            .application
123            .push(Default::default());
124        self
125    }
126
127    /// Select system canisters provisioned and validated by the PocketIC server.
128    pub fn with_icp_features(mut self, icp_features: IcpFeatures) -> Self {
129        self.config.icp_features = Some(icp_features);
130        self
131    }
132
133    /// Select the initial timestamp, subject to the server's system-canister minimum.
134    pub fn with_initial_time(mut self, initial_time: Time) -> Self {
135        self.config.initial_time = Some(InitialTime::Timestamp(RawTime {
136            nanos_since_epoch: initial_time.as_nanos_since_unix_epoch(),
137        }));
138        self
139    }
140
141    /// Progress automatically from certified real time after instance creation.
142    pub fn with_auto_progress(mut self) -> Self {
143        self.config.initial_time = Some(InitialTime::AutoProgress(AutoProgressConfig {
144            artificial_delay_ms: None,
145        }));
146        self
147    }
148
149    pub fn with_http_gateway(mut self, http_gateway_config: InstanceHttpGatewayConfig) -> Self {
150        self.config.http_gateway_config = Some(http_gateway_config);
151        self
152    }
153}
154
155/// Representation of system time as duration since UNIX epoch
156/// with cross-platform nanosecond precision.
157#[derive(Copy, Clone, PartialEq, PartialOrd)]
158pub struct Time(Duration);
159
160impl Time {
161    /// Number of nanoseconds since UNIX EPOCH.
162    pub fn as_nanos_since_unix_epoch(&self) -> u64 {
163        self.0.as_nanos().try_into().unwrap()
164    }
165
166    pub const fn from_nanos_since_unix_epoch(nanos: u64) -> Self {
167        Time(Duration::from_nanos(nanos))
168    }
169}
170
171impl std::fmt::Debug for Time {
172    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
173        let nanos_since_unix_epoch = self.as_nanos_since_unix_epoch();
174        write!(f, "{nanos_since_unix_epoch}")
175    }
176}
177
178impl std::ops::Add<Duration> for Time {
179    type Output = Time;
180    fn add(self, dur: Duration) -> Time {
181        Time(self.0 + dur)
182    }
183}
184
185impl From<SystemTime> for Time {
186    fn from(time: SystemTime) -> Self {
187        Self::from_nanos_since_unix_epoch(
188            time.duration_since(UNIX_EPOCH)
189                .unwrap()
190                .as_nanos()
191                .try_into()
192                .unwrap(),
193        )
194    }
195}
196
197impl TryFrom<Time> for SystemTime {
198    type Error = String;
199
200    fn try_from(time: Time) -> Result<SystemTime, String> {
201        let nanos = time.as_nanos_since_unix_epoch();
202        let system_time = UNIX_EPOCH + Duration::from_nanos(nanos);
203        let roundtrip: Time = system_time.into();
204        if roundtrip.as_nanos_since_unix_epoch() == nanos {
205            Ok(system_time)
206        } else {
207            Err(format!(
208                "Converting UNIX timestamp {nanos} in nanoseconds to SystemTime failed due to losing precision"
209            ))
210        }
211    }
212}
213
214/// Specifies where to place a newly created canister.
215#[derive(Clone, Debug)]
216pub enum CreateCanisterPlacement {
217    /// Place the canister on the given subnet.
218    SubnetId(SubnetId),
219    /// Create the canister with the given specific canister ID.
220    CanisterId(CanisterId),
221}
222
223/// Parameters for [`PocketIc::create_canister_with_params`].
224#[derive(Clone, Debug, Default)]
225pub struct CreateCanisterParams {
226    /// Initial cycles balance; defaults to 100T if `None`.
227    pub cycles: Option<u128>,
228    /// Canister settings; defaults to default canister settings if `None`.
229    pub settings: Option<CanisterSettings>,
230    /// Canister placement (subnet or specific canister ID); a random application subnet is chosen if `None`.
231    pub placement: Option<CreateCanisterPlacement>,
232}
233
234/// Error type for [`TryFrom<u64>`].
235#[derive(Clone, Copy, Debug)]
236pub enum TryFromError {
237    ValueOutOfRange(u64),
238}
239
240/// User-facing error codes.
241///
242/// The error codes are currently assigned using an HTTP-like
243/// convention: the most significant digit is the corresponding reject
244/// code and the rest is just a sequentially assigned two-digit
245/// number.
246#[derive(PartialOrd, Ord, Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
247#[cfg_attr(test, derive(strum::EnumIter))]
248pub enum ErrorCode {
249    // 1xx -- `RejectCode::SysFatal`
250    SubnetOversubscribed = 101,
251    MaxNumberOfCanistersReached = 102,
252    // 2xx -- `RejectCode::SysTransient`
253    CanisterQueueFull = 201,
254    IngressMessageTimeout = 202,
255    CanisterQueueNotEmpty = 203,
256    IngressHistoryFull = 204,
257    CanisterIdAlreadyExists = 205,
258    StopCanisterRequestTimeout = 206,
259    CanisterOutOfCycles = 207,
260    CertifiedStateUnavailable = 208,
261    CanisterInstallCodeRateLimited = 209,
262    CanisterHeapDeltaRateLimited = 210,
263    SubnetCoolingDown = 211,
264    // 3xx -- `RejectCode::DestinationInvalid`
265    CanisterNotFound = 301,
266    CanisterSnapshotNotFound = 305,
267    // 4xx -- `RejectCode::CanisterReject`
268    InsufficientMemoryAllocation = 402,
269    InsufficientCyclesForCreateCanister = 403,
270    SubnetNotFound = 404,
271    CanisterNotHostedBySubnet = 405,
272    CanisterRejectedMessage = 406,
273    UnknownManagementMessage = 407,
274    InvalidManagementPayload = 408,
275    CanisterSnapshotImmutable = 409,
276    InvalidSubnetAdmin = 410,
277    // 5xx -- `RejectCode::CanisterError`
278    CanisterTrapped = 502,
279    CanisterCalledTrap = 503,
280    CanisterContractViolation = 504,
281    CanisterInvalidWasm = 505,
282    CanisterDidNotReply = 506,
283    CanisterOutOfMemory = 507,
284    CanisterStopped = 508,
285    CanisterStopping = 509,
286    CanisterNotStopped = 510,
287    CanisterStoppingCancelled = 511,
288    CanisterInvalidController = 512,
289    CanisterFunctionNotFound = 513,
290    CanisterNonEmpty = 514,
291    QueryCallGraphLoopDetected = 517,
292    InsufficientCyclesInCall = 520,
293    CanisterWasmEngineError = 521,
294    CanisterInstructionLimitExceeded = 522,
295    CanisterMemoryAccessLimitExceeded = 524,
296    QueryCallGraphTooDeep = 525,
297    QueryCallGraphTotalInstructionLimitExceeded = 526,
298    CompositeQueryCalledInReplicatedMode = 527,
299    QueryTimeLimitExceeded = 528,
300    QueryCallGraphInternal = 529,
301    InsufficientCyclesInComputeAllocation = 530,
302    InsufficientCyclesInMemoryAllocation = 531,
303    InsufficientCyclesInMemoryGrow = 532,
304    ReservedCyclesLimitExceededInMemoryAllocation = 533,
305    ReservedCyclesLimitExceededInMemoryGrow = 534,
306    InsufficientCyclesInMessageMemoryGrow = 535,
307    CanisterMethodNotFound = 536,
308    CanisterWasmModuleNotFound = 537,
309    CanisterAlreadyInstalled = 538,
310    CanisterWasmMemoryLimitExceeded = 539,
311    ReservedCyclesLimitIsTooLow = 540,
312    CanisterInvalidControllerOrSubnetAdmin = 541,
313    CanisterStatusAccessDenied = 542,
314    // 6xx -- `RejectCode::SysUnknown`
315    DeadlineExpired = 601,
316    ResponseDropped = 602,
317}
318
319impl TryFrom<u64> for ErrorCode {
320    type Error = TryFromError;
321    fn try_from(err: u64) -> Result<ErrorCode, Self::Error> {
322        match err {
323            // 1xx -- `RejectCode::SysFatal`
324            101 => Ok(ErrorCode::SubnetOversubscribed),
325            102 => Ok(ErrorCode::MaxNumberOfCanistersReached),
326            // 2xx -- `RejectCode::SysTransient`
327            201 => Ok(ErrorCode::CanisterQueueFull),
328            202 => Ok(ErrorCode::IngressMessageTimeout),
329            203 => Ok(ErrorCode::CanisterQueueNotEmpty),
330            204 => Ok(ErrorCode::IngressHistoryFull),
331            205 => Ok(ErrorCode::CanisterIdAlreadyExists),
332            206 => Ok(ErrorCode::StopCanisterRequestTimeout),
333            207 => Ok(ErrorCode::CanisterOutOfCycles),
334            208 => Ok(ErrorCode::CertifiedStateUnavailable),
335            209 => Ok(ErrorCode::CanisterInstallCodeRateLimited),
336            210 => Ok(ErrorCode::CanisterHeapDeltaRateLimited),
337            211 => Ok(ErrorCode::SubnetCoolingDown),
338            // 3xx -- `RejectCode::DestinationInvalid`
339            301 => Ok(ErrorCode::CanisterNotFound),
340            305 => Ok(ErrorCode::CanisterSnapshotNotFound),
341            // 4xx -- `RejectCode::CanisterReject`
342            402 => Ok(ErrorCode::InsufficientMemoryAllocation),
343            403 => Ok(ErrorCode::InsufficientCyclesForCreateCanister),
344            404 => Ok(ErrorCode::SubnetNotFound),
345            405 => Ok(ErrorCode::CanisterNotHostedBySubnet),
346            406 => Ok(ErrorCode::CanisterRejectedMessage),
347            407 => Ok(ErrorCode::UnknownManagementMessage),
348            408 => Ok(ErrorCode::InvalidManagementPayload),
349            409 => Ok(ErrorCode::CanisterSnapshotImmutable),
350            410 => Ok(ErrorCode::InvalidSubnetAdmin),
351            // 5xx -- `RejectCode::CanisterError`
352            502 => Ok(ErrorCode::CanisterTrapped),
353            503 => Ok(ErrorCode::CanisterCalledTrap),
354            504 => Ok(ErrorCode::CanisterContractViolation),
355            505 => Ok(ErrorCode::CanisterInvalidWasm),
356            506 => Ok(ErrorCode::CanisterDidNotReply),
357            507 => Ok(ErrorCode::CanisterOutOfMemory),
358            508 => Ok(ErrorCode::CanisterStopped),
359            509 => Ok(ErrorCode::CanisterStopping),
360            510 => Ok(ErrorCode::CanisterNotStopped),
361            511 => Ok(ErrorCode::CanisterStoppingCancelled),
362            512 => Ok(ErrorCode::CanisterInvalidController),
363            513 => Ok(ErrorCode::CanisterFunctionNotFound),
364            514 => Ok(ErrorCode::CanisterNonEmpty),
365            517 => Ok(ErrorCode::QueryCallGraphLoopDetected),
366            520 => Ok(ErrorCode::InsufficientCyclesInCall),
367            521 => Ok(ErrorCode::CanisterWasmEngineError),
368            522 => Ok(ErrorCode::CanisterInstructionLimitExceeded),
369            524 => Ok(ErrorCode::CanisterMemoryAccessLimitExceeded),
370            525 => Ok(ErrorCode::QueryCallGraphTooDeep),
371            526 => Ok(ErrorCode::QueryCallGraphTotalInstructionLimitExceeded),
372            527 => Ok(ErrorCode::CompositeQueryCalledInReplicatedMode),
373            528 => Ok(ErrorCode::QueryTimeLimitExceeded),
374            529 => Ok(ErrorCode::QueryCallGraphInternal),
375            530 => Ok(ErrorCode::InsufficientCyclesInComputeAllocation),
376            531 => Ok(ErrorCode::InsufficientCyclesInMemoryAllocation),
377            532 => Ok(ErrorCode::InsufficientCyclesInMemoryGrow),
378            533 => Ok(ErrorCode::ReservedCyclesLimitExceededInMemoryAllocation),
379            534 => Ok(ErrorCode::ReservedCyclesLimitExceededInMemoryGrow),
380            535 => Ok(ErrorCode::InsufficientCyclesInMessageMemoryGrow),
381            536 => Ok(ErrorCode::CanisterMethodNotFound),
382            537 => Ok(ErrorCode::CanisterWasmModuleNotFound),
383            538 => Ok(ErrorCode::CanisterAlreadyInstalled),
384            539 => Ok(ErrorCode::CanisterWasmMemoryLimitExceeded),
385            540 => Ok(ErrorCode::ReservedCyclesLimitIsTooLow),
386            541 => Ok(ErrorCode::CanisterInvalidControllerOrSubnetAdmin),
387            542 => Ok(ErrorCode::CanisterStatusAccessDenied),
388            // 6xx -- `RejectCode::SysUnknown`
389            601 => Ok(ErrorCode::DeadlineExpired),
390            602 => Ok(ErrorCode::ResponseDropped),
391            _ => Err(TryFromError::ValueOutOfRange(err)),
392        }
393    }
394}
395
396impl std::fmt::Display for ErrorCode {
397    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
398        // E.g. "IC0301"
399        write!(f, "IC{:04}", *self as i32)
400    }
401}
402
403/// User-facing reject codes.
404///
405/// They can be derived from the most significant digit of the
406/// corresponding error code.
407#[derive(PartialOrd, Ord, Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
408#[cfg_attr(test, derive(strum::EnumIter))]
409pub enum RejectCode {
410    SysFatal = 1,
411    SysTransient = 2,
412    DestinationInvalid = 3,
413    CanisterReject = 4,
414    CanisterError = 5,
415    SysUnknown = 6,
416}
417
418impl TryFrom<u64> for RejectCode {
419    type Error = TryFromError;
420    fn try_from(err: u64) -> Result<RejectCode, Self::Error> {
421        match err {
422            1 => Ok(RejectCode::SysFatal),
423            2 => Ok(RejectCode::SysTransient),
424            3 => Ok(RejectCode::DestinationInvalid),
425            4 => Ok(RejectCode::CanisterReject),
426            5 => Ok(RejectCode::CanisterError),
427            6 => Ok(RejectCode::SysUnknown),
428            _ => Err(TryFromError::ValueOutOfRange(err)),
429        }
430    }
431}
432
433/// User-facing type describing an unsuccessful (also called reject) call response.
434#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
435pub struct RejectResponse {
436    pub reject_code: RejectCode,
437    pub reject_message: String,
438    pub error_code: ErrorCode,
439    pub certified: bool,
440}
441
442impl std::fmt::Display for RejectResponse {
443    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
444        // Follows [agent-rs](https://github.com/dfinity/agent-rs/blob/a651dbbe69e61d4e8508c144cd60cfa3118eeb3a/ic-agent/src/agent/agent_error.rs#L54)
445        write!(
446            f,
447            "PocketIC returned a rejection error: reject code {:?}, reject message {}, error code {:?}",
448            self.reject_code, self.reject_message, self.error_code
449        )
450    }
451}
452
453/// This enum describes the result of retrieving ingress status.
454/// The `IngressStatusResult::Forbidden` variant is produced
455/// if an optional caller is provided and a corresponding read state request
456/// for the status of the same update call signed by that specified caller
457/// was rejected because the update call was submitted by a different caller.
458#[derive(Debug, Serialize, Deserialize)]
459pub enum IngressStatusResult {
460    NotAvailable,
461    Forbidden(String),
462    Success(Result<Vec<u8>, RejectResponse>),
463}
464
465#[derive(Clone, Debug, Default)]
466pub struct TickConfigs {
467    pub blockmakers: Option<Vec<SubnetBlockmakers>>,
468}
469
470impl From<TickConfigs> for RawTickConfigs {
471    fn from(tick_configs: TickConfigs) -> Self {
472        Self {
473            blockmakers: tick_configs.blockmakers.map(|blockmakers| {
474                blockmakers
475                    .into_iter()
476                    .map(|blockmaker| blockmaker.into())
477                    .collect()
478            }),
479        }
480    }
481}
482
483#[derive(Clone, Debug)]
484pub struct SubnetBlockmakers {
485    pub subnet: Principal,
486    pub blockmaker: Principal,
487    pub failed_blockmakers: Vec<Principal>,
488}
489
490impl From<SubnetBlockmakers> for RawSubnetBlockmakers {
491    fn from(blockmaker: SubnetBlockmakers) -> Self {
492        Self {
493            subnet: blockmaker.subnet.into(),
494            blockmaker: blockmaker.blockmaker.into(),
495            failed_blockmakers: blockmaker
496                .failed_blockmakers
497                .into_iter()
498                .map(|p| p.into())
499                .collect(),
500        }
501    }
502}
503
504/// Converts an absolute Windows path with a drive prefix (e.g. `C:\Users\x\y`)
505/// to the path under which WSL mounts it (e.g. `/mnt/c/Users/x/y`).
506///
507/// The drive letter is lower-cased and backslashes are replaced by forward slashes.
508/// A verbatim prefix (`\\?\`), as produced by `std::fs::canonicalize` on Windows,
509/// is stripped. Paths without a drive prefix (UNC paths, relative paths, ...) are
510/// rejected.
511#[cfg_attr(
512    all(not(windows), not(test)),
513    expect(
514        dead_code,
515        reason = "the retained WSL converter is used by Windows implementations"
516    )
517)]
518fn windows_to_wsl(path: &str) -> Result<String, String> {
519    let path = path.strip_prefix(r"\\?\").unwrap_or(path);
520    let mut chars = path.chars();
521    let drive = match (chars.next(), chars.next()) {
522        (Some(drive), Some(':')) if drive.is_ascii_alphabetic() => drive.to_ascii_lowercase(),
523        _ => {
524            return Err(format!(
525                "`{path}` does not start with a drive prefix such as `C:`"
526            ));
527        }
528    };
529    let rest = chars.as_str();
530    if !rest.is_empty() && !rest.starts_with(['\\', '/']) {
531        return Err(format!("`{path}` is a drive-relative path"));
532    }
533    Ok(format!("/mnt/{drive}{}", rest.replace('\\', "/")))
534}
535
536#[cfg(windows)]
537fn wsl_path(path: &PathBuf, desc: &str) -> String {
538    windows_to_wsl(
539        path.as_os_str()
540            .to_str()
541            .unwrap_or_else(|| panic!("Could not convert {} path ({:?}) to String", desc, path)),
542    )
543    .unwrap_or_else(|e| {
544        panic!(
545            "Could not convert {} path ({:?}) to WSL path: {:?}",
546            desc, path, e
547        )
548    })
549}
550
551#[cfg(test)]
552mod tests;