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