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#[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}` and place its directory before the selected executable on PATH"
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; run `{BINARYEN_REPAIR_COMMAND}` and place its directory on PATH"
192    )]
193    MissingOptimizer,
194
195    #[error("installed Binaryen candidate is not executable: {path}")]
196    NotExecutable { path: PathBuf },
197
198    #[error("temporary Binaryen installation directory allocation was exhausted under {root}")]
199    TempDirectoryExhausted { root: PathBuf },
200
201    #[error("unsupported Binaryen platform: {os} {arch}")]
202    UnsupportedPlatform {
203        os: &'static str,
204        arch: &'static str,
205    },
206
207    #[error(
208        "selected Binaryen executable {path} reports `{actual}`; required `{expected}`; run `{BINARYEN_REPAIR_COMMAND}` and place its directory before the selected executable on PATH"
209    )]
210    VersionMismatch {
211        path: PathBuf,
212        actual: String,
213        expected: &'static str,
214    },
215
216    #[error("failed to inspect Binaryen version at {path}: {source}")]
217    VersionProcess {
218        path: PathBuf,
219        #[source]
220        source: io::Error,
221    },
222}
223
224/// Return the checksum authority for the current supported host platform.
225pub fn current_binaryen_authority() -> Result<BinaryenAuthority, BinaryenToolError> {
226    binaryen_authority_for(env::consts::OS, env::consts::ARCH)
227}
228
229fn binaryen_authority_for(
230    os: &'static str,
231    arch: &'static str,
232) -> Result<BinaryenAuthority, BinaryenToolError> {
233    SUPPORTED_BINARYEN_AUTHORITIES
234        .iter()
235        .find(|projection| (projection.os, projection.arch) == (os, arch))
236        .map(|projection| projection.authority)
237        .ok_or(BinaryenToolError::UnsupportedPlatform { os, arch })
238}
239
240/// Resolve and admit the exact optimizer selected by the caller's current PATH.
241pub fn resolve_required_binaryen() -> Result<BinaryenExecutable, BinaryenToolError> {
242    let authority = current_binaryen_authority()?;
243    let path = resolve_executable(OsStr::new(WASM_OPT_TOOL))?;
244    admit_binaryen_executable(&path, authority.executable_sha256())
245}
246
247/// Install the official current-platform optimizer under `~/.local/bin`.
248///
249/// The returned absolute path is suitable for deriving the PATH prefix used by
250/// a governed downstream release build.
251pub fn install_required_binaryen() -> Result<BinaryenExecutable, BinaryenToolError> {
252    let authority = current_binaryen_authority()?;
253    let install_path = default_binaryen_install_path()?;
254    let temp = TempDirectory::create()?;
255    let archive_path = temp.path().join(authority.archive_name());
256    download_archive(authority, &archive_path)?;
257    verify_archive(authority, &archive_path)?;
258    extract_archive(&archive_path, temp.path())?;
259    let candidate = temp
260        .path()
261        .join(format!("binaryen-version_{BINARYEN_VERSION}/bin/wasm-opt"));
262    admit_binaryen_executable(&candidate, authority.executable_sha256())?;
263    publish_executable(&candidate, &install_path, authority.executable_sha256())?;
264    admit_binaryen_executable(&install_path, authority.executable_sha256())
265}
266
267/// Return the fixed downstream installation path named by repair diagnostics.
268pub fn default_binaryen_install_path() -> Result<PathBuf, BinaryenToolError> {
269    let home = env::var_os("HOME")
270        .filter(|value| !value.is_empty())
271        .ok_or(BinaryenToolError::MissingHome)?;
272    Ok(PathBuf::from(home).join(".local/bin/wasm-opt"))
273}
274
275fn resolve_executable(command: &OsStr) -> Result<PathBuf, BinaryenToolError> {
276    let requested = Path::new(command);
277    if requested.components().count() > 1 {
278        return canonical_executable(requested);
279    }
280    let Some(path) = env::var_os("PATH") else {
281        return Err(BinaryenToolError::MissingOptimizer);
282    };
283    for directory in env::split_paths(&path) {
284        let candidate = directory.join(requested);
285        if is_executable(&candidate) {
286            return canonical_executable(&candidate);
287        }
288    }
289    Err(BinaryenToolError::MissingOptimizer)
290}
291
292fn canonical_executable(path: &Path) -> Result<PathBuf, BinaryenToolError> {
293    if !is_executable(path) {
294        return Err(BinaryenToolError::MissingOptimizer);
295    }
296    fs::canonicalize(path).map_err(|source| BinaryenToolError::Io {
297        operation: "resolve Binaryen executable",
298        path: path.to_path_buf(),
299        source,
300    })
301}
302
303fn is_executable(path: &Path) -> bool {
304    let Ok(metadata) = fs::metadata(path) else {
305        return false;
306    };
307    if !metadata.is_file() {
308        return false;
309    }
310    #[cfg(unix)]
311    {
312        metadata.permissions().mode() & 0o111 != 0
313    }
314    #[cfg(not(unix))]
315    {
316        false
317    }
318}
319
320fn admit_binaryen_executable(
321    path: &Path,
322    expected_sha256: &str,
323) -> Result<BinaryenExecutable, BinaryenToolError> {
324    let path = fs::canonicalize(path).map_err(|source| BinaryenToolError::Io {
325        operation: "resolve Binaryen executable",
326        path: path.to_path_buf(),
327        source,
328    })?;
329    if !is_executable(&path) {
330        return Err(BinaryenToolError::NotExecutable { path });
331    }
332    let sha256 = sha256_file(&path)?;
333    if sha256 != expected_sha256 {
334        return Err(BinaryenToolError::ExecutableHashMismatch {
335            path,
336            actual: sha256,
337            expected: expected_sha256.to_string(),
338        });
339    }
340
341    let mut command = Command::new(&path);
342    command.arg("--version");
343    let output = output_with_executable_busy_retry(&mut command).map_err(|source| {
344        BinaryenToolError::VersionProcess {
345            path: path.clone(),
346            source,
347        }
348    })?;
349    let actual = if output.status.success() {
350        String::from_utf8_lossy(&output.stdout).trim().to_string()
351    } else {
352        String::from_utf8_lossy(&output.stderr).trim().to_string()
353    };
354    if !output.status.success() || actual != BINARYEN_VERSION_IDENTITY {
355        return Err(BinaryenToolError::VersionMismatch {
356            path,
357            actual,
358            expected: BINARYEN_VERSION_IDENTITY,
359        });
360    }
361
362    Ok(BinaryenExecutable {
363        path,
364        version_identity: actual,
365        sha256,
366    })
367}
368
369fn download_archive(
370    authority: BinaryenAuthority,
371    archive_path: &Path,
372) -> Result<(), BinaryenToolError> {
373    let mut command = Command::new(DOWNLOAD_TOOL);
374    command
375        .args([
376            "--proto",
377            "=https",
378            "--proto-redir",
379            "=https",
380            "--tlsv1.2",
381            "-fsSL",
382            "-o",
383        ])
384        .arg(archive_path)
385        .arg(authority.archive_url());
386    let output = output_with_executable_busy_retry(&mut command).map_err(|source| {
387        BinaryenToolError::Io {
388            operation: "run curl for Binaryen archive",
389            path: archive_path.to_path_buf(),
390            source,
391        }
392    })?;
393    if !output.status.success() {
394        return Err(BinaryenToolError::ArchiveDownload {
395            status: output.status.to_string(),
396            stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
397        });
398    }
399    Ok(())
400}
401
402fn verify_archive(
403    authority: BinaryenAuthority,
404    archive_path: &Path,
405) -> Result<(), BinaryenToolError> {
406    let actual = sha256_file(archive_path)?;
407    if actual != authority.archive_sha256() {
408        return Err(BinaryenToolError::ArchiveHashMismatch {
409            path: archive_path.to_path_buf(),
410            actual,
411            expected: authority.archive_sha256(),
412        });
413    }
414    Ok(())
415}
416
417fn extract_archive(archive_path: &Path, destination: &Path) -> Result<(), BinaryenToolError> {
418    let member = format!("binaryen-version_{BINARYEN_VERSION}/bin/wasm-opt");
419    let mut command = Command::new(EXTRACT_TOOL);
420    command
421        .arg("-xzf")
422        .arg(archive_path)
423        .arg("-C")
424        .arg(destination)
425        .arg(member);
426    let output = output_with_executable_busy_retry(&mut command).map_err(|source| {
427        BinaryenToolError::Io {
428            operation: "run tar for Binaryen archive",
429            path: archive_path.to_path_buf(),
430            source,
431        }
432    })?;
433    if !output.status.success() {
434        return Err(BinaryenToolError::ArchiveExtraction {
435            status: output.status.to_string(),
436            stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
437        });
438    }
439    Ok(())
440}
441
442fn publish_executable(
443    candidate: &Path,
444    destination: &Path,
445    expected_sha256: &str,
446) -> Result<(), BinaryenToolError> {
447    let parent = destination.parent().ok_or_else(|| BinaryenToolError::Io {
448        operation: "select Binaryen installation directory",
449        path: destination.to_path_buf(),
450        source: io::Error::new(io::ErrorKind::InvalidInput, "destination has no parent"),
451    })?;
452    fs::create_dir_all(parent).map_err(|source| BinaryenToolError::Io {
453        operation: "create Binaryen installation directory",
454        path: parent.to_path_buf(),
455        source,
456    })?;
457    let stage = parent.join(format!(
458        ".wasm-opt.canic-install-{}-{}",
459        std::process::id(),
460        TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed)
461    ));
462    let result = (|| {
463        let mut source = File::open(candidate).map_err(|source| BinaryenToolError::Io {
464            operation: "open admitted Binaryen executable",
465            path: candidate.to_path_buf(),
466            source,
467        })?;
468        let mut output = OpenOptions::new()
469            .create_new(true)
470            .write(true)
471            .open(&stage)
472            .map_err(|source| BinaryenToolError::Io {
473                operation: "create staged Binaryen executable",
474                path: stage.clone(),
475                source,
476            })?;
477        io::copy(&mut source, &mut output).map_err(|source| BinaryenToolError::Io {
478            operation: "write staged Binaryen executable",
479            path: stage.clone(),
480            source,
481        })?;
482        #[cfg(unix)]
483        fs::set_permissions(&stage, fs::Permissions::from_mode(0o755)).map_err(|source| {
484            BinaryenToolError::Io {
485                operation: "set staged Binaryen executable permissions",
486                path: stage.clone(),
487                source,
488            }
489        })?;
490        output.sync_all().map_err(|source| BinaryenToolError::Io {
491            operation: "sync staged Binaryen executable",
492            path: stage.clone(),
493            source,
494        })?;
495        drop(output);
496        admit_binaryen_executable(&stage, expected_sha256)?;
497        fs::rename(&stage, destination).map_err(|source| BinaryenToolError::Io {
498            operation: "publish Binaryen executable",
499            path: destination.to_path_buf(),
500            source,
501        })?;
502        File::open(parent)
503            .and_then(|directory| directory.sync_all())
504            .map_err(|source| BinaryenToolError::Io {
505                operation: "sync Binaryen installation directory",
506                path: parent.to_path_buf(),
507                source,
508            })
509    })();
510    if result.is_err() {
511        let _ = fs::remove_file(&stage);
512    }
513    result
514}
515
516fn sha256_file(path: &Path) -> Result<String, BinaryenToolError> {
517    let mut file = File::open(path).map_err(|source| BinaryenToolError::Io {
518        operation: "open file for SHA-256",
519        path: path.to_path_buf(),
520        source,
521    })?;
522    let mut hasher = Sha256::new();
523    let mut buffer = [0_u8; 16 * 1024];
524    loop {
525        let read = file
526            .read(&mut buffer)
527            .map_err(|source| BinaryenToolError::Io {
528                operation: "read file for SHA-256",
529                path: path.to_path_buf(),
530                source,
531            })?;
532        if read == 0 {
533            break;
534        }
535        hasher.update(&buffer[..read]);
536    }
537    Ok(hex_bytes(hasher.finalize()))
538}
539
540#[cfg(test)]
541pub(crate) fn resolve_test_binaryen(
542    command: &str,
543) -> Result<BinaryenExecutable, BinaryenToolError> {
544    let path = resolve_executable(OsStr::new(command))?;
545    let expected = sha256_file(&path)?;
546    admit_binaryen_executable(&path, &expected)
547}
548
549struct TempDirectory {
550    path: PathBuf,
551}
552
553impl TempDirectory {
554    fn create() -> Result<Self, BinaryenToolError> {
555        let root = env::temp_dir();
556        for _ in 0..TEMP_ATTEMPTS {
557            let path = root.join(format!(
558                "canic-binaryen-install-{}-{}",
559                std::process::id(),
560                TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed)
561            ));
562            match fs::create_dir(&path) {
563                Ok(()) => return Ok(Self { path }),
564                Err(source) if source.kind() == io::ErrorKind::AlreadyExists => {}
565                Err(source) => {
566                    return Err(BinaryenToolError::Io {
567                        operation: "create temporary Binaryen installation directory",
568                        path,
569                        source,
570                    });
571                }
572            }
573        }
574        Err(BinaryenToolError::TempDirectoryExhausted { root })
575    }
576
577    fn path(&self) -> &Path {
578        &self.path
579    }
580}
581
582impl Drop for TempDirectory {
583    fn drop(&mut self) {
584        let _ = fs::remove_dir_all(&self.path);
585    }
586}