Skip to main content

canic_host/binaryen/
mod.rs

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