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