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, 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}` 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
224pub 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
240pub 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
247pub 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
267pub 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}