diff --git a/CHANGELOG.md b/CHANGELOG.md index 60cd163d..7fc4d968 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a ## Unreleased ### Added +- Add version namespaces to Git version dependencies, allowing versioned forks to be tagged and resolved under their own prefix (e.g. `companyX-v1.2.0`). The prefix may be given via the `version_prefix` field or embedded in the version string (`version: "companyX-v1.2.0"`). Defaults to `v` for full backwards compatibility; namespaces are strict and never mix, and the resolved prefix is recorded in `Bender.lock`. +- `bender audit` now aligns its version-bump suggestions to the namespace a dependency is currently resolved under, falling back to the default `v` namespace when the current checkout is not a version. - Add `git_submodules` config field and `--git-submodules ` flag (env `BENDER_GIT_SUBMODULES`) to control cloning of dependency submodules; defaults to `true`, the flag overrides the configured value in either direction (https://github.com/pulp-platform/bender/pull/314). ### Fixed diff --git a/book/src/dependencies.md b/book/src/dependencies.md index 6f8b97c5..dfe25182 100644 --- a/book/src/dependencies.md +++ b/book/src/dependencies.md @@ -26,7 +26,33 @@ dependencies: axi: { version: ">=0.23.0, <0.26.0" } ``` -> **Note:** Bender only recognizes Git tags that follow the `vX.Y.Z` format (e.g., `v1.2.1`). +> **Note:** By default, Bender only recognizes Git tags that follow the `vX.Y.Z` format (e.g., `v1.2.1`). See [Version Namespaces](#version-namespaces) if you need a different prefix. + +#### Version Namespaces + +The leading `v` in a version tag is simply the default *prefix*. If you maintain your own versioned fork of an open-source IP — for example to ship internally patched releases — you can tag those releases under your own namespace and depend on them with `version_prefix`: + +```yaml +dependencies: + # Resolves only tags of the form `companyX-v`, e.g. `companyX-v1.2.0`. + common_cells: { git: "...", version: "1.21.0", version_prefix: "companyX-v" } +``` + +You can equivalently embed the prefix directly in the version string: + +```yaml +dependencies: + common_cells: { git: "...", version: "companyX-v1.21.0" } +``` + +The embedded form requires the version requirement to begin with a number (e.g. `companyX-v1.21.0`, `companyX-v1.*`). For operator-based ranges such as `>=1.21.0`, use the `version_prefix` field alongside a plain `version`. If both a field and an embedded prefix are given, they must agree. + +The prefix is the entire literal string preceding the semantic version, so you are free to choose any convention (`companyX-v`, `acme-`, …). A dependency without a prefix keeps the default `v`, so existing manifests and lockfiles are unaffected. Prefixes only apply to Git version dependencies. + +Namespaces are **strict and never mix**: + +- A dependency resolves *only* tags carrying its own prefix. There is no fallback to the default `v` namespace (or any other). +- If the same dependency is required with two different prefixes anywhere in the dependency tree, resolution fails rather than guessing. Resolve this by adding an `overrides` entry that pins the dependency to a single namespace. #### Revision-based Use this for specific commits, branches, or tags that don't follow SemVer. diff --git a/book/src/lockfile.md b/book/src/lockfile.md index 85544c5d..d4333cb9 100644 --- a/book/src/lockfile.md +++ b/book/src/lockfile.md @@ -35,6 +35,7 @@ packages: - **revision:** The full 40-character Git commit hash. - **version:** The SemVer version that was resolved. +- **version_prefix:** The version [namespace](./dependencies.md#version-namespaces) the version was resolved under. Omitted for the default `v` prefix. - **source:** Where to download the package from. - **dependencies:** A list of other packages that this specific package depends on, ensuring the entire tree is captured. diff --git a/src/cmd/audit.rs b/src/cmd/audit.rs index a14b8ecb..e513e14a 100644 --- a/src/cmd/audit.rs +++ b/src/cmd/audit.rs @@ -33,6 +33,16 @@ pub struct AuditArgs { pub ignore_url_conflict: bool, } +/// Parse the version requirement from a parent constraint string. +/// +/// These strings come from `DependencyConstraint`'s `Display`. A namespaced +/// constraint renders as `" (prefix `

