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