feat(package): add component and WIT package tooling

- define the constrained .wasmpkg archive and integrity checks
- encode and inspect standard binary WIT packages
- resolve exact Registry dependencies with wit.lock and path replace
- provide wasmeld pack and wit build/fetch/publish CLI commands
This commit is contained in:
Maofeng
2026-07-27 04:57:34 +08:00
commit 893895a76c
8 changed files with 4047 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "wasmeld-package"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
semver.workspace = true
sha2.workspace = true
thiserror.workspace = true
toml.workspace = true
wit-component.workspace = true
wit-parser.workspace = true
zip.workspace = true
[dev-dependencies]
tempfile = "3.23.0"
[[bin]]
name = "wasmeld"
path = "src/main.rs"
+356
View File
@@ -0,0 +1,356 @@
//! Component and WIT packaging primitives shared by the Wasmeld CLI, Console,
//! and Runtime.
//!
//! A runnable component is distributed as a constrained `.wasmpkg` ZIP
//! containing exactly [`PACKAGE_MANIFEST_PATH`] and [`COMPONENT_PATH`]. WIT
//! packages use the Component Model binary WIT encoding implemented by the
//! [`wit_package`] module; they are not stored in `.wasmpkg` containers.
use std::{
collections::BTreeSet,
io::{self, Cursor, Read, Seek, Write},
};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use thiserror::Error;
use zip::{CompressionMethod, ZipArchive, ZipWriter, result::ZipError, write::SimpleFileOptions};
/// Resolves `wasmeld.toml`, `wit.lock`, Registry dependencies, and path replacements.
pub mod module;
/// Builds and inspects standard binary WIT packages.
pub mod wit_package;
/// Current `.wasmpkg` manifest schema.
pub const PACKAGE_SCHEMA_VERSION: u32 = 1;
/// Fixed manifest entry name inside a `.wasmpkg` archive.
pub const PACKAGE_MANIFEST_PATH: &str = "package.toml";
/// Fixed component entry name inside a `.wasmpkg` archive.
pub const COMPONENT_PATH: &str = "component.wasm";
/// Baseline service world implemented by Wasmeld components.
pub const SERVICE_WORLD: &str = "wasmeld:service/service-component@0.1.0";
const MAX_MANIFEST_BYTES: usize = 64 * 1024;
/// Identity and integrity metadata embedded in a `.wasmpkg` archive.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct PackageManifest {
pub schema_version: u32,
pub id: String,
pub revision: String,
pub world: String,
pub component: String,
pub sha256: String,
}
impl PackageManifest {
/// Creates a manifest and computes the component digest.
pub fn new(
id: impl Into<String>,
revision: impl Into<String>,
world: impl Into<String>,
component: &[u8],
) -> Self {
Self {
schema_version: PACKAGE_SCHEMA_VERSION,
id: id.into(),
revision: revision.into(),
world: world.into(),
component: COMPONENT_PATH.to_owned(),
sha256: component_sha256(component),
}
}
/// Validates schema compatibility, identifiers, world syntax, and digest format.
pub fn validate(&self) -> Result<(), PackageError> {
if self.schema_version != PACKAGE_SCHEMA_VERSION {
return Err(PackageError::InvalidPackage(format!(
"unsupported package schema version {}, expected {PACKAGE_SCHEMA_VERSION}",
self.schema_version
)));
}
validate_identifier("id", &self.id)?;
validate_identifier("revision", &self.revision)?;
validate_world(&self.world)?;
if self.component != COMPONENT_PATH {
return Err(PackageError::InvalidPackage(format!(
"component must be {COMPONENT_PATH:?}"
)));
}
if self.sha256.len() != 64
|| !self
.sha256
.bytes()
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
{
return Err(PackageError::InvalidPackage(
"sha256 must be a lowercase SHA-256 digest".to_owned(),
));
}
Ok(())
}
}
/// A validated component package decoded from a `.wasmpkg` archive.
#[derive(Debug)]
pub struct ComponentPackage {
pub manifest: PackageManifest,
pub component: Vec<u8>,
}
/// Errors produced while creating or validating component packages.
#[derive(Debug, Error)]
pub enum PackageError {
#[error("invalid component package: {0}")]
InvalidPackage(String),
#[error("component exceeds the {limit}-byte uncompressed limit")]
ComponentTooLarge { limit: usize },
#[error("component digest mismatch: expected {expected}, found {actual}")]
DigestMismatch { expected: String, actual: String },
#[error("failed to read or write package: {0}")]
Io(#[from] io::Error),
#[error("invalid ZIP container: {0}")]
Zip(#[from] ZipError),
#[error("invalid package manifest: {0}")]
ManifestParse(#[from] toml::de::Error),
#[error("failed to serialize package manifest: {0}")]
ManifestSerialize(#[from] toml::ser::Error),
}
/// Writes a deterministic two-entry `.wasmpkg` archive.
///
/// The caller owns the destination writer. Runtime limits are intentionally not
/// included because the Console injects platform policy during registration.
pub fn write_package<W>(
writer: W,
id: impl Into<String>,
revision: impl Into<String>,
world: impl Into<String>,
component: &[u8],
) -> Result<PackageManifest, PackageError>
where
W: Write + Seek,
{
if component.is_empty() {
return Err(PackageError::InvalidPackage(
"component must not be empty".to_owned(),
));
}
let manifest = PackageManifest::new(id, revision, world, component);
manifest.validate()?;
let manifest_toml = toml::to_string_pretty(&manifest)?;
let options = SimpleFileOptions::default()
.compression_method(CompressionMethod::Deflated)
.unix_permissions(0o644);
let mut archive = ZipWriter::new(writer);
archive.start_file(PACKAGE_MANIFEST_PATH, options)?;
archive.write_all(manifest_toml.as_bytes())?;
archive.start_file(COMPONENT_PATH, options)?;
archive.write_all(component)?;
archive.finish()?;
Ok(manifest)
}
/// Reads and validates a `.wasmpkg` archive.
///
/// `max_component_bytes` applies to the uncompressed component so compressed
/// archives cannot bypass the platform's artifact limit.
pub fn read_package(
package_bytes: &[u8],
max_component_bytes: usize,
) -> Result<ComponentPackage, PackageError> {
let mut archive = ZipArchive::new(Cursor::new(package_bytes))?;
if archive.len() != 2 {
return Err(PackageError::InvalidPackage(format!(
"archive must contain exactly {PACKAGE_MANIFEST_PATH} and {COMPONENT_PATH}"
)));
}
let mut names = BTreeSet::new();
for index in 0..archive.len() {
let entry = archive.by_index(index)?;
let name = entry.name().to_owned();
if name != PACKAGE_MANIFEST_PATH && name != COMPONENT_PATH {
return Err(PackageError::InvalidPackage(format!(
"unexpected archive entry {name:?}"
)));
}
if !names.insert(name.clone()) {
return Err(PackageError::InvalidPackage(format!(
"duplicate archive entry {name:?}"
)));
}
}
if !names.contains(PACKAGE_MANIFEST_PATH) || !names.contains(COMPONENT_PATH) {
return Err(PackageError::InvalidPackage(format!(
"archive must contain {PACKAGE_MANIFEST_PATH} and {COMPONENT_PATH}"
)));
}
let manifest_bytes = read_entry(&mut archive, PACKAGE_MANIFEST_PATH, MAX_MANIFEST_BYTES)
.map_err(|error| match error {
PackageError::ComponentTooLarge { .. } => PackageError::InvalidPackage(format!(
"{PACKAGE_MANIFEST_PATH} exceeds the {MAX_MANIFEST_BYTES}-byte limit"
)),
other => other,
})?;
let manifest = toml::from_slice::<PackageManifest>(&manifest_bytes)?;
manifest.validate()?;
let component = read_entry(&mut archive, COMPONENT_PATH, max_component_bytes)?;
if component.is_empty() {
return Err(PackageError::InvalidPackage(
"component must not be empty".to_owned(),
));
}
let actual = component_sha256(&component);
if actual != manifest.sha256 {
return Err(PackageError::DigestMismatch {
expected: manifest.sha256,
actual,
});
}
Ok(ComponentPackage {
manifest,
component,
})
}
fn read_entry<R: Read + Seek>(
archive: &mut ZipArchive<R>,
name: &str,
limit: usize,
) -> Result<Vec<u8>, PackageError> {
let entry = archive.by_name(name)?;
if entry.size() > limit as u64 {
return Err(PackageError::ComponentTooLarge { limit });
}
let mut bytes = Vec::with_capacity(entry.size() as usize);
entry
.take(limit.saturating_add(1) as u64)
.read_to_end(&mut bytes)?;
if bytes.len() > limit {
return Err(PackageError::ComponentTooLarge { limit });
}
Ok(bytes)
}
fn component_sha256(component: &[u8]) -> String {
format!("{:x}", Sha256::digest(component))
}
fn validate_identifier(name: &str, value: &str) -> Result<(), PackageError> {
let valid = !value.is_empty()
&& value.len() <= 128
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'));
if valid {
Ok(())
} else {
Err(PackageError::InvalidPackage(format!(
"{name} must contain only ASCII letters, digits, '.', '_' or '-' and be at most 128 bytes"
)))
}
}
/// Validates the canonical `namespace:package/world@version` world form.
pub fn validate_world(world: &str) -> Result<(), PackageError> {
let valid = !world.is_empty()
&& world.len() <= 256
&& world.contains(':')
&& world.contains('/')
&& world.contains('@')
&& world.bytes().all(|byte| {
byte.is_ascii_alphanumeric() || matches!(byte, b':' | b'/' | b'@' | b'-' | b'_' | b'.')
});
if valid {
Ok(())
} else {
Err(PackageError::InvalidPackage(
"world must be a versioned WIT world path and be at most 256 bytes".to_owned(),
))
}
}
#[cfg(test)]
mod tests {
use std::io::{Cursor, Write};
use zip::{ZipWriter, write::SimpleFileOptions};
use super::*;
#[test]
fn package_round_trip() {
let mut bytes = Cursor::new(Vec::new());
let written =
write_package(&mut bytes, "echo", "0.1.0", SERVICE_WORLD, b"component").unwrap();
let package = read_package(bytes.get_ref(), 1024).unwrap();
assert_eq!(package.manifest, written);
assert_eq!(package.component, b"component");
}
#[test]
fn rejects_unexpected_archive_entries() {
let mut bytes = Cursor::new(Vec::new());
let mut archive = ZipWriter::new(&mut bytes);
let options = SimpleFileOptions::default();
archive.start_file(PACKAGE_MANIFEST_PATH, options).unwrap();
archive.write_all(b"invalid").unwrap();
archive.start_file(COMPONENT_PATH, options).unwrap();
archive.write_all(b"component").unwrap();
archive.start_file("../escape", options).unwrap();
archive.write_all(b"escape").unwrap();
archive.finish().unwrap();
let error = read_package(bytes.get_ref(), 1024).unwrap_err();
assert!(error.to_string().contains("exactly"));
}
#[test]
fn enforces_the_uncompressed_component_limit() {
let mut bytes = Cursor::new(Vec::new());
write_package(&mut bytes, "echo", "0.1.0", SERVICE_WORLD, b"component").unwrap();
let error = read_package(bytes.get_ref(), 4).unwrap_err();
assert!(matches!(
error,
PackageError::ComponentTooLarge { limit: 4 }
));
}
#[test]
fn rejects_unsafe_service_identifiers() {
let mut bytes = Cursor::new(Vec::new());
let error = write_package(
&mut bytes,
"../escape",
"0.1.0",
SERVICE_WORLD,
b"component",
)
.unwrap_err();
assert!(error.to_string().contains("ASCII letters"));
}
#[test]
fn accepts_component_specific_worlds() {
let manifest = PackageManifest::new(
"clock-probe",
"0.1.0",
"component:clock-probe/clock-probe-component@0.1.0",
b"component",
);
manifest.validate().unwrap();
}
}
+454
View File
@@ -0,0 +1,454 @@
//! Command-line entry point for Component packaging and WIT dependency workflows.
use std::{
env, fs,
path::{Path, PathBuf},
process::{Command, ExitCode},
};
use serde::Deserialize;
use wasmeld_package::{
PackageManifest,
module::{MODULE_MANIFEST_FILE, ModuleManifest, find_module_manifest, sync_dependencies},
wit_package::{WitPackageMetadata, build_wit_package},
write_package,
};
const TARGET: &str = "wasm32-wasip2";
#[derive(Debug, Deserialize)]
struct CargoMetadata {
packages: Vec<CargoPackage>,
target_directory: PathBuf,
workspace_root: PathBuf,
}
#[derive(Debug, Deserialize)]
struct CargoPackage {
manifest_path: PathBuf,
version: String,
metadata: serde_json::Value,
targets: Vec<CargoTarget>,
}
#[derive(Debug, Deserialize)]
struct CargoTarget {
name: String,
crate_types: Vec<String>,
}
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("wasmeld: {error}");
ExitCode::FAILURE
}
}
}
fn run() -> Result<(), Box<dyn std::error::Error>> {
let mut arguments = env::args().skip(1);
match arguments.next().as_deref() {
Some("pack") => run_pack(arguments.collect()),
Some("wit") => run_wit(arguments.collect()),
_ => Err(usage().into()),
}
}
fn run_pack(arguments: Vec<String>) -> Result<(), Box<dyn std::error::Error>> {
let mut arguments = arguments.into_iter();
let manifest_path = arguments.next().ok_or_else(usage)?;
let mut output = None;
let mut no_build = false;
let mut locked = false;
while let Some(argument) = arguments.next() {
match argument.as_str() {
"--output" | "-o" => {
output = Some(
arguments
.next()
.map(PathBuf::from)
.ok_or("--output requires a path")?,
);
}
"--no-build" => no_build = true,
"--locked" => locked = true,
_ => return Err(format!("unknown argument {argument:?}\n{}", usage()).into()),
}
}
let manifest_path = fs::canonicalize(manifest_path)?;
sync_component_dependencies(&manifest_path, locked)?;
let metadata = cargo_metadata(&manifest_path)?;
let package = metadata
.packages
.iter()
.find(|package| package.manifest_path == manifest_path)
.ok_or("Cargo metadata did not contain the requested package")?;
let id = package
.metadata
.get("wasmeld")
.and_then(|metadata| metadata.get("id"))
.and_then(serde_json::Value::as_str)
.ok_or("[package.metadata.wasmeld].id is required")?;
let world = package
.metadata
.get("wasmeld")
.and_then(|metadata| metadata.get("world"))
.and_then(serde_json::Value::as_str)
.ok_or("[package.metadata.wasmeld].world is required")?;
PackageManifest::new(id, &package.version, world, b"identity-validation").validate()?;
let target = package
.targets
.iter()
.find(|target| target.crate_types.iter().any(|kind| kind == "cdylib"))
.ok_or("component package must expose a cdylib target")?;
if !no_build {
build_component(&manifest_path)?;
}
let artifact = metadata
.target_directory
.join(TARGET)
.join("release")
.join(format!("{}.wasm", target.name.replace('-', "_")));
let component = fs::read(&artifact).map_err(|error| {
format!(
"failed to read built component {}: {error}",
artifact.display()
)
})?;
let output = output.unwrap_or_else(|| {
metadata
.workspace_root
.join("dist")
.join(format!("{id}-{}.wasmpkg", package.version))
});
if let Some(parent) = output.parent() {
fs::create_dir_all(parent)?;
}
let file = fs::File::create(&output)?;
let manifest = write_package(file, id, &package.version, world, &component)?;
println!(
"packed {}@{} -> {}",
manifest.id,
manifest.revision,
output.display()
);
println!("component: {}", artifact.display());
println!("sha256: {}", manifest.sha256);
Ok(())
}
fn run_wit(arguments: Vec<String>) -> Result<(), Box<dyn std::error::Error>> {
let mut arguments = arguments.into_iter();
match arguments.next().as_deref() {
Some("fetch" | "tidy") => {
let options = parse_wit_sync_options(arguments)?;
let manifest = resolve_module_manifest(options.manifest)?;
print_sync_report(&manifest, options.locked)
}
Some("graph") => {
let manifest = resolve_module_manifest(parse_manifest_option(arguments)?)?;
print_dependency_graph(&manifest)
}
Some("replace") => run_wit_replace(arguments),
Some("build") => run_wit_build(arguments),
Some("publish") => run_wit_publish(arguments),
_ => Err(usage().into()),
}
}
struct WitSyncOptions {
manifest: Option<PathBuf>,
locked: bool,
}
fn parse_wit_sync_options(
mut arguments: impl Iterator<Item = String>,
) -> Result<WitSyncOptions, Box<dyn std::error::Error>> {
let mut manifest = None;
let mut locked = false;
while let Some(argument) = arguments.next() {
match argument.as_str() {
"--manifest" => {
manifest = Some(
arguments
.next()
.map(PathBuf::from)
.ok_or("--manifest requires a path")?,
);
}
"--locked" => locked = true,
_ => return Err(format!("unknown argument {argument:?}\n{}", usage()).into()),
}
}
Ok(WitSyncOptions { manifest, locked })
}
fn parse_manifest_option(
mut arguments: impl Iterator<Item = String>,
) -> Result<Option<PathBuf>, Box<dyn std::error::Error>> {
let mut manifest = None;
while let Some(argument) = arguments.next() {
match argument.as_str() {
"--manifest" => {
manifest = Some(
arguments
.next()
.map(PathBuf::from)
.ok_or("--manifest requires a path")?,
);
}
_ => return Err(format!("unknown argument {argument:?}\n{}", usage()).into()),
}
}
Ok(manifest)
}
fn run_wit_replace(
mut arguments: impl Iterator<Item = String>,
) -> Result<(), Box<dyn std::error::Error>> {
let package = arguments.next().ok_or("wit replace requires a package")?;
let mut manifest_path = None;
let mut replacement_path = None;
let mut drop = false;
while let Some(argument) = arguments.next() {
match argument.as_str() {
"--manifest" => {
manifest_path = Some(
arguments
.next()
.map(PathBuf::from)
.ok_or("--manifest requires a path")?,
);
}
"--path" => {
replacement_path = Some(
arguments
.next()
.map(PathBuf::from)
.ok_or("--path requires a directory")?,
);
}
"--drop" => drop = true,
_ => return Err(format!("unknown argument {argument:?}\n{}", usage()).into()),
}
}
if drop == replacement_path.is_some() {
return Err("wit replace requires exactly one of --path or --drop".into());
}
let manifest_path = resolve_module_manifest(manifest_path)?;
let mut manifest = ModuleManifest::read(&manifest_path)?;
if drop {
if !manifest.drop_replacement(&package) {
return Err(format!("replacement {package:?} does not exist").into());
}
manifest.write(&manifest_path)?;
println!("dropped replace {package}");
} else {
let replacement_path = replacement_path.expect("validated above");
manifest.set_path_replacement(&package, &replacement_path)?;
manifest.write(&manifest_path)?;
println!("replaced {package} => path+{}", replacement_path.display());
}
println!("manifest: {}", manifest_path.display());
Ok(())
}
fn run_wit_build(
mut arguments: impl Iterator<Item = String>,
) -> Result<(), Box<dyn std::error::Error>> {
let source = arguments.next().ok_or("wit build requires a source path")?;
let mut output = None;
while let Some(argument) = arguments.next() {
match argument.as_str() {
"--output" | "-o" => {
output = Some(
arguments
.next()
.map(PathBuf::from)
.ok_or("--output requires a path")?,
);
}
_ => return Err(format!("unknown argument {argument:?}\n{}", usage()).into()),
}
}
let package = build_wit_package(&source)?;
let output = output.unwrap_or_else(|| {
let basename = package.metadata.name.replace(':', "-");
PathBuf::from("dist/wit").join(format!("{basename}-{}.wasm", package.metadata.version))
});
if let Some(parent) = output.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&output, &package.bytes)?;
println!(
"built {}@{} -> {}",
package.metadata.name,
package.metadata.version,
output.display()
);
println!("sha256: {}", package.metadata.sha256);
Ok(())
}
fn run_wit_publish(
mut arguments: impl Iterator<Item = String>,
) -> Result<(), Box<dyn std::error::Error>> {
let source = arguments
.next()
.ok_or("wit publish requires a source path")?;
let mut registry = env::var("WASMELD_REGISTRY").ok();
while let Some(argument) = arguments.next() {
match argument.as_str() {
"--registry" => {
registry = Some(
arguments
.next()
.ok_or("--registry requires an HTTP(S) URL")?,
);
}
_ => return Err(format!("unknown argument {argument:?}\n{}", usage()).into()),
}
}
let registry = registry
.ok_or("wit publish requires --registry or the WASMELD_REGISTRY environment variable")?;
let registry = registry.trim_end_matches('/');
let package = build_wit_package(&source)?;
let url = format!("{registry}/api/v1/wit/packages");
let part = reqwest::blocking::multipart::Part::bytes(package.bytes)
.file_name("package.wasm")
.mime_str("application/wasm")?;
let response = reqwest::blocking::Client::builder()
.user_agent(format!("wasmeld/{}", env!("CARGO_PKG_VERSION")))
.build()?
.post(&url)
.multipart(reqwest::blocking::multipart::Form::new().part("package", part))
.send()?;
let status = response.status();
if !status.is_success() {
return Err(format!("registry returned HTTP {status}: {}", response.text()?).into());
}
let published = response.json::<WitPackageMetadata>()?;
if published != package.metadata {
return Err(format!(
"registry metadata mismatch: expected {}@{} ({})",
package.metadata.name, package.metadata.version, package.metadata.sha256
)
.into());
}
println!(
"published {}@{} -> {registry}",
published.name, published.version
);
println!("sha256: {}", published.sha256);
Ok(())
}
fn resolve_module_manifest(
manifest: Option<PathBuf>,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
match manifest {
Some(path) => Ok(fs::canonicalize(path)?),
None => Ok(find_module_manifest(env::current_dir()?)?),
}
}
fn sync_component_dependencies(
cargo_manifest: &Path,
locked: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let module_manifest = cargo_manifest.with_file_name(MODULE_MANIFEST_FILE);
if module_manifest.is_file() {
print_sync_report(&module_manifest, locked)?;
}
Ok(())
}
fn print_sync_report(manifest: &Path, locked: bool) -> Result<(), Box<dyn std::error::Error>> {
let report = sync_dependencies(manifest, locked)?;
let action = if locked { "verified" } else { "resolved" };
println!(
"{action} {} WIT package(s) -> {}",
report.packages.len(),
report.wit_root.join("deps").display()
);
for package in report.packages {
println!(
" {}@{} => {} ({})",
package.name, package.version, package.source, package.sha256
);
}
println!("lock: {}", report.lock_path.display());
Ok(())
}
fn print_dependency_graph(manifest_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
let manifest = ModuleManifest::read(manifest_path)?;
println!("{}", manifest_path.display());
for (name, version) in &manifest.dependencies {
let exact = format!("{name}@{version}");
let replacement = manifest
.replacements
.get(&exact)
.or_else(|| manifest.replacements.get(name));
match replacement {
Some(replacement) => {
println!(" {exact} => path+{}", replacement.path.display());
}
None => match &manifest.registry.url {
Some(registry) => println!(" {exact} => registry+{registry}"),
None => println!(" {exact} => registry (unconfigured)"),
},
}
}
Ok(())
}
fn cargo_metadata(manifest_path: &Path) -> Result<CargoMetadata, Box<dyn std::error::Error>> {
let output = Command::new("cargo")
.args(["metadata", "--format-version", "1", "--no-deps"])
.arg("--manifest-path")
.arg(manifest_path)
.output()?;
if !output.status.success() {
return Err(format!(
"cargo metadata failed:\n{}",
String::from_utf8_lossy(&output.stderr)
)
.into());
}
Ok(serde_json::from_slice(&output.stdout)?)
}
fn build_component(manifest_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
let toolchain = env::var("WASMELD_COMPONENT_TOOLCHAIN").unwrap_or_else(|_| "1.90.0".to_owned());
let status = Command::new("rustup")
.args(["run", &toolchain, "cargo", "build"])
.arg("--manifest-path")
.arg(manifest_path)
.args(["--target", TARGET, "--release"])
.status()?;
if !status.success() {
return Err(format!("component build failed with status {status}").into());
}
Ok(())
}
fn usage() -> String {
"usage:
wasmeld pack <Cargo.toml> [--output <file.wasmpkg>] [--no-build] [--locked]
wasmeld wit fetch [--manifest <wasmeld.toml>] [--locked]
wasmeld wit tidy [--manifest <wasmeld.toml>] [--locked]
wasmeld wit graph [--manifest <wasmeld.toml>]
wasmeld wit replace <package[@version]> --path <directory> [--manifest <wasmeld.toml>]
wasmeld wit replace <package[@version]> --drop [--manifest <wasmeld.toml>]
wasmeld wit build <wit-path> [--output <package.wasm>]
wasmeld wit publish <wit-path> --registry <url>"
.to_owned()
}
File diff suppressed because it is too large Load Diff
+173
View File
@@ -0,0 +1,173 @@
//! Standard binary WIT package encoding and inspection.
//!
//! Package identity is derived from the encoded WIT itself. Registry clients
//! never submit a separate package name, version, or dependency manifest that
//! could disagree with the artifact.
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use thiserror::Error;
use wit_parser::{
Resolve,
decoding::{DecodedWasm, decode},
};
/// Metadata schema returned by the Wasmeld WIT Registry API.
pub const WIT_PACKAGE_SCHEMA_VERSION: u32 = 1;
/// One exact, direct WIT package dependency.
#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(deny_unknown_fields)]
pub struct WitDependency {
pub name: String,
pub version: String,
}
/// Metadata derived from a binary WIT package.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct WitPackageMetadata {
pub schema_version: u32,
pub name: String,
pub version: String,
pub sha256: String,
pub dependencies: Vec<WitDependency>,
}
/// Encoded package bytes together with metadata derived from those bytes.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct BuiltWitPackage {
pub metadata: WitPackageMetadata,
pub bytes: Vec<u8>,
}
/// Errors raised while parsing WIT source or decoding a binary WIT package.
#[derive(Debug, Error)]
pub enum WitPackageError {
#[error("failed to parse WIT package at {path}: {message}")]
Source { path: PathBuf, message: String },
#[error("failed to encode WIT package: {0}")]
Encode(String),
#[error("invalid binary WIT package: {0}")]
Decode(String),
#[error("WIT package {0} must declare an explicit semantic version")]
MissingVersion(String),
}
/// Parses a WIT source path and encodes its main package as binary WIT.
///
/// The package must declare an explicit semantic version. Dependencies
/// available through the source path are encoded as Component Model package
/// references and reported in [`WitPackageMetadata::dependencies`].
pub fn build_wit_package(path: impl AsRef<Path>) -> Result<BuiltWitPackage, WitPackageError> {
let path = path.as_ref();
let mut resolve = Resolve::default();
let (package, _) = resolve
.push_path(path)
.map_err(|error| WitPackageError::Source {
path: path.to_path_buf(),
message: error.to_string(),
})?;
let bytes = wit_component::encode(&resolve, package)
.map_err(|error| WitPackageError::Encode(error.to_string()))?;
let metadata = metadata_from_resolve(&resolve, package, &bytes)?;
Ok(BuiltWitPackage { metadata, bytes })
}
/// Decodes binary WIT and derives its identity, direct dependencies, and digest.
///
/// Ordinary WebAssembly Components are rejected even though both formats use a
/// Component Model binary container.
pub fn inspect_wit_package(bytes: &[u8]) -> Result<WitPackageMetadata, WitPackageError> {
let decoded = decode(bytes).map_err(|error| WitPackageError::Decode(error.to_string()))?;
let DecodedWasm::WitPackage(resolve, package) = decoded else {
return Err(WitPackageError::Decode(
"artifact is a WebAssembly Component, not a binary WIT package".to_owned(),
));
};
metadata_from_resolve(&resolve, package, bytes)
}
/// Returns the lowercase SHA-256 digest used as the Registry artifact identity.
pub fn wit_package_sha256(bytes: &[u8]) -> String {
format!("{:x}", Sha256::digest(bytes))
}
fn metadata_from_resolve(
resolve: &Resolve,
package: wit_parser::PackageId,
bytes: &[u8],
) -> Result<WitPackageMetadata, WitPackageError> {
let package_name = &resolve.packages[package].name;
let name = format!("{}:{}", package_name.namespace, package_name.name);
let version = package_name
.version
.as_ref()
.map(ToString::to_string)
.ok_or_else(|| WitPackageError::MissingVersion(name.clone()))?;
let mut dependencies = resolve
.package_direct_deps(package)
.map(|dependency| {
let dependency = &resolve.packages[dependency].name;
let dependency_name = format!("{}:{}", dependency.namespace, dependency.name);
let dependency_version = dependency
.version
.as_ref()
.map(ToString::to_string)
.ok_or_else(|| WitPackageError::MissingVersion(dependency_name.clone()))?;
Ok(WitDependency {
name: dependency_name,
version: dependency_version,
})
})
.collect::<Result<Vec<_>, WitPackageError>>()?;
dependencies.sort();
dependencies.dedup();
Ok(WitPackageMetadata {
schema_version: WIT_PACKAGE_SCHEMA_VERSION,
name,
version,
sha256: wit_package_sha256(bytes),
dependencies,
})
}
#[cfg(test)]
mod tests {
use std::fs;
use tempfile::tempdir;
use super::*;
#[test]
fn builds_and_inspects_a_binary_wit_package() {
let source = tempdir().unwrap();
fs::write(
source.path().join("package.wit"),
"package wasmeld:clock@1.2.3;\ninterface clock { now: func() -> u64; }\n",
)
.unwrap();
let package = build_wit_package(source.path()).unwrap();
let inspected = inspect_wit_package(&package.bytes).unwrap();
assert_eq!(package.metadata, inspected);
assert_eq!(inspected.name, "wasmeld:clock");
assert_eq!(inspected.version, "1.2.3");
assert!(inspected.dependencies.is_empty());
assert_eq!(inspected.sha256.len(), 64);
}
#[test]
fn rejects_a_component_as_a_wit_package() {
let error = inspect_wit_package(b"not wasm").unwrap_err();
assert!(matches!(error, WitPackageError::Decode(_)));
}
}