Skip to main content

ic_testkit/pic/server_assets/
mod.rs

1//! Explicit, release-bound `PocketIC` assets for Rust fixtures and the CLI.
2//!
3//! [`check`] is offline and never sets up a missing bundle. [`setup`] explicitly
4//! provisions it. Callers retain namespace custody and select the bundle root;
5//! managed startup and application safety remain separate responsibilities.
6
7use super::POCKET_IC_SERVER_VERSION;
8use ic_host_artifacts::artifact::{Sha256Digest, copy_gzip, hash_gzip, hash_reader};
9use ic_host_fs::path::validate_physical_directory_allow_missing;
10use ic_host_fs::{durable, read::read_file_no_follow};
11use ic_host_process::{
12    child::CleanupPolicy,
13    tool::{
14        AdmittedTool, ExecutionContext, OutputLimit, OutputLimits, VersionSpec,
15        capture_group_command_with_cancellation,
16    },
17};
18use std::{
19    error::Error,
20    ffi::OsString,
21    fs::{self, File},
22    io,
23    path::{Path, PathBuf},
24    process::{Command, Stdio},
25    sync::atomic::{AtomicU64, Ordering},
26    time::Duration,
27};
28
29type Result<T> = std::result::Result<T, Box<dyn Error>>;
30const ARCHIVE_LIMIT: usize = 80 * 1024 * 1024;
31const EXECUTABLE_LIMIT: usize = 512 * 1024 * 1024;
32static SEQUENCE: AtomicU64 = AtomicU64::new(0);
33/// Default bundle root used by `ic-testkit-server`.
34pub const DEFAULT_DIRECTORY: &str = ".tools/ic-testkit-server";
35
36struct Asset {
37    host: &'static str,
38    digest: Sha256Digest,
39}
40
41fn write_evidence(path: &Path, bytes: &[u8]) -> Result<()> {
42    durable::write_bytes(
43        path,
44        durable::WriteOptions {
45            mode: durable::PublicationMode::Replace,
46            permissions: 0o666,
47        },
48        bytes,
49    )?;
50    Ok(())
51}
52
53fn selected_asset() -> Result<Asset> {
54    // Official GitHub release asset digests, reviewed 2026-10-09.
55    let (host, digest) = match (std::env::consts::OS, std::env::consts::ARCH) {
56        ("linux", "x86_64") => (
57            "x86_64-linux",
58            "131219d90dcf9bf6f3ed8ee02d8f55504ea24a6db382f5680675b7bd6b7ed3bf",
59        ),
60        ("macos", "x86_64") => (
61            "x86_64-darwin",
62            "af9ad2d781530a43556ef2d1c8f93db99a2425c6cabdc78520f61922399ed530",
63        ),
64        ("macos", "aarch64") => (
65            "arm64-darwin",
66            "9ae843fbb7ae6c6eb30137671c8629a80ef53b3a3652eb85b344847ac39f9fcd",
67        ),
68        _ => return Err("no reviewed PocketIC server asset for this host".into()),
69    };
70    Ok(Asset {
71        host,
72        digest: digest.parse()?,
73    })
74}
75
76fn root_path(directory: &Path) -> Result<PathBuf> {
77    if directory.as_os_str().is_empty() {
78        return Err("server directory must not be empty".into());
79    }
80    let absolute = if directory.is_absolute() {
81        directory.to_owned()
82    } else {
83        std::env::current_dir()?.join(directory)
84    };
85    if absolute
86        .as_os_str()
87        .as_encoded_bytes()
88        .iter()
89        .any(|byte| matches!(byte, b'\n' | b'\r'))
90    {
91        return Err("server directory and its ancestors must not contain line breaks".into());
92    }
93    validate_physical_directory_allow_missing(&absolute)?;
94    Ok(absolute.components().collect())
95}
96
97fn bundle_path(root: &Path, asset: &Asset) -> PathBuf {
98    root.join(format!("{}-{}", POCKET_IC_SERVER_VERSION, asset.host))
99}
100
101fn authenticated_archive(archive: &Path, digest: Sha256Digest) -> Result<Vec<u8>> {
102    let bytes = read_file_no_follow(archive, ARCHIVE_LIMIT)?;
103    hash_reader(bytes.as_slice(), ARCHIVE_LIMIT as u64)?.verify(digest)?;
104    Ok(bytes)
105}
106
107fn admit(executable: &Path, digest: Sha256Digest) -> Result<()> {
108    let directory = executable.parent().ok_or("missing executable parent")?;
109    AdmittedTool::admit_no_follow(
110        &VersionSpec {
111            executable,
112            executable_bytes: EXECUTABLE_LIMIT as u64,
113            version_arguments: &[OsString::from("--version")],
114        },
115        digest,
116        &format!("pocket-ic-server {POCKET_IC_SERVER_VERSION}"),
117        &ExecutionContext {
118            current_dir: directory,
119            environment: &[],
120        },
121        OutputLimits {
122            stdout: OutputLimit::Terminate(4096),
123            stderr: OutputLimit::Terminate(4096),
124            timeout: Some(Duration::from_secs(10)),
125        },
126    )?;
127    Ok(())
128}
129
130fn check_bundle(bundle: &Path, asset: &Asset) -> Result<PathBuf> {
131    let metadata = fs::symlink_metadata(bundle)?;
132    if !metadata.is_dir() || metadata.is_symlink() {
133        return Err("PocketIC bundle must be a physical directory".into());
134    }
135    let bytes = authenticated_archive(&bundle.join("archive.gz"), asset.digest)?;
136    let digest = hash_gzip(&bytes, ARCHIVE_LIMIT, EXECUTABLE_LIMIT as u64)?.sha256;
137    drop(bytes);
138    let executable = bundle.join("pocket-ic");
139    admit(&executable, digest)?;
140    Ok(executable)
141}
142
143/// Admit an existing reviewed bundle without downloading or changing it.
144///
145/// Authenticates the bounded archive before decoding, compares the executable's
146/// exact bytes, then runs bounded `--version` admission with an empty environment.
147/// Returns the absolute executable path, not ongoing custody of its namespace.
148/// Callers must prevent replacement between admission and managed startup.
149///
150/// # Errors
151/// Rejects unsupported hosts, redirected paths, missing or modified bundles,
152/// invalid archives, or failed executable/version admission.
153pub fn check(directory: &Path) -> Result<PathBuf> {
154    let asset = selected_asset()?;
155    check_bundle(&bundle_path(&root_path(directory)?, &asset), &asset)
156}
157
158/// Explicitly prepare or admit the reviewed bundle, retaining failed attempts.
159///
160/// Downloads only when the selected bundle is absent. Cooperating setups share
161/// one lock; existing bundles must pass the same offline [`check`] admission.
162/// `interrupted` lets the caller request bounded downloader cancellation without
163/// this library installing process-global signal handlers.
164///
165/// # Errors
166/// Returns path, lock, download, authentication, decoding, publication or
167/// executable/version failures. Retained evidence is never automatically removed.
168pub fn setup(directory: &Path, mut interrupted: impl FnMut() -> bool) -> Result<PathBuf> {
169    let asset = selected_asset()?;
170    setup_with(directory, &asset, |archive| {
171        let url = format!(
172            "https://github.com/dfinity/pocketic/releases/download/{POCKET_IC_SERVER_VERSION}/pocket-ic-{}.gz",
173            asset.host
174        );
175        let mut command = Command::new("curl");
176        command
177            .args([
178                "--proto",
179                "=https",
180                "--proto-redir",
181                "=https",
182                "--tlsv1.2",
183                "--fail",
184                "--silent",
185                "--show-error",
186                "--location",
187                "--connect-timeout",
188                "15",
189                "--max-time",
190                "300",
191                "--max-filesize",
192                "83886080",
193                "--output",
194            ])
195            .arg(archive)
196            .arg(url)
197            .stdin(Stdio::null())
198            .stdout(Stdio::piped())
199            .stderr(Stdio::piped());
200        let outcome = capture_group_command_with_cancellation(
201            &mut command,
202            OutputLimits {
203                stdout: OutputLimit::Terminate(4096),
204                stderr: OutputLimit::Terminate(64 * 1024),
205                timeout: Some(Duration::from_secs(310)),
206            },
207            CleanupPolicy::KillAndWait,
208            None,
209            &mut interrupted,
210        )
211        .and_then(ic_host_process::tool::CompletedExecution::require_success);
212        let stderr = match &outcome {
213            Ok(completed) => Some(completed.stderr.as_slice()),
214            Err(error) => error.evidence().map(|evidence| evidence.stderr.as_slice()),
215        };
216        if let Some(stderr) = stderr
217            && let Err(record_error) = write_evidence(
218                &archive
219                    .parent()
220                    .ok_or("missing archive parent")?
221                    .join("download.stderr"),
222                stderr,
223            )
224        {
225            if outcome.is_ok() {
226                return Err(record_error);
227            }
228            // Recording evidence must not replace the failed download's cause.
229            eprintln!("could not record download stderr: {record_error}");
230        }
231        outcome?;
232        Ok(())
233    })
234}
235
236fn setup_with(
237    directory: &Path,
238    asset: &Asset,
239    download: impl FnOnce(&Path) -> Result<()>,
240) -> Result<PathBuf> {
241    let root = root_path(directory)?;
242    // Namespace custody remains with the caller. All cooperating setups serialize
243    // through Host's admitted regular lock; checks are read-only.
244    let lock = durable::open_regular_lock_file_with_parents(&root.join(".setup-v1.lock"))?;
245    durable::lock_exclusive(&lock)?;
246    let bundle = bundle_path(&root, asset);
247    let attempt = root.join(format!(
248        ".setup-v1-{}-{}",
249        std::process::id(),
250        SEQUENCE.fetch_add(1, Ordering::Relaxed)
251    ));
252    let staging_directory = match fs::symlink_metadata(&bundle) {
253        Ok(_) => {
254            check_bundle(&bundle, asset)?;
255            None
256        }
257        Err(error) if error.kind() == io::ErrorKind::NotFound => {
258            let held = durable::create_private_directory(&attempt)?;
259            eprintln!("PocketIC setup evidence: {}", attempt.display());
260            if let Err(error) = install(&attempt, asset, download) {
261                if let Err(record_error) =
262                    write_evidence(&attempt.join("failure.txt"), error.to_string().as_bytes())
263                {
264                    eprintln!("could not record setup failure: {record_error}");
265                }
266                return Err(error);
267            }
268            Some(held)
269        }
270        Err(error) => return Err(error.into()),
271    };
272    // Host owns no-replace publication and phase-aware durability failures.
273    // Fresh staging must retain its original directory identity. Recovery admits
274    // the installed bundle again before Host synchronizes its parent.
275    durable::commit_existing_directory_with(
276        &attempt,
277        &bundle,
278        |selected, path| -> io::Result<()> {
279            if let Some(held) = &staging_directory {
280                durable::admit_directory(path, held)?;
281            }
282            selected.sync_all()?;
283            Ok(())
284        },
285        |_, _| Ok(()),
286    )?;
287    eprintln!("PocketIC setup evidence retained: {}", bundle.display());
288    Ok(bundle.join("pocket-ic"))
289}
290
291fn install(
292    attempt: &Path,
293    asset: &Asset,
294    download: impl FnOnce(&Path) -> Result<()>,
295) -> Result<()> {
296    let archive = attempt.join("archive.gz");
297    download(&archive)?;
298    let bytes = authenticated_archive(&archive, asset.digest)?;
299    File::open(&archive)?.sync_all()?;
300    let executable = attempt.join("pocket-ic");
301    let identity = durable::write_with(
302        &executable,
303        durable::WriteOptions {
304            mode: durable::PublicationMode::CreateNew,
305            permissions: 0o755,
306        },
307        |file| copy_gzip(&bytes, file, ARCHIVE_LIMIT, EXECUTABLE_LIMIT as u64),
308    )?;
309    drop(bytes);
310    // PocketIC requires its canonical basename. The whole directory is private
311    // and unselected until this admission succeeds; no existing bundle is replaced.
312    if let Err(error) = admit(&executable, identity.sha256) {
313        if let Some(evidence) = error
314            .downcast_ref::<ic_host_process::tool::ToolError>()
315            .and_then(|error| error.evidence())
316        {
317            for (name, bytes) in [
318                ("version.stdout", &evidence.stdout),
319                ("version.stderr", &evidence.stderr),
320            ] {
321                if let Err(record_error) = write_evidence(&attempt.join(name), bytes) {
322                    eprintln!("could not record version evidence: {record_error}");
323                }
324            }
325        }
326        return Err(error);
327    }
328    Ok(())
329}
330
331#[cfg(test)]
332mod tests;