#![deny(missing_docs)]
use crate::target::install_wasm32_wasi;
use anyhow::{bail, Context, Result};
use bindings::BindingsGenerator;
use bytes::Bytes;
use cargo_component_core::{
lock::{LockFile, LockFileResolver, LockedPackage, LockedPackageVersion},
registry::create_client,
terminal::{Colors, Terminal},
};
use cargo_config2::{PathAndArgs, TargetTripleRef};
use cargo_metadata::{Message, Metadata, MetadataCommand, Package};
use config::{CargoArguments, CargoPackageSpec, Config};
use lock::{acquire_lock_file_ro, acquire_lock_file_rw};
use metadata::ComponentMetadata;
use registry::{PackageDependencyResolution, PackageResolutionMap};
use semver::Version;
use std::{
borrow::Cow,
env, fs,
io::{BufRead, BufReader},
path::{Path, PathBuf},
process::{Command, Stdio},
time::{Duration, SystemTime},
};
use warg_client::storage::{ContentStorage, PublishEntry, PublishInfo};
use warg_crypto::signing::PrivateKey;
use warg_protocol::registry::PackageId;
use wasm_metadata::{Link, LinkType, RegistryMetadata};
use wit_component::ComponentEncoder;
mod bindings;
pub mod commands;
pub mod config;
mod generator;
mod lock;
mod metadata;
mod registry;
mod target;
pub const BINDINGS_CRATE_NAME: &str = "cargo-component-bindings";
fn is_wasm_target(target: &str) -> bool {
target == "wasm32-wasi" || target == "wasm32-unknown-unknown"
}
pub struct PackageComponentMetadata<'a> {
pub package: &'a Package,
pub metadata: Option<ComponentMetadata>,
}
impl<'a> PackageComponentMetadata<'a> {
pub fn new(package: &'a Package) -> Result<Self> {
Ok(Self {
package,
metadata: ComponentMetadata::from_package(package)?,
})
}
}
struct BuildArtifact {
path: PathBuf,
package: String,
target: String,
fresh: bool,
}
pub async fn run_cargo_command(
config: &Config,
metadata: &Metadata,
packages: &[PackageComponentMetadata<'_>],
subcommand: Option<&str>,
cargo_args: &CargoArguments,
spawn_args: &[String],
) -> Result<Vec<PathBuf>> {
generate_bindings(config, metadata, packages, cargo_args).await?;
let cargo = std::env::var("CARGO")
.map(PathBuf::from)
.ok()
.unwrap_or_else(|| PathBuf::from("cargo"));
let is_build = matches!(subcommand, Some("b") | Some("build") | Some("rustc"));
let is_run = matches!(subcommand, Some("r") | Some("run"));
let is_test = matches!(subcommand, Some("t") | Some("test") | Some("bench"));
let (build_args, runtime_args) = match spawn_args.iter().position(|a| a == "--") {
Some(position) => spawn_args.split_at(position),
None => (spawn_args, &[] as &[String]),
};
let needs_runner = !build_args.iter().any(|a| a == "--no-run");
let mut args = build_args.iter().peekable();
if let Some(arg) = args.peek() {
if *arg == "component" {
args.next().unwrap();
}
}
log::debug!(
"spawning cargo `{cargo}` with arguments `{args:?}`",
cargo = cargo.display(),
args = args.clone().collect::<Vec<_>>(),
);
let mut cmd = Command::new(&cargo);
if is_run {
cmd.arg("build");
if let Some(arg) = args.peek() {
if Some((*arg).as_str()) == subcommand {
args.next().unwrap();
}
}
}
cmd.args(args);
if is_build || is_run || is_test {
install_wasm32_wasi(config)?;
if !cargo_args.targets.iter().any(|t| is_wasm_target(t)) {
cmd.arg("--target").arg("wasm32-wasi");
}
if let Some(format) = &cargo_args.message_format {
if format != "json-render-diagnostics" {
bail!("unsupported cargo message format `{format}`");
}
}
cmd.arg("--message-format").arg("json-render-diagnostics");
cmd.stdout(Stdio::piped());
} else {
cmd.stdout(Stdio::inherit());
}
if needs_runner && is_test {
cmd.arg("--no-run");
}
let mut runner: Option<PathAndArgs> = None;
if needs_runner && (is_run || is_test) {
let cargo_config = cargo_config2::Config::load()?;
let (r, using_default) = cargo_config
.runner(TargetTripleRef::from("wasm32-wasi"))
.unwrap_or_default()
.map(|runner_override| (runner_override, false))
.unwrap_or_else(|| {
(
PathAndArgs::new("wasmtime")
.args(vec![
"-W",
"component-model",
"-S",
"preview2",
"-S",
"common",
])
.to_owned(),
true,
)
});
runner = Some(r.clone());
let wasi_runner = r.path.to_string_lossy().into_owned();
if !using_default {
if !(r.path.exists() || which::which(r.path).is_ok()) {
bail!(
"failed to find `{wasi_runner}` specified by either the `CARGO_TARGET_WASM32_WASI_RUNNER`\
environment variable or as the `wasm32-wasi` runner in `.cargo/config.toml`"
);
}
} else if which::which(r.path).is_err() {
bail!(
"failed to find `{wasi_runner}` on PATH\n\n\
ensure Wasmtime is installed before running this command\n\n\
{msg}:\n\n {instructions}",
msg = if cfg!(unix) {
"Wasmtime can be installed via a shell script"
} else {
"Wasmtime can be installed via the GitHub releases page"
},
instructions = if cfg!(unix) {
"curl https://wasmtime.dev/install.sh -sSf | bash"
} else {
"https://github.com/bytecodealliance/wasmtime/releases"
},
);
}
}
let mut outputs = Vec::new();
log::debug!("spawning command {:?}", cmd);
let mut child = cmd.spawn().context(format!(
"failed to spawn `{cargo}`",
cargo = cargo.display()
))?;
let mut artifacts = Vec::new();
if is_build || is_run || is_test {
let stdout = child.stdout.take().expect("no stdout");
let reader = BufReader::new(stdout);
for line in reader.lines() {
let line = line.context("failed to read output from `cargo`")?;
if cargo_args.message_format.is_some() {
println!("{line}");
}
if line.is_empty() {
continue;
}
for message in Message::parse_stream(line.as_bytes()) {
if let Message::CompilerArtifact(artifact) =
message.context("unexpected JSON message from cargo")?
{
for path in artifact.filenames {
let path = PathBuf::from(path);
if path.extension().and_then(|s| s.to_str()) == Some("wasm") {
log::debug!(
"found WebAssembly build artifact `{path}`",
path = path.display()
);
artifacts.push(BuildArtifact {
path,
package: artifact.package_id.to_string(),
target: artifact.target.name.clone(),
fresh: artifact.fresh,
});
}
}
}
}
}
}
let status = child.wait().context(format!(
"failed to wait for `{cargo}` to finish",
cargo = cargo.display()
))?;
if !status.success() {
std::process::exit(status.code().unwrap_or(1));
}
for artifact in &artifacts {
if artifact.path.exists() {
for PackageComponentMetadata { package, metadata } in packages {
if artifact.target == package.name || artifact.package.starts_with(&package.name) {
if let Some(metadata) = &metadata {
let is_bin = is_test || package.targets.iter().any(|t| t.is_bin());
let bytes = &mut fs::read(&artifact.path).with_context(|| {
format!(
"failed to read output module `{path}`",
path = artifact.path.display()
)
})?;
if bytes.len() < 8 || bytes[0..4] != [0x0, b'a', b's', b'm'] {
bail!(
"expected `{path}` to be a WebAssembly module or component",
path = artifact.path.display()
);
}
if bytes[4..8] == [0x01, 0x00, 0x00, 0x00] {
create_component(
config,
metadata,
&artifact.path,
is_bin,
artifact.fresh,
)?;
} else {
log::debug!(
"output file `{path}` is already a WebAssembly component",
path = artifact.path.display()
);
}
}
outputs.push(artifact.path.clone());
}
}
}
}
for PackageComponentMetadata {
package,
metadata: _,
} in packages
{
if !artifacts.iter().any(
|BuildArtifact {
package: output, ..
}| output.starts_with(&package.name),
) {
log::warn!(
"no build output found for package `{name}`",
name = package.name
);
}
}
if let Some(runner) = runner {
for run in outputs.iter() {
let mut cmd = Command::new(&runner.path);
cmd.args(&runner.args)
.arg("--")
.arg(run)
.args(runtime_args.iter().skip(1))
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
log::debug!("spawning command {:?}", cmd);
let mut child = cmd.spawn().context(format!(
"failed to spawn `{runner}`",
runner = runner.path.display()
))?;
let status = child.wait().context(format!(
"failed to wait for `{runner}` to finish",
runner = runner.path.display()
))?;
if !status.success() {
std::process::exit(status.code().unwrap_or(1));
}
}
}
Ok(outputs)
}
fn last_modified_time(path: &Path) -> Result<SystemTime> {
path.metadata()
.with_context(|| {
format!(
"failed to read file metadata for `{path}`",
path = path.display()
)
})?
.modified()
.with_context(|| {
format!(
"failed to retrieve last modified time for `{path}`",
path = path.display()
)
})
}
pub fn load_metadata(
terminal: &Terminal,
manifest_path: Option<&Path>,
ignore_version_mismatch: bool,
) -> Result<Metadata> {
let mut command = MetadataCommand::new();
command.no_deps();
if let Some(path) = manifest_path {
log::debug!(
"loading metadata from manifest `{path}`",
path = path.display()
);
command.manifest_path(path);
} else {
log::debug!("loading metadata from current directory");
}
let metadata = command.exec().context("failed to load cargo metadata")?;
if !ignore_version_mismatch {
let this_version = Version::parse(env!("CARGO_PKG_VERSION")).unwrap();
for package in &metadata.packages {
match package.dependencies.iter().find(|dep| {
dep.rename.as_deref().unwrap_or(dep.name.as_str()) == BINDINGS_CRATE_NAME
}) {
Some(bindings_crate) => {
let s = bindings_crate.req.to_string();
match s.strip_prefix('^').unwrap_or(&s).parse::<Version>() {
Ok(v) => {
if this_version.major == v.major
&& (this_version.major > 0 || this_version.minor == v.minor)
{
continue;
}
if this_version.major > v.major
|| (this_version.major == v.major && this_version.minor > v.minor)
{
terminal.warn(format!("manifest `{path}` uses an older version of `{BINDINGS_CRATE_NAME}` ({v}) than cargo-component ({this_version}); use `cargo component upgrade --no-install` to update the manifest", path = package.manifest_path))?;
} else {
terminal.warn(format!("manifest `{path}` uses a newer version of `{BINDINGS_CRATE_NAME}` ({v}) than cargo-component ({this_version}); use `cargo component upgrade` to upgrade to the latest version", path = package.manifest_path))?;
};
}
_ => continue,
}
}
None => continue,
}
}
}
Ok(metadata)
}
pub fn load_component_metadata<'a>(
metadata: &'a Metadata,
specs: impl ExactSizeIterator<Item = &'a CargoPackageSpec>,
workspace: bool,
) -> Result<Vec<PackageComponentMetadata<'a>>> {
let pkgs = if workspace {
metadata.workspace_packages()
} else if specs.len() > 0 {
let mut pkgs = Vec::with_capacity(specs.len());
for spec in specs {
let pkg = metadata
.packages
.iter()
.find(|p| {
p.name == spec.name
&& match spec.version.as_ref() {
Some(v) => &p.version == v,
None => true,
}
})
.with_context(|| {
format!("package ID specification `{spec}` did not match any packages")
})?;
pkgs.push(pkg);
}
pkgs
} else {
metadata.workspace_packages()
};
pkgs.into_iter()
.map(PackageComponentMetadata::new)
.collect::<Result<_>>()
}
async fn generate_bindings(
config: &Config,
metadata: &Metadata,
packages: &[PackageComponentMetadata<'_>],
cargo_args: &CargoArguments,
) -> Result<()> {
let last_modified_exe = last_modified_time(&std::env::current_exe()?)?;
let bindings_dir = metadata.target_directory.join("bindings");
let file_lock = acquire_lock_file_ro(config.terminal(), metadata)?;
let lock_file = file_lock
.as_ref()
.map(|f| {
LockFile::read(f.file()).with_context(|| {
format!(
"failed to read lock file `{path}`",
path = f.path().display()
)
})
})
.transpose()?;
let resolver = lock_file.as_ref().map(LockFileResolver::new);
let map =
create_resolution_map(config, packages, resolver, cargo_args.network_allowed()).await?;
for PackageComponentMetadata { package, .. } in packages {
let resolution = match map.get(&package.id) {
Some(resolution) => resolution,
None => continue,
};
generate_package_bindings(
config,
resolution,
bindings_dir.as_std_path(),
last_modified_exe,
)
.await?;
}
let new_lock_file = map.to_lock_file();
if (lock_file.is_some() || !new_lock_file.packages.is_empty())
&& Some(&new_lock_file) != lock_file.as_ref()
{
drop(file_lock);
let file_lock = acquire_lock_file_rw(
config.terminal(),
metadata,
cargo_args.lock_update_allowed(),
cargo_args.locked,
)?;
new_lock_file
.write(file_lock.file(), "cargo-component")
.with_context(|| {
format!(
"failed to write lock file `{path}`",
path = file_lock.path().display()
)
})?;
}
Ok(())
}
async fn create_resolution_map<'a>(
config: &Config,
packages: &'a [PackageComponentMetadata<'_>],
lock_file: Option<LockFileResolver<'_>>,
network_allowed: bool,
) -> Result<PackageResolutionMap<'a>> {
let mut map = PackageResolutionMap::default();
for PackageComponentMetadata { package, metadata } in packages {
match metadata {
Some(metadata) => {
let resolution =
PackageDependencyResolution::new(config, metadata, lock_file, network_allowed)
.await?;
map.insert(package.id.clone(), resolution);
}
None => continue,
}
}
Ok(map)
}
async fn generate_package_bindings(
config: &Config,
resolution: &PackageDependencyResolution<'_>,
bindings_dir: &Path,
last_modified_exe: SystemTime,
) -> Result<()> {
let output_dir = bindings_dir.join(&resolution.metadata.name);
let bindings_path = output_dir.join("bindings.rs");
let last_modified_output = bindings_path
.is_file()
.then(|| last_modified_time(&bindings_path))
.transpose()?
.unwrap_or(SystemTime::UNIX_EPOCH);
let generator = BindingsGenerator::new(resolution)?;
match generator.reason(last_modified_exe, last_modified_output)? {
Some(reason) => {
::log::debug!(
"generating bindings for package `{name}` at `{path}` because {reason}",
name = resolution.metadata.name,
path = bindings_path.display(),
);
config.terminal().status(
"Generating",
format!(
"bindings for {name} ({path})",
name = resolution.metadata.name,
path = bindings_path.display()
),
)?;
let bindings = generator.generate()?;
fs::create_dir_all(&output_dir).with_context(|| {
format!(
"failed to create output directory `{path}`",
path = output_dir.display()
)
})?;
fs::write(&bindings_path, bindings).with_context(|| {
format!(
"failed to write bindings file `{path}`",
path = bindings_path.display()
)
})?;
}
None => {
::log::debug!(
"existing bindings for package `{name}` at `{path}` is up-to-date",
name = resolution.metadata.name,
path = bindings_path.display(),
);
}
}
Ok(())
}
fn adapter_bytes(metadata: &ComponentMetadata, binary: bool) -> Result<Cow<[u8]>> {
if let Some(adapter) = &metadata.section.adapter {
return Ok(fs::read(adapter)
.with_context(|| {
format!(
"failed to read module adapter `{path}`",
path = adapter.display()
)
})?
.into());
}
if binary {
Ok(Cow::Borrowed(include_bytes!(concat!(
"../adapters/",
env!("WASI_ADAPTER_VERSION"),
"/wasi_snapshot_preview1.command.wasm"
))))
} else {
Ok(Cow::Borrowed(include_bytes!(concat!(
"../adapters/",
env!("WASI_ADAPTER_VERSION"),
"/wasi_snapshot_preview1.reactor.wasm"
))))
}
}
fn create_component(
config: &Config,
metadata: &ComponentMetadata,
path: &Path,
binary: bool,
fresh: bool,
) -> Result<()> {
::log::debug!(
"componentizing WebAssembly module `{path}` as a {kind} component (fresh = {fresh})",
path = path.display(),
kind = if binary { "command" } else { "reactor" },
);
let module = fs::read(path).with_context(|| {
format!(
"failed to read output module `{path}`",
path = path.display()
)
})?;
if !fresh {
config.terminal().status(
"Creating",
format!("component {path}", path = path.display()),
)?;
}
let encoder = ComponentEncoder::default()
.module(&module)?
.adapter("wasi_snapshot_preview1", &adapter_bytes(metadata, binary)?)
.with_context(|| {
format!(
"failed to load adapter module `{path}`",
path = if let Some(path) = &metadata.section.adapter {
path.as_path()
} else {
Path::new("<built-in>")
}
.display()
)
})?
.validate(true);
let mut producers = wasm_metadata::Producers::empty();
producers.add(
"processed-by",
env!("CARGO_PKG_NAME"),
option_env!("CARGO_VERSION_INFO").unwrap_or(env!("CARGO_PKG_VERSION")),
);
let component = producers.add_to_wasm(&encoder.encode()?).with_context(|| {
format!(
"failed to add metadata to output component `{path}`",
path = path.display()
)
})?;
fs::write(path, component).with_context(|| {
format!(
"failed to write output component `{path}`",
path = path.display()
)
})
}
pub struct PublishOptions<'a> {
pub package: &'a Package,
pub registry_url: &'a str,
pub init: bool,
pub id: &'a PackageId,
pub version: &'a Version,
pub path: &'a Path,
pub signing_key: &'a PrivateKey,
pub dry_run: bool,
}
fn add_registry_metadata(package: &Package, bytes: &[u8], path: &Path) -> Result<Vec<u8>> {
let mut metadata = RegistryMetadata::default();
if !package.authors.is_empty() {
metadata.set_authors(Some(package.authors.clone()));
}
if !package.categories.is_empty() {
metadata.set_categories(Some(package.categories.clone()));
}
metadata.set_description(package.description.clone());
metadata.set_license(package.license.clone());
let mut links = Vec::new();
if let Some(docs) = &package.documentation {
links.push(Link {
ty: LinkType::Documentation,
value: docs.clone(),
});
}
if let Some(homepage) = &package.homepage {
links.push(Link {
ty: LinkType::Homepage,
value: homepage.clone(),
});
}
if let Some(repo) = &package.repository {
links.push(Link {
ty: LinkType::Repository,
value: repo.clone(),
});
}
if !links.is_empty() {
metadata.set_links(Some(links));
}
metadata.add_to_wasm(bytes).with_context(|| {
format!(
"failed to add registry metadata to component `{path}`",
path = path.display()
)
})
}
pub async fn publish(config: &Config, options: &PublishOptions<'_>) -> Result<()> {
if options.dry_run {
config
.terminal()
.warn("not publishing component to the registry due to the --dry-run option")?;
return Ok(());
}
let client = create_client(config.warg(), options.registry_url, config.terminal())?;
let bytes = fs::read(options.path).with_context(|| {
format!(
"failed to read component `{path}`",
path = options.path.display()
)
})?;
let bytes = add_registry_metadata(options.package, &bytes, options.path)?;
let content = client
.content()
.store_content(
Box::pin(futures::stream::once(async { Ok(Bytes::from(bytes)) })),
None,
)
.await?;
config.terminal().status(
"Publishing",
format!(
"component {path} ({content})",
path = options.path.display()
),
)?;
let mut info = PublishInfo {
id: options.id.clone(),
head: None,
entries: Default::default(),
};
if options.init {
info.entries.push(PublishEntry::Init);
}
info.entries.push(PublishEntry::Release {
version: options.version.clone(),
content,
});
let record_id = client.publish_with_info(options.signing_key, info).await?;
client
.wait_for_publish(options.id, &record_id, Duration::from_secs(1))
.await?;
config.terminal().status(
"Published",
format!(
"package `{id}` v{version}",
id = options.id,
version = options.version
),
)?;
Ok(())
}
pub async fn update_lockfile(
config: &Config,
metadata: &Metadata,
packages: &[PackageComponentMetadata<'_>],
network_allowed: bool,
lock_update_allowed: bool,
locked: bool,
dry_run: bool,
) -> Result<()> {
let map = create_resolution_map(config, packages, None, network_allowed).await?;
let file_lock = acquire_lock_file_ro(config.terminal(), metadata)?;
let orig_lock_file = file_lock
.as_ref()
.map(|f| {
LockFile::read(f.file()).with_context(|| {
format!(
"failed to read lock file `{path}`",
path = f.path().display()
)
})
})
.transpose()?
.unwrap_or_default();
let new_lock_file = map.to_lock_file();
for old_pkg in &orig_lock_file.packages {
let new_pkg = match new_lock_file
.packages
.binary_search_by_key(&old_pkg.key(), LockedPackage::key)
.map(|index| &new_lock_file.packages[index])
{
Ok(pkg) => pkg,
Err(_) => {
for old_ver in &old_pkg.versions {
config.terminal().status_with_color(
if dry_run { "Would remove" } else { "Removing" },
format!(
"dependency `{id}` v{version}",
id = old_pkg.id,
version = old_ver.version,
),
Colors::Red,
)?;
}
continue;
}
};
for old_ver in &old_pkg.versions {
let new_ver = match new_pkg
.versions
.binary_search_by_key(&old_ver.key(), LockedPackageVersion::key)
.map(|index| &new_pkg.versions[index])
{
Ok(ver) => ver,
Err(_) => {
config.terminal().status_with_color(
if dry_run { "Would remove" } else { "Removing" },
format!(
"dependency `{id}` v{version}",
id = old_pkg.id,
version = old_ver.version,
),
Colors::Red,
)?;
continue;
}
};
if old_ver.version != new_ver.version {
config.terminal().status_with_color(
if dry_run { "Would update" } else { "Updating" },
format!(
"dependency `{id}` v{old} -> v{new}",
id = old_pkg.id,
old = old_ver.version,
new = new_ver.version
),
Colors::Cyan,
)?;
}
}
}
for new_pkg in &new_lock_file.packages {
let old_pkg = match orig_lock_file
.packages
.binary_search_by_key(&new_pkg.key(), LockedPackage::key)
.map(|index| &orig_lock_file.packages[index])
{
Ok(pkg) => pkg,
Err(_) => {
for new_ver in &new_pkg.versions {
config.terminal().status_with_color(
if dry_run { "Would add" } else { "Adding" },
format!(
"dependency `{id}` v{version}",
id = new_pkg.id,
version = new_ver.version,
),
Colors::Green,
)?;
}
continue;
}
};
for new_ver in &new_pkg.versions {
if old_pkg
.versions
.binary_search_by_key(&new_ver.key(), LockedPackageVersion::key)
.map(|index| &old_pkg.versions[index])
.is_err()
{
config.terminal().status_with_color(
if dry_run { "Would add" } else { "Adding" },
format!(
"dependency `{id}` v{version}",
id = new_pkg.id,
version = new_ver.version,
),
Colors::Green,
)?;
}
}
}
if dry_run {
config
.terminal()
.warn("not updating component lock file due to --dry-run option")?;
} else {
if new_lock_file != orig_lock_file {
drop(file_lock);
let file_lock =
acquire_lock_file_rw(config.terminal(), metadata, lock_update_allowed, locked)?;
new_lock_file
.write(file_lock.file(), "cargo-component")
.with_context(|| {
format!(
"failed to write lock file `{path}`",
path = file_lock.path().display()
)
})?;
}
}
Ok(())
}