1#[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#[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#[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#[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
240pub 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
256pub 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
263pub 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
282pub 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}