diff --git a/crates/wasmeld-package/src/dev.rs b/crates/wasmeld-package/src/dev.rs index 98cb5d0..c3a9ca5 100644 --- a/crates/wasmeld-package/src/dev.rs +++ b/crates/wasmeld-package/src/dev.rs @@ -1,4 +1,22 @@ //! Local build, watch, and deployment loop for Component development. +//! +//! The loop fingerprints Rust/WIT inputs, materializes WIT dependencies, +//! compiles a Component, creates a content-addressed development revision, +//! registers it, and atomically switches the Console deployment. A failed +//! build or deployment never removes the previously active revision. +//! +//! Build and deployment are separate states. Once a build succeeds, +//! `pending_deployment` retains that exact package and retries network or +//! Console failures without rebuilding it. A later source change supersedes +//! the pending package and starts a new build. +//! +//! # Example +//! +//! ```text +//! cargo run -p wasmeld-package --bin wasmeld -- \ +//! dev components/counter/Cargo.toml \ +//! --console http://127.0.0.1:8080 +//! ``` use std::{ collections::BTreeSet, @@ -57,6 +75,9 @@ pub(crate) fn run(arguments: Vec) -> Result<(), Box Result<(), Box> { + // Order is intentional: register makes the immutable revision available, + // activate switches new gateway resolutions, and only then may old + // development revisions be removed. Cleanup is best-effort because a + // successful deployment must not be reported as failed due to stale files. register(client, console, packed)?; activate( client, @@ -235,6 +260,9 @@ fn register( if response.status().is_success() { return Ok(()); } + // Retrying a package whose registration response was lost is safe only + // when the exact immutable identity already exists. Do not treat every + // conflict as success; it may represent a different control-plane error. if response.status() == StatusCode::CONFLICT && service_exists( client, @@ -334,6 +362,9 @@ fn watch_roots(manifest_path: &Path) -> Result, Box Result, Box digest.update(bytes), @@ -384,6 +417,8 @@ fn ignored_directory(path: &Path) -> bool { name, Some(".git" | ".wasmeld" | "deps" | "dist" | "node_modules" | "target") ) { + // `wit/deps` is generated by dependency sync. Watching it would make + // each build rewrite watched files and trigger an endless rebuild. return name != Some("deps") || path .parent() @@ -395,6 +430,8 @@ fn ignored_directory(path: &Path) -> bool { } fn is_source_file(path: &Path) -> bool { + // The list is deliberately narrow: fingerprints should represent inputs + // to Cargo/WIT resolution, not editor state or generated artifacts. matches!( path.extension().and_then(|extension| extension.to_str()), Some("rs" | "wit") diff --git a/crates/wasmeld-package/src/lib.rs b/crates/wasmeld-package/src/lib.rs index 49e4150..69f811e 100644 --- a/crates/wasmeld-package/src/lib.rs +++ b/crates/wasmeld-package/src/lib.rs @@ -5,6 +5,34 @@ //! 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. +//! +//! # Example +//! +//! ``` +//! use std::io::Cursor; +//! use wasmeld_package::{read_package, write_package}; +//! +//! let component = b"\0asm\x0d\0\x01\0"; +//! let mut archive = Cursor::new(Vec::new()); +//! write_package( +//! &mut archive, +//! "image-resize", +//! "1.0.0", +//! "example:image-resize/service@1.0.0", +//! component, +//! )?; +//! +//! let decoded = read_package(archive.get_ref(), 1024 * 1024)?; +//! assert_eq!(decoded.manifest.id, "image-resize"); +//! assert_eq!(decoded.component, component); +//! # Ok::<(), wasmeld_package::PackageError>(()) +//! ``` +//! +//! The decoder treats an uploaded archive as untrusted input: it accepts only +//! the two canonical entries, limits uncompressed sizes, validates identifiers, +//! and verifies the Component digest before returning bytes to the Runtime. + +#![warn(missing_docs)] use std::{ collections::BTreeSet, @@ -35,16 +63,26 @@ const MAX_MANIFEST_BYTES: usize = 64 * 1024; #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(deny_unknown_fields)] pub struct PackageManifest { + /// Manifest schema understood by this version of Wasmeld. pub schema_version: u32, + /// Stable service identifier used in management and gateway routes. pub id: String, + /// Immutable service revision; the same `id@revision` cannot be overwritten. pub revision: String, + /// Fully versioned WIT world implemented by the Component. pub world: String, + /// Archive path of the executable Component; currently always [`COMPONENT_PATH`]. pub component: String, + /// Lowercase SHA-256 digest of the uncompressed Component bytes. pub sha256: String, } impl PackageManifest { /// Creates a manifest and computes the component digest. + /// + /// This constructor does not reject invalid identifiers or worlds. Call + /// [`Self::validate`] before persisting it; [`write_package`] does this + /// automatically. pub fn new( id: impl Into, revision: impl Into, @@ -94,31 +132,48 @@ impl PackageManifest { /// A validated component package decoded from a `.wasmpkg` archive. #[derive(Debug)] pub struct ComponentPackage { + /// Validated identity, world, and integrity metadata. pub manifest: PackageManifest, + /// Uncompressed Component bytes whose digest matches the manifest. pub component: Vec, } /// Errors produced while creating or validating component packages. #[derive(Debug, Error)] pub enum PackageError { + /// The archive layout or a manifest value violates the package contract. #[error("invalid component package: {0}")] InvalidPackage(String), + /// The uncompressed Component exceeds the caller-selected upload limit. #[error("component exceeds the {limit}-byte uncompressed limit")] - ComponentTooLarge { limit: usize }, + ComponentTooLarge { + /// Maximum number of accepted uncompressed bytes. + limit: usize, + }, + /// Component bytes do not match the immutable digest in `package.toml`. #[error("component digest mismatch: expected {expected}, found {actual}")] - DigestMismatch { expected: String, actual: String }, + DigestMismatch { + /// Digest declared by the package manifest. + expected: String, + /// Digest computed from the uploaded Component. + actual: String, + }, + /// Reading or writing the package stream failed. #[error("failed to read or write package: {0}")] Io(#[from] io::Error), + /// The bytes are not a valid constrained ZIP archive. #[error("invalid ZIP container: {0}")] Zip(#[from] ZipError), + /// `package.toml` could not be decoded. #[error("invalid package manifest: {0}")] ManifestParse(#[from] toml::de::Error), + /// A generated package manifest could not be encoded. #[error("failed to serialize package manifest: {0}")] ManifestSerialize(#[from] toml::ser::Error), } @@ -127,6 +182,11 @@ pub enum PackageError { /// /// The caller owns the destination writer. Runtime limits are intentionally not /// included because the Console injects platform policy during registration. +/// +/// # Errors +/// +/// Returns an error when the identity or world is invalid, the Component is +/// empty, or the destination cannot be written. pub fn write_package( writer: W, id: impl Into, @@ -162,6 +222,11 @@ where /// /// `max_component_bytes` applies to the uncompressed component so compressed /// archives cannot bypass the platform's artifact limit. +/// +/// # Errors +/// +/// Returns an error for malformed ZIP data, extra or duplicate entries, +/// oversized content, invalid metadata, empty Components, or digest mismatch. pub fn read_package( package_bytes: &[u8], max_component_bytes: usize, diff --git a/crates/wasmeld-package/src/main.rs b/crates/wasmeld-package/src/main.rs index b60d2e9..13bcd8f 100644 --- a/crates/wasmeld-package/src/main.rs +++ b/crates/wasmeld-package/src/main.rs @@ -1,4 +1,18 @@ //! Command-line entry point for Component packaging and WIT dependency workflows. +//! +//! Typical release packaging: +//! +//! ```text +//! wasmeld wit fetch --manifest components/counter/wasmeld.toml --locked +//! wasmeld pack components/counter/Cargo.toml --locked +//! ``` +//! +//! `wit fetch` resolves registry or local `replace` sources into `wit/deps` +//! and writes `wit.lock`. `--locked` verifies that resolution matches the +//! existing lock file and is the appropriate mode for CI. A `replace` changes +//! only the development source used for a declared package identity; it does +//! not rename that package or make the local path part of the published +//! Component contract. mod dev; @@ -21,7 +35,9 @@ const TARGET: &str = "wasm32-wasip2"; #[derive(Clone, Copy)] pub(crate) enum BuildProfile { + /// Fast local build with Cargo's `debug` profile. Debug, + /// Optimized artifact from Cargo's `release` profile. Release, } @@ -35,22 +51,36 @@ impl BuildProfile { } pub(crate) enum PackageIdentity { + /// Use `[package.metadata.wasmeld].id` and the Cargo package version. Cargo, + /// Use a development service ID and a Component-content revision suffix. Development { id: Option }, } pub(crate) struct PackRequest { + /// Cargo manifest for the Component crate. pub manifest_path: PathBuf, + /// Destination package, or the workspace `dist` default. pub output: Option, + /// Reuse an artifact already present under Cargo's target directory. + /// + /// The caller is responsible for ensuring it matches current sources and + /// the requested profile. pub no_build: bool, + /// Require `wit.lock` to match dependency resolution exactly. pub locked: bool, + /// Cargo profile used to build and locate the Component. pub profile: BuildProfile, + /// Stable release or content-addressed development identity. pub identity: PackageIdentity, } pub(crate) struct PackedComponent { + /// Validated metadata embedded in the `.wasmpkg`. pub manifest: PackageManifest, + /// Path to the resulting `.wasmpkg`. pub output: PathBuf, + /// Path to the raw Component produced or reused by Cargo. pub artifact: PathBuf, } @@ -141,6 +171,9 @@ pub(crate) fn pack_component( request: PackRequest, ) -> Result> { let manifest_path = fs::canonicalize(request.manifest_path)?; + // Materialize WIT before asking Cargo for metadata or compiling: the + // component's `wit_bindgen::generate!` reads `wit/world.wit` and + // `wit/deps` during macro expansion. sync_component_dependencies(&manifest_path, request.locked)?; let metadata = cargo_metadata(&manifest_path)?; let package = metadata @@ -189,6 +222,9 @@ pub(crate) fn pack_component( let (id, revision) = match request.identity { PackageIdentity::Cargo => (cargo_id, cargo_revision), PackageIdentity::Development { id } => { + // The revision changes only when compiled Component bytes change. + // This makes registration retryable and avoids mutating an + // immutable identity while editing. let digest = format!("{:x}", Sha256::digest(&component)); ( id.unwrap_or_else(|| format!("{cargo_id}-dev")), @@ -316,6 +352,8 @@ fn run_wit_replace( let manifest_path = resolve_module_manifest(manifest_path)?; let mut manifest = ModuleManifest::read(&manifest_path)?; + // Replace is recorded in the source manifest. The next fetch/pack resolves + // and locks its contents; generated `wit/deps` must not be hand-edited. if drop { if !manifest.drop_replacement(&package) { return Err(format!("replacement {package:?} does not exist").into()); @@ -407,6 +445,9 @@ fn run_wit_publish( return Err(format!("registry returned HTTP {status}: {}", response.text()?).into()); } let published = response.json::()?; + // A successful status is insufficient: compare the server's canonical + // identity and digest to detect a proxy or Registry returning metadata for + // different bytes. if published != package.metadata { return Err(format!( "registry metadata mismatch: expected {}@{} ({})", diff --git a/crates/wasmeld-package/src/module.rs b/crates/wasmeld-package/src/module.rs index d13226d..2dbad1e 100644 --- a/crates/wasmeld-package/src/module.rs +++ b/crates/wasmeld-package/src/module.rs @@ -4,6 +4,23 @@ //! prefers root-level path replacements and otherwise downloads immutable //! binary packages from the configured Registry. The complete transitive graph //! is materialized under `wit/deps` and recorded in `wit.lock`. +//! +//! `wasmeld.toml` is the human-authored input; `wit.lock` is the reproducibility +//! record; and `wit/deps` is disposable generated output. Commit the first two, +//! but regenerate `wit/deps` with [`crate::module::sync_dependencies`] after +//! cloning. +//! +//! # Example +//! +//! ```no_run +//! use wasmeld_package::module::sync_dependencies; +//! +//! // CI uses locked mode so changed Registry content, replacements, or +//! // transitive dependencies fail instead of rewriting wit.lock. +//! let report = sync_dependencies("components/image-resize/wasmeld.toml", true)?; +//! println!("resolved {} packages", report.packages.len()); +//! # Ok::<(), wasmeld_package::module::ModuleError>(()) +//! ``` use std::{ collections::{BTreeMap, BTreeSet}, @@ -156,39 +173,79 @@ pub struct SyncReport { /// Errors raised while parsing, resolving, locking, or materializing WIT dependencies. #[derive(Debug, Error)] pub enum ModuleError { + /// A manifest value, dependency identity, version, or path is invalid. #[error("invalid WIT module: {0}")] InvalidManifest(String), + /// A manifest, lock file, or local WIT source could not be read. #[error("failed to read {path}: {source}")] - Read { path: PathBuf, source: io::Error }, + Read { + /// File or directory involved in the failed read. + path: PathBuf, + /// Underlying filesystem error. + source: io::Error, + }, + /// A lock file or materialized dependency could not be written. #[error("failed to write {path}: {source}")] - Write { path: PathBuf, source: io::Error }, + Write { + /// Destination that could not be updated. + path: PathBuf, + /// Underlying filesystem error. + source: io::Error, + }, + /// TOML input could not be decoded. #[error("failed to parse {path}: {source}")] ParseManifest { + /// Manifest or lock file containing invalid TOML. path: PathBuf, + /// TOML decoder error. source: toml::de::Error, }, + /// A canonical manifest or lock file could not be encoded as TOML. #[error("failed to serialize {path}: {source}")] SerializeManifest { + /// Destination whose model could not be serialized. path: PathBuf, + /// TOML encoder error. source: toml::ser::Error, }, + /// Local or downloaded bytes are not the requested versioned WIT package. #[error("invalid WIT package at {path}: {message}")] - InvalidWitPackage { path: PathBuf, message: String }, + InvalidWitPackage { + /// Source path used while validating the package. + path: PathBuf, + /// Identity, version, parsing, or dependency validation detail. + message: String, + }, + /// Locked resolution produced a graph different from `wit.lock`. #[error("locked dependency graph differs from {path}; run `wasmeld wit tidy`")] - LockMismatch { path: PathBuf }, + LockMismatch { + /// Existing lock file that must be updated outside locked mode. + path: PathBuf, + }, + /// Fetching or validating an immutable Registry artifact failed. #[error("WIT registry request to {url} failed: {message}")] - Registry { url: String, message: String }, + Registry { + /// Exact Registry endpoint requested by the resolver. + url: String, + /// HTTP, size-limit, digest, or package-validation detail. + message: String, + }, } impl ModuleManifest { /// Reads and validates a `wasmeld.toml` manifest. + /// + /// # Errors + /// + /// Returns [`ModuleError::Read`], [`ModuleError::ParseManifest`], or + /// [`ModuleError::InvalidManifest`] without modifying the source file. pub fn read(path: impl AsRef) -> Result { let path = path.as_ref(); let source = fs::read_to_string(path).map_err(|source| ModuleError::Read { @@ -205,6 +262,9 @@ impl ModuleManifest { } /// Validates and writes a canonical TOML manifest. + /// + /// Callers editing replacements should mutate a parsed value and invoke + /// this method once, so invalid intermediate state is never persisted. pub fn write(&self, path: impl AsRef) -> Result<(), ModuleError> { self.validate()?; let path = path.as_ref(); @@ -248,6 +308,9 @@ impl ModuleManifest { } /// Sets a package-wide or exact-version path replacement. + /// + /// An exact key such as `wasmeld:kv@0.1.0` takes precedence over the + /// package-wide `wasmeld:kv` key during resolution. pub fn set_path_replacement( &mut self, package: impl Into, @@ -311,6 +374,16 @@ impl ModuleLock { /// dependencies embedded in Registry artifacts. With `locked = true`, the /// computed graph must exactly match the existing `wit.lock`; the function does /// not update the lock file. +/// +/// The dependency directory is assembled in a temporary sibling directory and +/// swapped into place only after the complete graph validates. A failed fetch +/// therefore leaves both the lock and materialized dependencies intact. +/// +/// # Errors +/// +/// Returns an error when the manifest or lock is invalid, a replacement has the +/// wrong identity, a Registry package is unavailable or oversized, or two paths +/// resolve the same package version to different content. pub fn sync_dependencies( manifest_path: impl AsRef, locked: bool, diff --git a/crates/wasmeld-package/src/wit_package.rs b/crates/wasmeld-package/src/wit_package.rs index de3adb2..66c5180 100644 --- a/crates/wasmeld-package/src/wit_package.rs +++ b/crates/wasmeld-package/src/wit_package.rs @@ -21,7 +21,9 @@ pub const WIT_PACKAGE_SCHEMA_VERSION: u32 = 1; #[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] #[serde(deny_unknown_fields)] pub struct WitDependency { + /// Package identity in `namespace:name` form. pub name: String, + /// Exact semantic version encoded by the dependency package. pub version: String, } @@ -29,32 +31,48 @@ pub struct WitDependency { #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(deny_unknown_fields)] pub struct WitPackageMetadata { + /// Metadata response schema used by the Registry API. pub schema_version: u32, + /// Package identity derived from binary WIT contents. pub name: String, + /// Explicit semantic version derived from binary WIT contents. pub version: String, + /// SHA-256 digest of the complete binary WIT artifact. pub sha256: String, + /// Exact direct dependencies embedded in the package. pub dependencies: Vec, } /// Encoded package bytes together with metadata derived from those bytes. #[derive(Clone, Debug, Eq, PartialEq)] pub struct BuiltWitPackage { + /// Metadata derived by decoding the bytes after encoding. pub metadata: WitPackageMetadata, + /// Standard Component Model binary WIT package. pub bytes: Vec, } /// Errors raised while parsing WIT source or decoding a binary WIT package. #[derive(Debug, Error)] pub enum WitPackageError { + /// WIT source could not be parsed or resolved. #[error("failed to parse WIT package at {path}: {message}")] - Source { path: PathBuf, message: String }, + Source { + /// Source file or directory passed to the parser. + path: PathBuf, + /// Parser or dependency-resolution detail. + message: String, + }, + /// A resolved WIT package could not be encoded. #[error("failed to encode WIT package: {0}")] Encode(String), + /// Input bytes are not a standard binary WIT package. #[error("invalid binary WIT package: {0}")] Decode(String), + /// The package identity omits the version required by the Registry. #[error("WIT package {0} must declare an explicit semantic version")] MissingVersion(String), } @@ -64,6 +82,11 @@ pub enum WitPackageError { /// 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`]. +/// +/// # Errors +/// +/// Returns an error when WIT parsing, dependency resolution, binary encoding, +/// or post-encode inspection fails. pub fn build_wit_package(path: impl AsRef) -> Result { let path = path.as_ref(); let mut resolve = Resolve::default(); @@ -83,6 +106,11 @@ pub fn build_wit_package(path: impl AsRef) -> Result Result { let decoded = decode(bytes).map_err(|error| WitPackageError::Decode(error.to_string()))?; let DecodedWasm::WitPackage(resolve, package) = decoded else {