diff --git a/Cargo.lock b/Cargo.lock index 190dba9..50d94fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5179,6 +5179,7 @@ dependencies = [ "petgraph 0.8.3", "rcgen", "reqwest 0.12.28", + "rstest", "secrecy", "serde", "serde_json", @@ -5198,6 +5199,7 @@ dependencies = [ "wasm-metadata", "wasm-pkg-common", "wit-component", + "wit-parser", ] [[package]] @@ -5748,6 +5750,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 c90977c..51f0bd5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,7 +34,7 @@ rcgen = "0.14.8" reqwest = { version = "0.12.0", default-features = false } rstest = "0.23" secrecy = "0.8" -semver = "1.0.23" +semver = "1.0.28" serde = { version = "1.0", features = ["derive"] } serde_json = "1" sha2 = "0.10" @@ -56,11 +56,9 @@ wit-parser = "0.244" # https://github.com/crate-ci/typos/blob/master/docs/reference.md [workspace.metadata.typos.default] -extend-ignore-re = [ - "\\d\\w{4,}\\d", -] +extend-ignore-re = ["\\d\\w{4,}\\d"] extend-ignore-words-re = [ - '^[a-zA-Z]{1,3}$' # ignore words up to length 3 + '^[a-zA-Z]{1,3}$', # ignore words up to length 3 ] [workspace.metadata.typos.default.extend-words] diff --git a/crates/wasm-pkg-client/Cargo.toml b/crates/wasm-pkg-client/Cargo.toml index 004e039..3b90c63 100644 --- a/crates/wasm-pkg-client/Cargo.toml +++ b/crates/wasm-pkg-client/Cargo.toml @@ -40,10 +40,12 @@ warg-crypto = { workspace = true } wasm-metadata = { workspace = true } warg-protocol = { workspace = true } wasm-pkg-common = { workspace = true, features = ["registry-config"] } -wit-component = { workspace = true } +wit-component = { workspace = true, features = ["semver-check"] } +wit-parser = { workspace = true } petgraph = { workspace = true } tempfile = { workspace = true } [dev-dependencies] rcgen = { workspace = true } +rstest = "0.23" testcontainers = { 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..7c16fe2 --- /dev/null +++ b/crates/wasm-pkg-client/src/decoded_component.rs @@ -0,0 +1,216 @@ +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::{ + Error, + package::{PackageRef, Version}, +}; +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, + } + }) + } +} + +impl PartialEq for DecodedComponent { + fn eq(&self, other: &Self) -> bool { + self.package_ref == other.package_ref && self.version == other.version + } +} + +impl Eq for DecodedComponent {} + +impl PartialOrd for DecodedComponent { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for DecodedComponent { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + (&self.package_ref, &self.version).cmp(&(&other.package_ref, &other.version)) + } +} + +async fn decode(reader: R) -> Result<(R, DecodedWasm), Error> +where + R: Read + Send + 'static, +{ + // wit_component::decode_reader is CPU-bound sync work + // run it on the blocking pool so we don't stall an async worker thread + // see also: https://docs.rs/tokio/latest/tokio/index.html#cpu-bound-tasks-and-blocking-code + 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 f887b48..ccf63e0 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,27 +36,24 @@ mod publisher; mod release; pub mod warg; -use std::path::Path; -use std::sync::Arc; -use std::{collections::HashMap, pin::Pin}; +use std::{cmp::Ordering, collections::HashMap, path::Path, pin::Pin, sync::Arc}; use anyhow::anyhow; use bytes::Bytes; +use decoded_component::DecodedComponent; use futures_util::Stream; -use tokio::io::AsyncSeekExt; use tokio::sync::RwLock; -use tokio_util::io::SyncIoBridge; use wasm_pkg_common::metadata::{LOCAL_PROTOCOL, OCI_PROTOCOL, WARG_PROTOCOL}; pub use wasm_pkg_common::{ Error, config::{Config, CustomConfig, RegistryMapping}, digest::ContentDigest, metadata::RegistryMetadata, - package::{PackageRef, Version}, + package::{PackageRef, Version, VersionReq}, registry::Registry, }; -use wit_component::DecodedWasm; +use crate::loader::VersionSort; use crate::local::LocalBackend; use crate::metadata::RegistryMetadataExt; pub use crate::{loader::PackageLoader, publisher::PackagePublisher}; @@ -91,6 +89,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. @@ -169,24 +169,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)) - .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?; + // 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 = + futures_util::future::join_all(prepare_neighbor_ops) + .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(); + 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) @@ -310,64 +353,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 :@" - ) - .context(format!("package: {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 26fae09..00da25f 100644 --- a/crates/wasm-pkg-client/src/loader.rs +++ b/crates/wasm-pkg-client/src/loader.rs @@ -2,7 +2,7 @@ use async_trait::async_trait; use futures_util::StreamExt; use wasm_pkg_common::{ Error, - package::{PackageRef, Version}, + package::{PackageRef, Version, VersionReq}, }; use crate::{ @@ -10,10 +10,42 @@ use crate::{ release::{Release, VersionInfo}, }; +#[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 versions = match self.list_all_versions(package).await { + Ok(v) => v, + Err(Error::PackageNotFound) => Vec::new(), + Err(e) => return Err(e), + }; + + let mut matching: Vec = versions + .into_iter() + .filter(|v| predicate.matches(&v.version)) + .collect(); + + matching.sort(); + if matches!(sort, VersionSort::Descending) { + matching.reverse(); + } + + Ok(matching) + } + async fn get_release(&self, package: &PackageRef, version: &Version) -> Result; async fn stream_content_unvalidated( @@ -31,3 +63,161 @@ 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 rstest::rstest; + use wasm_pkg_common::{ + Error, + package::{PackageRef, Version, VersionReq}, + }; + + #[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") + } + } + + 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() + } + + // These cases include the examples from the function docs and edge cases + // for lane filtering behavior. + #[rstest] + #[case::target_0_0_0( + "~0.0.*", + VersionSort::Ascending, + &["0.0.0", "0.0.1", "0.1.0", "1.0.0"], + &["0.0.0", "0.0.1"] + )] + #[case::target_0_0_3( + "~0.0.*", + VersionSort::Ascending, + &["0.0.0", "0.0.3", "0.0.7", "0.1.0"], + &["0.0.0", "0.0.3", "0.0.7"] + )] + #[case::target_1_0_0( + "~1.0.*", + VersionSort::Ascending, + &["1.0.0", "1.0.9", "1.1.0", "2.0.0"], + &["1.0.0", "1.0.9"] + )] + #[case::target_2_2_0( + "~2.2.*", + VersionSort::Ascending, + &["2.1.9", "2.2.0", "2.2.5", "2.3.0"], + &["2.2.0", "2.2.5"] + )] + #[case::empty_history("~1.2.*", VersionSort::Ascending, &[], &[])] + #[case::no_matching_major_minor_in_history( + "~3.4.*", + VersionSort::Ascending, + &["3.5.0", "3.6.1", "4.4.5"], + &[] + )] + #[case::all_patches_in_series( + "~1.2.*", + VersionSort::Ascending, + &["1.2.0", "1.2.1", "1.2.99", "1.3.0", "0.2.9"], + &["1.2.0", "1.2.1", "1.2.99"] + )] + // The exclusion of pre-release versions in range queries is a bit + // unintuitive but apparently intentional. + // + // https://github.com/dtolnay/semver/issues/98 + #[case::pre_release_excluded_from_series( + "~1.2.*", + VersionSort::Ascending, + &["1.2.0", "1.2.1-beta.2", "1.2.4+build.7", "1.3.0-alpha.1"], + &["1.2.0", "1.2.4+build.7"] + )] + #[case::duplication_is_preserved( + "~1.2.*", + VersionSort::Ascending, + &["1.2.1", "1.2.1", "1.2.2", "1.3.0"], + &["1.2.1", "1.2.1", "1.2.2"] + )] + #[case::descending_sort_orders_matches_high_to_low( + "~1.2.*", + VersionSort::Descending, + &["1.2.0", "1.2.5", "1.2.1", "1.3.0"], + &["1.2.5", "1.2.1", "1.2.0"] + )] + #[case::descending_sort_with_empty_matches( + "~9.9.*", + VersionSort::Descending, + &["1.0.0", "2.0.0"], + &[] + )] + #[tokio::test] + async fn list_matching_versions_filters_by_version_req( + #[case] req: &str, + #[case] sort: VersionSort, + #[case] history: &[&str], + #[case] expected: &[&str], + ) { + let history = versions(history); + let expected = versions(expected); + let filter = VersionReq::parse(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, sort) + .await + .expect("list_matching_versions should succeed") + .into_iter() + .map(|v| v.version) + .collect(); + + assert_eq!(got, expected.as_slice()); + } +} diff --git a/crates/wasm-pkg-client/src/local.rs b/crates/wasm-pkg-client/src/local.rs index c9a11be..42c1765 100644 --- a/crates/wasm-pkg-client/src/local.rs +++ b/crates/wasm-pkg-client/src/local.rs @@ -90,9 +90,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 - .map_err(|e| registry_path_context(e, &package_dir))?; + + 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(registry_path_context(e, &package_dir)), + }; 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 344e8c0..0e9d43d 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::{ - Reference, RegistryOperation, errors::OciDistributionError, secrets::RegistryAuth, + Reference, RegistryOperation, + errors::{OciDistributionError, OciError, OciErrorCode}, + secrets::RegistryAuth, }; 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 7646cf3..3479de8 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..5dee7c9 --- /dev/null +++ b/crates/wasm-pkg-client/tests/publish_semver_check.rs @@ -0,0 +1,217 @@ +//! Integration tests for publish-time semver compatibility checking (issue #128). + +use rstest::rstest; +use std::{fmt, 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 WorldDiff { + // + export base: func() -> u32; + AddBase, + // + export extra: func() -> u32; + AddExtra, + // - export base: func() -> u32; + // + export base: func() -> string; + ChangeBase, +} + +impl fmt::Display for WorldDiff { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let body = match self { + WorldDiff::AddBase => "export base: func() -> u32;", + WorldDiff::AddExtra => "export base: func() -> u32;\n export extra: func() -> u32;", + WorldDiff::ChangeBase => "export base: func() -> string;", + }; + f.write_str(body) + } +} + +fn semver_incompatible(previous: &str, new: &str) -> Error { + Error::SemverIncompatible { + previous: previous.parse().unwrap(), + new: new.parse().unwrap(), + source: anyhow::anyhow!(""), + } +} + +fn version_already_exists(version: &str) -> Error { + Error::VersionAlreadyExists(version.parse().unwrap()) +} + +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, diff: WorldDiff) -> String { + format!( + r#" +package {NAMESPACE}:{package}@{version}; + +world the-world {{ + {diff} +}} +"# + ) +} + +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(|_| ()) +} + +#[rstest] +#[case::first_publish_in_empty_series( + "first-in-series", + None, + ("0.1.0", WorldDiff::AddBase), + false, + Ok(()) +)] +#[case::compatible_in_same_zero_y_series( + "compat-same-series", + Some(("0.1.0", WorldDiff::AddBase)), + ("0.1.1", WorldDiff::AddExtra), + false, + Ok(()) +)] +#[case::incompatible_in_same_zero_y_series( + "incompat-same-series", + Some(("0.1.0", WorldDiff::AddBase)), + ("0.1.1", WorldDiff::ChangeBase), + false, + Err(semver_incompatible("0.1.0", "0.1.1")) +)] +#[case::incompatible_across_zero_y_series_boundary( + "incompat-cross-zero-y", + Some(("0.1.0", WorldDiff::AddBase)), + ("0.2.0", WorldDiff::ChangeBase), + false, + Ok(()) +)] +#[case::incompatible_across_minors_within_a_major( + "incompat-cross-minor", + Some(("1.2.0", WorldDiff::AddBase)), + ("1.3.0", WorldDiff::ChangeBase), + false, + Err(semver_incompatible("1.2.0", "1.3.0")) +)] +#[case::incompatible_across_major_boundary( + "incompat-cross-major", + Some(("1.2.0", WorldDiff::AddBase)), + ("2.0.0", WorldDiff::ChangeBase), + false, + Ok(()) +)] +#[case::incompatible_with_skip_semver_check( + "incompat-opt-out", + Some(("0.1.0", WorldDiff::AddBase)), + ("0.1.1", WorldDiff::ChangeBase), + true, + Ok(()) +)] +#[case::duplicate_version_is_rejected( + "dup-version", + Some(("0.1.0", WorldDiff::AddBase)), + ("0.1.0", WorldDiff::AddBase), + false, + Err(version_already_exists("0.1.0")) +)] +#[case::duplicate_version_with_skip_semver_check( + "dup-version-opt-out", + Some(("0.1.0", WorldDiff::AddBase)), + ("0.1.0", WorldDiff::AddBase), + true, + Ok(()) +)] +// 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. +#[case::prerelease_priors_are_ignored( + "ignore-prereleases", + Some(("0.1.1-beta.1", WorldDiff::ChangeBase)), + ("0.1.1", WorldDiff::AddBase), + false, + Ok(()) +)] +#[tokio::test] +// `package` is unique per row -> isolated series. `initial` is `None` to skip +// seeding, in which case the candidate is the first publish in the series. +async fn publish_semver_check( + #[case] package: &str, + #[case] initial: Option<(&str, WorldDiff)>, + #[case] candidate: (&str, WorldDiff), + #[case] skip_semver_check: bool, + #[case] expected: Result<(), Error>, +) { + let tmp = TempDir::new().unwrap(); + let client = make_client(tmp.path()); + + if let Some((init_version, init_diff)) = initial { + publish( + &client, + wit_to_wasm(&wit_for(package, init_version, init_diff)), + Default::default(), + ) + .await + .unwrap_or_else(|e| panic!("seeding {init_version} failed: {e:?}")); + } + + let (cand_version, cand_diff) = candidate; + + let opts = PublishOpts { + skip_semver_check, + ..Default::default() + }; + let result = publish( + &client, + wit_to_wasm(&wit_for(package, cand_version, cand_diff)), + opts, + ) + .await; + + match (&expected, &result) { + (Ok(()), Ok(())) => {} + ( + Err(Error::SemverIncompatible { + previous: exp_prev, + new: exp_new, + .. + }), + Err(Error::SemverIncompatible { previous, new, .. }), + ) => { + assert_eq!(previous, exp_prev, "previous version mismatch"); + assert_eq!(new, exp_new, "new version mismatch"); + } + (Err(Error::VersionAlreadyExists(exp)), Err(Error::VersionAlreadyExists(actual))) => { + assert_eq!(actual, exp, "duplicate version mismatch"); + } + _ => panic!("expectation mismatch\n expected: {expected:?}\n actual: {result:?}",), + } +} diff --git a/crates/wasm-pkg-common/src/lib.rs b/crates/wasm-pkg-common/src/lib.rs index a6d401f..a4053c8 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 c1de700..5df67d2 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::{Error, label::Label}; -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 56f0286..3e61343 100644 --- a/crates/wkg/src/main.rs +++ b/crates/wkg/src/main.rs @@ -286,6 +286,10 @@ struct PublishArgs { #[arg(long)] dry_run: bool, + /// Disable semver compatibility checks. + #[arg(long)] + skip_semver_check: bool, + #[command(flatten)] common: Common, } @@ -358,6 +362,7 @@ impl PublishArgs { registry: Some(local_registry.clone()), // we want to publish to "tmp_local_publish" regardless of flags passed in dry_run: false, + skip_semver_check: publish_opts.skip_semver_check, }, ) .await?; @@ -464,6 +469,7 @@ impl PublishArgs { package, registry: self.registry_args.registry.clone(), dry_run: self.dry_run, + skip_semver_check: self.skip_semver_check, }) } } diff --git a/crates/wkg/tests/e2e.rs b/crates/wkg/tests/e2e.rs index f41dfe1..fd17e51 100644 --- a/crates/wkg/tests/e2e.rs +++ b/crates/wkg/tests/e2e.rs @@ -146,10 +146,12 @@ async fn publish_multiple_transitive_local_packages() { .list_all_versions(&pkg) .await .unwrap_or_else(|e| panic!("list versions for {name}: {e:#}")); - std::assert_matches!( - &versions[..], - [VersionInfo { version, .. }] if version == &expected_version, - "{name} should have exactly one published version", + assert!( + matches!( + &versions[..], + [VersionInfo { version, .. }] if version == &expected_version, + ), + "{name} should have exactly one published version, got {versions:?}", ); } }