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 IC_WASM_REPAIR_COMMAND: &str = "canic toolchain install";
30pub const IC_WASM_TOOL: &str = "ic-wasm";
31pub const IC_WASM_VERSION: &str = "0.11.1";
32pub const IC_WASM_VERSION_IDENTITY: &str = "ic-wasm 0.11.1";
33
34const NPM_LAUNCHER_SHA256: &str =
37 "ff4f9bd1d3734f7aa69078ecd4c5716dbfaf67d094c69d5082b00bac5b8bf936";
38
39const DOWNLOAD_TOOL: &str = "curl";
40const EXTRACT_TOOL: &str = "tar";
41const TEMP_ATTEMPTS: usize = 64;
42static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
43
44#[derive(Clone, Copy, Debug, Eq, PartialEq)]
51pub struct IcWasmAuthority {
52 archive_platform: &'static str,
53 archive_sha256: &'static str,
54}
55
56impl IcWasmAuthority {
57 #[must_use]
58 pub const fn archive_platform(self) -> &'static str {
59 self.archive_platform
60 }
61
62 #[must_use]
63 pub const fn archive_sha256(self) -> &'static str {
64 self.archive_sha256
65 }
66
67 fn archive_name(self) -> String {
68 format!("ic-wasm-{}.tar.xz", self.archive_platform)
69 }
70
71 fn archive_url(self) -> String {
72 format!(
73 "https://github.com/dfinity/ic-wasm/releases/download/{IC_WASM_VERSION}/{}",
74 self.archive_name()
75 )
76 }
77
78 fn package_name(self) -> String {
79 format!("ic-wasm-{}", self.archive_platform)
80 }
81}
82
83#[derive(Clone, Copy, Debug, Eq, PartialEq)]
84struct IcWasmPlatformAuthority {
85 os: &'static str,
86 arch: &'static str,
87 authority: IcWasmAuthority,
88}
89
90const SUPPORTED_IC_WASM_AUTHORITIES: [IcWasmPlatformAuthority; 4] = [
91 IcWasmPlatformAuthority {
92 os: "macos",
93 arch: "aarch64",
94 authority: IcWasmAuthority {
95 archive_platform: "aarch64-apple-darwin",
96 archive_sha256: "1feeb253498b783ce19e9e166d4f205ed35a8ab7fa679173aaa4b41fb78c852d",
97 },
98 },
99 IcWasmPlatformAuthority {
100 os: "macos",
101 arch: "x86_64",
102 authority: IcWasmAuthority {
103 archive_platform: "x86_64-apple-darwin",
104 archive_sha256: "9bf63f9daaee8d812207807435a1bff23d7b7e50c573b7c2a36b8aa50974e99a",
105 },
106 },
107 IcWasmPlatformAuthority {
108 os: "linux",
109 arch: "aarch64",
110 authority: IcWasmAuthority {
111 archive_platform: "aarch64-unknown-linux-gnu",
112 archive_sha256: "49d5992ee5f050f8869b6b4b8357eceb1cb3b84f79fbd2de37ed8166c5dc5e30",
113 },
114 },
115 IcWasmPlatformAuthority {
116 os: "linux",
117 arch: "x86_64",
118 authority: IcWasmAuthority {
119 archive_platform: "x86_64-unknown-linux-gnu",
120 archive_sha256: "099776a745c4d4495761da18f2fe2216759a4166beacd05453bf031d61631746",
121 },
122 },
123];
124
125#[derive(Clone, Debug, Eq, PartialEq)]
132pub struct IcWasmExecutable {
133 path: PathBuf,
134 version_identity: String,
135}
136
137impl IcWasmExecutable {
138 #[must_use]
139 pub fn path(&self) -> &Path {
140 &self.path
141 }
142
143 #[must_use]
144 pub fn version_identity(&self) -> &str {
145 &self.version_identity
146 }
147}
148
149#[derive(Debug, ThisError)]
156pub enum IcWasmToolError {
157 #[error("ic-wasm archive download failed with status {status}: {stderr}")]
158 ArchiveDownload { status: String, stderr: String },
159
160 #[error("downloaded ic-wasm archive {path} has SHA-256 {actual}; required {expected}")]
161 ArchiveHashMismatch {
162 path: PathBuf,
163 actual: String,
164 expected: &'static str,
165 },
166
167 #[error("ic-wasm archive extraction failed with status {status}: {stderr}")]
168 ArchiveExtraction { status: String, stderr: String },
169
170 #[error("staged ic-wasm executable {path} has SHA-256 {actual}; required {expected}")]
171 ExecutableHashMismatch {
172 path: PathBuf,
173 actual: String,
174 expected: String,
175 },
176
177 #[error("failed to {operation} {path}: {source}")]
178 Io {
179 operation: &'static str,
180 path: PathBuf,
181 #[source]
182 source: io::Error,
183 },
184
185 #[error("HOME is unavailable; `{IC_WASM_REPAIR_COMMAND}` cannot select ~/.local/bin")]
186 MissingHome,
187
188 #[error(
189 "required {IC_WASM_TOOL} {IC_WASM_VERSION} was not found on PATH or at {canonical_path}; run `{IC_WASM_REPAIR_COMMAND}`"
190 )]
191 MissingTool { canonical_path: PathBuf },
192
193 #[error(
194 "required {IC_WASM_TOOL} {IC_WASM_VERSION} was not found on PATH and HOME is unavailable; run `{IC_WASM_REPAIR_COMMAND}` with HOME set to the intended account home"
195 )]
196 MissingToolWithoutHome,
197
198 #[error(
199 "required {IC_WASM_TOOL} {IC_WASM_VERSION} was not found on PATH or at {canonical_path}; HOME resolves to `/`, so confirm that root-level install location is intentional before running `{IC_WASM_REPAIR_COMMAND}`"
200 )]
201 MissingToolWithRootHome { canonical_path: PathBuf },
202
203 #[error("installed ic-wasm candidate is not executable: {path}")]
204 NotExecutable { path: PathBuf },
205
206 #[error(
207 "requested ic-wasm executable {path} is missing or not executable; artifact builds require ic-wasm {IC_WASM_VERSION}; run `{IC_WASM_REPAIR_COMMAND}`"
208 )]
209 RequestedExecutableMissing { path: PathBuf },
210
211 #[error("temporary ic-wasm installation directory allocation was exhausted under {root}")]
212 TempDirectoryExhausted { root: PathBuf },
213
214 #[error("unsupported ic-wasm platform: {os} {arch}")]
215 UnsupportedPlatform {
216 os: &'static str,
217 arch: &'static str,
218 },
219
220 #[error(
221 "selected ic-wasm executable {path} reports `{actual}`; required `{expected}`; run `{IC_WASM_REPAIR_COMMAND}`"
222 )]
223 VersionMismatch {
224 path: PathBuf,
225 actual: String,
226 expected: &'static str,
227 },
228
229 #[error("failed to inspect ic-wasm version at {path}: {source}")]
230 VersionProcess {
231 path: PathBuf,
232 #[source]
233 source: io::Error,
234 },
235}
236
237pub fn current_ic_wasm_authority() -> Result<IcWasmAuthority, IcWasmToolError> {
239 ic_wasm_authority_for(env::consts::OS, env::consts::ARCH)
240}
241
242fn ic_wasm_authority_for(
243 os: &'static str,
244 arch: &'static str,
245) -> Result<IcWasmAuthority, IcWasmToolError> {
246 SUPPORTED_IC_WASM_AUTHORITIES
247 .iter()
248 .find(|projection| (projection.os, projection.arch) == (os, arch))
249 .map(|projection| projection.authority)
250 .ok_or(IcWasmToolError::UnsupportedPlatform { os, arch })
251}
252
253pub fn resolve_required_ic_wasm() -> Result<IcWasmExecutable, IcWasmToolError> {
255 current_ic_wasm_authority()?;
256 let path = resolve_selected_executable()?;
257 let path = resolve_distribution_executable(&path)?;
258 admit_ic_wasm_executable(&path)
259}
260
261pub fn install_required_ic_wasm() -> Result<IcWasmExecutable, IcWasmToolError> {
263 let authority = current_ic_wasm_authority()?;
264 let install_path = default_ic_wasm_install_path()?;
265 let temp = TempDirectory::create()?;
266 let archive_path = temp.path().join(authority.archive_name());
267 download_archive(authority, &archive_path)?;
268 verify_archive(authority, &archive_path)?;
269 extract_archive(authority, &archive_path, temp.path())?;
270 let candidate = temp
271 .path()
272 .join(authority.package_name())
273 .join(IC_WASM_TOOL);
274 admit_ic_wasm_executable(&candidate)?;
275 publish_executable(&candidate, &install_path)?;
276 admit_ic_wasm_executable(&install_path)
277}
278
279pub fn default_ic_wasm_install_path() -> Result<PathBuf, IcWasmToolError> {
281 let home = env::var_os("HOME")
282 .filter(|value| !value.is_empty())
283 .ok_or(IcWasmToolError::MissingHome)?;
284 Ok(PathBuf::from(home).join(".local/bin/ic-wasm"))
285}
286
287fn resolve_distribution_executable(path: &Path) -> Result<PathBuf, IcWasmToolError> {
288 if path.extension().and_then(|extension| extension.to_str()) != Some("js")
289 || sha256_file(path)? != NPM_LAUNCHER_SHA256
290 {
291 return Ok(path.to_path_buf());
292 }
293 let cwd = env::current_dir().map_err(|source| IcWasmToolError::Io {
294 operation: "resolve npm ic-wasm invocation directory",
295 path: path.to_path_buf(),
296 source,
297 })?;
298 let candidates = npm_binary_candidates(path, &cwd, env::consts::OS, env::consts::ARCH)?;
299 for candidate in candidates {
300 if candidate.exists() {
301 return canonical_executable(&candidate);
302 }
303 }
304 Err(IcWasmToolError::RequestedExecutableMissing {
305 path: path.to_path_buf(),
306 })
307}
308
309fn npm_binary_candidates(
310 launcher: &Path,
311 cwd: &Path,
312 os: &'static str,
313 arch: &'static str,
314) -> Result<[PathBuf; 3], IcWasmToolError> {
315 let platform = match (os, arch) {
316 ("linux", "x86_64") => "linux-x64",
317 ("linux", "aarch64") => "linux-arm64",
318 ("macos", "x86_64") => "darwin-x64",
319 ("macos", "aarch64") => "darwin-arm64",
320 _ => return Err(IcWasmToolError::UnsupportedPlatform { os, arch }),
321 };
322 let directory =
323 launcher
324 .parent()
325 .ok_or_else(|| IcWasmToolError::RequestedExecutableMissing {
326 path: launcher.to_path_buf(),
327 })?;
328 let binary = format!("@icp-sdk/ic-wasm-{platform}/bin/ic-wasm");
329 Ok([
330 directory.join("../../..").join(&binary),
331 directory.join("../node_modules").join(&binary),
332 cwd.join("node_modules").join(binary),
333 ])
334}
335
336fn resolve_selected_executable() -> Result<PathBuf, IcWasmToolError> {
337 match default_ic_wasm_install_path() {
338 Ok(path) if is_executable(&path) => return canonical_executable(&path),
339 Ok(_) | Err(IcWasmToolError::MissingHome) => {}
340 Err(error) => return Err(error),
341 }
342 if let Some(path) = executable_on_path(OsStr::new(IC_WASM_TOOL))? {
343 return Ok(path);
344 }
345 match default_ic_wasm_install_path() {
346 Ok(path) if is_executable(&path) => canonical_executable(&path),
347 Ok(canonical_path) if home_is_root() => {
348 Err(IcWasmToolError::MissingToolWithRootHome { canonical_path })
349 }
350 Ok(canonical_path) => Err(IcWasmToolError::MissingTool { canonical_path }),
351 Err(IcWasmToolError::MissingHome) => Err(IcWasmToolError::MissingToolWithoutHome),
352 Err(error) => Err(error),
353 }
354}
355
356fn executable_on_path(command: &OsStr) -> Result<Option<PathBuf>, IcWasmToolError> {
357 let Some(path) = env::var_os("PATH") else {
358 return Ok(None);
359 };
360 for directory in env::split_paths(&path) {
361 let candidate = directory.join(command);
362 if is_executable(&candidate) {
363 return canonical_executable(&candidate).map(Some);
364 }
365 }
366 Ok(None)
367}
368
369fn canonical_executable(path: &Path) -> Result<PathBuf, IcWasmToolError> {
370 if !is_executable(path) {
371 return Err(IcWasmToolError::RequestedExecutableMissing {
372 path: path.to_path_buf(),
373 });
374 }
375 fs::canonicalize(path).map_err(|source| IcWasmToolError::Io {
376 operation: "resolve ic-wasm executable",
377 path: path.to_path_buf(),
378 source,
379 })
380}
381
382fn is_executable(path: &Path) -> bool {
383 let Ok(metadata) = fs::metadata(path) else {
384 return false;
385 };
386 if !metadata.is_file() {
387 return false;
388 }
389 #[cfg(unix)]
390 {
391 metadata.permissions().mode() & 0o111 != 0
392 }
393 #[cfg(not(unix))]
394 {
395 false
396 }
397}
398
399fn admit_ic_wasm_executable(path: &Path) -> Result<IcWasmExecutable, IcWasmToolError> {
400 let path = canonical_executable(path)?;
401 if !is_executable(&path) {
402 return Err(IcWasmToolError::NotExecutable { path });
403 }
404 let mut command = Command::new(&path);
405 command.arg("--version");
406 let output = output_with_executable_busy_retry(&mut command).map_err(|source| {
407 IcWasmToolError::VersionProcess {
408 path: path.clone(),
409 source,
410 }
411 })?;
412 let actual = if output.status.success() {
413 String::from_utf8_lossy(&output.stdout).trim().to_string()
414 } else {
415 String::from_utf8_lossy(&output.stderr).trim().to_string()
416 };
417 if !output.status.success() || actual != IC_WASM_VERSION_IDENTITY {
418 return Err(IcWasmToolError::VersionMismatch {
419 path,
420 actual,
421 expected: IC_WASM_VERSION_IDENTITY,
422 });
423 }
424 Ok(IcWasmExecutable {
425 path,
426 version_identity: actual,
427 })
428}
429
430fn download_archive(
431 authority: IcWasmAuthority,
432 archive_path: &Path,
433) -> Result<(), IcWasmToolError> {
434 let mut command = Command::new(DOWNLOAD_TOOL);
435 command
436 .args([
437 "--proto",
438 "=https",
439 "--proto-redir",
440 "=https",
441 "--tlsv1.2",
442 "-fsSL",
443 "-o",
444 ])
445 .arg(archive_path)
446 .arg(authority.archive_url());
447 let output =
448 output_with_executable_busy_retry(&mut command).map_err(|source| IcWasmToolError::Io {
449 operation: "run curl for ic-wasm archive",
450 path: archive_path.to_path_buf(),
451 source,
452 })?;
453 if !output.status.success() {
454 return Err(IcWasmToolError::ArchiveDownload {
455 status: output.status.to_string(),
456 stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
457 });
458 }
459 Ok(())
460}
461
462fn verify_archive(authority: IcWasmAuthority, archive_path: &Path) -> Result<(), IcWasmToolError> {
463 let actual = sha256_file(archive_path)?;
464 if actual != authority.archive_sha256() {
465 return Err(IcWasmToolError::ArchiveHashMismatch {
466 path: archive_path.to_path_buf(),
467 actual,
468 expected: authority.archive_sha256(),
469 });
470 }
471 Ok(())
472}
473
474fn extract_archive(
475 authority: IcWasmAuthority,
476 archive_path: &Path,
477 destination: &Path,
478) -> Result<(), IcWasmToolError> {
479 let member = format!("{}/{IC_WASM_TOOL}", authority.package_name());
480 let mut command = Command::new(EXTRACT_TOOL);
481 command
482 .arg("-xJf")
483 .arg(archive_path)
484 .arg("-C")
485 .arg(destination)
486 .arg(member);
487 let output =
488 output_with_executable_busy_retry(&mut command).map_err(|source| IcWasmToolError::Io {
489 operation: "run tar for ic-wasm archive",
490 path: archive_path.to_path_buf(),
491 source,
492 })?;
493 if !output.status.success() {
494 return Err(IcWasmToolError::ArchiveExtraction {
495 status: output.status.to_string(),
496 stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
497 });
498 }
499 Ok(())
500}
501
502fn publish_executable(candidate: &Path, destination: &Path) -> Result<(), IcWasmToolError> {
503 let parent = destination.parent().ok_or_else(|| IcWasmToolError::Io {
504 operation: "select ic-wasm installation directory",
505 path: destination.to_path_buf(),
506 source: io::Error::new(io::ErrorKind::InvalidInput, "destination has no parent"),
507 })?;
508 fs::create_dir_all(parent).map_err(|source| IcWasmToolError::Io {
509 operation: "create ic-wasm installation directory",
510 path: parent.to_path_buf(),
511 source,
512 })?;
513 let expected_sha256 = sha256_file(candidate)?;
514 let stage = parent.join(format!(
515 ".ic-wasm.canic-install-{}-{}",
516 std::process::id(),
517 TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed)
518 ));
519 let result = (|| {
520 let mut source = File::open(candidate).map_err(|source| IcWasmToolError::Io {
521 operation: "open admitted ic-wasm executable",
522 path: candidate.to_path_buf(),
523 source,
524 })?;
525 let mut output = OpenOptions::new()
526 .create_new(true)
527 .write(true)
528 .open(&stage)
529 .map_err(|source| IcWasmToolError::Io {
530 operation: "create staged ic-wasm executable",
531 path: stage.clone(),
532 source,
533 })?;
534 io::copy(&mut source, &mut output).map_err(|source| IcWasmToolError::Io {
535 operation: "write staged ic-wasm executable",
536 path: stage.clone(),
537 source,
538 })?;
539 #[cfg(unix)]
540 fs::set_permissions(&stage, fs::Permissions::from_mode(0o755)).map_err(|source| {
541 IcWasmToolError::Io {
542 operation: "set staged ic-wasm executable permissions",
543 path: stage.clone(),
544 source,
545 }
546 })?;
547 output.sync_all().map_err(|source| IcWasmToolError::Io {
548 operation: "sync staged ic-wasm executable",
549 path: stage.clone(),
550 source,
551 })?;
552 drop(output);
553 let actual = sha256_file(&stage)?;
554 if actual != expected_sha256 {
555 return Err(IcWasmToolError::ExecutableHashMismatch {
556 path: stage.clone(),
557 actual,
558 expected: expected_sha256.clone(),
559 });
560 }
561 admit_ic_wasm_executable(&stage)?;
562 fs::rename(&stage, destination).map_err(|source| IcWasmToolError::Io {
563 operation: "publish ic-wasm executable",
564 path: destination.to_path_buf(),
565 source,
566 })?;
567 File::open(parent)
568 .and_then(|directory| directory.sync_all())
569 .map_err(|source| IcWasmToolError::Io {
570 operation: "sync ic-wasm installation directory",
571 path: parent.to_path_buf(),
572 source,
573 })
574 })();
575 if result.is_err() {
576 let _ = fs::remove_file(&stage);
577 }
578 result
579}
580
581fn sha256_file(path: &Path) -> Result<String, IcWasmToolError> {
582 let mut file = File::open(path).map_err(|source| IcWasmToolError::Io {
583 operation: "open file for SHA-256",
584 path: path.to_path_buf(),
585 source,
586 })?;
587 let mut hasher = Sha256::new();
588 let mut buffer = [0_u8; 16 * 1024];
589 loop {
590 let read = file
591 .read(&mut buffer)
592 .map_err(|source| IcWasmToolError::Io {
593 operation: "read file for SHA-256",
594 path: path.to_path_buf(),
595 source,
596 })?;
597 if read == 0 {
598 break;
599 }
600 hasher.update(&buffer[..read]);
601 }
602 Ok(hex_bytes(hasher.finalize()))
603}
604
605fn home_is_root() -> bool {
606 env::var_os("HOME").is_some_and(|home| Path::new(&home) == Path::new("/"))
607}
608
609#[cfg(test)]
610pub(crate) fn resolve_test_ic_wasm(command: &str) -> Result<IcWasmExecutable, IcWasmToolError> {
611 let path = canonical_executable(Path::new(command))?;
612 admit_ic_wasm_executable(&path)
613}
614
615struct TempDirectory {
616 path: PathBuf,
617}
618
619impl TempDirectory {
620 fn create() -> Result<Self, IcWasmToolError> {
621 let root = env::temp_dir();
622 for _ in 0..TEMP_ATTEMPTS {
623 let path = root.join(format!(
624 "canic-ic-wasm-install-{}-{}",
625 std::process::id(),
626 TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed)
627 ));
628 match fs::create_dir(&path) {
629 Ok(()) => return Ok(Self { path }),
630 Err(source) if source.kind() == io::ErrorKind::AlreadyExists => {}
631 Err(source) => {
632 return Err(IcWasmToolError::Io {
633 operation: "create temporary ic-wasm installation directory",
634 path,
635 source,
636 });
637 }
638 }
639 }
640 Err(IcWasmToolError::TempDirectoryExhausted { root })
641 }
642
643 fn path(&self) -> &Path {
644 &self.path
645 }
646}
647
648impl Drop for TempDirectory {
649 fn drop(&mut self) {
650 let _ = fs::remove_dir_all(&self.path);
651 }
652}