1#[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#[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, 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#[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
187pub 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
209pub 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
216pub 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
236pub 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}