diff --git a/lib/backend-api/schema.graphql b/lib/backend-api/schema.graphql index 08a94349f022..81e94377bfc3 100644 --- a/lib/backend-api/schema.graphql +++ b/lib/backend-api/schema.graphql @@ -1441,6 +1441,19 @@ type ChangePackageVersionArchivedStatusPayload { packageVersion: PackageVersion! } +input YankPackageVersionsInput { + packageVersionIds: [ID!]! + reason: String + undo: Boolean + clientMutationId: String +} + +type YankPackageVersionsPayload { + clientMutationId: String + package: Package! + packageVersions: [PackageVersion!]! +} + input ChangeUserEmailInput { newEmail: String! clientMutationId: String @@ -4112,6 +4125,7 @@ type Mutation { archivePackage(input: ArchivePackageInput!): ArchivePackagePayload renamePackage(input: RenamePackageInput!): RenamePackagePayload changePackageVersionArchivedStatus(input: ChangePackageVersionArchivedStatusInput!): ChangePackageVersionArchivedStatusPayload + yankPackageVersions(input: YankPackageVersionsInput!): YankPackageVersionsPayload invitePackageCollaborator(input: InvitePackageCollaboratorInput!): InvitePackageCollaboratorPayload acceptPackageCollaboratorInvite(input: AcceptPackageCollaboratorInviteInput!): AcceptPackageCollaboratorInvitePayload removePackageCollaboratorInvite(input: RemovePackageCollaboratorInviteInput!): RemovePackageCollaboratorInvitePayload @@ -5016,6 +5030,7 @@ type PackageVersion implements Node & PackageInstance & PackageReleaseInterface license: String licenseFile: String readme: String + rebuilds: [PackageVersion!]! witMd: String repository: String homepage: String @@ -5023,6 +5038,8 @@ type PackageVersion implements Node & PackageInstance & PackageReleaseInterface nativeExecutablesCompiled: Boolean! signature: Signature isArchived: Boolean! + yankedAt: DateTime + yankReason: String file: String! fileSize: BigInt! totalDownloads: Int! diff --git a/lib/backend-api/src/query.rs b/lib/backend-api/src/query.rs index bd7ac9ab331a..d466ca20506f 100644 --- a/lib/backend-api/src/query.rs +++ b/lib/backend-api/src/query.rs @@ -880,7 +880,7 @@ pub async fn fetch_webc_package( PackageIdent::Named(n) => Url::parse(&format!( "{default_registry}/{}:{}", n.full_name(), - n.version_or_default() + n.version_string() ))?, PackageIdent::Hash(h) => match get_package_release(client, &h.to_string()).await? { Some(webc) => Url::parse(&webc.webc_url)?, @@ -1385,6 +1385,64 @@ pub async fn push_package_release( .map(|r| r.push_package_release) } +/// Yank (or, with `undo`, unyank) an explicit set of package versions by node +/// id. +/// +/// Requires package-admin rights. Returns only the versions whose yank state +/// changed, so a repeated yank comes back empty. +pub async fn yank_package_versions( + client: &WasmerClient, + package_version_ids: Vec, + reason: Option<&str>, + undo: bool, +) -> Result, anyhow::Error> { + client + .run_graphql_strict(types::YankPackageVersions::build( + types::YankPackageVersionsVariables { + package_version_ids, + reason, + undo: Some(undo), + }, + )) + .await + .map(|response| { + response + .yank_package_versions + .map(|payload| payload.package_versions) + .unwrap_or_default() + }) +} + +/// List every package version as `(node id, version)` pairs, including +/// superseded rebuilds, or `None` when the package doesn't exist. +pub async fn get_package_version_ids( + client: &WasmerClient, + name: String, +) -> Result>, anyhow::Error> { + let package = client + .run_graphql_strict(types::GetPackageVersionNumbers::build( + types::GetPackageVars { name }, + )) + .await? + .get_package; + + Ok(package.map(|p| { + p.versions + .unwrap_or_default() + .into_iter() + .flatten() + .flat_map(|version| { + std::iter::once((version.id, version.version)).chain( + version + .rebuilds + .into_iter() + .map(|rebuild| (rebuild.id, rebuild.version)), + ) + }) + .collect() + })) +} + #[allow(clippy::too_many_arguments)] pub async fn tag_package_release( client: &WasmerClient, @@ -2172,7 +2230,10 @@ pub async fn get_package_version_numbers( .unwrap_or_default() .into_iter() .flatten() - .map(|v| v.version) + .flat_map(|version| { + std::iter::once(version.version) + .chain(version.rebuilds.into_iter().map(|rebuild| rebuild.version)) + }) .collect() })) } diff --git a/lib/backend-api/src/types.rs b/lib/backend-api/src/types.rs index e7357110c263..36e809154202 100644 --- a/lib/backend-api/src/types.rs +++ b/lib/backend-api/src/types.rs @@ -210,6 +210,9 @@ mod queries { pub pirita_manifest: Option, pub package: Package, + pub yanked_at: Option, + pub yank_reason: Option, + #[arguments(version: "V3")] #[cynic(rename = "distribution")] pub distribution_v3: PackageDistribution, @@ -457,6 +460,15 @@ mod queries { #[derive(cynic::QueryFragment, Debug)] #[cynic(graphql_type = "PackageVersion")] pub struct PackageVersionNumber { + pub id: cynic::Id, + pub version: String, + pub rebuilds: Vec, + } + + #[derive(cynic::QueryFragment, Debug)] + #[cynic(graphql_type = "PackageVersion")] + pub struct PackageVersionRebuildNumber { + pub id: cynic::Id, pub version: String, } @@ -529,6 +541,33 @@ mod queries { pub package_version: Option, } + #[derive(cynic::QueryVariables, Debug)] + pub struct YankPackageVersionsVariables<'a> { + pub package_version_ids: Vec, + pub reason: Option<&'a str>, + pub undo: Option, + } + + #[derive(cynic::QueryFragment, Debug)] + #[cynic(graphql_type = "Mutation", variables = "YankPackageVersionsVariables")] + pub struct YankPackageVersions { + #[arguments(input: { packageVersionIds: $package_version_ids, reason: $reason, undo: $undo })] + pub yank_package_versions: Option, + } + + #[derive(cynic::QueryFragment, Debug)] + pub struct YankPackageVersionsPayload { + pub package_versions: Vec, + } + + #[derive(cynic::QueryFragment, Debug, Clone)] + #[cynic(graphql_type = "PackageVersion")] + pub struct YankedPackageVersion { + pub version: String, + pub yanked_at: Option, + pub yank_reason: Option, + } + #[derive(cynic::InputObject, Debug)] pub struct InputSignature<'a> { pub public_key_key_id: &'a str, diff --git a/lib/cli/src/commands/mod.rs b/lib/cli/src/commands/mod.rs index a55ec92d4753..eb8c4cadcc9f 100644 --- a/lib/cli/src/commands/mod.rs +++ b/lib/cli/src/commands/mod.rs @@ -202,6 +202,7 @@ impl WasmerCmd { Package::Unpack(cmd) => cmd.execute(), Package::Search(cmd) => cmd.run(), Package::Get(cmd) => cmd.run(), + Package::Yank(cmd) => cmd.run(), }, Some(Cmd::Container(cmd)) => match cmd { crate::commands::Container::Unpack(cmd) => cmd.execute(), diff --git a/lib/cli/src/commands/package/common/mod.rs b/lib/cli/src/commands/package/common/mod.rs index 13c7775a36db..cb53a08158ca 100644 --- a/lib/cli/src/commands/package/common/mod.rs +++ b/lib/cli/src/commands/package/common/mod.rs @@ -277,8 +277,9 @@ pub(super) fn package_web_url( /// Adapter over [`package_web_url`] for a [`NamedPackageIdent`]. pub(super) fn package_web_url_for_ident(client: &WasmerClient, pkg: &NamedPackageIdent) -> String { - // `*` when no version; an exact requirement renders as `=x.y.z`. - let version = pkg.version_or_default().to_string().replace('=', ""); + // `*` when no version; an exact requirement renders as `=x.y.z`; an + // exact-build pin keeps its `+wasix.N` build metadata. + let version = pkg.version_string().replace('=', ""); package_web_url(client, &pkg.full_name(), Some(&version)) } diff --git a/lib/cli/src/commands/package/download.rs b/lib/cli/src/commands/package/download.rs index e6adb9f51f84..834b5510d23d 100644 --- a/lib/cli/src/commands/package/download.rs +++ b/lib/cli/src/commands/package/download.rs @@ -133,11 +133,11 @@ impl PackageDownload { // produce an authenticated client. let client = self.env.client_unauthennticated()?; - let version = id.version_or_default().to_string(); + let version = id.version_string(); let version = if version == "*" { String::from("latest") } else { - version.to_string() + version }; let full_name = id.full_name(); @@ -155,6 +155,19 @@ impl PackageDownload { ) })?; + // A yanked version still downloads when pinned exactly, so warn and continue. + if package.yanked_at.is_some() { + match package.yank_reason.as_deref() { + Some(reason) => eprintln!( + "warning: {}@{} has been yanked: {reason}", + full_name, package.version + ), + None => { + eprintln!("warning: {}@{} has been yanked", full_name, package.version) + } + } + } + let download_url = package .distribution_v3 .pirita_download_url diff --git a/lib/cli/src/commands/package/get.rs b/lib/cli/src/commands/package/get.rs index 2b1c5e4441c1..6f4fe0765173 100644 --- a/lib/cli/src/commands/package/get.rs +++ b/lib/cli/src/commands/package/get.rs @@ -125,7 +125,7 @@ impl PackageGet { PackageSource::Ident(PackageIdent::Named(id)) => { let client = self.env.client_unauthennticated()?; - let version = id.version_or_default().to_string(); + let version = id.version_string(); let version = if version == "*" { String::from("latest") } else { diff --git a/lib/cli/src/commands/package/mod.rs b/lib/cli/src/commands/package/mod.rs index 052706fb765a..16fdafeaabdf 100644 --- a/lib/cli/src/commands/package/mod.rs +++ b/lib/cli/src/commands/package/mod.rs @@ -8,6 +8,7 @@ mod search; mod tag; mod tree; mod unpack; +mod yank; pub use build::PackageBuild; pub use common::wait::PublishWait; @@ -28,4 +29,5 @@ pub enum Package { Unpack(unpack::PackageUnpack), Search(search::PackageSearch), Get(get::PackageGet), + Yank(yank::PackageYank), } diff --git a/lib/cli/src/commands/package/tree.rs b/lib/cli/src/commands/package/tree.rs index 8913a924a158..afca2cf0b9f7 100644 --- a/lib/cli/src/commands/package/tree.rs +++ b/lib/cli/src/commands/package/tree.rs @@ -179,6 +179,7 @@ fn is_fixed_to_resolved(specified: &PackageSource, resolved_id: &PackageId) -> b match &specified.tag { Some(Tag::Named(tag)) => tag == &resolved.version.to_string(), Some(Tag::VersionReq(req)) => version_req_is_exact(req, &resolved.version), + Some(Tag::ExactBuild(v)) => &resolved.version == v, None => false, } } diff --git a/lib/cli/src/commands/package/yank.rs b/lib/cli/src/commands/package/yank.rs new file mode 100644 index 000000000000..b6014a80f2b9 --- /dev/null +++ b/lib/cli/src/commands/package/yank.rs @@ -0,0 +1,284 @@ +//! Yank packages from the registry. + +use wasmer_backend_api::{WasmerClient, types::Id}; +use wasmer_sdk::package::yank::{YankOptions, YankedPackageVersion, yank_package_versions}; + +use crate::{commands::AsyncCliCommand, config::WasmerEnv}; + +/// Split `ns/pkg@selector` into the package name and version selector. +/// +/// Looks for the `@` after the last `/`, so a namespace is never mistaken for +/// a version. An empty selector is rejected. +fn split_package_and_selector(spec: &str) -> Result<(&str, &str), anyhow::Error> { + let name_end = spec.rfind('/').map(|index| index + 1).unwrap_or(0); + let Some(at) = spec[name_end..].find('@').map(|index| index + name_end) else { + anyhow::bail!( + "`{spec}` does not specify a version. Pass an exact version \ + (`{spec}@1.2.3`) or a semver range (`{spec}@'>=1.0, <1.3'`)." + ); + }; + + let (name, selector) = (&spec[..at], &spec[at + 1..]); + if name.is_empty() { + anyhow::bail!("`{spec}` does not name a package."); + } + if selector.is_empty() { + anyhow::bail!( + "`{spec}` has an empty version selector. Pass an exact version \ + or a semver range." + ); + } + Ok((name, selector)) +} + +fn render_versions(versions: &[YankedPackageVersion]) -> String { + versions + .iter() + .map(|version| version.version.as_str()) + .collect::>() + .join(", ") +} + +/// Match `selector` against a package's `(id, version)` list, returning the ids +/// to yank. +/// +/// A fully specified version (`X.Y.Z`) yanks only that exact version, even when +/// it is absent, so a mistyped version yanks nothing rather than widening to a +/// range. Anything else is a semver range, so `1.2` becomes `^1.2`. +fn match_selector<'a, I>(versions: I, selector: &str) -> Result, anyhow::Error> +where + I: IntoIterator, +{ + let versions: Vec<(&Id, &str)> = versions.into_iter().collect(); + + if semver::Version::parse(selector).is_ok() { + return Ok(versions + .iter() + .filter(|(_, version)| *version == selector) + .map(|(id, _)| (*id).clone()) + .collect()); + } + + let req = semver::VersionReq::parse(selector) + .map_err(|err| anyhow::anyhow!("`{selector}` is not a valid version or range: {err}"))?; + Ok(versions + .iter() + .filter(|(_, version)| { + semver::Version::parse(version) + .map(|parsed| req.matches(&parsed)) + .unwrap_or(false) + }) + .map(|(id, _)| (*id).clone()) + .collect()) +} + +/// Resolve `ns/pkg@` into the version node ids to yank by listing the +/// package's versions and matching `selector` locally. +async fn resolve_version_ids( + client: &WasmerClient, + package_name: &str, + selector: &str, +) -> Result, anyhow::Error> { + let versions = + wasmer_backend_api::query::get_package_version_ids(client, package_name.to_string()) + .await? + .ok_or_else(|| anyhow::anyhow!("Package '{package_name}' was not found."))?; + match_selector( + versions.iter().map(|(id, version)| (id, version.as_str())), + selector, + ) +} + +/// Yank a package version, or a range of versions, from the registry. +/// +/// A yanked version is still downloadable when pinned exactly, so existing +/// lockfiles keep working. It is skipped by `latest` and by semver-range +/// resolution. Pass `--undo` to reverse a yank. +#[derive(clap::Parser, Debug)] +pub struct PackageYank { + #[clap(flatten)] + env: WasmerEnv, + + /// Why the version is being yanked. Shown to users who still pin it. + #[clap(long)] + reason: Option, + + /// Restore previously yanked versions instead of yanking them. + #[clap(long)] + undo: bool, + + /// The package and version to yank, as `/@`. + /// + /// The version may be exact (`ns/pkg@1.2.3`) or a semver range + /// (`ns/pkg@'>=1.0, <1.3'`), in which case every matching version is + /// yanked. + package: String, +} + +#[async_trait::async_trait] +impl AsyncCliCommand for PackageYank { + type Output = (); + + async fn run_async(self) -> Result<(), anyhow::Error> { + let (package_name, version_selector) = split_package_and_selector(&self.package)?; + let client = self.env.client()?; + let action = if self.undo { "unyank" } else { "yank" }; + + // Selector->ids expansion is client-side (see `match_selector`). + let version_ids = resolve_version_ids(&client, package_name, version_selector).await?; + if version_ids.is_empty() { + eprintln!("No versions of '{package_name}' match '{version_selector}'."); + return Ok(()); + } + + let versions = yank_package_versions( + &client, + YankOptions { + version_ids, + reason: self.reason.clone(), + undo: self.undo, + }, + ) + .await?; + + if versions.is_empty() { + eprintln!( + "The matching versions of '{package_name}' were already in that state; \ + nothing to {action}." + ); + return Ok(()); + } + + eprintln!( + "{}ed {} of '{package_name}': {}", + // Capitalised for the summary line. + if self.undo { "Unyank" } else { "Yank" }, + if versions.len() == 1 { + "1 version".to_string() + } else { + format!("{} versions", versions.len()) + }, + render_versions(&versions), + ); + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn versions() -> Vec<(Id, String)> { + [("pkv_1", "1.0.0"), ("pkv_2", "1.1.0"), ("pkv_3", "2.0.0")] + .into_iter() + .map(|(id, v)| (Id::new(id), v.to_string())) + .collect() + } + + fn match_ids(selector: &str) -> Vec { + let versions = versions(); + match_selector(versions.iter().map(|(id, v)| (id, v.as_str())), selector) + .expect("selector parses") + .into_iter() + .map(|id| id.into_inner()) + .collect() + } + + /// A full version yanks that version and only that version. + #[test] + fn an_exact_version_yanks_only_that_version() { + assert_eq!(match_ids("1.0.0"), vec!["pkv_1"]); + } + + /// A full version never widens to a range, so an absent one yanks nothing + /// instead of everything matching its caret. + #[test] + fn an_absent_exact_version_yanks_nothing() { + assert!(match_ids("1.0.5").is_empty()); + } + + #[test] + fn an_exact_build_can_select_a_superseded_rebuild() { + let versions = [ + (Id::new("old"), "1.0.0+wasix.2".to_string()), + (Id::new("current"), "1.0.0+wasix.11".to_string()), + ]; + let ids = match_selector( + versions.iter().map(|(id, version)| (id, version.as_str())), + "1.0.0+wasix.2", + ) + .expect("selector parses"); + + assert_eq!(ids, vec![Id::new("old")]); + } + + /// A non-exact selector is a range, so it can yank several versions. + #[test] + fn a_range_yanks_every_matching_version() { + assert_eq!(match_ids(">=1.0.0, <2.0.0"), vec!["pkv_1", "pkv_2"]); + } + + #[test] + fn an_unparsable_selector_is_rejected() { + let versions = versions(); + let err = match_selector( + versions.iter().map(|(id, v)| (id, v.as_str())), + "not a selector", + ) + .expect_err("invalid selector"); + assert!( + err.to_string().contains("not a valid version or range"), + "unexpected error: {err}" + ); + } + + #[test] + fn splits_a_package_and_version() { + assert_eq!( + split_package_and_selector("wasmer/bash@1.2.3").expect("splits"), + ("wasmer/bash", "1.2.3") + ); + assert_eq!( + split_package_and_selector("bash@1.2.3").expect("splits"), + ("bash", "1.2.3") + ); + } + + /// The version separator is the `@` after the last path segment, so an `@` + /// earlier in the spec stays part of the name. + #[test] + fn only_the_last_segment_at_separates_the_version() { + assert_eq!( + split_package_and_selector("some@owner/bash@1.2.3").expect("splits"), + ("some@owner/bash", "1.2.3") + ); + } + + #[test] + fn a_spec_without_a_version_is_rejected() { + let err = split_package_and_selector("wasmer/bash").expect_err("no version"); + assert!( + err.to_string().contains("does not specify a version"), + "unexpected error: {err}" + ); + } + + #[test] + fn an_empty_version_is_rejected() { + let err = split_package_and_selector("wasmer/bash@").expect_err("empty version"); + assert!( + err.to_string().contains("empty version selector"), + "unexpected error: {err}" + ); + } + + #[test] + fn an_empty_package_name_is_rejected() { + let err = split_package_and_selector("@1.2.3").expect_err("no package"); + assert!( + err.to_string().contains("does not name a package"), + "unexpected error: {err}" + ); + } +} diff --git a/lib/config/src/package/named_package_ident.rs b/lib/config/src/package/named_package_ident.rs index e303135017d2..23a73f9b56be 100644 --- a/lib/config/src/package/named_package_ident.rs +++ b/lib/config/src/package/named_package_ident.rs @@ -8,6 +8,9 @@ use super::{NamedPackageId, PackageParseError}; pub enum Tag { Named(String), VersionReq(semver::VersionReq), + /// A fully specified version pinned to a rebuild by its `+wasix.N` build + /// metadata (`1.2.3+wasix.2`), matched exactly including the build metadata. + ExactBuild(semver::Version), } impl Tag { @@ -33,6 +36,7 @@ impl std::fmt::Display for Tag { match self { Tag::Named(n) => n.fmt(f), Tag::VersionReq(v) => v.fmt(f), + Tag::ExactBuild(v) => v.fmt(f), } } } @@ -42,26 +46,32 @@ impl std::str::FromStr for Tag { fn from_str(s: &str) -> Result { if s == "latest" { - Ok(Self::VersionReq(semver::VersionReq::STAR)) - } else { - match semver::VersionReq::from_str(s) { - Ok(v) => { - // A successful `VersionReq` parse silently drops any build metadata: a - // `Comparator` has no build field and matching ignores it, so `1.2.3+rel.1` - // would behave as `^1.2.3`. Within this arm a `+` can only be dropped build - // metadata, so reject it. - if s.contains('+') { - return Err(PackageParseError::new( - s, - "build metadata (`+...`) is not supported in a version requirement: \ - it is ignored when matching versions and cannot select a specific \ - build, so pin an exact version or a package hash instead", - )); - } - Ok(Self::VersionReq(v)) + return Ok(Self::VersionReq(semver::VersionReq::STAR)); + } + // A fully specified version carrying build metadata (`1.2.3+wasix.2`) + // pins a specific rebuild. A `VersionReq` can't express it (a + // `Comparator` has no build field and matching ignores it), so match the + // exact version instead. + if let Ok(version) = semver::Version::from_str(s) + && !version.build.is_empty() + { + return Ok(Self::ExactBuild(version)); + } + match semver::VersionReq::from_str(s) { + Ok(v) => { + // Any remaining `+` is build metadata on something that isn't a + // plain version (e.g. a range), where it is silently dropped, so + // reject it rather than mislead. + if s.contains('+') { + return Err(PackageParseError::new( + s, + "build metadata (`+...`) is only valid on a fully specified version \ + (e.g. `1.2.3+wasix.2`) that pins an exact rebuild", + )); } - Err(_) => Ok(Self::Named(s.to_string())), + Ok(Self::VersionReq(v)) } + Err(_) => Ok(Self::Named(s.to_string())), } } } @@ -119,17 +129,48 @@ impl NamedPackageIdent { pub fn version_opt(&self) -> Option<&VersionReq> { match &self.tag { Some(Tag::VersionReq(v)) => Some(v), - Some(Tag::Named(_)) | None => None, + Some(Tag::Named(_)) | Some(Tag::ExactBuild(_)) | None => None, } } pub fn version_or_default(&self) -> VersionReq { match &self.tag { Some(Tag::VersionReq(v)) => v.clone(), + // `VersionReq` can't carry build metadata, so an exact-build pin + // falls back to its core version here; exact resolution goes through + // `matches_version`. + Some(Tag::ExactBuild(v)) => { + let mut core = v.clone(); + core.build = semver::BuildMetadata::EMPTY; + VersionReq::from_str(&format!("={core}")).unwrap_or(semver::VersionReq::STAR) + } Some(Tag::Named(_)) | None => semver::VersionReq::STAR, } } + /// The version string that addresses this ident in a registry URL or query. + /// A range renders as its requirement (`^1.2.3`, `=1.2.3`, `*`); an + /// exact-build pin renders as the full version including its build metadata + /// (`1.2.3+wasix.2`), which a `VersionReq` would otherwise drop. + pub fn version_string(&self) -> String { + match &self.tag { + Some(Tag::ExactBuild(v)) => v.to_string(), + _ => self.version_or_default().to_string(), + } + } + + /// Whether `version` satisfies this ident's tag. A version-req tag matches by + /// SemVer range (build metadata ignored); an exact-build tag matches only + /// that exact version, build metadata included; a name tag or no tag matches + /// any version. + pub fn matches_version(&self, version: &semver::Version) -> bool { + match &self.tag { + Some(Tag::VersionReq(req)) => req.matches(version), + Some(Tag::ExactBuild(v)) => version == v, + Some(Tag::Named(_)) | None => true, + } + } + pub fn registry_url(&self) -> Result, PackageParseError> { let Some(reg) = &self.registry else { return Ok(None); @@ -198,6 +239,7 @@ impl NamedPackageIdent { match tag { Tag::Named(n) => n == &id.version.to_string(), Tag::VersionReq(v) => v.matches(&id.version), + Tag::ExactBuild(v) => id.version == *v, } } else { true @@ -453,37 +495,41 @@ mod tests { } #[test] - fn test_reject_build_metadata_in_version_req() { - // Build metadata silently drops out of a `VersionReq`, so it must be rejected. - assert!( - Tag::from_str("1.2.3+rel.1").is_err(), - "build metadata must be rejected in a version requirement" + fn build_metadata_pins_an_exact_build() { + // A fully specified version with build metadata pins that exact rebuild. + assert_eq!( + Tag::from_str("1.2.3+wasix.2").unwrap(), + Tag::ExactBuild(semver::Version::from_str("1.2.3+wasix.2").unwrap()), ); - assert!( - NamedPackageIdent::from_str("ns/name@1.2.3+rel.1").is_err(), - "a package ident with build metadata in its version must be rejected" + assert_eq!( + NamedPackageIdent::from_str("ns/name@1.2.3+wasix.2") + .unwrap() + .tag, + Some(Tag::ExactBuild( + semver::Version::from_str("1.2.3+wasix.2").unwrap() + )), ); - // A plain version requirement (no build metadata) still parses. + // A bare version is a caret range, not an exact build. assert_eq!( NamedPackageIdent::from_str("ns/name@1.2.3").unwrap().tag, Some(Tag::VersionReq(VersionReq::from_str("1.2.3").unwrap())), ); - // A `+` in a string that is not a valid version requirement still parses as a named tag. - assert_eq!( - Tag::from_str("my+tag").unwrap(), - Tag::Named("my+tag".to_string()), - ); - - // The rejection also surfaces through `PackageSource`, with the specific - // message rather than a generic "invalid package ident" error. - let err = crate::package::PackageSource::from_str("ns/name@1.2.3+rel.1") - .expect_err("build metadata must be rejected via PackageSource too"); + // Build metadata on a range (not a plain version) is rejected, since a + // `VersionReq` would silently drop it. + let err = crate::package::PackageSource::from_str("ns/name@^1.2.3+build") + .expect_err("build metadata on a range must be rejected"); assert!( err.to_string().contains("build metadata"), "user-facing error should explain the build metadata problem, got: {err}" ); + + // A `+` in a string that is not a valid version still parses as a named tag. + assert_eq!( + Tag::from_str("my+tag").unwrap(), + Tag::Named("my+tag".to_string()), + ); } #[test] diff --git a/lib/sdk/src/package/mod.rs b/lib/sdk/src/package/mod.rs index ec52049e3b1e..d46d3975d38a 100644 --- a/lib/sdk/src/package/mod.rs +++ b/lib/sdk/src/package/mod.rs @@ -2,3 +2,4 @@ pub mod publish; pub mod search; +pub mod yank; diff --git a/lib/sdk/src/package/yank.rs b/lib/sdk/src/package/yank.rs new file mode 100644 index 000000000000..06a1be2b2074 --- /dev/null +++ b/lib/sdk/src/package/yank.rs @@ -0,0 +1,35 @@ +//! Yank packages from the Wasmer registry. + +use wasmer_backend_api::WasmerClient; + +pub use wasmer_backend_api::types::YankedPackageVersion; + +/// Options for [`yank_package_versions`]. +#[derive(Debug, Clone, Default)] +pub struct YankOptions { + /// The exact `PackageVersion` node ids to act on. All must belong to the + /// same package. + pub version_ids: Vec, + /// Why the versions were yanked. Shown to anyone still pinning them. + pub reason: Option, + /// Unyank the given versions when set. + pub undo: bool, +} + +/// Yank (or unyank) an explicit set of package versions by node id. +/// +/// Requires package-admin rights. Returns only the versions whose yank state +/// changed, so re-yanking an already-yanked version comes back empty. +pub async fn yank_package_versions( + client: &WasmerClient, + opts: YankOptions, +) -> Result, anyhow::Error> { + let YankOptions { + version_ids, + reason, + undo, + } = opts; + + wasmer_backend_api::query::yank_package_versions(client, version_ids, reason.as_deref(), undo) + .await +} diff --git a/lib/wasix/src/runtime/resolver/backend_source.rs b/lib/wasix/src/runtime/resolver/backend_source.rs index f0d18d6fe4ca..4ec63fa4120d 100644 --- a/lib/wasix/src/runtime/resolver/backend_source.rs +++ b/lib/wasix/src/runtime/resolver/backend_source.rs @@ -8,7 +8,9 @@ use anyhow::{Context, Error}; use http::{HeaderMap, Method}; use semver::{Version, VersionReq}; use url::Url; -use wasmer_config::package::{NamedPackageId, PackageHash, PackageId, PackageIdent, PackageSource}; +use wasmer_config::package::{ + NamedPackageId, NamedPackageIdent, PackageHash, PackageId, PackageIdent, PackageSource, Tag, +}; use webc::metadata::Manifest; use crate::{ @@ -234,11 +236,10 @@ impl BackendSource { impl Source for BackendSource { #[tracing::instrument(level = "debug", skip_all, fields(%package))] async fn query(&self, package: &PackageSource) -> Result, QueryError> { - let (package_name, version_constraint) = match package { - PackageSource::Ident(PackageIdent::Named(n)) => ( - n.full_name(), - n.version_opt().cloned().unwrap_or(semver::VersionReq::STAR), - ), + let (package_name, matcher) = match package { + PackageSource::Ident(PackageIdent::Named(n)) => { + (n.full_name(), VersionMatcher::for_ident(n)) + } PackageSource::Ident(PackageIdent::Hash(hash)) => { // TODO: implement caching! match self.query_by_hash(hash).await { @@ -246,7 +247,7 @@ impl Source for BackendSource { Ok(None) => { return Err(QueryError::NoMatches { query: package.clone(), - archived_versions: Vec::new(), + yanked_versions: Vec::new(), }); } Err(error) => { @@ -267,7 +268,7 @@ impl Source for BackendSource { if let Ok(cached) = matching_package_summaries( package, cached, - &version_constraint, + &matcher, self.preferred_webc_version, ) { tracing::debug!("Cache hit!"); @@ -300,20 +301,72 @@ impl Source for BackendSource { ); } - matching_package_summaries( - package, - response, - &version_constraint, - self.preferred_webc_version, - ) + matching_package_summaries(package, response, &matcher, self.preferred_webc_version) + } +} + +/// How a named query selects among a package's versions. +enum VersionMatcher { + /// A SemVer range; build metadata is ignored, so `@1.2.3` matches every + /// build of 1.2.3 and the caller picks the highest. + Range(VersionReq), + /// A specific rebuild pinned by build metadata (`1.2.3+wasix.2`), matched + /// exactly including the build metadata. + ExactBuild(Version), +} + +impl VersionMatcher { + fn for_ident(ident: &NamedPackageIdent) -> Self { + match &ident.tag { + Some(Tag::ExactBuild(v)) => VersionMatcher::ExactBuild(v.clone()), + Some(Tag::VersionReq(req)) => VersionMatcher::Range(req.clone()), + Some(Tag::Named(_)) | None => VersionMatcher::Range(VersionReq::STAR), + } + } + + fn matches(&self, version: &Version) -> bool { + match self { + VersionMatcher::Range(req) => req.matches(version), + VersionMatcher::ExactBuild(target) => version == target, + } + } + + /// The single version this pins exactly, if any. A yanked version still + /// resolves when pinned this way (with a warning). + fn exact_pin(&self) -> Option { + match self { + VersionMatcher::Range(req) => exact_pinned_version(req), + VersionMatcher::ExactBuild(v) => Some(v.clone()), + } } } +/// If `req` is an exact pin of a single fully-specified version (`=X.Y.Z`), +/// return that version; otherwise `None`. +/// +/// A bare `X.Y.Z` parses to a caret requirement, so only the `=` prefix counts +/// as an exact pin. +fn exact_pinned_version(req: &VersionReq) -> Option { + let [comparator] = req.comparators.as_slice() else { + return None; + }; + if comparator.op != semver::Op::Exact { + return None; + } + Some(Version { + major: comparator.major, + minor: comparator.minor?, + patch: comparator.patch?, + pre: comparator.pre.clone(), + build: semver::BuildMetadata::EMPTY, + }) +} + #[allow(clippy::result_large_err)] fn matching_package_summaries( query: &PackageSource, response: WebQuery, - version_constraint: &VersionReq, + matcher: &VersionMatcher, preferred_webc_version: webc::Version, ) -> Result, QueryError> { let mut summaries = Vec::new(); @@ -329,7 +382,10 @@ fn matching_package_summaries( .ok_or_else(|| QueryError::NotFound { query: query.clone(), })?; - let mut archived_versions = Vec::new(); + // A yanked version is skipped for range and `latest` resolution. An exact + // pin still resolves it. + let exact_pin = matcher.exact_pin(); + let mut yanked_versions = Vec::new(); for pkg_version in versions { let version = match Version::parse(&pkg_version.version) { @@ -344,30 +400,46 @@ fn matching_package_summaries( } }; - if pkg_version.is_archived { - tracing::debug!( - pkg.version=%version, - "Skipping an archived version", - ); - archived_versions.push(version); + if !matcher.matches(&version) { continue; } - if version_constraint.matches(&version) { - match decode_summary( - &namespace, - &package_name, - pkg_version, - preferred_webc_version, - ) { - Ok(summary) => summaries.push(summary), - Err(e) => { - tracing::debug!( - version=%version, - error=&*e, - "Skipping version because its metadata couldn't be parsed" - ); + if pkg_version.yanked_at.is_some() { + if exact_pin.as_ref() == Some(&version) { + match &pkg_version.yank_reason { + Some(reason) => tracing::warn!( + package = %format_args!("{namespace}/{package_name}@{version}"), + %reason, + "Resolving an exactly pinned yanked package version", + ), + None => tracing::warn!( + package = %format_args!("{namespace}/{package_name}@{version}"), + "Resolving an exactly pinned yanked package version", + ), } + } else { + tracing::debug!( + pkg.version=%version, + "Skipping a yanked version for a non-exact constraint", + ); + yanked_versions.push(version); + continue; + } + } + + match decode_summary( + &namespace, + &package_name, + pkg_version, + preferred_webc_version, + ) { + Ok(summary) => summaries.push(summary), + Err(e) => { + tracing::debug!( + version=%version, + error=&*e, + "Skipping version because its metadata couldn't be parsed" + ); } } } @@ -375,7 +447,7 @@ fn matching_package_summaries( if summaries.is_empty() { Err(QueryError::NoMatches { query: query.clone(), - archived_versions, + yanked_versions, }) } else { Ok(summaries) @@ -593,7 +665,8 @@ pub const WASMER_WEBC_QUERY_ALL: &str = r#"{ namespace versions { version - isArchived + yankedAt + yankReason v2: distribution(version: V2) { piritaDownloadUrl piritaSha256Hash @@ -611,10 +684,10 @@ pub const WASMER_WEBC_QUERY_ALL: &str = r#"{ } }"#; +// A content-digest pin always resolves, so this query never inspects yank state. pub const WASMER_WEBC_QUERY_BY_HASH: &str = r#"{ getPackageRelease(hash: "$HASH") { piritaManifest - isArchived webcUrl } }"#; @@ -634,8 +707,6 @@ struct GetPackageRelease { struct PackageWebc { #[serde(rename = "piritaManifest")] pub pirita_manifest: String, - #[serde(rename = "isArchived")] - pub is_archived: bool, #[serde(rename = "webcUrl")] pub webc_url: url::Url, } @@ -691,9 +762,10 @@ pub struct WebQueryGetPackage { #[derive(Debug, serde::Serialize, serde::Deserialize, Clone)] pub struct WebQueryGetPackageVersion { pub version: String, - /// Has the package been archived? - #[serde(rename = "isArchived", default)] - pub is_archived: bool, + #[serde(rename = "yankedAt", default)] + pub yanked_at: Option, + #[serde(rename = "yankReason", default)] + pub yank_reason: Option, pub v2: WebQueryGetPackageVersionDistribution, pub v3: WebQueryGetPackageVersionDistribution, } @@ -744,7 +816,7 @@ mod tests { // -d '@wasmer_pack_cli_request.json' > wasmer_pack_cli_response.json const WASMER_PACK_CLI_REQUEST: &[u8] = br#" { - "query":"{\n getPackage(name: \"wasmer/wasmer-pack-cli\") {\n packageName\n namespace\n versions {\n version\n isArchived\n v2: distribution(version: V2) {\n piritaDownloadUrl\n piritaSha256Hash\n webcManifest\n }\n v3: distribution(version: V3) {\n piritaDownloadUrl\n piritaSha256Hash\n webcManifest\n }\n }\n }\n info {\n defaultFrontend\n }\n}" + "query":"{\n getPackage(name: \"wasmer/wasmer-pack-cli\") {\n packageName\n namespace\n versions {\n version\n yankedAt\n yankReason\n v2: distribution(version: V2) {\n piritaDownloadUrl\n piritaSha256Hash\n webcManifest\n }\n v3: distribution(version: V3) {\n piritaDownloadUrl\n piritaSha256Hash\n webcManifest\n }\n }\n }\n info {\n defaultFrontend\n }\n}" } "#; const WASMER_PACK_CLI_RESPONSE: &[u8] = br#" @@ -756,7 +828,7 @@ mod tests { "versions": [ { "version": "0.7.1", - "isArchived": false, + "yankedAt": null, "v2": { "webcManifest": "{\"atoms\": {\"wasmer-pack\": {\"kind\": \"https://webc.org/kind/wasm\", \"signature\": \"sha256:gGeLZqPitpg893Jj/nvGa+1235RezSWA9FjssopzOZY=\"}}, \"package\": {\"wapm\": {\"name\": \"wasmer/wasmer-pack-cli\", \"readme\": {\"path\": \"README.md\", \"volume\": \"metadata\"}, \"license\": \"MIT\", \"version\": \"0.7.1\", \"homepage\": \"https://wasmer.io/\", \"repository\": \"https://github.com/wasmerio/wasmer-pack\", \"description\": \"A code generator that lets you treat WebAssembly modules like native dependencies.\"}}, \"commands\": {\"wasmer-pack\": {\"runner\": \"https://webc.org/runner/wasi/command@unstable_\", \"annotations\": {\"wasi\": {\"atom\": \"wasmer-pack\", \"package\": \"wasmer/wasmer-pack-cli\", \"main_args\": null}}}}, \"entrypoint\": \"wasmer-pack\"}", "piritaDownloadUrl": "https://storage.googleapis.com/wapm-registry-prod/webc/wasmer/wasmer-pack-cli/0.7.1/wasmer-pack-cli-0.7.1.webc", @@ -770,7 +842,7 @@ mod tests { }, { "version": "0.7.0", - "isArchived": false, + "yankedAt": null, "v2": { "webcManifest": "{\"atoms\": {\"wasmer-pack\": {\"kind\": \"https://webc.org/kind/wasm\", \"signature\": \"sha256:FesCIAS6URjrIAAyy4G5u5HjJjGQBLGmnafjHPHRvqo=\"}}, \"package\": {\"wapm\": {\"name\": \"wasmer/wasmer-pack-cli\", \"readme\": {\"path\": \"/home/consulting/Documents/wasmer/wasmer-pack/crates/cli/../../README.md\", \"volume\": \"metadata\"}, \"license\": \"MIT\", \"version\": \"0.7.0\", \"homepage\": \"https://wasmer.io/\", \"repository\": \"https://github.com/wasmerio/wasmer-pack\", \"description\": \"A code generator that lets you treat WebAssembly modules like native dependencies.\"}}, \"commands\": {\"wasmer-pack\": {\"runner\": \"https://webc.org/runner/wasi/command@unstable_\", \"annotations\": {\"wasi\": {\"atom\": \"wasmer-pack\", \"package\": \"wasmer/wasmer-pack-cli\", \"main_args\": null}}}}, \"entrypoint\": \"wasmer-pack\"}", "piritaDownloadUrl": "https://storage.googleapis.com/wapm-registry-prod/webc/wasmer/wasmer-pack-cli/0.7.0/wasmer-pack-cli-0.7.0.webc", @@ -784,7 +856,7 @@ mod tests { }, { "version": "0.6.0", - "isArchived": false, + "yankedAt": null, "v2": { "webcManifest": "{\"atoms\": {\"wasmer-pack\": {\"kind\": \"https://webc.org/kind/wasm\", \"signature\": \"sha256:CzzhNaav3gjBkCJECGbk7e+qAKurWbcIAzQvEqsr2Co=\"}}, \"package\": {\"wapm\": {\"name\": \"wasmer/wasmer-pack-cli\", \"readme\": {\"path\": \"/home/consulting/Documents/wasmer/wasmer-pack/crates/cli/../../README.md\", \"volume\": \"metadata\"}, \"license\": \"MIT\", \"version\": \"0.6.0\", \"homepage\": \"https://wasmer.io/\", \"repository\": \"https://github.com/wasmerio/wasmer-pack\", \"description\": \"A code generator that lets you treat WebAssembly modules like native dependencies.\"}}, \"commands\": {\"wasmer-pack\": {\"runner\": \"https://webc.org/runner/wasi/command@unstable_\", \"annotations\": {\"wasi\": {\"atom\": \"wasmer-pack\", \"package\": \"wasmer/wasmer-pack-cli\", \"main_args\": null}}}}, \"entrypoint\": \"wasmer-pack\"}", "piritaDownloadUrl": "https://storage.googleapis.com/wapm-registry-prod/webc/wasmer/wasmer-pack-cli/0.6.0/wasmer-pack-cli-0.6.0.webc", @@ -798,7 +870,7 @@ mod tests { }, { "version": "0.5.3", - "isArchived": false, + "yankedAt": null, "v2": { "webcManifest": "{\"atoms\": {\"wasmer-pack\": {\"kind\": \"https://webc.org/kind/wasm\", \"signature\": \"sha256:qdiJVfpi4icJXdR7Y5US/pJ4PjqbAq9PkU+obMZIMlE=\"}}, \"package\": {\"wapm\": {\"name\": \"wasmer/wasmer-pack-cli\", \"readme\": {\"path\": \"/home/runner/work/wasmer-pack/wasmer-pack/crates/cli/../../README.md\", \"volume\": \"metadata\"}, \"license\": \"MIT\", \"version\": \"0.5.3\", \"homepage\": \"https://wasmer.io/\", \"repository\": \"https://github.com/wasmerio/wasmer-pack\", \"description\": \"A code generator that lets you treat WebAssembly modules like native dependencies.\"}}, \"commands\": {\"wasmer-pack\": {\"runner\": \"https://webc.org/runner/wasi/command@unstable_\", \"annotations\": {\"wasi\": {\"atom\": \"wasmer-pack\", \"package\": \"wasmer/wasmer-pack-cli\", \"main_args\": null}}}}, \"entrypoint\": \"wasmer-pack\"}", "piritaDownloadUrl": "https://storage.googleapis.com/wapm-registry-prod/webc/wasmer/wasmer-pack-cli/0.5.3/wasmer-pack-cli-0.5.3.webc", @@ -812,7 +884,7 @@ mod tests { }, { "version": "0.5.2", - "isArchived": false, + "yankedAt": null, "v2": { "webcManifest": "{\"atoms\": {\"wasmer-pack\": {\"kind\": \"https://webc.org/kind/wasm\", \"signature\": \"sha256:xiwrUFAo+cU1xW/IE6MVseiyjNGHtXooRlkYKiOKzQc=\"}}, \"package\": {\"wapm\": {\"name\": \"wasmer/wasmer-pack-cli\", \"readme\": {\"path\": \"/home/consulting/Documents/wasmer/wasmer-pack/crates/cli/../../README.md\", \"volume\": \"metadata\"}, \"license\": \"MIT\", \"version\": \"0.5.2\", \"homepage\": \"https://wasmer.io/\", \"repository\": \"https://github.com/wasmerio/wasmer-pack\", \"description\": \"A code generator that lets you treat WebAssembly modules like native dependencies.\"}}, \"commands\": {\"wasmer-pack\": {\"runner\": \"https://webc.org/runner/wasi/command@unstable_\", \"annotations\": {\"wasi\": {\"atom\": \"wasmer-pack\", \"package\": \"wasmer/wasmer-pack-cli\", \"main_args\": null}}}}, \"entrypoint\": \"wasmer-pack\"}", "piritaDownloadUrl": "https://storage.googleapis.com/wapm-registry-prod/webc/wasmer/wasmer-pack-cli/0.5.2/wasmer-pack-cli-0.5.2.webc", @@ -826,7 +898,7 @@ mod tests { }, { "version": "0.5.1", - "isArchived": false, + "yankedAt": null, "v2": { "webcManifest": "{\"atoms\": {\"wasmer-pack\": {\"kind\": \"https://webc.org/kind/wasm\", \"signature\": \"sha256:TliPwutfkFvRite/3/k3OpLqvV0EBKGwyp3L5UjCuEI=\"}}, \"package\": {\"wapm\": {\"name\": \"wasmer/wasmer-pack-cli\", \"readme\": {\"path\": \"/home/runner/work/wasmer-pack/wasmer-pack/crates/cli/../../README.md\", \"volume\": \"metadata\"}, \"license\": \"MIT\", \"version\": \"0.5.1\", \"homepage\": \"https://wasmer.io/\", \"repository\": \"https://github.com/wasmerio/wasmer-pack\", \"description\": \"A code generator that lets you treat WebAssembly modules like native dependencies.\"}}, \"commands\": {\"wasmer-pack\": {\"runner\": \"https://webc.org/runner/wasi/command@unstable_\", \"annotations\": {\"wasi\": {\"atom\": \"wasmer-pack\", \"package\": \"wasmer/wasmer-pack-cli\", \"main_args\": null}}}}, \"entrypoint\": \"wasmer-pack\"}", "piritaDownloadUrl": "https://storage.googleapis.com/wapm-registry-prod/webc/wasmer/wasmer-pack-cli/0.5.1/wasmer-pack-cli-0.5.1.webc", @@ -840,7 +912,7 @@ mod tests { }, { "version": "0.5.0", - "isArchived": false, + "yankedAt": null, "v2": { "webcManifest": "{\"atoms\": {\"wasmer-pack\": {\"kind\": \"https://webc.org/kind/wasm\", \"signature\": \"sha256:6UD7NS4KtyNYa3TcnKOvd+kd3LxBCw+JQ8UWRpMXeC0=\"}}, \"package\": {\"wapm\": {\"name\": \"wasmer/wasmer-pack-cli\", \"readme\": {\"path\": \"README.md\", \"volume\": \"metadata\"}, \"license\": \"MIT\", \"version\": \"0.5.0\", \"homepage\": \"https://wasmer.io/\", \"repository\": \"https://github.com/wasmerio/wasmer-pack\", \"description\": \"A code generator that lets you treat WebAssembly modules like native dependencies.\"}}, \"commands\": {\"wasmer-pack\": {\"runner\": \"https://webc.org/runner/wasi/command@unstable_\", \"annotations\": {\"wasi\": {\"atom\": \"wasmer-pack\", \"package\": \"wasmer/wasmer-pack-cli\", \"main_args\": null}}}}, \"entrypoint\": \"wasmer-pack\"}", "piritaDownloadUrl": "https://storage.googleapis.com/wapm-registry-prod/webc/wasmer/wasmer-pack-cli/0.5.0/wasmer-pack-cli-0.5.0.webc", @@ -854,7 +926,7 @@ mod tests { }, { "version": "0.5.0-rc.1", - "isArchived": false, + "yankedAt": null, "v2": { "webcManifest": "{\"atoms\": {\"wasmer-pack\": {\"kind\": \"https://webc.org/kind/wasm\", \"signature\": \"sha256:ThybHIc2elJEcDdQiq5ffT1TVaNs70+WAqoKw4Tkh3E=\"}}, \"package\": {\"wapm\": {\"name\": \"wasmer/wasmer-pack-cli\", \"readme\": {\"path\": \"README.md\", \"volume\": \"metadata\"}, \"license\": \"MIT\", \"version\": \"0.5.0-rc.1\", \"homepage\": \"https://wasmer.io/\", \"repository\": \"https://github.com/wasmerio/wasmer-pack\", \"description\": \"A code generator that lets you treat WebAssembly modules like native dependencies.\"}}, \"commands\": {\"wasmer-pack\": {\"runner\": \"https://webc.org/runner/wasi/command@unstable_\", \"annotations\": {\"wasi\": {\"atom\": \"wasmer-pack\", \"package\": \"wasmer/wasmer-pack-cli\", \"main_args\": null}}}}, \"entrypoint\": \"wasmer-pack\"}", "piritaDownloadUrl": "https://storage.googleapis.com/wapm-registry-prod/webc/wasmer/wasmer-pack-cli/0.5.0-rc.1/wasmer-pack-cli-0.5.0-rc.1.webc", @@ -1050,7 +1122,7 @@ mod tests { } #[tokio::test] - async fn skip_archived_package_versions() { + async fn skip_yanked_package_versions_for_a_range() { let body = serde_json::json! { { "data": { @@ -1060,7 +1132,7 @@ mod tests { "versions": [ { "version": "3.12.2", - "isArchived": true, + "yankedAt": "2024-01-01T00:00:00Z", "v2": { "webcManifest": "{\"atoms\": {\"python\": {\"kind\": \"https://webc.org/kind/wasm\", \"signature\": \"sha256:ibsq6QL4qB4GtCE8IA2yfHVwI4fLoIGXsALsAx16y5M=\"}}, \"package\": {\"wapm\": {\"name\": \"wasmer/python\", \"license\": \"ISC\", \"version\": \"3.12.2\", \"repository\": \"https://github.com/wapm-packages/python\", \"description\": \"Python is an interpreted, high-level, general-purpose programming language\"}}, \"commands\": {\"python\": {\"runner\": \"https://webc.org/runner/wasi/command@unstable_\", \"annotations\": {\"wasi\": {\"atom\": \"python\", \"package\": null, \"main_args\": null}}}}, \"entrypoint\": \"python\"}", "piritaDownloadUrl": "https://storage.googleapis.com/wapm-registry-prod/packages/wasmer/python/python-3.12.0-build.5-a11e0414-c68d-473c-958f-fc96ef7adb20.webc", @@ -1074,7 +1146,7 @@ mod tests { }, { "version": "3.12.1", - "isArchived": false, + "yankedAt": null, "v2": { "webcManifest": "{\"atoms\": {\"python\": {\"kind\": \"https://webc.org/kind/wasm\", \"signature\": \"sha256:O36BXLHv3/80cABbAiF7gzuSHzzin1blTfJ42LDhT18=\"}}, \"package\": {\"wapm\": {\"name\": \"wasmer/python\", \"license\": \"ISC\", \"version\": \"3.12.1\", \"repository\": \"https://github.com/wapm-packages/python\", \"description\": \"Python is an interpreted, high-level, general-purpose programming language\"}}, \"commands\": {\"python\": {\"runner\": \"https://webc.org/runner/wasi/command@unstable_\", \"annotations\": {\"wasi\": {\"atom\": \"python\", \"package\": null, \"main_args\": null}}}}, \"entrypoint\": \"python\"}", "piritaDownloadUrl": "https://storage.googleapis.com/wapm-registry-prod/packages/wasmer/python/python-3.12.0-build.2-ed98c999-fcda-4f80-96dc-7c0f8be8baa6.webc", @@ -1088,7 +1160,7 @@ mod tests { }, { "version": "3.12.0", - "isArchived": true, + "yankedAt": "2024-01-01T00:00:00Z", "v2": { "webcManifest": "{\"atoms\": {\"python\": {\"kind\": \"https://webc.org/kind/wasm\", \"signature\": \"sha256:O36BXLHv3/80cABbAiF7gzuSHzzin1blTfJ42LDhT18=\"}}, \"package\": {\"wapm\": {\"name\": \"wasmer/python\", \"license\": \"ISC\", \"version\": \"3.12.0\", \"repository\": \"https://github.com/wapm-packages/python\", \"description\": \"Python is an interpreted, high-level, general-purpose programming language\"}}, \"commands\": {\"python\": {\"runner\": \"https://webc.org/runner/wasi/command@unstable_\", \"annotations\": {\"wasi\": {\"atom\": \"python\", \"package\": null, \"main_args\": null}}}}, \"entrypoint\": \"python\"}", "piritaDownloadUrl": "https://storage.googleapis.com/wapm-registry-prod/packages/wasmer/python/python-3.12.0-32065e5e-84fe-4483-a380-0aa750772a3a.webc", @@ -1128,6 +1200,145 @@ mod tests { ); } + fn python_versions_body() -> serde_json::Value { + serde_json::json! { + { + "data": { + "getPackage": { + "packageName": "python", + "namespace": "wasmer", + "versions": [ + { + "version": "3.12.1", + "yankedAt": "2024-01-01T00:00:00Z", + "yankReason": "broken build", + "v2": { + "webcManifest": "{\"package\": {\"wapm\": {\"name\": \"wasmer/python\", \"version\": \"3.12.1\", \"description\": \"Python\"}}}", + "piritaDownloadUrl": "https://wasmer.io/wasmer/python@3.12.1", + "piritaSha256Hash": "1111111111111111111111111111111111111111111111111111111111111111" + }, + "v3": { + "webcManifest": "{\"package\": {\"wapm\": {\"name\": \"wasmer/python\", \"version\": \"3.12.1\", \"description\": \"Python\"}}}", + "piritaDownloadUrl": "https://wasmer.io/wasmer/python@3.12.1", + "piritaSha256Hash": "1111111111111111111111111111111111111111111111111111111111111111" + } + }, + { + "version": "3.12.0", + "yankedAt": null, + "v2": { + "webcManifest": "{\"package\": {\"wapm\": {\"name\": \"wasmer/python\", \"version\": \"3.12.0\", \"description\": \"Python\"}}}", + "piritaDownloadUrl": "https://wasmer.io/wasmer/python@3.12.0", + "piritaSha256Hash": "0000000000000000000000000000000000000000000000000000000000000000" + }, + "v3": { + "webcManifest": "{\"package\": {\"wapm\": {\"name\": \"wasmer/python\", \"version\": \"3.12.0\", \"description\": \"Python\"}}}", + "piritaDownloadUrl": "https://wasmer.io/wasmer/python@3.12.0", + "piritaSha256Hash": "0000000000000000000000000000000000000000000000000000000000000000" + } + } + ] + }, + "info": { "defaultFrontend": "https://wasmer.io/" } + } + } + } + } + + fn python_source(body: serde_json::Value) -> BackendSource { + let response = HttpResponse { + body: Some(serde_json::to_vec(&body).unwrap()), + redirected: false, + status: StatusCode::OK, + headers: HeaderMap::new(), + }; + let client = Arc::new(DummyClient::new(vec![response])); + let registry_endpoint = BackendSource::WASMER_PROD_ENDPOINT.parse().unwrap(); + BackendSource::new(registry_endpoint, client) + } + + #[tokio::test] + async fn exact_pin_resolves_a_yanked_version() { + let source = python_source(python_versions_body()); + let request = PackageSource::from_str("wasmer/python@=3.12.1").unwrap(); + + let summaries = source.query(&request).await.unwrap(); + + assert_eq!(summaries.len(), 1); + assert_eq!( + summaries[0].pkg.id.as_named().unwrap().version.to_string(), + "3.12.1" + ); + } + + #[tokio::test] + async fn a_range_skips_the_yanked_version() { + let source = python_source(python_versions_body()); + let request = PackageSource::from_str("wasmer/python@^3.12").unwrap(); + + let summaries = source.query(&request).await.unwrap(); + + assert_eq!(summaries.len(), 1); + assert_eq!( + summaries[0].pkg.id.as_named().unwrap().version.to_string(), + "3.12.0" + ); + } + + #[tokio::test] + async fn a_range_matching_only_a_yanked_version_errors() { + let source = python_source(python_versions_body()); + // A range that only the yanked 3.12.1 satisfies. + let request = PackageSource::from_str("wasmer/python@>=3.12.1, <3.12.2").unwrap(); + + let err = source.query(&request).await.unwrap_err(); + let QueryError::NoMatches { + yanked_versions, .. + } = &err + else { + panic!("expected NoMatches, got {err:?}"); + }; + assert_eq!(yanked_versions.len(), 1); + assert_eq!(yanked_versions[0].to_string(), "3.12.1"); + assert!( + err.to_string().contains("yanked"), + "message should explain the yank: {err}" + ); + } + + #[test] + fn only_a_full_equals_version_is_an_exact_pin() { + // `=X.Y.Z` pins; whether bare/`^`/`~` reqs do is the async tests' job, + // since those exercise the resolver end to end. + let exact = exact_pinned_version(&VersionReq::from_str("=3.12.1").unwrap()); + assert_eq!(exact.map(|v| v.to_string()).as_deref(), Some("3.12.1")); + + // `=X.Y` ranges over the patch, and a two-comparator range is not a + // single version, so neither is an exact pin. + for not_a_pin in ["=3.12", ">=3.12.1, <4"] { + assert!( + exact_pinned_version(&VersionReq::from_str(not_a_pin).unwrap()).is_none(), + "`{not_a_pin}` must not be treated as an exact pin", + ); + } + } + + #[tokio::test] + async fn a_bare_version_is_not_an_exact_pin_and_skips_a_yanked_version() { + let source = python_source(python_versions_body()); + let request = PackageSource::from_str("wasmer/python@3.12.1").unwrap(); + + let err = source.query(&request).await.unwrap_err(); + let QueryError::NoMatches { + yanked_versions, .. + } = &err + else { + panic!("expected NoMatches, got {err:?}"); + }; + assert_eq!(yanked_versions.len(), 1); + assert_eq!(yanked_versions[0].to_string(), "3.12.1"); + } + #[tokio::test] async fn query_the_backend_again_if_cached_queries_dont_match() { let cached_value = serde_json::from_value(serde_json::json! { diff --git a/lib/wasix/src/runtime/resolver/in_memory_source.rs b/lib/wasix/src/runtime/resolver/in_memory_source.rs index 9717669e6119..272121e0ec04 100644 --- a/lib/wasix/src/runtime/resolver/in_memory_source.rs +++ b/lib/wasix/src/runtime/resolver/in_memory_source.rs @@ -7,7 +7,9 @@ use std::{ use anyhow::{Context, Error}; use wasmer_config::package::{NamedPackageId, PackageHash, PackageId, PackageIdent, PackageSource}; -use crate::runtime::resolver::{PackageSummary, QueryError, Source}; +use crate::runtime::resolver::{ + PackageSummary, QueryError, Source, utils::cmp_versions_with_build, +}; /// A [`Source`] that tracks packages in memory. /// @@ -87,8 +89,9 @@ impl InMemorySource { .entry(ident.full_name.clone()) .or_default(); summaries.push(NamedPackageSummary { ident, summary }); - summaries - .sort_by(|left, right| left.ident.version.cmp_precedence(&right.ident.version)); + summaries.sort_by(|left, right| { + cmp_versions_with_build(&left.ident.version, &right.ident.version) + }); summaries.dedup_by(|left, right| left.ident.version == right.ident.version); } PackageId::Hash(hash) => { @@ -142,9 +145,7 @@ impl Source for InMemorySource { Some(summaries) => { let matches: Vec<_> = summaries .iter() - .filter(|summary| { - named.version_or_default().matches(&summary.ident.version) - }) + .filter(|summary| named.matches_version(&summary.ident.version)) .map(|n| n.summary.clone()) .collect(); @@ -159,7 +160,7 @@ impl Source for InMemorySource { if matches.is_empty() { return Err(QueryError::NoMatches { query: package.clone(), - archived_versions: Vec::new(), + yanked_versions: Vec::new(), }); } @@ -176,7 +177,7 @@ impl Source for InMemorySource { .map(|x| vec![x.clone()]) .ok_or_else(|| QueryError::NoMatches { query: package.clone(), - archived_versions: Vec::new(), + yanked_versions: Vec::new(), }), PackageSource::Url(_) | PackageSource::Path(_) => Err(QueryError::Unsupported { query: package.clone(), diff --git a/lib/wasix/src/runtime/resolver/local_registry_source.rs b/lib/wasix/src/runtime/resolver/local_registry_source.rs index 54d3a8112760..49cb343e5f20 100644 --- a/lib/wasix/src/runtime/resolver/local_registry_source.rs +++ b/lib/wasix/src/runtime/resolver/local_registry_source.rs @@ -7,7 +7,9 @@ use wasmer_config::package::{ NamedPackageId, NamedPackageIdent, PackageHash, PackageId, PackageIdent, PackageSource, }; -use crate::runtime::resolver::{PackageSummary, QueryError, Source, WebcHash}; +use crate::runtime::resolver::{ + PackageSummary, QueryError, Source, WebcHash, utils::cmp_versions_with_build, +}; /// A [`Source`] backed by a directory tree laid out like a registry: /// `///.webc`, or `//.webc` @@ -54,18 +56,17 @@ impl LocalRegistrySource { }); } - let constraint = named.version_or_default(); let matches: Vec<_> = published_versions(&dir) .map_err(|error| QueryError::new_other(error, query))? .into_iter() - .filter(|(version, _)| constraint.matches(version)) - .sorted_by(|(left, _), (right, _)| left.cmp_precedence(right)) + .filter(|(version, _)| named.matches_version(version)) + .sorted_by(|(left, _), (right, _)| cmp_versions_with_build(left, right)) .collect(); if matches.is_empty() { return Err(QueryError::NoMatches { query: query.clone(), - archived_versions: Vec::new(), + yanked_versions: Vec::new(), }); } diff --git a/lib/wasix/src/runtime/resolver/resolve.rs b/lib/wasix/src/runtime/resolver/resolve.rs index 67ad8a21d1c1..b487318a28e0 100644 --- a/lib/wasix/src/runtime/resolver/resolve.rs +++ b/lib/wasix/src/runtime/resolver/resolve.rs @@ -14,7 +14,7 @@ use crate::runtime::resolver::{ Dependency, DependencyGraph, ItemLocation, PackageInfo, PackageSummary, QueryError, Resolution, ResolvedPackage, Source, outputs::{Edge, Node}, - utils::cmp_version_precedence, + utils::cmp_version_with_build, }; use super::ResolvedFileSystemMapping; @@ -297,11 +297,11 @@ fn select_latest_named_dependency( let left_version = left.pkg.id.as_named().map(|id| &id.version); let right_version = right.pkg.id.as_named().map(|id| &id.version); - cmp_version_precedence(left_version, right_version) + cmp_version_with_build(left_version, right_version) }) .ok_or_else(|| QueryError::NoMatches { query: dep.pkg.clone(), - archived_versions: Vec::new(), + yanked_versions: Vec::new(), }) } @@ -369,7 +369,7 @@ async fn select_unified_named_dependency( let left_version = left.pkg.id.as_named().map(|id| &id.version); let right_version = right.pkg.id.as_named().map(|id| &id.version); - cmp_version_precedence(left_version, right_version) + cmp_version_with_build(left_version, right_version) })) } @@ -560,7 +560,7 @@ fn sort_named_candidates_desc(candidates: &mut [PackageSummary]) { let left_version = left.pkg.id.as_named().map(|id| &id.version); let right_version = right.pkg.id.as_named().map(|id| &id.version); - cmp_version_precedence(right_version, left_version) + cmp_version_with_build(right_version, left_version) }); } diff --git a/lib/wasix/src/runtime/resolver/source.rs b/lib/wasix/src/runtime/resolver/source.rs index e5b2dc61815f..bfa283210ece 100644 --- a/lib/wasix/src/runtime/resolver/source.rs +++ b/lib/wasix/src/runtime/resolver/source.rs @@ -5,7 +5,7 @@ use std::{ use wasmer_config::package::{PackageIdent, PackageSource}; -use crate::runtime::resolver::{PackageSummary, utils::cmp_version_precedence}; +use crate::runtime::resolver::{PackageSummary, utils::cmp_version_with_build}; /// Something that packages can be downloaded from. #[async_trait::async_trait] @@ -34,11 +34,11 @@ pub trait Source: Sync + Debug { let left_version = left.pkg.id.as_named().map(|x| &x.version); let right_version = right.pkg.id.as_named().map(|x| &x.version); - cmp_version_precedence(left_version, right_version) + cmp_version_with_build(left_version, right_version) }) .ok_or(QueryError::NoMatches { query: pkg.clone(), - archived_versions: Vec::new(), + yanked_versions: Vec::new(), }), _ => candidates .into_iter() @@ -69,7 +69,8 @@ pub enum QueryError { }, NoMatches { query: PackageSource, - archived_versions: Vec, + /// The matched versions that were skipped as yanked. + yanked_versions: Vec, }, Timeout { query: PackageSource, @@ -111,20 +112,26 @@ impl Display for QueryError { Self::Timeout { .. } => f.write_str("timeout"), Self::NoMatches { query: _, - archived_versions, - } => match archived_versions.as_slice() { + yanked_versions, + } => match yanked_versions.as_slice() { [] => f.write_str( "the package was found, but no published versions matched the constraint", ), [version] => write!( f, - "the only version satisfying the constraint, {version}, is archived" + "the only version satisfying the constraint, {version}, has been yanked; \ + pin it exactly to use it anyway" ), [first, rest @ ..] => { - let num_others = rest.len(); + let others = if rest.len() == 1 { + "1 other".to_string() + } else { + format!("{} others", rest.len()) + }; write!( f, - "unable to satisfy the request - version {first}, and {num_others} are all archived" + "every version satisfying the constraint has been yanked ({first} and \ + {others}); pin one exactly to use it anyway" ) } }, diff --git a/lib/wasix/src/runtime/resolver/utils.rs b/lib/wasix/src/runtime/resolver/utils.rs index f089cb070ae4..3bb6ef62c916 100644 --- a/lib/wasix/src/runtime/resolver/utils.rs +++ b/lib/wasix/src/runtime/resolver/utils.rs @@ -10,12 +10,30 @@ use url::Url; use crate::http::{HttpResponse, USER_AGENT}; -/// Compare optional package versions by SemVer *precedence*, i.e. ignoring build -/// metadata as the spec requires (`1.0.0+a` and `1.0.0+b` rank equally). `None` -/// orders below any `Some`, matching [`Option`]'s own ordering. -pub(crate) fn cmp_version_precedence(left: Option<&Version>, right: Option<&Version>) -> Ordering { +fn cmp_build_metadata(left: &semver::BuildMetadata, right: &semver::BuildMetadata) -> Ordering { + match (left.is_empty(), right.is_empty()) { + (true, true) => return Ordering::Equal, + (true, false) => return Ordering::Greater, + (false, true) => return Ordering::Less, + (false, false) => {} + } + + left.cmp(right) +} + +/// Compare two versions so that, among versions of equal SemVer precedence, a +/// dotted build metadata ordering breaks ties. Numeric identifiers compare +/// numerically, and a version without build metadata ranks above one with it. +pub(crate) fn cmp_versions_with_build(left: &Version, right: &Version) -> Ordering { + left.cmp_precedence(right) + .then_with(|| cmp_build_metadata(&left.build, &right.build)) +} + +/// [`cmp_versions_with_build`] over optionals; `None` orders below any `Some`, +/// matching [`Option`]'s own ordering. +pub(crate) fn cmp_version_with_build(left: Option<&Version>, right: Option<&Version>) -> Ordering { match (left, right) { - (Some(left), Some(right)) => left.cmp_precedence(right), + (Some(left), Some(right)) => cmp_versions_with_build(left, right), (left, right) => left.is_some().cmp(&right.is_some()), } } @@ -102,6 +120,45 @@ mod tests { #[allow(unused_imports)] use super::*; + fn version(value: &str) -> Version { + Version::parse(value).unwrap() + } + + #[test] + fn build_metadata_identifiers_follow_prerelease_style_ordering() { + assert_eq!( + cmp_versions_with_build(&version("1.0.0+abc.2"), &version("1.0.0+abc.11")), + Ordering::Less + ); + assert_eq!( + cmp_versions_with_build(&version("1.0.0+abc.2"), &version("1.0.0+abc.beta")), + Ordering::Less + ); + assert_eq!( + cmp_versions_with_build(&version("1.0.0+abc"), &version("1.0.0+abc.1")), + Ordering::Less + ); + } + + #[test] + fn bare_version_ranks_above_build_metadata() { + assert_eq!( + cmp_versions_with_build(&version("1.0.0"), &version("1.0.0+wasix.10")), + Ordering::Greater + ); + } + + #[test] + fn lifecycle_category_ordering_is_preserved() { + let ordered = ["1.0.0-alpha+build", "1.0.0-alpha", "1.0.0+build", "1.0.0"]; + for pair in ordered.windows(2) { + assert_eq!( + cmp_versions_with_build(&version(pair[0]), &version(pair[1])), + Ordering::Less + ); + } + } + #[test] #[cfg(unix)] fn from_file_path_behaviour_is_identical() {