Skip to main content

ic_testkit/pic/
runtime.rs

1use flate2::read::GzDecoder;
2use sha2::{Digest, Sha256};
3use std::{
4    env,
5    fs::{self, OpenOptions},
6    io::{self, Read},
7    path::{Path, PathBuf},
8    process,
9    time::{SystemTime, UNIX_EPOCH},
10};
11
12#[cfg(unix)]
13use std::os::unix::fs::PermissionsExt;
14
15use super::startup::PicStartError;
16
17const POCKET_IC_BIN_ENV: &str = "POCKET_IC_BIN";
18const CACHE_DIR_ENV: &str = "IC_TESTKIT_POCKET_IC_CACHE_DIR";
19const ALLOW_DOWNLOAD_ENV: &str = "IC_TESTKIT_ALLOW_POCKET_IC_DOWNLOAD";
20const SERVER_SHA256_ENV: &str = "POCKET_IC_SERVER_SHA256";
21
22const SERVER_NAME: &str = "pocket-ic";
23
24///
25/// Runtime policy for resolving the PocketIC server binary used by [`PicBuilder`](super::PicBuilder).
26///
27#[derive(Clone, Debug, Default, Eq, PartialEq)]
28pub struct PicRuntimeConfig {
29    pocket_ic_bin: Option<PathBuf>,
30    cache_dir: Option<PathBuf>,
31    allow_download: bool,
32    server_sha256: Option<String>,
33}
34
35impl PicRuntimeConfig {
36    /// Build a runtime config from supported environment variables.
37    ///
38    /// Supported variables:
39    ///
40    /// - `POCKET_IC_BIN`: explicit trusted PocketIC server binary path
41    /// - `IC_TESTKIT_POCKET_IC_CACHE_DIR`: cache root for resolved binaries
42    /// - `IC_TESTKIT_ALLOW_POCKET_IC_DOWNLOAD=1`: allow network download on cache miss
43    /// - `POCKET_IC_SERVER_SHA256`: optional expected SHA-256 for the ungzipped server binary
44    #[must_use]
45    pub fn from_env() -> Self {
46        Self {
47            pocket_ic_bin: non_empty_env_path(POCKET_IC_BIN_ENV),
48            cache_dir: non_empty_env_path(CACHE_DIR_ENV),
49            allow_download: env::var(ALLOW_DOWNLOAD_ENV).is_ok_and(|value| env_truthy(&value)),
50            server_sha256: env::var(SERVER_SHA256_ENV)
51                .ok()
52                .map(|value| value.trim().to_ascii_lowercase())
53                .filter(|value| !value.is_empty()),
54        }
55    }
56
57    /// Set an explicit PocketIC server binary path.
58    #[must_use]
59    pub fn pocket_ic_bin(mut self, path: impl Into<PathBuf>) -> Self {
60        self.pocket_ic_bin = Some(path.into());
61        self
62    }
63
64    /// Set the cache root used for resolved PocketIC server binaries.
65    #[must_use]
66    pub fn cache_dir(mut self, path: impl Into<PathBuf>) -> Self {
67        self.cache_dir = Some(path.into());
68        self
69    }
70
71    /// Allow or deny downloading the pinned PocketIC server binary on cache miss.
72    #[must_use]
73    pub const fn allow_download(mut self, allow_download: bool) -> Self {
74        self.allow_download = allow_download;
75        self
76    }
77
78    /// Set an expected SHA-256 digest for the ungzipped PocketIC server binary.
79    #[must_use]
80    pub fn server_sha256(mut self, sha256: impl Into<String>) -> Self {
81        self.server_sha256 = Some(sha256.into().trim().to_ascii_lowercase());
82        self
83    }
84
85    /// Resolve, validate, and optionally download the PocketIC server binary.
86    pub fn ensure_binary(&self) -> Result<PathBuf, PicStartError> {
87        if let Some(path) = self.pocket_ic_bin.as_deref() {
88            return self.validate_binary(path);
89        }
90
91        let path = self.cache_binary_path();
92        if path.exists() {
93            return self.validate_binary(&path);
94        }
95
96        if !self.allow_download {
97            return Err(PicStartError::BinaryUnavailable {
98                message: missing_binary_message(&path),
99            });
100        }
101
102        self.download_binary(&path)?;
103        self.validate_binary(&path)
104    }
105
106    fn cache_binary_path(&self) -> PathBuf {
107        self.cache_root()
108            .join(format!(
109                "pocket-ic-server-{}",
110                pocket_ic::LATEST_SERVER_VERSION
111            ))
112            .join(SERVER_NAME)
113    }
114
115    fn cache_root(&self) -> PathBuf {
116        self.cache_dir.clone().unwrap_or_else(default_cache_root)
117    }
118
119    fn validate_binary(&self, path: &Path) -> Result<PathBuf, PicStartError> {
120        if path.as_os_str().is_empty() {
121            return Err(PicStartError::BinaryUnavailable {
122                message: missing_binary_message(path),
123            });
124        }
125
126        let metadata = fs::metadata(path).map_err(|err| PicStartError::BinaryUnavailable {
127            message: format!(
128                "PocketIC server binary is unavailable at {}: {err}. {}",
129                path.display(),
130                setup_guidance()
131            ),
132        })?;
133
134        if !metadata.is_file() {
135            return Err(PicStartError::BinaryInvalid {
136                message: format!(
137                    "PocketIC server binary path {} is not a file.",
138                    path.display()
139                ),
140            });
141        }
142
143        #[cfg(unix)]
144        if metadata.permissions().mode() & 0o111 == 0 {
145            return Err(PicStartError::BinaryInvalid {
146                message: format!(
147                    "PocketIC server binary at {} is not executable.",
148                    path.display()
149                ),
150            });
151        }
152
153        if let Some(expected) = self.server_sha256.as_deref() {
154            validate_sha256(path, expected)?;
155        }
156
157        Ok(path.to_path_buf())
158    }
159
160    fn download_binary(&self, path: &Path) -> Result<(), PicStartError> {
161        let url = pocket_ic_download_url()?;
162        let parent = path.parent().ok_or_else(|| PicStartError::DownloadFailed {
163            message: format!(
164                "failed to resolve parent directory for PocketIC cache path {}",
165                path.display()
166            ),
167        })?;
168        fs::create_dir_all(parent).map_err(|err| PicStartError::DownloadFailed {
169            message: format!(
170                "failed to create PocketIC cache directory {}: {err}",
171                parent.display()
172            ),
173        })?;
174
175        if path.exists() {
176            return Ok(());
177        }
178
179        let temp_path = download_temp_path(parent);
180        let result = download_gzip_to_file(&url, &temp_path)
181            .and_then(|()| make_executable(&temp_path))
182            .and_then(|()| {
183                if let Some(expected) = self.server_sha256.as_deref() {
184                    validate_sha256(&temp_path, expected)?;
185                }
186                Ok(())
187            })
188            .and_then(|()| {
189                if path.exists() {
190                    fs::remove_file(&temp_path).map_err(download_failed)?;
191                    Ok(())
192                } else {
193                    fs::rename(&temp_path, path).map_err(download_failed)
194                }
195            });
196
197        if result.is_err() {
198            let _ = fs::remove_file(&temp_path);
199        }
200
201        result
202    }
203}
204
205pub(super) fn ensure_pocket_ic_bin_from_env() -> Result<PathBuf, PicStartError> {
206    PicRuntimeConfig::from_env().ensure_binary()
207}
208
209fn non_empty_env_path(name: &str) -> Option<PathBuf> {
210    env::var_os(name)
211        .filter(|value| !value.is_empty())
212        .map(PathBuf::from)
213}
214
215fn env_truthy(value: &str) -> bool {
216    matches!(
217        value.trim().to_ascii_lowercase().as_str(),
218        "1" | "true" | "yes" | "on"
219    )
220}
221
222fn default_cache_root() -> PathBuf {
223    env::temp_dir()
224}
225
226const fn setup_guidance() -> &'static str {
227    "Set POCKET_IC_BIN to an existing ungzipped executable PocketIC server binary, or set IC_TESTKIT_ALLOW_POCKET_IC_DOWNLOAD=1 to let ic-testkit download the pinned server binary into its cache."
228}
229
230fn missing_binary_message(path: &Path) -> String {
231    format!(
232        "PocketIC server binary is unavailable. Checked cache path {}. {}",
233        path.display(),
234        setup_guidance()
235    )
236}
237
238fn pocket_ic_download_url() -> Result<String, PicStartError> {
239    Ok(format!(
240        "https://github.com/dfinity/pocketic/releases/download/{}/pocket-ic-{}-{}.gz",
241        pocket_ic::LATEST_SERVER_VERSION,
242        pocket_ic_arch()?,
243        pocket_ic_os()?
244    ))
245}
246
247fn pocket_ic_arch() -> Result<&'static str, PicStartError> {
248    match env::consts::ARCH {
249        "aarch64" => Ok("arm64"),
250        "x86_64" => Ok("x86_64"),
251        arch => Err(PicStartError::DownloadFailed {
252            message: format!("PocketIC server download is unsupported on architecture {arch}."),
253        }),
254    }
255}
256
257fn pocket_ic_os() -> Result<&'static str, PicStartError> {
258    match env::consts::OS {
259        "linux" => Ok("linux"),
260        "macos" => Ok("darwin"),
261        os => Err(PicStartError::DownloadFailed {
262            message: format!("PocketIC server download is unsupported on operating system {os}."),
263        }),
264    }
265}
266
267fn download_temp_path(parent: &Path) -> PathBuf {
268    let nanos = SystemTime::now()
269        .duration_since(UNIX_EPOCH)
270        .map_or(0, |duration| duration.as_nanos());
271    parent.join(format!(".pocket-ic-download-{}-{nanos}.tmp", process::id()))
272}
273
274fn download_gzip_to_file(url: &str, path: &Path) -> Result<(), PicStartError> {
275    let response = reqwest::blocking::get(url)
276        .and_then(reqwest::blocking::Response::error_for_status)
277        .map_err(|err| PicStartError::DownloadFailed {
278            message: format!("failed to download PocketIC server from {url}: {err}"),
279        })?;
280    let bytes = response
281        .bytes()
282        .map_err(|err| PicStartError::DownloadFailed {
283            message: format!("failed to read PocketIC server download from {url}: {err}"),
284        })?;
285    let mut gz = GzDecoder::new(&bytes[..]);
286    let mut out = OpenOptions::new()
287        .write(true)
288        .create_new(true)
289        .open(path)
290        .map_err(download_failed)?;
291    io::copy(&mut gz, &mut out).map_err(download_failed)?;
292    Ok(())
293}
294
295#[cfg(unix)]
296fn make_executable(path: &Path) -> Result<(), PicStartError> {
297    let mut permissions = fs::metadata(path).map_err(download_failed)?.permissions();
298    permissions.set_mode(0o755);
299    fs::set_permissions(path, permissions).map_err(download_failed)
300}
301
302#[cfg(not(unix))]
303fn make_executable(_path: &Path) -> Result<(), PicStartError> {
304    Ok(())
305}
306
307fn validate_sha256(path: &Path, expected: &str) -> Result<(), PicStartError> {
308    if !is_sha256_hex(expected) {
309        return Err(PicStartError::BinaryInvalid {
310            message: format!(
311                "{SERVER_SHA256_ENV} must be a 64-character lowercase or uppercase hex SHA-256 digest"
312            ),
313        });
314    }
315
316    let actual = sha256_file(path).map_err(|err| PicStartError::BinaryInvalid {
317        message: format!(
318            "failed to calculate SHA-256 for PocketIC server binary {}: {err}",
319            path.display()
320        ),
321    })?;
322
323    if actual != expected {
324        return Err(PicStartError::BinaryInvalid {
325            message: format!(
326                "PocketIC server binary {} has SHA-256 {actual}, expected {expected}.",
327                path.display()
328            ),
329        });
330    }
331
332    Ok(())
333}
334
335fn is_sha256_hex(value: &str) -> bool {
336    value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
337}
338
339fn sha256_file(path: &Path) -> io::Result<String> {
340    let mut file = fs::File::open(path)?;
341    let mut hasher = Sha256::new();
342    let mut buffer = vec![0_u8; 64 * 1024].into_boxed_slice();
343
344    loop {
345        let bytes = file.read(&mut buffer)?;
346        if bytes == 0 {
347            break;
348        }
349        hasher.update(&buffer[..bytes]);
350    }
351
352    Ok(format!("{:x}", hasher.finalize()))
353}
354
355fn download_failed(err: io::Error) -> PicStartError {
356    PicStartError::DownloadFailed {
357        message: err.to_string(),
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    use super::{PicRuntimeConfig, env_truthy, is_sha256_hex, missing_binary_message};
364
365    #[test]
366    fn truthy_env_accepts_common_opt_in_values() {
367        assert!(env_truthy("1"));
368        assert!(env_truthy("true"));
369        assert!(env_truthy("YES"));
370        assert!(!env_truthy("0"));
371        assert!(!env_truthy(""));
372    }
373
374    #[test]
375    fn sha256_validation_requires_hex_digest() {
376        assert!(is_sha256_hex(
377            "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
378        ));
379        assert!(!is_sha256_hex("not-a-sha"));
380    }
381
382    #[test]
383    fn empty_explicit_binary_is_unavailable() {
384        let error = PicRuntimeConfig::default()
385            .pocket_ic_bin("")
386            .ensure_binary()
387            .unwrap_err();
388
389        assert!(matches!(
390            error,
391            super::PicStartError::BinaryUnavailable { .. }
392        ));
393    }
394
395    #[test]
396    fn missing_binary_guidance_mentions_opt_in_download() {
397        let message = missing_binary_message(std::path::Path::new("/tmp/missing-pocket-ic"));
398
399        assert!(message.contains("POCKET_IC_BIN"));
400        assert!(message.contains("IC_TESTKIT_ALLOW_POCKET_IC_DOWNLOAD=1"));
401    }
402}