Skip to main content

canic_host/ic_wasm/
mod.rs

1//! Module: ic_wasm
2//!
3//! Responsibility: own the published `ic-wasm` build-tool authority, installation, and
4//! executable admission used by canonical Canic artifact builds.
5//! Does not own: artifact transformation order, build provenance shape, or Binaryen policy.
6//! Boundary: only the repository-pinned official tool version may transform Canic Wasm.
7
8#[cfg(test)]
9mod tests;
10
11use crate::output_with_executable_busy_retry;
12use std::{
13    env,
14    ffi::OsStr,
15    fs::{self, File, OpenOptions},
16    io::{self, Read},
17    path::{Path, PathBuf},
18    process::Command,
19    sync::atomic::{AtomicU64, Ordering},
20};
21
22use canic_core::cdk::utils::hash::hex_bytes;
23use sha2::{Digest, Sha256};
24use thiserror::Error as ThisError;
25
26#[cfg(unix)]
27use std::os::unix::fs::PermissionsExt;
28
29pub const IC_WASM_REPAIR_COMMAND: &str = "canic toolchain install";
30pub const IC_WASM_TOOL: &str = "ic-wasm";
31pub const IC_WASM_VERSION: &str = "0.11.1";
32pub const IC_WASM_VERSION_IDENTITY: &str = "ic-wasm 0.11.1";
33
34// Exact launcher from the integrity-verified @icp-sdk/ic-wasm 0.11.1 npm archive.
35// Bind the executable it selects rather than a script whose payload can change independently.
36const NPM_LAUNCHER_SHA256: &str =
37    "ff4f9bd1d3734f7aa69078ecd4c5716dbfaf67d094c69d5082b00bac5b8bf936";
38
39const DOWNLOAD_TOOL: &str = "curl";
40const EXTRACT_TOOL: &str = "tar";
41const TEMP_ATTEMPTS: usize = 64;
42static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
43
44///
45/// IcWasmAuthority
46///
47/// Immutable official archive identity for one install-capable host platform.
48///
49
50#[derive(Clone, Copy, Debug, Eq, PartialEq)]
51pub struct IcWasmAuthority {
52    archive_platform: &'static str,
53    archive_sha256: &'static str,
54}
55
56impl IcWasmAuthority {
57    #[must_use]
58    pub const fn archive_platform(self) -> &'static str {
59        self.archive_platform
60    }
61
62    #[must_use]
63    pub const fn archive_sha256(self) -> &'static str {
64        self.archive_sha256
65    }
66
67    fn archive_name(self) -> String {
68        format!("ic-wasm-{}.tar.xz", self.archive_platform)
69    }
70
71    fn archive_url(self) -> String {
72        format!(
73            "https://github.com/dfinity/ic-wasm/releases/download/{IC_WASM_VERSION}/{}",
74            self.archive_name()
75        )
76    }
77
78    fn package_name(self) -> String {
79        format!("ic-wasm-{}", self.archive_platform)
80    }
81}
82
83#[derive(Clone, Copy, Debug, Eq, PartialEq)]
84struct IcWasmPlatformAuthority {
85    os: &'static str,
86    arch: &'static str,
87    authority: IcWasmAuthority,
88}
89
90const SUPPORTED_IC_WASM_AUTHORITIES: [IcWasmPlatformAuthority; 4] = [
91    IcWasmPlatformAuthority {
92        os: "macos",
93        arch: "aarch64",
94        authority: IcWasmAuthority {
95            archive_platform: "aarch64-apple-darwin",
96            archive_sha256: "1feeb253498b783ce19e9e166d4f205ed35a8ab7fa679173aaa4b41fb78c852d",
97        },
98    },
99    IcWasmPlatformAuthority {
100        os: "macos",
101        arch: "x86_64",
102        authority: IcWasmAuthority {
103            archive_platform: "x86_64-apple-darwin",
104            archive_sha256: "9bf63f9daaee8d812207807435a1bff23d7b7e50c573b7c2a36b8aa50974e99a",
105        },
106    },
107    IcWasmPlatformAuthority {
108        os: "linux",
109        arch: "aarch64",
110        authority: IcWasmAuthority {
111            archive_platform: "aarch64-unknown-linux-gnu",
112            archive_sha256: "49d5992ee5f050f8869b6b4b8357eceb1cb3b84f79fbd2de37ed8166c5dc5e30",
113        },
114    },
115    IcWasmPlatformAuthority {
116        os: "linux",
117        arch: "x86_64",
118        authority: IcWasmAuthority {
119            archive_platform: "x86_64-unknown-linux-gnu",
120            archive_sha256: "099776a745c4d4495761da18f2fe2216759a4166beacd05453bf031d61631746",
121        },
122    },
123];
124
125///
126/// IcWasmExecutable
127///
128/// Exact admitted `ic-wasm` executable consumed by one artifact-build invocation.
129///
130
131#[derive(Clone, Debug, Eq, PartialEq)]
132pub struct IcWasmExecutable {
133    path: PathBuf,
134    version_identity: String,
135}
136
137impl IcWasmExecutable {
138    #[must_use]
139    pub fn path(&self) -> &Path {
140        &self.path
141    }
142
143    #[must_use]
144    pub fn version_identity(&self) -> &str {
145        &self.version_identity
146    }
147}
148
149///
150/// IcWasmToolError
151///
152/// Typed setup or admission failure for the mandatory canonical Wasm helper.
153///
154
155#[derive(Debug, ThisError)]
156pub enum IcWasmToolError {
157    #[error("ic-wasm archive download failed with status {status}: {stderr}")]
158    ArchiveDownload { status: String, stderr: String },
159
160    #[error("downloaded ic-wasm archive {path} has SHA-256 {actual}; required {expected}")]
161    ArchiveHashMismatch {
162        path: PathBuf,
163        actual: String,
164        expected: &'static str,
165    },
166
167    #[error("ic-wasm archive extraction failed with status {status}: {stderr}")]
168    ArchiveExtraction { status: String, stderr: String },
169
170    #[error("staged ic-wasm executable {path} has SHA-256 {actual}; required {expected}")]
171    ExecutableHashMismatch {
172        path: PathBuf,
173        actual: String,
174        expected: String,
175    },
176
177    #[error("failed to {operation} {path}: {source}")]
178    Io {
179        operation: &'static str,
180        path: PathBuf,
181        #[source]
182        source: io::Error,
183    },
184
185    #[error("HOME is unavailable; `{IC_WASM_REPAIR_COMMAND}` cannot select ~/.local/bin")]
186    MissingHome,
187
188    #[error(
189        "required {IC_WASM_TOOL} {IC_WASM_VERSION} was not found on PATH or at {canonical_path}; run `{IC_WASM_REPAIR_COMMAND}`"
190    )]
191    MissingTool { canonical_path: PathBuf },
192
193    #[error(
194        "required {IC_WASM_TOOL} {IC_WASM_VERSION} was not found on PATH and HOME is unavailable; run `{IC_WASM_REPAIR_COMMAND}` with HOME set to the intended account home"
195    )]
196    MissingToolWithoutHome,
197
198    #[error(
199        "required {IC_WASM_TOOL} {IC_WASM_VERSION} was not found on PATH or at {canonical_path}; HOME resolves to `/`, so confirm that root-level install location is intentional before running `{IC_WASM_REPAIR_COMMAND}`"
200    )]
201    MissingToolWithRootHome { canonical_path: PathBuf },
202
203    #[error("installed ic-wasm candidate is not executable: {path}")]
204    NotExecutable { path: PathBuf },
205
206    #[error(
207        "requested ic-wasm executable {path} is missing or not executable; artifact builds require ic-wasm {IC_WASM_VERSION}; run `{IC_WASM_REPAIR_COMMAND}`"
208    )]
209    RequestedExecutableMissing { path: PathBuf },
210
211    #[error("temporary ic-wasm installation directory allocation was exhausted under {root}")]
212    TempDirectoryExhausted { root: PathBuf },
213
214    #[error("unsupported ic-wasm platform: {os} {arch}")]
215    UnsupportedPlatform {
216        os: &'static str,
217        arch: &'static str,
218    },
219
220    #[error(
221        "selected ic-wasm executable {path} reports `{actual}`; required `{expected}`; run `{IC_WASM_REPAIR_COMMAND}`"
222    )]
223    VersionMismatch {
224        path: PathBuf,
225        actual: String,
226        expected: &'static str,
227    },
228
229    #[error("failed to inspect ic-wasm version at {path}: {source}")]
230    VersionProcess {
231        path: PathBuf,
232        #[source]
233        source: io::Error,
234    },
235}
236
237/// Return the archive authority for the current install-capable host platform.
238pub fn current_ic_wasm_authority() -> Result<IcWasmAuthority, IcWasmToolError> {
239    ic_wasm_authority_for(env::consts::OS, env::consts::ARCH)
240}
241
242fn ic_wasm_authority_for(
243    os: &'static str,
244    arch: &'static str,
245) -> Result<IcWasmAuthority, IcWasmToolError> {
246    SUPPORTED_IC_WASM_AUTHORITIES
247        .iter()
248        .find(|projection| (projection.os, projection.arch) == (os, arch))
249        .map(|projection| projection.authority)
250        .ok_or(IcWasmToolError::UnsupportedPlatform { os, arch })
251}
252
253/// Resolve and admit the governed install path, falling back to the first `ic-wasm` on PATH.
254pub fn resolve_required_ic_wasm() -> Result<IcWasmExecutable, IcWasmToolError> {
255    current_ic_wasm_authority()?;
256    let path = resolve_selected_executable()?;
257    let path = resolve_distribution_executable(&path)?;
258    admit_ic_wasm_executable(&path)
259}
260
261/// Install the official current-platform `ic-wasm` under `~/.local/bin`.
262pub fn install_required_ic_wasm() -> Result<IcWasmExecutable, IcWasmToolError> {
263    let authority = current_ic_wasm_authority()?;
264    let install_path = default_ic_wasm_install_path()?;
265    let temp = TempDirectory::create()?;
266    let archive_path = temp.path().join(authority.archive_name());
267    download_archive(authority, &archive_path)?;
268    verify_archive(authority, &archive_path)?;
269    extract_archive(authority, &archive_path, temp.path())?;
270    let candidate = temp
271        .path()
272        .join(authority.package_name())
273        .join(IC_WASM_TOOL);
274    admit_ic_wasm_executable(&candidate)?;
275    publish_executable(&candidate, &install_path)?;
276    admit_ic_wasm_executable(&install_path)
277}
278
279/// Return the fixed installation path used by setup and fallback resolution.
280pub fn default_ic_wasm_install_path() -> Result<PathBuf, IcWasmToolError> {
281    let home = env::var_os("HOME")
282        .filter(|value| !value.is_empty())
283        .ok_or(IcWasmToolError::MissingHome)?;
284    Ok(PathBuf::from(home).join(".local/bin/ic-wasm"))
285}
286
287fn resolve_distribution_executable(path: &Path) -> Result<PathBuf, IcWasmToolError> {
288    if path.extension().and_then(|extension| extension.to_str()) != Some("js")
289        || sha256_file(path)? != NPM_LAUNCHER_SHA256
290    {
291        return Ok(path.to_path_buf());
292    }
293    let cwd = env::current_dir().map_err(|source| IcWasmToolError::Io {
294        operation: "resolve npm ic-wasm invocation directory",
295        path: path.to_path_buf(),
296        source,
297    })?;
298    let candidates = npm_binary_candidates(path, &cwd, env::consts::OS, env::consts::ARCH)?;
299    for candidate in candidates {
300        if candidate.exists() {
301            return canonical_executable(&candidate);
302        }
303    }
304    Err(IcWasmToolError::RequestedExecutableMissing {
305        path: path.to_path_buf(),
306    })
307}
308
309fn npm_binary_candidates(
310    launcher: &Path,
311    cwd: &Path,
312    os: &'static str,
313    arch: &'static str,
314) -> Result<[PathBuf; 3], IcWasmToolError> {
315    let platform = match (os, arch) {
316        ("linux", "x86_64") => "linux-x64",
317        ("linux", "aarch64") => "linux-arm64",
318        ("macos", "x86_64") => "darwin-x64",
319        ("macos", "aarch64") => "darwin-arm64",
320        _ => return Err(IcWasmToolError::UnsupportedPlatform { os, arch }),
321    };
322    let directory =
323        launcher
324            .parent()
325            .ok_or_else(|| IcWasmToolError::RequestedExecutableMissing {
326                path: launcher.to_path_buf(),
327            })?;
328    let binary = format!("@icp-sdk/ic-wasm-{platform}/bin/ic-wasm");
329    Ok([
330        directory.join("../../..").join(&binary),
331        directory.join("../node_modules").join(&binary),
332        cwd.join("node_modules").join(binary),
333    ])
334}
335
336fn resolve_selected_executable() -> Result<PathBuf, IcWasmToolError> {
337    match default_ic_wasm_install_path() {
338        Ok(path) if is_executable(&path) => return canonical_executable(&path),
339        Ok(_) | Err(IcWasmToolError::MissingHome) => {}
340        Err(error) => return Err(error),
341    }
342    if let Some(path) = executable_on_path(OsStr::new(IC_WASM_TOOL))? {
343        return Ok(path);
344    }
345    match default_ic_wasm_install_path() {
346        Ok(path) if is_executable(&path) => canonical_executable(&path),
347        Ok(canonical_path) if home_is_root() => {
348            Err(IcWasmToolError::MissingToolWithRootHome { canonical_path })
349        }
350        Ok(canonical_path) => Err(IcWasmToolError::MissingTool { canonical_path }),
351        Err(IcWasmToolError::MissingHome) => Err(IcWasmToolError::MissingToolWithoutHome),
352        Err(error) => Err(error),
353    }
354}
355
356fn executable_on_path(command: &OsStr) -> Result<Option<PathBuf>, IcWasmToolError> {
357    let Some(path) = env::var_os("PATH") else {
358        return Ok(None);
359    };
360    for directory in env::split_paths(&path) {
361        let candidate = directory.join(command);
362        if is_executable(&candidate) {
363            return canonical_executable(&candidate).map(Some);
364        }
365    }
366    Ok(None)
367}
368
369fn canonical_executable(path: &Path) -> Result<PathBuf, IcWasmToolError> {
370    if !is_executable(path) {
371        return Err(IcWasmToolError::RequestedExecutableMissing {
372            path: path.to_path_buf(),
373        });
374    }
375    fs::canonicalize(path).map_err(|source| IcWasmToolError::Io {
376        operation: "resolve ic-wasm executable",
377        path: path.to_path_buf(),
378        source,
379    })
380}
381
382fn is_executable(path: &Path) -> bool {
383    let Ok(metadata) = fs::metadata(path) else {
384        return false;
385    };
386    if !metadata.is_file() {
387        return false;
388    }
389    #[cfg(unix)]
390    {
391        metadata.permissions().mode() & 0o111 != 0
392    }
393    #[cfg(not(unix))]
394    {
395        false
396    }
397}
398
399fn admit_ic_wasm_executable(path: &Path) -> Result<IcWasmExecutable, IcWasmToolError> {
400    let path = canonical_executable(path)?;
401    if !is_executable(&path) {
402        return Err(IcWasmToolError::NotExecutable { path });
403    }
404    let mut command = Command::new(&path);
405    command.arg("--version");
406    let output = output_with_executable_busy_retry(&mut command).map_err(|source| {
407        IcWasmToolError::VersionProcess {
408            path: path.clone(),
409            source,
410        }
411    })?;
412    let actual = if output.status.success() {
413        String::from_utf8_lossy(&output.stdout).trim().to_string()
414    } else {
415        String::from_utf8_lossy(&output.stderr).trim().to_string()
416    };
417    if !output.status.success() || actual != IC_WASM_VERSION_IDENTITY {
418        return Err(IcWasmToolError::VersionMismatch {
419            path,
420            actual,
421            expected: IC_WASM_VERSION_IDENTITY,
422        });
423    }
424    Ok(IcWasmExecutable {
425        path,
426        version_identity: actual,
427    })
428}
429
430fn download_archive(
431    authority: IcWasmAuthority,
432    archive_path: &Path,
433) -> Result<(), IcWasmToolError> {
434    let mut command = Command::new(DOWNLOAD_TOOL);
435    command
436        .args([
437            "--proto",
438            "=https",
439            "--proto-redir",
440            "=https",
441            "--tlsv1.2",
442            "-fsSL",
443            "-o",
444        ])
445        .arg(archive_path)
446        .arg(authority.archive_url());
447    let output =
448        output_with_executable_busy_retry(&mut command).map_err(|source| IcWasmToolError::Io {
449            operation: "run curl for ic-wasm archive",
450            path: archive_path.to_path_buf(),
451            source,
452        })?;
453    if !output.status.success() {
454        return Err(IcWasmToolError::ArchiveDownload {
455            status: output.status.to_string(),
456            stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
457        });
458    }
459    Ok(())
460}
461
462fn verify_archive(authority: IcWasmAuthority, archive_path: &Path) -> Result<(), IcWasmToolError> {
463    let actual = sha256_file(archive_path)?;
464    if actual != authority.archive_sha256() {
465        return Err(IcWasmToolError::ArchiveHashMismatch {
466            path: archive_path.to_path_buf(),
467            actual,
468            expected: authority.archive_sha256(),
469        });
470    }
471    Ok(())
472}
473
474fn extract_archive(
475    authority: IcWasmAuthority,
476    archive_path: &Path,
477    destination: &Path,
478) -> Result<(), IcWasmToolError> {
479    let member = format!("{}/{IC_WASM_TOOL}", authority.package_name());
480    let mut command = Command::new(EXTRACT_TOOL);
481    command
482        .arg("-xJf")
483        .arg(archive_path)
484        .arg("-C")
485        .arg(destination)
486        .arg(member);
487    let output =
488        output_with_executable_busy_retry(&mut command).map_err(|source| IcWasmToolError::Io {
489            operation: "run tar for ic-wasm archive",
490            path: archive_path.to_path_buf(),
491            source,
492        })?;
493    if !output.status.success() {
494        return Err(IcWasmToolError::ArchiveExtraction {
495            status: output.status.to_string(),
496            stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
497        });
498    }
499    Ok(())
500}
501
502fn publish_executable(candidate: &Path, destination: &Path) -> Result<(), IcWasmToolError> {
503    let parent = destination.parent().ok_or_else(|| IcWasmToolError::Io {
504        operation: "select ic-wasm installation directory",
505        path: destination.to_path_buf(),
506        source: io::Error::new(io::ErrorKind::InvalidInput, "destination has no parent"),
507    })?;
508    fs::create_dir_all(parent).map_err(|source| IcWasmToolError::Io {
509        operation: "create ic-wasm installation directory",
510        path: parent.to_path_buf(),
511        source,
512    })?;
513    let expected_sha256 = sha256_file(candidate)?;
514    let stage = parent.join(format!(
515        ".ic-wasm.canic-install-{}-{}",
516        std::process::id(),
517        TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed)
518    ));
519    let result = (|| {
520        let mut source = File::open(candidate).map_err(|source| IcWasmToolError::Io {
521            operation: "open admitted ic-wasm executable",
522            path: candidate.to_path_buf(),
523            source,
524        })?;
525        let mut output = OpenOptions::new()
526            .create_new(true)
527            .write(true)
528            .open(&stage)
529            .map_err(|source| IcWasmToolError::Io {
530                operation: "create staged ic-wasm executable",
531                path: stage.clone(),
532                source,
533            })?;
534        io::copy(&mut source, &mut output).map_err(|source| IcWasmToolError::Io {
535            operation: "write staged ic-wasm executable",
536            path: stage.clone(),
537            source,
538        })?;
539        #[cfg(unix)]
540        fs::set_permissions(&stage, fs::Permissions::from_mode(0o755)).map_err(|source| {
541            IcWasmToolError::Io {
542                operation: "set staged ic-wasm executable permissions",
543                path: stage.clone(),
544                source,
545            }
546        })?;
547        output.sync_all().map_err(|source| IcWasmToolError::Io {
548            operation: "sync staged ic-wasm executable",
549            path: stage.clone(),
550            source,
551        })?;
552        drop(output);
553        let actual = sha256_file(&stage)?;
554        if actual != expected_sha256 {
555            return Err(IcWasmToolError::ExecutableHashMismatch {
556                path: stage.clone(),
557                actual,
558                expected: expected_sha256.clone(),
559            });
560        }
561        admit_ic_wasm_executable(&stage)?;
562        fs::rename(&stage, destination).map_err(|source| IcWasmToolError::Io {
563            operation: "publish ic-wasm executable",
564            path: destination.to_path_buf(),
565            source,
566        })?;
567        File::open(parent)
568            .and_then(|directory| directory.sync_all())
569            .map_err(|source| IcWasmToolError::Io {
570                operation: "sync ic-wasm installation directory",
571                path: parent.to_path_buf(),
572                source,
573            })
574    })();
575    if result.is_err() {
576        let _ = fs::remove_file(&stage);
577    }
578    result
579}
580
581fn sha256_file(path: &Path) -> Result<String, IcWasmToolError> {
582    let mut file = File::open(path).map_err(|source| IcWasmToolError::Io {
583        operation: "open file for SHA-256",
584        path: path.to_path_buf(),
585        source,
586    })?;
587    let mut hasher = Sha256::new();
588    let mut buffer = [0_u8; 16 * 1024];
589    loop {
590        let read = file
591            .read(&mut buffer)
592            .map_err(|source| IcWasmToolError::Io {
593                operation: "read file for SHA-256",
594                path: path.to_path_buf(),
595                source,
596            })?;
597        if read == 0 {
598            break;
599        }
600        hasher.update(&buffer[..read]);
601    }
602    Ok(hex_bytes(hasher.finalize()))
603}
604
605fn home_is_root() -> bool {
606    env::var_os("HOME").is_some_and(|home| Path::new(&home) == Path::new("/"))
607}
608
609#[cfg(test)]
610pub(crate) fn resolve_test_ic_wasm(command: &str) -> Result<IcWasmExecutable, IcWasmToolError> {
611    let path = canonical_executable(Path::new(command))?;
612    admit_ic_wasm_executable(&path)
613}
614
615struct TempDirectory {
616    path: PathBuf,
617}
618
619impl TempDirectory {
620    fn create() -> Result<Self, IcWasmToolError> {
621        let root = env::temp_dir();
622        for _ in 0..TEMP_ATTEMPTS {
623            let path = root.join(format!(
624                "canic-ic-wasm-install-{}-{}",
625                std::process::id(),
626                TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed)
627            ));
628            match fs::create_dir(&path) {
629                Ok(()) => return Ok(Self { path }),
630                Err(source) if source.kind() == io::ErrorKind::AlreadyExists => {}
631                Err(source) => {
632                    return Err(IcWasmToolError::Io {
633                        operation: "create temporary ic-wasm installation directory",
634                        path,
635                        source,
636                    });
637                }
638            }
639        }
640        Err(IcWasmToolError::TempDirectoryExhausted { root })
641    }
642
643    fn path(&self) -> &Path {
644        &self.path
645    }
646}
647
648impl Drop for TempDirectory {
649    fn drop(&mut self) {
650        let _ = fs::remove_dir_all(&self.path);
651    }
652}