cargo_wasix/
lib.rs

1use crate::cache::Cache;
2use crate::config::Config;
3use crate::utils::CommandExt;
4use anyhow::{bail, Context, Result};
5use std::env;
6use std::fs;
7use std::io;
8use std::io::Read;
9use std::path::{Path, PathBuf};
10use std::process::{Command, Stdio};
11use std::time::Duration;
12use tool_path::ToolPath;
13
14mod cache;
15mod config;
16mod dependencies;
17mod internal;
18mod tool_path;
19mod toolchain;
20mod utils;
21
22/// Timeout used by [`download`].
23const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(30);
24
25pub fn main() {
26    // See comments in `rmain` around `*_RUNNER` for why this exists here.
27    if env::var("__CARGO_WASIX_RUNNER_SHIM").is_ok() {
28        let args = env::args().skip(1).collect();
29        println!(
30            "{}",
31            serde_json::to_string(&CargoMessage::RunWithArgs { args }).unwrap(),
32        );
33        return;
34    }
35
36    let mut config = Config::new();
37    match rmain(&mut config) {
38        Ok(()) => {}
39        Err(e) => {
40            config.print_error(&e);
41            std::process::exit(1);
42        }
43    }
44}
45
46#[derive(Debug)]
47enum Subcommand {
48    Build,
49    DownloadToolchain,
50    Run,
51    Test,
52    Bench,
53    Check,
54    Tree,
55    Fix,
56}
57
58fn rmain(config: &mut Config) -> Result<()> {
59    config.load_cache()?;
60
61    // skip the current executable and the `wasix` inserted by Cargo
62    let mut no_message_format = false;
63    let mut args = env::args_os().skip(2);
64
65    let subcommand = args.next().and_then(|s| s.into_string().ok());
66    let subcommand = match subcommand.as_deref() {
67        Some("build") => Subcommand::Build,
68        Some("download-toolchain") => Subcommand::DownloadToolchain,
69        Some("run") => Subcommand::Run,
70        Some("test") => Subcommand::Test,
71        Some("bench") => Subcommand::Bench,
72        Some("check") => Subcommand::Check,
73        Some("tree") => {
74            no_message_format = true;
75            Subcommand::Tree
76        }
77        Some("fix") => Subcommand::Fix,
78        Some("self") => return internal::main(&args.collect::<Vec<_>>(), config),
79        Some("version") | Some("-V") | Some("--version") => {
80            let git_info = match option_env!("GIT_INFO") {
81                Some(s) => format!(" ({})", s),
82                None => String::new(),
83            };
84            println!("cargo-wasix {}{}", env!("CARGO_PKG_VERSION"), git_info);
85            std::process::exit(0)
86        }
87        _ => print_help(),
88    };
89
90    let mut cargo = Command::new("cargo");
91    cargo.arg("+wasix");
92    cargo.arg(match subcommand {
93        Subcommand::Build => "build",
94        Subcommand::DownloadToolchain => "download-toolchain",
95        Subcommand::Check => "check",
96        Subcommand::Fix => "fix",
97        Subcommand::Test => "test",
98        Subcommand::Tree => "tree",
99        Subcommand::Bench => "bench",
100        Subcommand::Run => "run",
101    });
102
103    let manifest_config = if matches!(subcommand, Subcommand::DownloadToolchain) {
104        Default::default()
105    } else {
106        read_manifest_config()?
107    };
108
109    let target = if manifest_config.dl.unwrap_or(false) {
110        "wasm32-wasmer-wasi-dl"
111    } else {
112        "wasm32-wasmer-wasi"
113    };
114
115    cargo.arg("--target").arg(target);
116    if !no_message_format {
117        cargo.arg("--message-format").arg("json-render-diagnostics");
118    }
119
120    let args = args.collect::<Vec<_>>();
121    for arg in args.clone() {
122        if let Some(arg) = arg.to_str() {
123            if arg.starts_with("--verbose") || arg.starts_with("-v") {
124                config.set_verbose(true);
125            }
126        }
127
128        cargo.arg(arg);
129    }
130
131    let runner_env_var = format!(
132        "CARGO_TARGET_{}_RUNNER",
133        target.to_uppercase().replace('-', "_")
134    );
135
136    // If Cargo actually executes a wasm file, we don't want it to. We need to
137    // postprocess wasm files (wasm-opt, wasm-bindgen, etc). As a result we will
138    // actually postprocess wasm files after the build. To work around this we
139    // could pass `--no-run` for `test`/`bench`, but there's unfortunately no
140    // equivalent for `run`. Additionally we want to learn what arguments Cargo
141    // parsed to pass to each wasm file.
142    //
143    // To solve this all we do a bit of a switcharoo. We say that *we* are the
144    // runner, and our binary is configured to simply print a json message at
145    // the beginning. We'll slurp up these json messages and then actually
146    // execute everything at the end.
147    //
148    // Also note that we check here before we actually build that a runtime is
149    // present. We first check the CARGO_TARGET_WASM32_WASMER_WASI_RUNNER environement
150    // variable for a user-supplied runtime (path or executable) and use the
151    // default, namely `wasmer`, if it is not set.
152    let (wasix_runner, using_default) = env::var(&runner_env_var)
153        .map(|runner_override| (runner_override, false))
154        .unwrap_or_else(|_| ("wasmer".to_string(), true));
155
156    let mut check_deps = false;
157    match subcommand {
158        Subcommand::DownloadToolchain => {
159            let version = args
160                .first()
161                .cloned()
162                .map(|v| v.into_string().unwrap().into())
163                .unwrap_or(toolchain::ToolchainSpec::Latest);
164            let _lock = Config::acquire_lock()?;
165            let chain = toolchain::install_prebuilt_toolchain(&Config::toolchain_dir()?, version)?;
166            config.info(&format!(
167                "Toolchain {} downloaded and installed to path {}.\nThe wasix toolchain is now ready to use.",
168                chain.name,
169                chain.path.display(),
170            ));
171            return Ok(());
172        }
173        Subcommand::Run | Subcommand::Bench | Subcommand::Test => {
174            check_deps = true;
175            if !using_default {
176                // check if the override is either a valid path or command found on $PATH
177                if !(Path::new(&wasix_runner).exists() || which::which(&wasix_runner).is_ok()) {
178                    bail!(
179                        "failed to find `{}` (specified by ${runner_env_var}) \
180                         on the filesytem or in $PATH, you'll want to fix the path or unset \
181                         the ${runner_env_var} environment variable before \
182                         running this command\n",
183                        &wasix_runner
184                    );
185                }
186            } else if which::which(&wasix_runner).is_err() {
187                let mut msg = format!(
188                    "failed to find `{}` in $PATH, you'll want to \
189                     install `{}` before running this command\n",
190                    wasix_runner, wasix_runner
191                );
192                // Because we know what runtime is being used here, we can print
193                // out installation information.
194                msg.push_str("you can also install through a shell:\n\n");
195                msg.push_str("\tcurl https://get.wasmer.io -sSfL | sh\n");
196                bail!("{}", msg);
197            }
198            cargo.env("__CARGO_WASIX_RUNNER_SHIM", "1");
199            cargo.env(runner_env_var, env::current_exe()?);
200        }
201        Subcommand::Build | Subcommand::Check => check_deps = true,
202        Subcommand::Tree | Subcommand::Fix => {}
203    }
204
205    let update_check_opt = if config.is_offline {
206        Some(internal::UpdateCheck::new(config))
207    } else {
208        None
209    };
210    let toolchain = toolchain::ensure_toolchain(config)?;
211
212    std::env::set_var("RUSTUP_TOOLCHAIN", &toolchain.name);
213
214    // Set some flags for rustc (only if RUSTFLAGS is not already set)
215    if std::env::var("RUSTFLAGS").is_err() {
216        env::set_var("RUSTFLAGS", "-C target-feature=+atomics");
217    }
218
219    // Check the dependencies, if needed, before running cargo.
220    if check_deps {
221        if let Err(err) = dependencies::check(config, target) {
222            config.warn(&format!("failed to check dependencies: {err}"));
223        }
224    }
225
226    // Run the cargo commands
227    let build = execute_cargo(&mut cargo, config, manifest_config)?;
228
229    config.info("Post-processing WebAssembly files");
230
231    for (wasm, profile, fresh) in build.wasms.iter() {
232        // Cargo will always overwrite our `wasm` above with its own internal
233        // cache. It's internal cache largely uses hard links.
234        //
235        // If `fresh` is *false*, then Cargo just built `wasm` and we need to
236        // process it. If `fresh` is *true*, then we may have previously
237        // processed it. If our previous processing was successful the output
238        // was placed at `*.wasi.wasm`, so we use that to overwrite the
239        // `*.wasm` file. In the process we also create a `*.rustc.wasm` for
240        // debugging.
241        //
242        // Note that we remove files before renaming and such to ensure that
243        // we're not accidentally updating the wrong hard link and such.
244        let temporary_rustc = wasm.with_extension("rustc.wasm");
245        let temporary_wasi = wasm.with_extension("wasi.wasm");
246
247        drop(fs::remove_file(&temporary_rustc));
248        fs::rename(wasm, &temporary_rustc)?;
249        if !*fresh || !temporary_wasi.exists() {
250            let result = process_wasm(&temporary_wasi, &temporary_rustc, profile, &build, config);
251            result.with_context(|| {
252                format!("failed to process wasm at `{}`", temporary_rustc.display())
253            })?;
254        }
255        drop(fs::remove_file(wasm));
256        fs::hard_link(&temporary_wasi, wasm)
257            .or_else(|_| fs::copy(&temporary_wasi, wasm).map(|_| ()))?;
258    }
259
260    for run in build.runs.iter() {
261        config.status("Running", &format!("`{}`", run.join(" ")));
262        let mut cmd = Command::new(&wasix_runner);
263
264        if wasix_runner == "wasmer" {
265            cmd.arg("--enable-threads");
266        }
267
268        cmd.arg("--")
269            .args(run.iter())
270            .run()
271            .map_err(|e| utils::hide_normal_process_exit(e, config))?;
272    }
273
274    if let Some(check) = update_check_opt {
275        check.print();
276    }
277    Ok(())
278}
279
280pub const HELP: &str = include_str!("txt/help.txt");
281
282fn print_help() -> ! {
283    println!("{}", HELP);
284    std::process::exit(0)
285}
286
287#[derive(Default, Debug)]
288struct CargoBuild {
289    // The version of `wasm-bindgen` used in this build, if any.
290    wasm_bindgen: Option<String>,
291    // The `*.wasm` artifacts we found during this build, in addition to the
292    // profile that they were built with and whether or not it was `fresh`
293    // during this build.
294    wasms: Vec<(PathBuf, Profile, bool)>,
295    // executed commands as part of the cargo build
296    runs: Vec<Vec<String>>,
297    // Configuration we found in the `Cargo.toml` workspace manifest for these
298    // builds.
299    manifest_config: ManifestConfig,
300}
301
302#[derive(serde::Deserialize, serde::Serialize, Debug, Clone)]
303struct Profile {
304    opt_level: String,
305    debuginfo: Option<u32>,
306    test: bool,
307}
308
309#[derive(serde::Deserialize, Debug, Default)]
310#[serde(rename_all = "kebab-case")]
311struct ManifestConfig {
312    dl: Option<bool>,
313    wasm_opt: Option<bool>,
314    wasm_name_section: Option<bool>,
315    wasm_producers_section: Option<bool>,
316}
317
318#[derive(serde::Deserialize)]
319struct CargoMetadata {
320    workspace_root: String,
321}
322
323#[derive(serde::Deserialize, Debug)]
324struct CargoManifest {
325    package: Option<CargoPackage>,
326}
327
328#[derive(serde::Deserialize, Debug)]
329struct CargoPackage {
330    metadata: Option<ManifestConfig>,
331}
332
333#[derive(serde::Deserialize, serde::Serialize)]
334#[serde(tag = "reason", rename_all = "kebab-case")]
335enum CargoMessage {
336    CompilerArtifact {
337        filenames: Vec<String>,
338        package_id: String,
339        profile: Profile,
340        fresh: bool,
341    },
342    BuildScriptExecuted,
343    RunWithArgs {
344        args: Vec<String>,
345    },
346    BuildFinished,
347}
348
349impl CargoBuild {
350    fn enable_name_section(&self, profile: &Profile) -> bool {
351        match profile.debuginfo {
352            Some(0) | None => self.manifest_config.wasm_name_section.unwrap_or(true),
353            Some(_) => true,
354        }
355    }
356
357    fn enable_producers_section(&self, profile: &Profile) -> bool {
358        match profile.debuginfo {
359            Some(0) | None => self.manifest_config.wasm_producers_section.unwrap_or(true),
360            Some(_) => true,
361        }
362    }
363}
364
365/// Process a wasm file that doesn't use `wasm-bindgen`.
366fn process_wasm(
367    wasm: &Path,
368    temp: &Path,
369    profile: &Profile,
370    build: &CargoBuild,
371    config: &Config,
372) -> Result<()> {
373    config.verbose(|| {
374        config.status("Processing", &temp.display().to_string());
375    });
376    run_wasm_opt(wasm, temp, profile, build, config)?;
377    Ok(())
378}
379
380fn run_wasm_opt(
381    wasm: &Path,
382    temp: &Path,
383    profile: &Profile,
384    build: &CargoBuild,
385    config: &Config,
386) -> Result<()> {
387    // Allow explicitly disabling wasm-opt via `Cargo.toml`.
388    if build.manifest_config.wasm_opt == Some(false) {
389        std::fs::rename(temp, wasm).context("failed to rename build output")?;
390        return Ok(());
391    }
392
393    config.status("Optimizing", "with wasm-opt");
394    let wasm_opt = config.get_wasm_opt();
395
396    let mut cmd = Command::new(wasm_opt.bin_path());
397    cmd.arg(temp);
398    cmd.arg(format!("-O{}", profile.opt_level));
399    cmd.arg("-o").arg(wasm);
400    cmd.arg("--enable-bulk-memory");
401    cmd.arg("--enable-threads");
402    cmd.arg("--enable-reference-types");
403    cmd.arg("--no-validation");
404    cmd.arg("--translate-to-exnref");
405
406    if !build.enable_producers_section(profile) {
407        cmd.arg("--strip-producers");
408    }
409
410    match profile.debuginfo {
411        Some(0) | None => {
412            // release build
413            if build.enable_name_section(profile) {
414                cmd.arg("--debuginfo");
415            } else {
416                cmd.arg("--strip-debug");
417            }
418        }
419        Some(_) if profile.opt_level == "0" => {
420            // debug build
421            cmd.arg("--debuginfo");
422        }
423        _ => {
424            // release build
425            cmd.arg("--strip-debug");
426        }
427    }
428
429    run_or_download(
430        wasm_opt.bin_path(),
431        wasm_opt.is_overridden(),
432        &mut cmd,
433        config,
434        || install_wasm_opt(&wasm_opt, config),
435    )
436    .context("`wasm-opt` failed to execute")?;
437    Ok(())
438}
439
440fn read_manifest_config() -> Result<ManifestConfig> {
441    let output = Command::new("cargo")
442        .arg("metadata")
443        .arg("--no-deps")
444        .arg("--format-version=1")
445        .capture_stdout()?;
446    let metadata = serde_json::from_str::<CargoMetadata>(&output)
447        .context("failed to deserialize `cargo metadata`")?;
448
449    let manifest = Path::new(&metadata.workspace_root).join("Cargo.toml");
450    let toml = fs::read_to_string(&manifest)
451        .context(format!("failed to read manifest: {}", manifest.display()))?;
452    let toml = toml::from_str::<CargoManifest>(&toml).context(format!(
453        "failed to deserialize as TOML: {}",
454        manifest.display()
455    ))?;
456
457    if let Some(meta) = toml.package.and_then(|p| p.metadata) {
458        Ok(meta)
459    } else {
460        Ok(ManifestConfig::default())
461    }
462}
463
464/// Executes the `cargo` command, reading all of the JSON that pops out and
465/// parsing that into a `CargoBuild`.
466fn execute_cargo(
467    cargo: &mut Command,
468    config: &Config,
469    manifest_config: ManifestConfig,
470) -> Result<CargoBuild> {
471    config.verbose(|| config.status("Running", &format!("{:?}", cargo)));
472    let mut process = cargo
473        .stdout(Stdio::piped())
474        .spawn()
475        .context("failed to spawn `cargo`")?;
476    let mut json = String::new();
477    process
478        .stdout
479        .take()
480        .unwrap()
481        .read_to_string(&mut json)
482        .context("failed to read cargo stdout into a json string")?;
483    let status = process.wait().context("failed to wait on `cargo`")?;
484    utils::check_success(cargo, &status, &[], &[])
485        .map_err(|e| utils::hide_normal_process_exit(e, config))?;
486
487    let mut build = CargoBuild::default();
488
489    for line in json.lines() {
490        if !line.starts_with('{') {
491            println!("{}", line);
492            continue;
493        }
494        match serde_json::from_str(line) {
495            Ok(CargoMessage::CompilerArtifact {
496                filenames,
497                profile,
498                package_id,
499                fresh,
500            }) => {
501                let mut parts = package_id.split_whitespace();
502                if parts.next() == Some("wasm-bindgen") {
503                    if let Some(version) = parts.next() {
504                        build.wasm_bindgen = Some(version.to_string());
505                    }
506                }
507                for file in filenames {
508                    let file = PathBuf::from(file);
509                    if file.extension().and_then(|s| s.to_str()) == Some("wasm") {
510                        build.wasms.push((file, profile.clone(), fresh));
511                    }
512                }
513            }
514            Ok(CargoMessage::RunWithArgs { args }) => build.runs.push(args),
515            Ok(CargoMessage::BuildScriptExecuted) => {}
516            Ok(CargoMessage::BuildFinished) => {}
517            Err(e) => bail!("failed to parse {}: {}", line, e),
518        }
519    }
520
521    build.manifest_config = manifest_config;
522
523    Ok(build)
524}
525
526/// Attempts to execute `cmd` which is executing `requested`.
527///
528/// If the execution fails because `requested` isn't found *and* `requested` is
529/// the same as the `cache` path provided, then `download` is invoked to
530/// download the tool and then we re-execute `cmd` after the download has
531/// finished.
532///
533/// Additionally nice diagnostics and such are printed along the way.
534fn run_or_download(
535    requested: &Path,
536    is_overridden: bool,
537    cmd: &mut Command,
538    config: &Config,
539    download: impl FnOnce() -> Result<()>,
540) -> Result<()> {
541    // NB: this is explicitly set up so that, by default, we simply execute the
542    // command and assume that it exists. That should ideally avoid a few extra
543    // syscalls to detect "will things work?"
544    config.verbose(|| {
545        if requested.exists() {
546            config.status("Running", &format!("{:?}", cmd));
547        }
548    });
549
550    let err = match cmd.run() {
551        Ok(()) => return Ok(()),
552        Err(e) => e,
553    };
554    let rerun_after_download = err.chain().any(|e| {
555        // NotFound means we need to clearly download, PermissionDenied may mean
556        // that we were racing a download and the file wasn't executable, so
557        // fall through and wait for the download to finish to try again.
558        if let Some(err) = e.downcast_ref::<io::Error>() {
559            return err.kind() == io::ErrorKind::NotFound
560                || err.kind() == io::ErrorKind::PermissionDenied;
561        }
562        false
563    });
564
565    // This may have failed for some reason other than `NotFound`, in which case
566    // it's a legitimate error. Additionally `requested` may not actually be a
567    // path that we download, in which case there's also nothing that we can do.
568    if !rerun_after_download || is_overridden {
569        return Err(err);
570    }
571
572    download()?;
573    config.verbose(|| {
574        config.status("Running", &format!("{:?}", cmd));
575    });
576    cmd.run()
577}
578
579fn install_wasm_opt(path: &ToolPath, config: &Config) -> Result<()> {
580    let tag = "version_123";
581    let binaryen_url = |target: &str| {
582        let mut url = "https://github.com/WebAssembly/binaryen/releases/download/".to_string();
583        url.push_str(tag);
584        url.push_str("/binaryen-");
585        url.push_str(tag);
586        url.push('-');
587        url.push_str(target);
588        url.push_str(".tar.gz");
589        url
590    };
591
592    let url = if cfg!(target_os = "linux") && cfg!(target_arch = "x86_64") {
593        binaryen_url("x86_64-linux")
594    } else if cfg!(target_os = "macos") && cfg!(target_arch = "x86_64") {
595        binaryen_url("x86_64-macos")
596    } else if cfg!(target_os = "macos") && cfg!(target_arch = "aarch64") {
597        binaryen_url("arm64-macos")
598    } else if cfg!(target_os = "windows") && cfg!(target_arch = "x86_64") {
599        binaryen_url("x86_64-windows")
600    } else {
601        bail!(
602            "no precompiled binaries of `wasm-opt` are available for this \
603             platform, you'll want to set `$WASM_OPT` to a preinstalled \
604             `wasm-opt` command or disable via `wasm-opt = false` in \
605             your manifest"
606        )
607    };
608
609    let (base_path, sub_paths) = path.cache_paths().unwrap();
610    download(
611        &url,
612        &format!("precompiled wasm-opt {}", tag),
613        base_path,
614        sub_paths,
615        config,
616    )
617}
618
619fn download(
620    url: &str,
621    name: &str,
622    parent: &Path,
623    sub_paths: &Vec<PathBuf>,
624    config: &Config,
625) -> Result<()> {
626    // Globally lock ourselves downloading things to coordinate with any other
627    // instances of `cargo-wasi` doing a download. This is a bit coarse, but it
628    // gets the job done. Additionally if someone else does the download for us
629    // then we can simply return.
630    let _flock = utils::flock(&config.cache().root().join("downloading"));
631    if sub_paths
632        .iter()
633        .all(|sub_path| parent.join(sub_path).exists())
634    {
635        return Ok(());
636    }
637
638    // Ok, let's actually do the download
639    config.status("Downloading", name);
640    config.verbose(|| config.status("Get", url));
641
642    let response = utils::get(url, DOWNLOAD_TIMEOUT)?;
643    (|| -> Result<()> {
644        fs::create_dir_all(parent)
645            .context(format!("failed to create directory `{}`", parent.display()))?;
646
647        let decompressed = flate2::read::GzDecoder::new(response);
648        let mut tar = tar::Archive::new(decompressed);
649        for entry in tar.entries()? {
650            let mut entry = entry?;
651            let path = entry.path()?.into_owned();
652            for sub_path in sub_paths {
653                if path.ends_with(sub_path) {
654                    let entry_path = parent.join(sub_path);
655                    let dir = entry_path.parent().unwrap();
656                    if !dir.exists() {
657                        fs::create_dir_all(dir)
658                            .context(format!("failed to create directory `{}`", dir.display()))?;
659                    }
660                    entry.unpack(entry_path)?;
661                }
662            }
663        }
664
665        if let Some(missing) = sub_paths
666            .iter()
667            .find(|sub_path| !parent.join(sub_path).exists())
668        {
669            bail!("failed to find {:?} in archive", missing);
670        }
671        Ok(())
672    })()
673    .context(format!("failed to extract tarball from {}", url))
674}