`)"`; since the namespace is +/// already fixed by resolution, only the requirement is relevant here, so we +/// drop any trailing prefix annotation before parsing. +fn parent_version_req(s: &str) -> std::result::Result { + VersionReq::parse(s.split(" (prefix ").next().unwrap_or(s).trim()) +} + /// Execute the `audit` subcommand. pub fn run(sess: &Session, args: &AuditArgs) -> Result<()> { let rt = Runtime::new().into_diagnostic()?; @@ -85,10 +95,24 @@ pub fn run(sess: &Session, args: &AuditArgs) -> Result<()> { .unwrap_or_default(); let current_revision = sess.dependency(*pkg).revision.clone(); let current_revision_unwrapped = current_revision.as_deref().unwrap_or_default(); + // Align update suggestions with the namespace the dependency is + // currently resolved under. When the current checkout is not a version + // (e.g. a path or revision), fall back to the default `v` namespace. + let current_prefix = if current_version.is_some() { + sess.dependency(*pkg) + .version_prefix + .as_deref() + .unwrap_or(crate::config::DEFAULT_VERSION_PREFIX) + } else { + crate::config::DEFAULT_VERSION_PREFIX + }; let available_versions = match dep_versions.get(pkg).unwrap() { - DependencyVersions::Git(versions) => { - versions.versions.iter().map(|(v, _)| v.clone()).collect() - } + DependencyVersions::Git(versions) => versions + .versions + .iter() + .filter(|tv| tv.prefix == current_prefix) + .map(|tv| tv.version.clone()) + .collect(), _ => vec![], }; let highest_version = available_versions.iter().max(); @@ -102,7 +126,7 @@ pub fn run(sess: &Session, args: &AuditArgs) -> Result<()> { .map(|v| (v[0].clone(), v[1].clone())) .unwrap_or_else(|| ("".to_string(), "".to_string())); for parent in parent_array.values() { - match VersionReq::parse(&parent[0]) { + match parent_version_req(&parent[0]) { Ok(parent_version) => { compatible_versions.retain(|v| parent_version.matches(v)); version_req_exists = true; diff --git a/src/cmd/packages.rs b/src/cmd/packages.rs index 274a19f3..26f62c15 100644 --- a/src/cmd/packages.rs +++ b/src/cmd/packages.rs @@ -120,7 +120,14 @@ pub fn run(sess: &Session, args: &PackagesArgs) -> Result<()> { "{}:\t{}\tat {}\t{}\n", pkg_source.name, match pkg_source.version { - Some(ref v) => format!("v{}", v), + Some(ref v) => format!( + "{}{}", + pkg_source + .version_prefix + .as_deref() + .unwrap_or(crate::config::DEFAULT_VERSION_PREFIX), + v + ), None => "".to_string(), }, pkg_source.source, diff --git a/src/config.rs b/src/config.rs index b150f659..fe1dddaa 100644 --- a/src/config.rs +++ b/src/config.rs @@ -143,6 +143,8 @@ pub enum Dependency { url: String, /// The version requirement of the package. version: semver::VersionReq, + /// Prefix for the version + version_prefix: Option, /// Targets to pass to the dependency pass_targets: Vec, }, @@ -214,12 +216,16 @@ impl Serialize for Dependency { ref target, ref url, ref version, + ref version_prefix, ref pass_targets, } => { let mut map = serializer.serialize_map(Some(4))?; map.serialize_entry("target", target)?; map.serialize_entry("git", url)?; map.serialize_entry("version", &format!("{}", version))?; + if let Some(prefix) = version_prefix { + map.serialize_entry("version_prefix", prefix)?; + } map.serialize_entry("pass_targets", pass_targets)?; map.end() } @@ -676,6 +682,8 @@ pub struct PartialDependency { remote: Option, /// The upstream name of the remote to use for this dependency upstream_name: Option, + /// The version prefix to use when specifying the version. This will modify the specified version string to require a `-v*` version. This is optional and can only be used when using git version dependencies. + version_prefix: Option, /// Targets to pass to the dependency pass_targets: Option>>, /// Unknown extra fields @@ -722,16 +730,37 @@ impl Validate for PartialDependency { .into_iter() .map(|s| s.validate(vctx)) .collect::>>()?; - let version = self - .version - .map(|v| { - semver::VersionReq::parse(&v) - .into_diagnostic() - .wrap_err_with(|| { - format!("\"{}\" is not a valid semantic version requirement.", v) - }) - }) - .transpose()?; + // Parse the version requirement, extracting any literal prefix embedded + // in the version string (e.g. `companyX-v1.2.*`). + let (version, embedded_prefix) = match self.version.as_deref() { + Some(v) => { + let (prefix, req) = split_version_req(v).ok_or_else(|| { + err!("\"{}\" is not a valid semantic version requirement.", v) + })?; + ( + (Some(req)), + (!prefix.is_empty()).then(|| prefix.to_string()), + ) + } + None => (None, None), + }; + // Reconcile an embedded prefix with an explicit `version_prefix` field; + // the two must agree if both are given. The default `v` prefix is + // represented as `None`. + let version_prefix = match (self.version_prefix, embedded_prefix) { + (Some(field), Some(embedded)) if field != embedded => { + bail!( + "Conflicting version prefixes for `{}`: `version_prefix: {}` does not match \ + the prefix `{}` embedded in the version string.", + vctx.package_name, + field, + embedded + ); + } + (Some(field), _) => Some(field), + (None, embedded) => embedded, + } + .filter(|p| p != DEFAULT_VERSION_PREFIX); if !vctx.pre_output { self.extra.iter().for_each(|(k, _)| { Warnings::IgnoreUnknownField { @@ -755,6 +784,7 @@ impl Validate for PartialDependency { target, url: default_remote.url.replace("{}", git_name), version, + version_prefix: version_prefix.clone(), pass_targets, }) } else { @@ -774,6 +804,7 @@ impl Validate for PartialDependency { target, url: remote.url.replace("{}", git_name), version, + version_prefix: version_prefix.clone(), pass_targets, }) } else { @@ -791,6 +822,7 @@ impl Validate for PartialDependency { target, url: git, version, + version_prefix: version_prefix.clone(), pass_targets, }), // Git dependencies with revisions, e.g.: @@ -2011,6 +2043,11 @@ pub struct LockedPackage { pub revision: Option, /// The version of the dependency. pub version: Option, + /// The version-tag prefix (namespace) the version was resolved under. + /// Omitted for the default `v` prefix to keep lockfiles backwards + /// compatible. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version_prefix: Option, /// The source of the dependency. #[serde(with = "serde_yaml_ng::with::singleton_map")] pub source: LockedSource, @@ -2044,3 +2081,166 @@ fn env_string_from_string(path_str: &str) -> Result { pub(crate) fn env_path_from_string(path_str: &str) -> Result { Ok(PathBuf::from(env_string_from_string(path_str)?)) } + +/// The default, backwards-compatible version-tag prefix (`v`, as in `v1.2.3`). +pub const DEFAULT_VERSION_PREFIX: &str = "v"; + +/// Split a git version tag into its literal prefix and semantic version. +/// +/// The prefix is the entire literal string preceding the semantic version, +/// e.g. `v` for `v1.2.3` or `companyX-v` for `companyX-v1.2.3`. The default, +/// backwards-compatible prefix is `v`. Returns `None` if no suffix of the tag +/// parses as a semantic version. +pub fn split_version_tag(tag: &str) -> Option<(&str, semver::Version)> { + let bytes = tag.as_bytes(); + for i in 0..bytes.len() { + // The semantic version starts at a digit that is not part of a longer + // run of digits (so we don't split in the middle of a number). + if !bytes[i].is_ascii_digit() || (i > 0 && bytes[i - 1].is_ascii_digit()) { + continue; + } + if let Ok(version) = semver::Version::parse(&tag[i..]) { + return Some((&tag[..i], version)); + } + } + None +} + +/// Split a version *requirement* string into an optional embedded literal +/// prefix and the semantic version requirement. +/// +/// A bare requirement — including operators and wildcards such as `1.2.*`, +/// `>=1.0.0` or `*` — yields an empty prefix. An embedded prefix is recognised +/// only when the version part begins with a number, e.g. `companyX-v1.2.*` +/// splits into (`companyX-v`, `1.2.*`). Prefixed ranges that start with an +/// operator or wildcard are not supported in this embedded form; use the +/// separate `version_prefix` field for those. +/// +/// Returns `None` if the string is not a valid (optionally prefixed) version +/// requirement. +pub fn split_version_req(s: &str) -> Option<(&str, semver::VersionReq)> { + // A bare requirement has no prefix. This also covers operator- and + // wildcard-led requirements, which an embedded prefix cannot precede. + if let Ok(req) = semver::VersionReq::parse(s) { + return Some(("", req)); + } + // Otherwise look for a literal prefix followed by a numeric requirement. + // The version part must begin with a digit that does not continue a + // longer run of digits, so we never split in the middle of a number. + let bytes = s.as_bytes(); + for i in 1..bytes.len() { + if !bytes[i].is_ascii_digit() || bytes[i - 1].is_ascii_digit() { + continue; + } + // The prefix must be a plain namespace, not part of a requirement. If it + // contains requirement syntax we would silently swallow an operator + // (e.g. read `companyX-v>=1.0.0` as prefix `companyX-v>=`, req `^1.0.0`). + // Such operator-led requirements must use the `version_prefix` field. + if s[..i] + .bytes() + .any(|b| matches!(b, b'*' | b'^' | b'~' | b'<' | b'>' | b'=' | b',' | b' ')) + { + break; + } + if let Ok(req) = semver::VersionReq::parse(&s[i..]) { + return Some((&s[..i], req)); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::{split_version_req, split_version_tag}; + + fn split(tag: &str) -> Option<(String, String)> { + split_version_tag(tag).map(|(p, v)| (p.to_string(), v.to_string())) + } + + fn split_req(s: &str) -> Option<(String, String)> { + split_version_req(s).map(|(p, r)| (p.to_string(), r.to_string())) + } + + #[test] + fn splits_default_v_prefix() { + assert_eq!(split("v1.2.3"), Some(("v".into(), "1.2.3".into()))); + } + + #[test] + fn splits_custom_prefix() { + assert_eq!( + split("companyX-v1.2.3"), + Some(("companyX-v".into(), "1.2.3".into())) + ); + assert_eq!( + split("release-1.0.0"), + Some(("release-".into(), "1.0.0".into())) + ); + } + + #[test] + fn prefix_may_contain_digits() { + // The split must not occur in the middle of `company2`. + assert_eq!( + split("company2-v1.0.0"), + Some(("company2-v".into(), "1.0.0".into())) + ); + } + + #[test] + fn empty_prefix_is_allowed() { + assert_eq!(split("1.2.3"), Some(("".into(), "1.2.3".into()))); + } + + #[test] + fn keeps_prerelease_and_build_metadata() { + assert_eq!( + split("v1.2.3-rc.1+build.5"), + Some(("v".into(), "1.2.3-rc.1+build.5".into())) + ); + } + + #[test] + fn rejects_non_versions() { + assert_eq!(split("nonsense"), None); + // Not a full `major.minor.patch` semantic version. + assert_eq!(split("v1.2"), None); + assert_eq!(split(""), None); + } + + #[test] + fn version_req_bare_has_no_prefix() { + // Plain requirements, operators and wildcards keep the default prefix. + assert_eq!(split_req("1.2.3"), Some(("".into(), "^1.2.3".into()))); + assert_eq!(split_req("1.2.*"), Some(("".into(), "1.2.*".into()))); + assert_eq!(split_req("*"), Some(("".into(), "*".into()))); + assert_eq!( + split_req(">=1.0.0, <2.0.0"), + Some(("".into(), ">=1.0.0, <2.0.0".into())) + ); + } + + #[test] + fn version_req_embedded_prefix() { + assert_eq!( + split_req("companyX-v1.2.*"), + Some(("companyX-v".into(), "1.2.*".into())) + ); + // An explicit leading `v` is just the default prefix. + assert_eq!(split_req("v1.2.3"), Some(("v".into(), "^1.2.3".into()))); + // A digit inside the prefix must not cause a mid-token split, because + // `2-v1.0.0` is not itself a valid requirement. + assert_eq!( + split_req("company2-v1.0.0"), + Some(("company2-v".into(), "^1.0.0".into())) + ); + } + + #[test] + fn version_req_rejects_unsupported() { + // Operator/wildcard-led requirements cannot carry an embedded prefix. + assert_eq!(split_req("companyX-v*"), None); + assert_eq!(split_req("companyX-v>=1.0.0"), None); + assert_eq!(split_req("garbage"), None); + } +} diff --git a/src/resolver.rs b/src/resolver.rs index 741bf39a..f6473420 100644 --- a/src/resolver.rs +++ b/src/resolver.rs @@ -198,6 +198,7 @@ impl<'ctx> DependencyResolver<'ctx> { LockedPackage { revision: None, version: None, + version_prefix: None, source: LockedSource::Path(path), dependencies: deps, } @@ -215,16 +216,26 @@ impl<'ctx> DependencyResolver<'ctx> { }; let pick = dep.state.pick().unwrap(); let rev = gv.revs[pick.1]; + let prefix = dep + .version_prefix + .as_deref() + .unwrap_or(config::DEFAULT_VERSION_PREFIX); let version = gv .versions .iter() - .filter(|&&(_, r)| r == rev) - .map(|(v, _)| v) + .filter(|tv| tv.hash == rev && tv.prefix == prefix) + .map(|tv| &tv.version) .max() .map(|v| v.to_string()); LockedPackage { revision: Some(String::from(rev)), version, + // Omit the default `v` prefix for backwards-compatible lockfiles. + version_prefix: if prefix == config::DEFAULT_VERSION_PREFIX { + None + } else { + Some(prefix.to_string()) + }, source: LockedSource::Git(url), dependencies: deps, } @@ -390,6 +401,7 @@ impl<'ctx> DependencyResolver<'ctx> { pre: parsed_version.pre, }], }, + version_prefix: locked_package.version_prefix.clone(), pass_targets: Vec::new(), } } else { @@ -439,12 +451,19 @@ impl<'ctx> DependencyResolver<'ctx> { pass_targets: Vec::new(), }, DependencySource::Git(u) => match &cnstr { - DependencyConstraint::Version(v) => config::Dependency::GitVersion { - target: TargetSpec::Wildcard, - url: u, - version: v.clone(), - pass_targets: Vec::new(), - }, + DependencyConstraint::Version { req: v, prefix } => { + config::Dependency::GitVersion { + target: TargetSpec::Wildcard, + url: u, + version: v.clone(), + version_prefix: if prefix.as_str() == config::DEFAULT_VERSION_PREFIX { + None + } else { + Some(prefix.clone()) + }, + pass_targets: Vec::new(), + } + } DependencyConstraint::Revision(r) => config::Dependency::GitRevision { target: TargetSpec::Wildcard, url: u, @@ -609,6 +628,34 @@ impl<'ctx> DependencyResolver<'ctx> { map }; + // Namespaced versions must not be mixed. A dependency required with more + // than one distinct version prefix has no shared namespace, so there is + // no automatic resolution (no fallback to the default `v`). The user must + // pick one explicitly via an override, which collapses all requirements + // for the dependency to a single constraint. + for (name, cons) in &cons_map { + let prefixes: IndexSet<&str> = cons + .iter() + .filter_map(|(_, con, _)| match con { + DependencyConstraint::Version { prefix, .. } => Some(prefix.as_str()), + _ => None, + }) + .collect(); + if prefixes.len() > 1 { + bail!( + "Dependency `{}` is required with conflicting version prefixes ({}). \ + Namespaced versions cannot be mixed; add an override for `{}` to select one.", + name, + prefixes + .iter() + .map(|p| format!("`{}`", p)) + .collect::>() + .join(", "), + name, + ); + } + } + let _src_cons_map = cons_map .iter() .map(|(name, cons)| { @@ -631,6 +678,14 @@ impl<'ctx> DependencyResolver<'ctx> { // Impose the constraints on the dependencies. let mut table = mem::take(&mut self.table); for (name, cons) in cons_map { + // Record the resolved namespace prefix for lockfile writing. The + // guard above guarantees all version constraints share one prefix. + if let Some((_, DependencyConstraint::Version { prefix, .. }, _)) = cons + .iter() + .find(|(_, con, _)| matches!(con, DependencyConstraint::Version { .. })) + { + table.get_mut(name).unwrap().version_prefix = Some(prefix.clone()); + } for (_, con, dsrc) in &cons { log::debug!("impose `{}` at `{}` on `{}`", con, dsrc, name); let table_item = table.get_mut(name).unwrap(); @@ -739,8 +794,8 @@ impl<'ctx> DependencyResolver<'ctx> { indices_list? }; if indices_list.is_empty() && id == con_src { - let additional_str = if let DependencyConstraint::Version(__) = con { - " Ensure git tags are formatted as `vX.Y.Z`.".to_string() + let additional_str = if let DependencyConstraint::Version { prefix, .. } = con { + format!(" Ensure git tags are formatted as `{}X.Y.Z`.", prefix) } else { "".to_string() }; @@ -811,7 +866,7 @@ impl<'ctx> DependencyResolver<'ctx> { fmt_pkg!(pkg_name), fmt_version!(con), match con { - DependencyConstraint::Version(req) => format!( + DependencyConstraint::Version { req, .. } => format!( " ({} <= x < {})", fmt_version!(version_req_bottom_bound(req)?.unwrap()), fmt_version!(version_req_top_bound(req)?.unwrap()) @@ -842,7 +897,10 @@ impl<'ctx> DependencyResolver<'ctx> { .flat_map(|(_src, group)| { let mut g: Vec<_> = group.collect(); g.sort_by(|a, b| match (a.0, b.0) { - (DependencyConstraint::Version(va), DependencyConstraint::Version(vb)) => { + ( + DependencyConstraint::Version { req: va, .. }, + DependencyConstraint::Version { req: vb, .. }, + ) => { if version_req_top_bound(vb).unwrap_or(Some(semver::Version::new( u64::MAX, u64::MAX, @@ -872,12 +930,14 @@ impl<'ctx> DependencyResolver<'ctx> { } (DependencyConstraint::Path, _) => std::cmp::Ordering::Greater, (_, DependencyConstraint::Path) => std::cmp::Ordering::Less, - (DependencyConstraint::Version(_), DependencyConstraint::Revision(_)) => { - std::cmp::Ordering::Greater - } - (DependencyConstraint::Revision(_), DependencyConstraint::Version(_)) => { - std::cmp::Ordering::Less - } + ( + DependencyConstraint::Version { .. }, + DependencyConstraint::Revision(_), + ) => std::cmp::Ordering::Greater, + ( + DependencyConstraint::Revision(_), + DependencyConstraint::Version { .. }, + ) => std::cmp::Ordering::Less, }); g }) @@ -1027,7 +1087,7 @@ impl<'ctx> DependencyResolver<'ctx> { use self::DependencyVersions as DepVer; match (con, &src.versions) { (&DepCon::Path, &DepVer::Path) => Ok(IndexSet::from([0])), - (DepCon::Version(con), DepVer::Git(gv)) => { + (DepCon::Version { req: con, prefix }, DepVer::Git(gv)) => { // TODO: Move this outside somewhere. Very inefficient! let hash_ids: IndexMap<&str, usize> = gv .revs @@ -1038,12 +1098,14 @@ impl<'ctx> DependencyResolver<'ctx> { let mut revs_tmp: IndexMap<_, _> = gv .versions .iter() - .sorted() - .filter_map( - |&(ref v, h)| { - if con.matches(v) { Some((v, h)) } else { None } - }, - ) + .sorted_by(|a, b| a.version.cmp(&b.version)) + .filter_map(|tv| { + if tv.prefix == prefix.as_str() && con.matches(&tv.version) { + Some((&tv.version, tv.hash)) + } else { + None + } + }) .collect(); revs_tmp.reverse(); let revs: IndexSet = revs_tmp @@ -1082,7 +1144,7 @@ impl<'ctx> DependencyResolver<'ctx> { revs.sort(); Ok(revs) } - (DepCon::Version(_con), DepVer::Registry(_rv)) => Err(err!( + (DepCon::Version { .. }, DepVer::Registry(_rv)) => Err(err!( "Constraints on registry dependency `{}` not implemented", name )), @@ -1267,6 +1329,9 @@ struct Dependency<'ctx> { sources: IndexMap>, /// The picked manifest for this dependency. manifest: Option<&'ctx config::Manifest>, + /// The resolved version-tag prefix (namespace), if version-constrained. + /// `None` is interpreted as the default `v` prefix. + version_prefix: Option, /// The current resolution state. state: State, } diff --git a/src/sess.rs b/src/sess.rs index 272933d0..8075ac8e 100644 --- a/src/sess.rs +++ b/src/sess.rs @@ -191,6 +191,7 @@ impl<'ctx> Session<'ctx> { source: src, revision: None, version: None, + version_prefix: None, })) } @@ -217,6 +218,7 @@ impl<'ctx> Session<'ctx> { .version .as_ref() .map(|s| semver::Version::parse(s).unwrap()), + version_prefix: pkg.version_prefix.clone(), }), ); graph_names.insert(id, &pkg.dependencies); @@ -902,21 +904,19 @@ impl<'io, 'sess: 'io, 'ctx: 'sess> SessionIo<'sess, 'ctx> { (tags, branches) }; - // Extract the tags that look like semantic versions. - let mut versions: Vec<(semver::Version, &'ctx str)> = tags + // Extract the tags that look like (optionally prefixed) semantic + // versions, e.g. `v1.2.3` or `companyX-v1.2.3`. + let mut versions: Vec> = tags .iter() .filter_map(|(tag, &hash)| { - if let Some(stripped) = tag.strip_prefix('v') { - match semver::Version::parse(stripped) { - Ok(v) => Some((v, hash)), - Err(_) => None, - } - } else { - None - } + config::split_version_tag(tag).map(|(prefix, version)| GitTagVersion { + prefix, + version, + hash, + }) }) .collect(); - versions.sort_by(|a, b| b.cmp(a)); + versions.sort_by(|a, b| b.version.cmp(&a.version)); // Merge tags and branches. let refs: IndexMap<&str, &str> = branches.into_iter().chain(tags).collect(); @@ -2006,6 +2006,9 @@ pub struct DependencyEntry { pub revision: Option, /// The picked version. pub version: Option, + /// The version-tag prefix (namespace) the version was resolved under. + /// `None` is interpreted as the default `v` prefix. + pub version_prefix: Option, } impl DependencyEntry { @@ -2114,12 +2117,23 @@ pub enum DependencyVersions<'ctx> { #[derive(Clone, Debug)] pub struct RegistryVersions; +/// A single version tag of a git dependency, e.g. `v1.2.3` or `companyX-v1.2.3`. +#[derive(Clone, Debug)] +pub struct GitTagVersion<'ctx> { + /// The literal prefix preceding the semantic version in the tag, e.g. `v`. + pub prefix: &'ctx str, + /// The semantic version parsed from the tag. + pub version: semver::Version, + /// The git revision hash this tag points to. + pub hash: &'ctx str, +} + /// All available versions a git dependency has. #[derive(Clone, Debug)] pub struct GitVersions<'ctx> { - /// The versions available for this dependency. This is basically a sorted - /// list of tags of the form `v`. - pub versions: Vec<(semver::Version, &'ctx str)>, + /// The versions available for this dependency. This is a list of tags of + /// the form `` (e.g. `v1.2.3`), sorted by version descending. + pub versions: Vec>, /// The named references available for this dependency. This is a mixture of /// branch names and tags, where the tags take precedence. pub refs: IndexMap<&'ctx str, &'ctx str>, @@ -2167,7 +2181,14 @@ pub enum DependencyConstraint { /// constraint on it. Path, /// A version constraint. These may occur for registry or git dependencies. - Version(semver::VersionReq), + /// Only tags carrying the given `prefix` (default `v`) satisfy the + /// constraint, which keeps namespaced versions separate. + Version { + /// The version requirement. + req: semver::VersionReq, + /// The literal version-tag prefix (default `v`). + prefix: String, + }, /// A revision constraint. These occur for git dependencies. Revision(String), } @@ -2176,10 +2197,20 @@ impl<'a> From<&'a config::Dependency> for DependencyConstraint { fn from(cfg: &'a config::Dependency) -> DependencyConstraint { match *cfg { config::Dependency::Path { .. } => DependencyConstraint::Path, - config::Dependency::Version { ref version, .. } - | config::Dependency::GitVersion { ref version, .. } => { - DependencyConstraint::Version(version.clone()) - } + config::Dependency::Version { ref version, .. } => DependencyConstraint::Version { + req: version.clone(), + prefix: config::DEFAULT_VERSION_PREFIX.to_string(), + }, + config::Dependency::GitVersion { + ref version, + ref version_prefix, + .. + } => DependencyConstraint::Version { + req: version.clone(), + prefix: version_prefix + .clone() + .unwrap_or_else(|| config::DEFAULT_VERSION_PREFIX.to_string()), + }, config::Dependency::GitRevision { ref rev, .. } => { DependencyConstraint::Revision(rev.clone()) } @@ -2191,7 +2222,16 @@ impl fmt::Display for DependencyConstraint { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match *self { DependencyConstraint::Path => write!(f, "path"), - DependencyConstraint::Version(ref v) => write!(f, "{}", v), + DependencyConstraint::Version { + ref req, + ref prefix, + } => { + if prefix == config::DEFAULT_VERSION_PREFIX { + write!(f, "{}", req) + } else { + write!(f, "{} (prefix `{}`)", req, prefix) + } + } DependencyConstraint::Revision(ref r) => write!(f, "{}", r), } } diff --git a/tests/version_namespacing.rs b/tests/version_namespacing.rs new file mode 100644 index 00000000..df4f84b8 --- /dev/null +++ b/tests/version_namespacing.rs @@ -0,0 +1,295 @@ +// Copyright (c) 2026 ETH Zurich + +//! Integration tests for namespaced (prefixed) version resolution. +//! +//! These build throwaway git repositories with both default `v*` tags and +//! `companyX-v*` tags, then drive the real `bender` binary to check that +//! resolution stays within the requested namespace, persists it, and refuses +//! to mix namespaces. + +// `file://` URLs with absolute paths are awkward on Windows; the git-based +// fixtures here mirror the bash regression scripts, which are Unix-only. +#![cfg(unix)] + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use assert_cmd::cargo; + +/// Run a git command in `dir`, asserting success. +fn git(dir: &Path, args: &[&str]) { + let status = Command::new("git") + .args(args) + .current_dir(dir) + .env("GIT_AUTHOR_NAME", "Test") + .env("GIT_AUTHOR_EMAIL", "test@localhost") + .env("GIT_COMMITTER_NAME", "Test") + .env("GIT_COMMITTER_EMAIL", "test@localhost") + .output() + .expect("failed to run git"); + assert!( + status.status.success(), + "git {:?} failed:\n{}", + args, + String::from_utf8_lossy(&status.stderr) + ); +} + +fn write(path: &Path, contents: &str) { + fs::write(path, contents).expect("failed to write file"); +} + +/// Create a fresh, empty directory under the test binary's tmp dir. +fn fresh_dir(name: &str) -> PathBuf { + let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join(name); + if dir.exists() { + fs::remove_dir_all(&dir).unwrap(); + } + fs::create_dir_all(&dir).unwrap(); + dir +} + +/// Create a `foo` dependency repo carrying both `v*` and `companyX-v*` tags. +/// +/// Tags: `v1.0.0`, `v1.1.0`, `companyX-v1.0.0`, `companyX-v2.0.0`. +fn setup_foo(base: &Path) -> String { + let repo = base.join("foo"); + fs::create_dir_all(&repo).unwrap(); + git(&repo, &["init", "-q"]); + write(&repo.join("Bender.yml"), "package:\n name: foo\n"); + git(&repo, &["add", "."]); + git(&repo, &["commit", "-q", "-m", "init"]); + git(&repo, &["tag", "v1.0.0"]); + git(&repo, &["commit", "-q", "--allow-empty", "-m", "c2"]); + git(&repo, &["tag", "v1.1.0"]); + git(&repo, &["commit", "-q", "--allow-empty", "-m", "c3"]); + git(&repo, &["tag", "companyX-v1.0.0"]); + git(&repo, &["commit", "-q", "--allow-empty", "-m", "c4"]); + git(&repo, &["tag", "companyX-v2.0.0"]); + format!("file://{}", repo.display()) +} + +/// Create a project directory with the given `Bender.yml` body. +fn setup_project(base: &Path, name: &str, manifest: &str) -> PathBuf { + let dir = base.join(name); + fs::create_dir_all(&dir).unwrap(); + write(&dir.join("Bender.yml"), manifest); + dir +} + +fn bender(root: &Path, args: &[&str]) -> Output { + cargo::cargo_bin_cmd!() + .args(args) + .current_dir(root) + .output() + .expect("failed to run bender") +} + +fn bender_update(root: &Path) -> Output { + bender(root, &["update"]) +} + +#[test] +fn resolves_default_v_namespace() { + let base = fresh_dir("default_v"); + let foo_url = setup_foo(&base); + let app = setup_project( + &base, + "app", + &format!( + "package:\n name: app\ndependencies:\n foo: {{ git: \"{foo_url}\", version: \"1\" }}\n" + ), + ); + + let out = bender_update(&app); + assert!( + out.status.success(), + "update failed:\n{}", + String::from_utf8_lossy(&out.stderr) + ); + + let lock = fs::read_to_string(app.join("Bender.lock")).unwrap(); + // Highest `v1.x` tag wins, and the default prefix is not recorded. + assert!(lock.contains("version: 1.1.0"), "lockfile:\n{lock}"); + assert!( + !lock.contains("version_prefix"), + "default prefix must not be persisted:\n{lock}" + ); +} + +#[test] +fn resolves_custom_namespace() { + let base = fresh_dir("custom_ns"); + let foo_url = setup_foo(&base); + let app = setup_project( + &base, + "app", + &format!( + "package:\n name: app\ndependencies:\n \ + foo: {{ git: \"{foo_url}\", version: \"*\", version_prefix: \"companyX-v\" }}\n" + ), + ); + + let out = bender_update(&app); + assert!( + out.status.success(), + "update failed:\n{}", + String::from_utf8_lossy(&out.stderr) + ); + + let lock = fs::read_to_string(app.join("Bender.lock")).unwrap(); + // Resolution stays in the `companyX-v` namespace: highest there is 2.0.0, + // and the `v1.x` tags are ignored entirely. + assert!(lock.contains("version: 2.0.0"), "lockfile:\n{lock}"); + assert!( + !lock.contains("version: 1.1.0"), + "must not leak the default `v` namespace:\n{lock}" + ); + assert!( + lock.contains("version_prefix: companyX-v"), + "custom prefix must be persisted:\n{lock}" + ); +} + +#[test] +fn conflicting_namespaces_fail() { + let base = fresh_dir("conflict"); + let foo_url = setup_foo(&base); + + // `bar` requires foo from the `companyX-v` namespace. + let bar_dir = setup_project( + &base, + "bar", + &format!( + "package:\n name: bar\ndependencies:\n \ + foo: {{ git: \"{foo_url}\", version: \"*\", version_prefix: \"companyX-v\" }}\n" + ), + ); + git(&bar_dir, &["init", "-q"]); + git(&bar_dir, &["add", "."]); + git(&bar_dir, &["commit", "-q", "-m", "init"]); + git(&bar_dir, &["tag", "v1.0.0"]); + let bar_url = format!("file://{}", bar_dir.display()); + + // The app requires foo from the default `v` namespace and bar (which pulls + // foo from `companyX-v`): two distinct prefixes, hence no resolution. + let app = setup_project( + &base, + "app", + &format!( + "package:\n name: app\ndependencies:\n \ + foo: {{ git: \"{foo_url}\", version: \"1\" }}\n \ + bar: {{ git: \"{bar_url}\", version: \"1\" }}\n" + ), + ); + + let out = bender_update(&app); + assert!( + !out.status.success(), + "update unexpectedly succeeded:\n{}", + String::from_utf8_lossy(&out.stdout) + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("conflicting version prefixes"), + "expected a prefix-conflict error, got:\n{stderr}" + ); +} + +#[test] +fn resolves_embedded_namespace() { + let base = fresh_dir("embedded_ns"); + let foo_url = setup_foo(&base); + // The namespace is embedded directly in the version string instead of the + // separate `version_prefix` field; `companyX-v2` means `^2` in that namespace. + let app = setup_project( + &base, + "app", + &format!( + "package:\n name: app\ndependencies:\n foo: {{ git: \"{foo_url}\", version: \"companyX-v2\" }}\n" + ), + ); + + let out = bender_update(&app); + assert!( + out.status.success(), + "update failed:\n{}", + String::from_utf8_lossy(&out.stderr) + ); + + let lock = fs::read_to_string(app.join("Bender.lock")).unwrap(); + assert!(lock.contains("version: 2.0.0"), "lockfile:\n{lock}"); + assert!( + !lock.contains("version: 1.1.0"), + "must not leak the default `v` namespace:\n{lock}" + ); + assert!( + lock.contains("version_prefix: companyX-v"), + "embedded prefix must be persisted:\n{lock}" + ); +} + +#[test] +fn embedded_and_field_prefix_conflict_fails() { + let base = fresh_dir("embedded_conflict"); + let foo_url = setup_foo(&base); + // The embedded prefix (`companyX-v`) disagrees with the explicit field. + let app = setup_project( + &base, + "app", + &format!( + "package:\n name: app\ndependencies:\n \ + foo: {{ git: \"{foo_url}\", version: \"companyX-v1.0.0\", version_prefix: \"acme-\" }}\n" + ), + ); + + let out = bender_update(&app); + assert!( + !out.status.success(), + "update unexpectedly succeeded:\n{}", + String::from_utf8_lossy(&out.stdout) + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("Conflicting version prefixes"), + "expected a prefix-conflict error, got:\n{stderr}" + ); +} + +#[test] +fn audit_aligns_suggestions_to_namespace() { + let base = fresh_dir("audit_ns"); + let foo_url = setup_foo(&base); + // Pin foo to `companyX-v1.0.0`. That namespace also has `companyX-v2.0.0`, + // while the default `v` namespace's highest is `v1.1.0`. + let app = setup_project( + &base, + "app", + &format!( + "package:\n name: app\ndependencies:\n foo: {{ git: \"{foo_url}\", version: \"companyX-v1.0.0\" }}\n" + ), + ); + + let out = bender_update(&app); + assert!( + out.status.success(), + "update failed:\n{}", + String::from_utf8_lossy(&out.stderr) + ); + + let out = bender(&app, &["audit"]); + assert!( + out.status.success(), + "audit failed:\n{}", + String::from_utf8_lossy(&out.stderr) + ); + let stdout = String::from_utf8_lossy(&out.stdout); + // The bump target is the highest version in the checked-out namespace... + assert!(stdout.contains("2.0.0"), "audit:\n{stdout}"); + // ...not the highest version of the unrelated default `v` namespace. + assert!( + !stdout.contains("1.1.0"), + "audit leaked the default namespace:\n{stdout}" + ); +}