diff --git a/Cargo.lock b/Cargo.lock index 6a89846..f320e58 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -466,9 +466,9 @@ dependencies = [ [[package]] name = "bitflags" -version = "2.11.1" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a" dependencies = [ "serde_core", ] @@ -1361,6 +1361,12 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.9" @@ -1423,6 +1429,19 @@ dependencies = [ "futures-sink", ] +[[package]] +name = "futures-concurrency" +version = "7.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6" +dependencies = [ + "fixedbitset 0.5.7", + "futures-core", + "futures-lite", + "pin-project", + "smallvec", +] + [[package]] name = "futures-core" version = "0.3.32" @@ -2297,9 +2316,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.30" +version = "0.4.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" +checksum = "113b30b4cd05f7c06868fdb2854f66a7b9fece9a48425351cd532e810d74024f" [[package]] name = "logos" @@ -2867,7 +2886,7 @@ version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" dependencies = [ - "fixedbitset", + "fixedbitset 0.4.2", "indexmap 2.14.0", ] @@ -5157,6 +5176,7 @@ dependencies = [ "bytes", "docker_credential", "etcetera 0.11.0", + "futures-concurrency", "futures-util", "oci-client", "oci-wasm", @@ -5181,6 +5201,7 @@ dependencies = [ "wasm-metadata", "wasm-pkg-common", "wit-component", + "wit-parser", ] [[package]] @@ -5780,6 +5801,7 @@ dependencies = [ "wasm-encoder 0.244.0", "wasm-metadata", "wasmparser 0.244.0", + "wat", "wit-parser", ] diff --git a/Cargo.toml b/Cargo.toml index 7c4bd80..4dfbaae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ base64 = "0.22" bytes = "1.11" docker_credential = "1.3.2" etcetera = "0.11" +futures-concurrency = "7.7" futures-util = "0.3.30" oci-client = { version = "0.16", default-features = false, features = [ "rustls-tls", @@ -22,7 +23,7 @@ oci-wasm = { version = "0.4", default-features = false, features = [ "rustls-tls", ] } rcgen = "0.14.8" -semver = "1.0.23" +semver = "1.0.28" serde = { version = "1.0", features = ["derive"] } serde_json = "1" sha2 = "0.10" diff --git a/crates/wasm-pkg-client/Cargo.toml b/crates/wasm-pkg-client/Cargo.toml index 3fa5f82..1d7cab3 100644 --- a/crates/wasm-pkg-client/Cargo.toml +++ b/crates/wasm-pkg-client/Cargo.toml @@ -20,6 +20,7 @@ base64 = { workspace = true } bytes = { workspace = true } docker_credential = { workspace = true } etcetera = { workspace = true } +futures-concurrency = { workspace = true } futures-util = { workspace = true, features = ["io"] } oci-client = { workspace = true } oci-wasm = { workspace = true } @@ -46,7 +47,8 @@ warg-crypto = "0.9.2" wasm-metadata = { workspace = true } warg-protocol = "0.9.2" wasm-pkg-common = { workspace = true, features = ["registry-config"] } -wit-component = { workspace = true } +wit-component = { workspace = true, features = ["semver-check"] } +wit-parser = { workspace = true } [dev-dependencies] rcgen = { workspace = true } diff --git a/crates/wasm-pkg-client/src/decoded_component.rs b/crates/wasm-pkg-client/src/decoded_component.rs new file mode 100644 index 0000000..d5e6d7b --- /dev/null +++ b/crates/wasm-pkg-client/src/decoded_component.rs @@ -0,0 +1,193 @@ +use crate::{ContentStream, PublishingSource}; +use futures_util::TryStreamExt; +use std::io::Read; +use tokio::io::AsyncSeekExt; +use tokio_util::io::{StreamReader, SyncIoBridge}; +use wasm_pkg_common::{ + package::{PackageRef, Version}, + Error, +}; +use wit_component::DecodedWasm; + +pub struct DecodedComponent { + version: Version, + package_ref: PackageRef, + decoded_wasm: DecodedWasm, +} + +impl DecodedComponent { + pub async fn from_publishing_source( + data: PublishingSource, + ) -> Result<(PublishingSource, DecodedComponent), Error> { + let (reader, decoded_wasm) = decode(SyncIoBridge::new(data)).await?; + let (package_ref, version) = extract_package_version(&decoded_wasm)?; + + let mut data = reader.into_inner(); + data.rewind().await?; + + Ok(( + data, + DecodedComponent { + version, + package_ref, + decoded_wasm, + }, + )) + } + + /// Like [`Self::from_publishing_source`] but overrides the derived + /// `(package, version)` identity with `package_override` when supplied. + pub async fn from_publishing_source_with_package( + data: PublishingSource, + package_override: Option<(PackageRef, Version)>, + ) -> Result<(PublishingSource, DecodedComponent), Error> { + let (data, mut decoded) = Self::from_publishing_source(data).await?; + if let Some((p, v)) = package_override { + decoded.package_ref = p; + decoded.version = v; + } + Ok((data, decoded)) + } + + /// Construct from a registry content stream. Callers already know the + /// `(package, version)` identity from the registry listing they followed + /// to get here, so we take it as input rather than re-deriving it from + /// the wasm metadata. + pub async fn from_content_stream( + stream: ContentStream, + package_ref: PackageRef, + version: Version, + ) -> Result { + let reader = SyncIoBridge::new(StreamReader::new(stream.map_err(std::io::Error::other))); + let (_reader, decoded_wasm) = decode(reader).await?; + Ok(DecodedComponent { + version, + package_ref, + decoded_wasm, + }) + } + + pub fn version(&self) -> &Version { + &self.version + } + + pub fn package(&self) -> &PackageRef { + &self.package_ref + } + + /// Check that `self` and `other` are semver-compatible neighbors in the + /// same cargo-`^` compatibility range. + pub fn semver_check(&self, other: &DecodedComponent) -> Result<(), Error> { + // `wit_component::semver_check` is asymmetric: its `new` may add + // imports / drop exports relative to its `prev`. To get a symmetric + // additive-only gate between two published versions we pass the + // newer-in-time release as `prev` and the older as `new`. + let (older, newer) = if self.version < other.version { + (self, other) + } else { + (other, self) + }; + + let (prev_resolve, prev_world) = extract_resolve_and_world_id(&newer.decoded_wasm)?; + let (new_resolve, new_world) = extract_resolve_and_world_id(&older.decoded_wasm)?; + + // Merge resolves, remap merged resolve, check for incompatibility + let mut merged = prev_resolve.clone(); + let new_world = merged + .merge(new_resolve.clone()) + .and_then(|remap| remap.map_world(new_world, None)) + .map_err(Error::InvalidComponent)?; + + wit_component::semver_check(merged, prev_world, new_world).map_err(|e| { + Error::SemverIncompatible { + previous: older.version.clone(), + new: newer.version.clone(), + source: e, + } + }) + } +} + +async fn decode(reader: R) -> Result<(R, DecodedWasm), Error> +where + R: Read + Send + 'static, +{ + tokio::task::spawn_blocking(move || { + let mut reader = reader; + let decoded_wasm = + wit_component::decode_reader(&mut reader).map_err(Error::InvalidComponent)?; + Ok::<_, Error>((reader, decoded_wasm)) + }) + .await + .map_err(|e| Error::IoError(std::io::Error::other(e)))? +} + +/// Extract the package name and version from a decoded candidate. +fn extract_package_version(decoded: &DecodedWasm) -> Result<(PackageRef, Version), Error> { + let resolve = decoded.resolve(); + let package_id = match decoded { + wit_component::DecodedWasm::Component(_, world_id) => { + resolve.worlds[*world_id].package.ok_or_else(|| { + crate::Error::InvalidComponent(anyhow::anyhow!( + "component world or package not found" + )) + })? + } + wit_component::DecodedWasm::WitPackage(_, pkg) => *pkg, + }; + let (package, version) = resolve + .package_names + .iter() + .find_map(|(pkg, id)| { + // SAFETY: We just parsed this from wit and should be able to unwrap. If it + // isn't a valid identifier, something else is majorly wrong + (*id == package_id).then(|| { + ( + PackageRef::new( + pkg.namespace.clone().try_into().unwrap(), + pkg.name.clone().try_into().unwrap(), + ), + pkg.version.clone(), + ) + }) + }) + .ok_or_else(|| { + crate::Error::InvalidComponent(anyhow::anyhow!( + "component package {package_id:?} not found" + )) + })?; + + let version = version.ok_or_else(|| { + crate::Error::InvalidComponent(anyhow::anyhow!( + "component package version not found in the Wasm binary\n\ + \n\ + The Wasm file was built without a version in the WIT `package` statement.\n\ + Add a version to the `package` statement in your .wit file, e.g.:\n\ + \n\ + \tpackage example:my-package@1.0.0;\n\ + \n\ + Alternatively, specify the package and version explicitly with the --package flag:\n\ + \n\ + \twkg publish --package :@" + )) + })?; + Ok((package, version)) +} + +/// Borrow the inner `wit_parser::Resolve` and resolve a concrete `WorldId`. +/// For a decoded component the world is fixed; for a WIT package we ask +/// `Resolve::select_world` to pick one — deferred until needed so a +/// multi-world WIT package can publish its first version unambiguously. +fn extract_resolve_and_world_id( + decoded: &DecodedWasm, +) -> Result<(&wit_parser::Resolve, wit_parser::WorldId), Error> { + match decoded { + DecodedWasm::Component(resolve, world_id) => Ok((resolve, *world_id)), + DecodedWasm::WitPackage(resolve, pkg) => { + let world_id = resolve + .select_world(&[*pkg], None) + .map_err(Error::InvalidPackage)?; + Ok((resolve, world_id)) + } + } +} diff --git a/crates/wasm-pkg-client/src/lib.rs b/crates/wasm-pkg-client/src/lib.rs index b093b88..3ed9b9d 100644 --- a/crates/wasm-pkg-client/src/lib.rs +++ b/crates/wasm-pkg-client/src/lib.rs @@ -27,6 +27,7 @@ //! ``` pub mod caching; +mod decoded_component; mod loader; pub mod local; pub mod metadata; @@ -35,31 +36,30 @@ mod publisher; mod release; pub mod warg; -use std::path::Path; -use std::sync::Arc; -use std::{collections::HashMap, pin::Pin}; - +use crate::{ + loader::{PackageLoader, VersionSort}, + local::LocalBackend, + metadata::RegistryMetadataExt, + oci::OciBackend, + warg::WargBackend, +}; use anyhow::anyhow; use bytes::Bytes; +use decoded_component::DecodedComponent; +use futures_concurrency::prelude::*; use futures_util::Stream; use publisher::PackagePublisher; -use tokio::io::AsyncSeekExt; +pub use release::{Release, VersionInfo}; +use std::{cmp::Ordering, collections::HashMap, path::Path, pin::Pin, sync::Arc}; use tokio::sync::RwLock; -use tokio_util::io::SyncIoBridge; pub use wasm_pkg_common::{ config::{Config, CustomConfig, RegistryMapping}, digest::ContentDigest, metadata::RegistryMetadata, - package::{PackageRef, Version}, + package::{PackageRef, Version, VersionReq}, registry::Registry, Error, }; -use wit_component::DecodedWasm; - -use crate::metadata::RegistryMetadataExt; -use crate::{loader::PackageLoader, local::LocalBackend, oci::OciBackend, warg::WargBackend}; - -pub use release::{Release, VersionInfo}; /// An alias for a stream of content bytes pub type ContentStream = Pin> + Send + 'static>>; @@ -88,6 +88,8 @@ pub struct PublishOpts { /// If true, resolve the package, version, and registry but do not call the /// backend to publish. pub dry_run: bool, + /// Disable semver compatibility verification. + pub skip_semver_check: bool, } /// A read-only registry client. @@ -166,24 +168,67 @@ impl Client { data: PublishingSource, additional_options: PublishOpts, ) -> Result<(PackageRef, Version), Error> { - let (data, package, version) = if let Some((p, v)) = additional_options.package { - (data, p, v) - } else { - let data = SyncIoBridge::new(data); - let (mut data, p, v) = tokio::task::spawn_blocking(|| resolve_package(data)) + // handle opts + let registry = additional_options.registry; + let semver_check: bool = additional_options.skip_semver_check; + let pkg_authority = additional_options.package; + + // construct verifiable publishing source + let (data, candidate) = + DecodedComponent::from_publishing_source_with_package(data, pkg_authority).await?; + + let (package, version) = ( + candidate.package().to_owned(), + candidate.version().to_owned(), + ); + let source = self.resolve_source(&package, registry).await?; + + // execute pre-flight checks + if !semver_check { + // fetch nearest neighbors of interest, sorted in descending order + let mut neighbors: [Option; 2] = [None, None]; + for version_info in + fetch_semver_series(source.as_ref().as_ref(), &package, &version).await? + { + match version.cmp(&version_info.version) { + Ordering::Equal => return Err(Error::VersionAlreadyExists(version.to_owned())), + Ordering::Greater => { + // incoming version is greater than neighbor + neighbors[0] = Some(version_info); + break; + } + Ordering::Less => { + // incoming version is lesser than neighbor + neighbors[1] = Some(version_info); + } + } + } + + // queue up load/decode futures + let prepare_neighbor_ops: Vec<_> = neighbors + .into_iter() + .flatten() + .map(|v| fetch_and_resolve_package(&**source, &package, v.version)) + .collect(); + + // execute load/decode ops, collect results. + let mut semver_series: Vec = prepare_neighbor_ops + .join() .await - .map_err(|e| { - crate::Error::IoError(std::io::Error::other(format!( - "Error when performing blocking IO: {e:?}" - ))) - })??; - // We must rewind the reader because we read to the end to parse the component. - data.rewind().await?; - (data, p, v) - }; - let source = self - .resolve_source(&package, additional_options.registry) - .await?; + .into_iter() + .collect::>()?; + + // verify candidate is in compliance with its semver neighbors + if !semver_series.is_empty() { + semver_series.push(candidate); + + semver_series.sort_by(|a, b| a.version().cmp(b.version())); + for window in semver_series.windows(2) { + let [prev, next] = window else { unreachable!() }; + prev.semver_check(next)?; + } + } + } source .publish(&package, &version, data, additional_options.dry_run) @@ -299,61 +344,41 @@ impl Client { } } -/// Resolves the package name and version from the given source. This takes a wrapped publishing -/// source to it can do a blocking read with wit_component. It returns back the underlying -/// PublishingSource but should be rewound to the beginning of the source -fn resolve_package( - mut data: SyncIoBridge, -) -> Result<(PublishingSource, PackageRef, Version), Error> { - let (resolve, package_id) = - match wit_component::decode_reader(&mut data).map_err(crate::Error::InvalidComponent)? { - DecodedWasm::Component(resolve, world_id) => { - let package_id = resolve - .worlds - .iter() - .find_map(|(id, w)| if id == world_id { w.package } else { None }) - .ok_or_else(|| { - crate::Error::InvalidComponent(anyhow::anyhow!( - "component world or package not found" - )) - })?; - (resolve, package_id) - } - DecodedWasm::WitPackage(resolve, package_id) => (resolve, package_id), - }; - let (package, version) = resolve - .package_names - .into_iter() - .find_map(|(pkg, id)| { - // SAFETY: We just parsed this from wit and should be able to unwrap. If it - // isn't a valid identifier, something else is majorly wrong - (id == package_id).then(|| { - ( - PackageRef::new( - pkg.namespace.try_into().unwrap(), - pkg.name.try_into().unwrap(), - ), - pkg.version, - ) - }) - }) - .ok_or_else(|| { - crate::Error::InvalidComponent(anyhow::anyhow!("component package not found")) - })?; - - let version = version.ok_or_else(|| { - crate::Error::InvalidComponent(anyhow::anyhow!( - "component package version not found in the Wasm binary\n\ - \n\ - The Wasm file was built without a version in the WIT `package` statement.\n\ - Add a version to the `package` statement in your .wit file, e.g.:\n\ - \n\ - \tpackage example:my-package@1.0.0;\n\ - \n\ - Alternatively, specify the package and version explicitly with the --package flag:\n\ - \n\ - \twkg publish --package :@" - )) - })?; - Ok((data.into_inner(), package, version)) +// Fetch every prior release in the same semver compatibility series as +// `version`, sorted in descending order. +// +// X.y.z (X >= 1) -> X.* (minors are additive within a major) +// 0.Y.z (Y >= 1) -> 0.Y.* (in 0.x, minor bumps are breaking) +// 0.0.Z -> 0.0.Z (every patch is its own series) +async fn fetch_semver_series( + source: &(dyn LoaderPublisher + Sync), + package: &PackageRef, + version: &Version, +) -> Result, Error> { + let mask = if version.major > 0 { + format!("{}.*", version.major) + } else if version.minor > 0 { + format!("0.{}.*", version.minor) + } else { + version.to_string() + }; + let req = VersionReq::parse(&mask) + .map_err(|e| Error::InvalidConfig(anyhow!("invalid version mask: {e}")))?; + + source + .list_matching_versions(package, req, VersionSort::Descending) + .await +} + +async fn fetch_and_resolve_package( + source: &(dyn LoaderPublisher + Sync), + package: &PackageRef, + version: Version, +) -> Result { + let stream = source + .stream_content(package, &source.get_release(package, &version).await?) + .await + .map_err(std::io::Error::other)?; + + DecodedComponent::from_content_stream(stream, package.clone(), version).await } diff --git a/crates/wasm-pkg-client/src/loader.rs b/crates/wasm-pkg-client/src/loader.rs index 7189bd1..e4b9c28 100644 --- a/crates/wasm-pkg-client/src/loader.rs +++ b/crates/wasm-pkg-client/src/loader.rs @@ -1,19 +1,50 @@ +use crate::{ + release::{Release, VersionInfo}, + ContentStream, +}; use async_trait::async_trait; use futures_util::StreamExt; use wasm_pkg_common::{ - package::{PackageRef, Version}, + package::{PackageRef, Version, VersionReq}, Error, }; -use crate::{ - release::{Release, VersionInfo}, - ContentStream, -}; +#[derive(Debug, Default)] +pub enum VersionSort { + #[default] + Ascending, + Descending, +} #[async_trait] pub trait PackageLoader: Send { async fn list_all_versions(&self, package: &PackageRef) -> Result, Error>; + async fn list_matching_versions( + &self, + package: &PackageRef, + predicate: VersionReq, + sort: VersionSort, + ) -> Result, Error> { + let mut versions = match self.list_all_versions(package).await { + Ok(v) => v, + Err(Error::PackageNotFound) => Vec::new(), + Err(e) => return Err(e), + }; + + match sort { + VersionSort::Ascending => versions.sort_by(|a, b| a.version.cmp(&b.version)), + VersionSort::Descending => versions.sort_by(|a, b| b.version.cmp(&a.version)), + }; + + let matching: Vec = versions + .into_iter() + .filter(|v| predicate.matches(&v.version)) + .collect(); + + Ok(matching) + } + async fn get_release(&self, package: &PackageRef, version: &Version) -> Result; async fn stream_content_unvalidated( @@ -31,3 +62,191 @@ pub trait PackageLoader: Send { Ok(release.content_digest.validating_stream(stream).boxed()) } } + +#[cfg(test)] +mod tests { + use super::{ContentStream, PackageLoader, Release, VersionInfo, VersionSort}; + use async_trait::async_trait; + use wasm_pkg_common::{ + package::{PackageRef, Version, VersionReq}, + Error, + }; + + #[derive(Clone, Debug)] + struct VerifiablePackageLoader { + history: Vec, + } + + impl VerifiablePackageLoader { + fn new(history: &[Version]) -> Self { + Self { + history: history + .iter() + .cloned() + .map(|version| VersionInfo { + version, + yanked: false, + }) + .collect(), + } + } + } + + #[async_trait] + impl PackageLoader for VerifiablePackageLoader { + async fn list_all_versions( + &self, + _package: &PackageRef, + ) -> Result, Error> { + Ok(self.history.clone()) + } + + async fn get_release( + &self, + _package: &PackageRef, + _version: &Version, + ) -> Result { + panic!("get_release is not needed in this unit test") + } + + async fn stream_content_unvalidated( + &self, + _package: &PackageRef, + _release: &Release, + ) -> Result { + panic!("stream_content_unvalidated is not needed in this unit test") + } + } + + #[derive(Debug)] + struct Case { + name: &'static str, + req: &'static str, + sort: VersionSort, + history: &'static [&'static str], + expected: &'static [&'static str], + } + + fn v(input: &str) -> Version { + input.parse().expect("valid semver in test case") + } + + fn versions(inputs: &[&str]) -> Vec { + inputs.iter().map(|s| v(s)).collect() + } + + /// `list_matching_versions` is a generic `VersionReq` filter; this test + /// exercises it with the cargo-`^` shaped series masks that the publish + /// gate constructs in `fetch_semver_series` (see `lib.rs`): + /// + /// - `X.y.z` (X >= 1) -> `X.*` + /// - `0.Y.z` (Y >= 1) -> `0.Y.*` + /// - `0.0.Z` -> exact `0.0.Z` + #[tokio::test] + async fn list_matching_versions_filters_by_version_req_table_driven() { + // These cases include the examples from the function docs and edge + // cases for lane filtering behavior. + let cases = [ + Case { + name: "target 0.0.0 -> 0.0.*", + req: "~0.0.*", + sort: VersionSort::Ascending, + history: &["0.0.0", "0.0.1", "0.1.0", "1.0.0"], + expected: &["0.0.0", "0.0.1"], + }, + Case { + name: "target 0.0.3 -> 0.0.*", + req: "~0.0.*", + sort: VersionSort::Ascending, + history: &["0.0.0", "0.0.3", "0.0.7", "0.1.0"], + expected: &["0.0.0", "0.0.3", "0.0.7"], + }, + Case { + name: "target 1.0.0 -> 1.0.*", + req: "~1.0.*", + sort: VersionSort::Ascending, + history: &["1.0.0", "1.0.9", "1.1.0", "2.0.0"], + expected: &["1.0.0", "1.0.9"], + }, + Case { + name: "target 2.2.0 -> 2.2.*", + req: "~2.2.*", + sort: VersionSort::Ascending, + history: &["2.1.9", "2.2.0", "2.2.5", "2.3.0"], + expected: &["2.2.0", "2.2.5"], + }, + Case { + name: "empty history", + req: "~1.2.*", + sort: VersionSort::Ascending, + history: &[], + expected: &[], + }, + Case { + name: "no matching major minor in history", + req: "~3.4.*", + sort: VersionSort::Ascending, + history: &["3.5.0", "3.6.1", "4.4.5"], + expected: &[], + }, + Case { + name: "all patches in series", + req: "~1.2.*", + sort: VersionSort::Ascending, + history: &["1.2.0", "1.2.1", "1.2.99", "1.3.0", "0.2.9"], + expected: &["1.2.0", "1.2.1", "1.2.99"], + }, + Case { + // The exclusion of pre-release versions in range queries + // is a bit unintuitive but apparently intentional. + // + // https://github.com/dtolnay/semver/issues/98 + name: "pre-release excluded from series", + req: "~1.2.*", + sort: VersionSort::Ascending, + history: &["1.2.0", "1.2.1-beta.2", "1.2.4+build.7", "1.3.0-alpha.1"], + expected: &["1.2.0", "1.2.4+build.7"], + }, + Case { + name: "duplication is preserved", + req: "~1.2.*", + sort: VersionSort::Ascending, + history: &["1.2.1", "1.2.1", "1.2.2", "1.3.0"], + expected: &["1.2.1", "1.2.1", "1.2.2"], + }, + Case { + name: "descending sort orders matches high to low", + req: "~1.2.*", + sort: VersionSort::Descending, + history: &["1.2.0", "1.2.5", "1.2.1", "1.3.0"], + expected: &["1.2.5", "1.2.1", "1.2.0"], + }, + Case { + name: "descending sort with empty matches", + req: "~9.9.*", + sort: VersionSort::Descending, + history: &["1.0.0", "2.0.0"], + expected: &[], + }, + ]; + + for case in cases { + let history = versions(case.history); + let expected = versions(case.expected); + let filter = VersionReq::parse(case.req).expect("valid series req"); + let package: PackageRef = "example:package".parse().expect("valid package ref"); + + let loader = VerifiablePackageLoader::new(&history); + + let got: Vec = loader + .list_matching_versions(&package, filter, case.sort) + .await + .expect("list_matching_versions should succeed") + .into_iter() + .map(|v| v.version) + .collect(); + + assert_eq!(got, expected.as_slice(), "case failed: {}", case.name); + } + } +} diff --git a/crates/wasm-pkg-client/src/local.rs b/crates/wasm-pkg-client/src/local.rs index 28a84bc..382a7c0 100644 --- a/crates/wasm-pkg-client/src/local.rs +++ b/crates/wasm-pkg-client/src/local.rs @@ -60,7 +60,14 @@ impl PackageLoader for LocalBackend { let mut versions = vec![]; let package_dir = self.package_dir(package); tracing::debug!(?package_dir, "Reading versions from path"); - let mut entries = tokio::fs::read_dir(package_dir).await?; + + let mut entries = match tokio::fs::read_dir(&package_dir).await { + Ok(entries) => entries, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Err(Error::PackageNotFound) + } + Err(e) => return Err(e.into()), + }; while let Some(entry) = entries.next_entry().await? { let path = entry.path(); if path.extension() != Some("wasm".as_ref()) { diff --git a/crates/wasm-pkg-client/src/oci/mod.rs b/crates/wasm-pkg-client/src/oci/mod.rs index 71ef22c..8bf7845 100644 --- a/crates/wasm-pkg-client/src/oci/mod.rs +++ b/crates/wasm-pkg-client/src/oci/mod.rs @@ -10,7 +10,9 @@ mod publisher; use docker_credential::{CredentialRetrievalError, DockerCredential}; use oci_client::{ - errors::OciDistributionError, secrets::RegistryAuth, Reference, RegistryOperation, + errors::{OciDistributionError, OciError, OciErrorCode}, + secrets::RegistryAuth, + Reference, RegistryOperation, }; use secrecy::ExposeSecret; use serde::Deserialize; @@ -154,6 +156,22 @@ pub(crate) fn oci_registry_error(err: OciDistributionError) -> Error { match err { // Technically this could be a missing version too, but there really isn't a way to find out OciDistributionError::ImageManifestNotFoundError(_) => Error::PackageNotFound, + // `list_tags` against a repository that doesn't yet exist surfaces + // as a `NameUnknown` envelope rather than a 404 manifest error. Only + // Cast when NameUnknown is the *sole* error in the envelope. + // Bundled errors (e.g. `[NameUnknown, Unauthorized]`) are preserved as a + // generic `RegistryError`. + OciDistributionError::RegistryError { ref envelope, .. } + if matches!( + envelope.errors.as_slice(), + [OciError { + code: OciErrorCode::NameUnknown, + .. + }], + ) => + { + Error::PackageNotFound + } _ => Error::RegistryError(err.into()), } } diff --git a/crates/wasm-pkg-client/tests/e2e.rs b/crates/wasm-pkg-client/tests/e2e.rs index aad96b8..db31b79 100644 --- a/crates/wasm-pkg-client/tests/e2e.rs +++ b/crates/wasm-pkg-client/tests/e2e.rs @@ -1,5 +1,5 @@ use futures_util::TryStreamExt; -use wasm_pkg_client::{Client, Config}; +use wasm_pkg_client::{Client, Config, PublishOpts}; const FIXTURE_WASM: &str = "./tests/testdata/binary_wit.wasm"; @@ -58,6 +58,54 @@ async fn publish_and_fetch_smoke_test() { assert_eq!(content, expected_content); } +// Exercises the publish-time semver gate against a real OCI registry. The +// override package name guarantees the namespace is empty on the registry, so +// `list_matching_versions` must swallow the OCI `NameUnknown` response into an +// empty history for the publish to succeed. +#[cfg(feature = "docker-tests")] +#[tokio::test] +async fn publish_with_semver_check_succeeds_for_new_package() { + use testcontainers::{ + core::{IntoContainerPort, WaitFor}, + runners::AsyncRunner, + GenericImage, ImageExt, + }; + + let _container = GenericImage::new("registry", "2") + .with_wait_for(WaitFor::message_on_stderr("listening on [::]:5000")) + .with_mapped_port(5002, 5000.tcp()) + .start() + .await + .expect("Failed to start test container"); + + let config = Config::from_toml( + r#" + default_registry = "localhost:5002" + + [registry."localhost:5002"] + type = "oci" + [registry."localhost:5002".oci] + protocol = "http" + "#, + ) + .unwrap(); + let client = Client::new(config); + + let package = "example:fresh-series".parse().unwrap(); + let version = "1.0.0".parse().unwrap(); + + client + .publish_release_file( + FIXTURE_WASM, + PublishOpts { + package: Some((package, version)), + ..Default::default() + }, + ) + .await + .expect("publish should succeed for a brand-new package (NameUnknown swallowed)"); +} + #[cfg(feature = "docker-tests")] #[tokio::test] async fn publish_and_fetch_succeed_with_self_signed_registry() { diff --git a/crates/wasm-pkg-client/tests/publish_semver_check.rs b/crates/wasm-pkg-client/tests/publish_semver_check.rs new file mode 100644 index 0000000..150b507 --- /dev/null +++ b/crates/wasm-pkg-client/tests/publish_semver_check.rs @@ -0,0 +1,257 @@ +//! Integration tests for publish-time semver compatibility checking (issue #128). + +use std::{io::Cursor, path::Path}; +use tempfile::TempDir; +use wasm_pkg_client::{Client, Config, PublishOpts}; +use wasm_pkg_common::Error; + +const NAMESPACE: &str = "example"; + +#[derive(Clone, Copy)] +enum Shape { + Base, + Compatible, + Incompatible, +} + +#[derive(Clone)] +enum Expected { + Ok, + SemverIncompatible { + previous: &'static str, + new: &'static str, + }, + VersionAlreadyExists(&'static str), +} + +struct Case { + name: &'static str, + /// Unique per row -> isolated series. + package: &'static str, + /// `None` skips seeding -> the candidate is the first publish in the + /// series. + initial: Option<(&'static str, Shape)>, + candidate: (&'static str, Shape), + skip_semver_check: bool, + expected: Expected, +} + +fn make_client(root: &Path) -> Client { + let toml = format!( + r#" +default_registry = "local" + +[registry."local"] +type = "local" + +[registry."local".local] +root = '{}' +"#, + root.display(), + ); + let config = Config::from_toml(&toml).expect("local-backend config should parse"); + Client::new(config) +} + +fn wit_for(package: &str, version: &str, shape: Shape) -> String { + let world_body = match shape { + Shape::Base => "export run: func() -> u32;", + Shape::Compatible => "export run: func() -> u32;\n export extra: func() -> u32;", + Shape::Incompatible => "export run: func() -> string;", + }; + format!( + r#" +package {NAMESPACE}:{package}@{version}; + +world the-world {{ + {world_body} +}} +"# + ) +} + +fn wit_to_wasm(wit: &str) -> Vec { + let mut resolve = wit_parser::Resolve::new(); + let pkg_id = resolve + .push_str("test.wit", wit) + .expect("test WIT should parse"); + wit_component::encode(&resolve, pkg_id).expect("test WIT should encode") +} + +async fn publish(client: &Client, bytes: Vec, opts: PublishOpts) -> Result<(), Error> { + client + .publish_release_data(Box::pin(Cursor::new(bytes)), opts) + .await + .map(|_| ()) +} + +#[tokio::test] +async fn publish_semver_check_table() { + let tmp = TempDir::new().unwrap(); + let client = make_client(tmp.path()); + + let cases = [ + Case { + name: "first publish in empty series", + package: "first-in-series", + initial: None, + candidate: ("0.1.0", Shape::Base), + skip_semver_check: false, + expected: Expected::Ok, + }, + Case { + name: "compatible in same 0.Y series", + package: "compat-same-series", + initial: Some(("0.1.0", Shape::Base)), + candidate: ("0.1.1", Shape::Compatible), + skip_semver_check: false, + expected: Expected::Ok, + }, + Case { + name: "incompatible in same 0.Y series", + package: "incompat-same-series", + initial: Some(("0.1.0", Shape::Base)), + candidate: ("0.1.1", Shape::Incompatible), + skip_semver_check: false, + expected: Expected::SemverIncompatible { + previous: "0.1.0", + new: "0.1.1", + }, + }, + Case { + name: "incompatible across 0.Y series boundary", + package: "incompat-cross-zero-y", + initial: Some(("0.1.0", Shape::Base)), + candidate: ("0.2.0", Shape::Incompatible), + skip_semver_check: false, + expected: Expected::Ok, + }, + Case { + name: "incompatible across minors within a major (>=1)", + package: "incompat-cross-minor", + initial: Some(("1.2.0", Shape::Base)), + candidate: ("1.3.0", Shape::Incompatible), + skip_semver_check: false, + expected: Expected::SemverIncompatible { + previous: "1.2.0", + new: "1.3.0", + }, + }, + Case { + name: "incompatible across major boundary", + package: "incompat-cross-major", + initial: Some(("1.2.0", Shape::Base)), + candidate: ("2.0.0", Shape::Incompatible), + skip_semver_check: false, + expected: Expected::Ok, + }, + Case { + name: "incompatible with skip_semver_check", + package: "incompat-opt-out", + initial: Some(("0.1.0", Shape::Base)), + candidate: ("0.1.1", Shape::Incompatible), + skip_semver_check: true, + expected: Expected::Ok, + }, + Case { + name: "duplicate version is rejected", + package: "dup-version", + initial: Some(("0.1.0", Shape::Base)), + candidate: ("0.1.0", Shape::Base), + skip_semver_check: false, + expected: Expected::VersionAlreadyExists("0.1.0"), + }, + Case { + name: "duplicate version with skip_semver_check", + package: "dup-version-opt-out", + initial: Some(("0.1.0", Shape::Base)), + candidate: ("0.1.0", Shape::Base), + skip_semver_check: true, + expected: Expected::Ok, + }, + Case { + // A `~0.1.*` / `^0.1` predicate excludes prereleases by design + // (semver crate behavior), so an incompatible 0.1.1-beta.1 prior + // must not be considered when publishing the stable 0.1.1. + name: "prerelease priors are ignored", + package: "ignore-prereleases", + initial: Some(("0.1.1-beta.1", Shape::Incompatible)), + candidate: ("0.1.1", Shape::Base), + skip_semver_check: false, + expected: Expected::Ok, + }, + ]; + + for case in cases { + if let Some((init_version, init_shape)) = case.initial { + publish( + &client, + wit_to_wasm(&wit_for(case.package, init_version, init_shape)), + Default::default(), + ) + .await + .unwrap_or_else(|e| panic!("[{}] seeding {init_version} failed: {e:?}", case.name)); + } + + let (cand_version, cand_shape) = case.candidate; + + let opts = PublishOpts { + skip_semver_check: case.skip_semver_check, + ..Default::default() + }; + let result = publish( + &client, + wit_to_wasm(&wit_for(case.package, cand_version, cand_shape)), + opts, + ) + .await; + + match (&case.expected, result) { + (Expected::Ok, Ok(())) => {} + ( + Expected::SemverIncompatible { + previous: exp_prev, + new: exp_new, + }, + Err(Error::SemverIncompatible { previous, new, .. }), + ) => { + assert_eq!( + previous.to_string(), + *exp_prev, + "[{}] previous version label mismatch", + case.name, + ); + assert_eq!( + new.to_string(), + *exp_new, + "[{}] new version label mismatch", + case.name, + ); + } + (Expected::VersionAlreadyExists(exp), Err(Error::VersionAlreadyExists(v))) => { + assert_eq!( + v.to_string(), + *exp, + "[{}] duplicate version mismatch", + case.name, + ); + } + (expected, actual) => panic!( + "[{}] expectation mismatch\n expected: {}\n actual: {:?}", + case.name, + describe(expected), + actual, + ), + } + } +} + +fn describe(e: &Expected) -> String { + match e { + Expected::Ok => "Ok".into(), + Expected::SemverIncompatible { previous, new } => { + format!("SemverIncompatible {{ previous: {previous}, new: {new} }}") + } + Expected::VersionAlreadyExists(v) => format!("VersionAlreadyExists({v})"), + } +} diff --git a/crates/wasm-pkg-common/src/lib.rs b/crates/wasm-pkg-common/src/lib.rs index e8759bd..e6bfede 100644 --- a/crates/wasm-pkg-common/src/lib.rs +++ b/crates/wasm-pkg-common/src/lib.rs @@ -19,6 +19,8 @@ pub enum Error { CredentialError(#[source] anyhow::Error), #[error("malformed component: {0:#}")] InvalidComponent(#[source] anyhow::Error), + #[error("malformed package: {0:#}")] + InvalidPackage(#[source] anyhow::Error), #[error("invalid config: {0}")] InvalidConfig(#[source] anyhow::Error), #[error("invalid content: {0}")] @@ -51,4 +53,15 @@ pub enum Error { RegistryMetadataError(#[source] anyhow::Error), #[error("version not found: {0}")] VersionNotFound(semver::Version), + #[error("version {0} is already published for this package")] + VersionAlreadyExists(semver::Version), + #[error( + "new version {new} is not semver-compatible with existing version {previous}: {source:#}" + )] + SemverIncompatible { + previous: semver::Version, + new: semver::Version, + #[source] + source: anyhow::Error, + }, } diff --git a/crates/wasm-pkg-common/src/package.rs b/crates/wasm-pkg-common/src/package.rs index 8360c97..1b25907 100644 --- a/crates/wasm-pkg-common/src/package.rs +++ b/crates/wasm-pkg-common/src/package.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; use crate::{label::Label, Error}; -pub use semver::Version; +pub use semver::{Version, VersionReq}; /// A package reference, consisting of kebab-case namespace and name. /// diff --git a/crates/wkg/src/main.rs b/crates/wkg/src/main.rs index 7272062..60e4d12 100644 --- a/crates/wkg/src/main.rs +++ b/crates/wkg/src/main.rs @@ -242,6 +242,10 @@ struct PublishArgs { #[arg(long)] dry_run: bool, + /// Disable semver compatibility checks. + #[arg(long)] + no_verify: bool, + #[command(flatten)] common: Common, } @@ -268,6 +272,7 @@ impl PublishArgs { package, registry: self.registry_args.registry, dry_run: self.dry_run, + skip_semver_check: self.no_verify, }, ) .await?;