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