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#[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 #[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 #[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 #[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 #[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 #[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 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}