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