#[derive(Debug, Clone)]
pub(crate) struct PolicyVersion {
pub(crate) selected: Option<String>,
pub(crate) blocked: Option<String>,
}
pub(crate) fn policy_version_info(
packument: &aube_registry::Packument,
registry_name: &str,
range_str: &str,
minimum_release_age: Option<&aube_resolver::MinimumReleaseAge>,
current_version: Option<&str>,
) -> PolicyVersion {
if range_str == "latest" && !packument.dist_tags.contains_key("latest") {
return PolicyVersion {
selected: None,
blocked: None,
};
}
let policy_selected = match aube_resolver::pick_version_for_add(
packument,
registry_name,
range_str,
minimum_release_age,
) {
aube_resolver::PickResult::Found(meta) => Some(meta.version.clone()),
aube_resolver::PickResult::NoMatch | aube_resolver::PickResult::AgeGated => None,
};
let selected = match (policy_selected, current_version) {
(Some(selected), Some(current)) if is_newer(current, &selected) => {
Some(current.to_string())
}
(None, Some(current)) => Some(current.to_string()),
(selected, _) => selected,
};
let blocked = minimum_release_age.and_then(|_| {
let ungated =
match aube_resolver::pick_version_for_add(packument, registry_name, range_str, None) {
aube_resolver::PickResult::Found(meta) => Some(meta.version.clone()),
aube_resolver::PickResult::NoMatch | aube_resolver::PickResult::AgeGated => None,
}?;
selected
.as_deref()
.is_some_and(|baseline| is_newer(&ungated, baseline))
.then_some(ungated)
});
PolicyVersion { selected, blocked }
}
fn is_newer(candidate: &str, selected: &str) -> bool {
match (
node_semver::Version::parse(candidate),
node_semver::Version::parse(selected),
) {
(Ok(candidate), Ok(selected)) => candidate > selected,
_ => candidate != selected,
}
}
pub(crate) fn record_age_gated_update(
blocked: &mut std::collections::BTreeMap<String, String>,
name: String,
version: String,
) {
let replace = blocked
.get(&name)
.is_none_or(|existing| is_newer(&version, existing));
if replace {
blocked.insert(name, version);
}
}
pub(crate) fn warn_age_gated_updates(blocked: &std::collections::BTreeMap<String, String>) {
if blocked.is_empty() {
return;
}
let packages = blocked
.iter()
.map(|(name, version)| format!("{name}@{version}"))
.collect::<Vec<_>>()
.join(", ");
tracing::warn!(
code = aube_codes::warnings::WARN_AUBE_MINIMUM_RELEASE_AGE_BLOCKED_UPDATE,
count = blocked.len(),
packages,
"updates hidden by minimumReleaseAge: {packages}"
);
}
pub(crate) fn resolve_version(packument: &serde_json::Value, spec: Option<&str>) -> Option<String> {
let dist_tags = packument.get("dist-tags").and_then(|v| v.as_object());
let versions = packument.get("versions").and_then(|v| v.as_object())?;
let spec = match spec {
None | Some("") => {
return dist_tags?
.get("latest")
.and_then(|v| v.as_str())
.map(String::from);
}
Some(s) => s,
};
if let Some(tag) = dist_tags.and_then(|t| t.get(spec)).and_then(|v| v.as_str()) {
return Some(tag.to_string());
}
if versions.contains_key(spec) {
return Some(spec.to_string());
}
let range: node_semver::Range = spec.parse().ok()?;
versions
.keys()
.filter_map(|v| {
v.parse::<node_semver::Version>()
.ok()
.filter(|parsed| parsed.satisfies(&range))
.map(|parsed| (v.clone(), parsed))
})
.max_by(|a, b| a.1.cmp(&b.1))
.map(|(raw, _)| raw)
}
pub(crate) fn split_name_spec(input: &str) -> (&str, Option<&str>) {
aube_util::pkg::split_name_spec(input)
}
pub(crate) fn encode_package_name(name: &str) -> String {
if let Some(rest) = name.strip_prefix('@')
&& let Some((scope, pkg)) = rest.split_once('/')
{
return format!("@{scope}%2F{pkg}");
}
name.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
fn wanted_version(packument: &aube_registry::Packument, range: &str) -> Option<String> {
policy_version_info(packument, &packument.name, range, None, None).selected
}
fn packument(json: serde_json::Value) -> aube_registry::Packument {
serde_json::from_value(json).expect("test packument parses")
}
fn codemirror() -> aube_registry::Packument {
packument(serde_json::json!({
"name": "codemirror",
"dist-tags": { "latest": "6.0.2", "version5": "5.65.21" },
"versions": {
"6.0.1": { "name": "codemirror", "version": "6.0.1" },
"6.0.2": { "name": "codemirror", "version": "6.0.2" },
"6.65.7": {
"name": "codemirror",
"version": "6.65.7",
"deprecated": "This is an accidentally mis-tagged instance of 5.65.7"
}
}
}))
}
fn graceful_fs() -> aube_registry::Packument {
packument(serde_json::json!({
"name": "graceful-fs",
"dist-tags": { "latest": "4.2.11" },
"versions": {
"4.2.6": { "name": "graceful-fs", "version": "4.2.6" },
"4.2.7": {
"name": "graceful-fs",
"version": "4.2.7",
"deprecated": "Please upgrade to v4.2.8 or higher"
},
"4.2.11": { "name": "graceful-fs", "version": "4.2.11" }
}
}))
}
#[test]
fn wanted_skips_deprecated_when_the_range_admits_another() {
assert_eq!(
wanted_version(&codemirror(), "^6.0.2").as_deref(),
Some("6.0.2")
);
assert_eq!(
wanted_version(&graceful_fs(), ">=4.2.0 <4.2.8").as_deref(),
Some("4.2.6")
);
}
#[test]
fn wanted_keeps_deprecated_when_nothing_else_matches() {
assert_eq!(
wanted_version(&codemirror(), "^6.65.0").as_deref(),
Some("6.65.7")
);
assert_eq!(
wanted_version(&graceful_fs(), "4.2.7").as_deref(),
Some("4.2.7")
);
}
#[test]
fn wanted_keeps_a_deprecated_latest_dist_tag() {
let is_odd = packument(serde_json::json!({
"name": "is-odd",
"dist-tags": { "latest": "3.0.1" },
"versions": {
"3.0.0": { "name": "is-odd", "version": "3.0.0" },
"3.0.1": {
"name": "is-odd",
"version": "3.0.1",
"deprecated": "please use is-even"
}
}
}));
assert_eq!(wanted_version(&is_odd, "^3.0.0").as_deref(), Some("3.0.1"));
}
#[test]
fn wanted_prefers_the_latest_dist_tag_over_a_higher_in_range_version() {
assert_eq!(
wanted_version(&graceful_fs(), "*").as_deref(),
Some("4.2.11")
);
assert_eq!(wanted_version(&codemirror(), "*").as_deref(), Some("6.0.2"));
}
#[test]
fn policy_version_skips_fresh_latest_release() {
let package = packument(serde_json::json!({
"name": "demo",
"dist-tags": { "latest": "2.0.0" },
"versions": {
"1.0.0": { "name": "demo", "version": "1.0.0" },
"2.0.0": { "name": "demo", "version": "2.0.0" }
},
"time": {
"1.0.0": "2000-01-01T00:00:00.000Z",
"2.0.0": "2999-01-01T00:00:00.000Z"
}
}));
let minimum_release_age = aube_resolver::MinimumReleaseAge {
minutes: 1440,
exclude: aube_resolver::PackageVersionPolicy::default(),
strict: false,
};
assert_eq!(
policy_version_info(&package, "demo", "latest", Some(&minimum_release_age), None,)
.selected
.as_deref(),
Some("1.0.0")
);
assert_eq!(
policy_version_info(&package, "demo", "*", Some(&minimum_release_age), None)
.selected
.as_deref(),
Some("1.0.0")
);
let info =
policy_version_info(&package, "demo", "latest", Some(&minimum_release_age), None);
assert_eq!(info.blocked.as_deref(), Some("2.0.0"));
let installed = policy_version_info(
&package,
"demo",
"latest",
Some(&minimum_release_age),
Some("2.0.0"),
);
assert_eq!(installed.selected.as_deref(), Some("2.0.0"));
assert_eq!(installed.blocked, None);
let alias = policy_version_info(
&package,
"demo",
"npm:demo@latest",
Some(&minimum_release_age),
None,
);
assert_eq!(alias.selected.as_deref(), Some("1.0.0"));
assert_eq!(alias.blocked.as_deref(), Some("2.0.0"));
}
#[test]
fn blocked_update_map_keeps_the_newest_version() {
let mut blocked = std::collections::BTreeMap::new();
record_age_gated_update(&mut blocked, "demo".to_string(), "2.0.0".to_string());
record_age_gated_update(&mut blocked, "demo".to_string(), "3.0.0".to_string());
record_age_gated_update(&mut blocked, "demo".to_string(), "1.0.0".to_string());
assert_eq!(blocked.get("demo").map(String::as_str), Some("3.0.0"));
}
#[test]
fn scoped_name_encodes_slash() {
assert_eq!(encode_package_name("@scope/pkg"), "@scope%2Fpkg");
}
#[test]
fn plain_name_passthrough() {
assert_eq!(encode_package_name("lodash"), "lodash");
}
#[test]
fn malformed_scoped_name_passthrough() {
assert_eq!(encode_package_name("@scope"), "@scope");
}
#[test]
fn split_plain_name() {
assert_eq!(split_name_spec("lodash"), ("lodash", None));
}
#[test]
fn split_name_with_version() {
assert_eq!(
split_name_spec("lodash@4.17.21"),
("lodash", Some("4.17.21"))
);
}
#[test]
fn split_name_with_range() {
assert_eq!(split_name_spec("lodash@^4"), ("lodash", Some("^4")));
}
#[test]
fn split_name_with_tag() {
assert_eq!(split_name_spec("react@next"), ("react", Some("next")));
}
#[test]
fn split_scoped_no_version() {
assert_eq!(split_name_spec("@babel/core"), ("@babel/core", None));
}
#[test]
fn split_scoped_with_version() {
assert_eq!(
split_name_spec("@babel/core@7.0.0"),
("@babel/core", Some("7.0.0"))
);
}
}