Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions e2e/backend/test_npm_install_before
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# Test minimum_release_age with npm backend (dist-tag fallback)
# Test minimum_release_age with npm backend (dist-tag date fallback)
# Regression test for https://github.com/jdx/mise/discussions/9136

export NPM_CONFIG_FUND=false
Expand All @@ -9,8 +9,8 @@ mise use node
# Test: mise latest with minimum_release_age should resolve to an older version
# prettier 3.0.0 was released 2023-07-05, 2.8.8 was released 2023-05-23
# Setting minimum_release_age to 2023-06-01 should give us 2.8.8 (not 3.x)
# This must bypass NPMBackend::latest_stable_version because npm dist-tags
# always return the absolute latest version.
# This must fall back after detecting NPMBackend::latest_stable_version is
# newer than the cutoff, because npm dist-tags return the absolute latest.
export MISE_MINIMUM_RELEASE_AGE="2023-06-01"
assert_contains "mise latest npm:prettier" "2.8.8"
unset MISE_MINIMUM_RELEASE_AGE
Expand Down
176 changes: 149 additions & 27 deletions src/backend/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ use crate::cli::args::{BackendArg, ToolVersionType};
use crate::cmd::CmdLineRunner;
use crate::config::config_file::config_root;
use crate::config::{Config, Settings};
use crate::duration::parse_into_timestamp;
use crate::file::{display_path, remove_all_with_progress, remove_all_with_warning};
use crate::install_before::resolve_before_date;
use crate::install_context::InstallContext;
Expand Down Expand Up @@ -1035,8 +1036,7 @@ pub trait Backend: Debug + Send + Sync {
/// Backend-specific fast path for the absolute latest stable version.
///
/// Do not call this from CLI/toolset code. Use `latest_version` instead so
/// `minimum_release_age` / `--before` cutoffs are resolved before this fast path
/// is used.
/// `minimum_release_age` / `--before` cutoffs are handled around this fast path.
///
/// Return `Ok(None)` when the backend does not have a fast path result.
/// `latest_version` centrally falls back to the shared version-list path,
Expand Down Expand Up @@ -1268,11 +1268,11 @@ pub trait Backend: Debug + Send + Sync {
/// Get the latest version, optionally filtered by release date.
///
/// `latest_stable_version` may use backend-specific fast paths (dist tags,
/// latest release endpoints, plugin scripts). Those fast paths return the
/// absolute latest stable version, so only use them when no install-before
/// cutoff is active. If the fast path returns `None`, fall back to the
/// shared version-list path here instead of duplicating that fallback in
/// each backend.
/// latest release endpoints, plugin scripts). If the fast path returns
/// `None`, fall back to the shared version-list path here instead of
/// duplicating that fallback in each backend. When a cutoff is active,
/// accept the fast path result only when remote-version metadata verifies
/// that the candidate is older than the cutoff.
async fn latest_version(
&self,
config: &Arc<Config>,
Expand Down Expand Up @@ -1300,13 +1300,25 @@ pub trait Backend: Debug + Send + Sync {
) -> eyre::Result<Option<String>> {
let before_date = effective_latest_before_date(self, config, before_date).await?;
let resolved_query = query.as_deref().unwrap_or("latest");
let mut fallback_refresh = refresh;
if resolved_query == "latest"
&& before_date.is_none()
&& let Some(version) = self.latest_stable_version(config).await?
{
return Ok(Some(version));
match before_date {
Some(before) => {
let versions = self
.list_remote_versions_with_info_with_refresh(config, refresh)
.await?;
fallback_refresh = false;
let info = versions.iter().find(|v| v.version == version);
if latest_stable_candidate_allowed_by_before_date(&version, info, before) {
return Ok(Some(version));
}
}
None => return Ok(Some(version)),
}
}
self.latest_version_for_query(config, resolved_query, before_date, refresh)
self.latest_version_for_query(config, resolved_query, before_date, fallback_refresh)
.await
}
fn latest_installed_version(&self, query: Option<String>) -> eyre::Result<Option<String>> {
Expand Down Expand Up @@ -2173,6 +2185,34 @@ async fn effective_latest_before_date<B: Backend + ?Sized>(
resolve_before_date(None, opts.minimum_release_age())
}

fn latest_stable_candidate_allowed_by_before_date(
version: &str,
info: Option<&VersionInfo>,
before: Timestamp,
) -> bool {
let Some(info) = info else {
debug!(
"Latest stable version {version} is missing from remote version metadata; falling back to full version list"
);
return false;
};
let Some(created_at) = info.created_at.as_deref() else {
debug!(
"Latest stable version {version} has no release date metadata; falling back to full version list"
);
return false;
};
match parse_into_timestamp(created_at) {
Ok(created) => created < before,
Err(err) => {
debug!(
"Failed to parse release date for latest stable version {version}: {created_at}: {err:#}"
);
false
}
}
}

#[cfg(test)]
mod latest_version_tests {
use super::*;
Expand All @@ -2187,6 +2227,7 @@ mod latest_version_tests {
struct LatestBackend {
ba: Arc<BackendArg>,
stable_result: Option<String>,
remote_versions: Vec<VersionInfo>,
stable_calls: AtomicUsize,
list_calls: AtomicUsize,
}
Expand All @@ -2196,6 +2237,18 @@ mod latest_version_tests {
Self {
ba: Arc::new(name.into()),
stable_result: Some("9.9.9".to_string()),
remote_versions: vec![
VersionInfo {
version: "1.0.0".to_string(),
created_at: Some("2024-01-01".to_string()),
..Default::default()
},
VersionInfo {
version: "2.0.0".to_string(),
created_at: Some("2025-01-01".to_string()),
..Default::default()
},
],
stable_calls: AtomicUsize::new(0),
list_calls: AtomicUsize::new(0),
}
Expand Down Expand Up @@ -2226,18 +2279,7 @@ mod latest_version_tests {
_config: &Arc<Config>,
) -> eyre::Result<Vec<VersionInfo>> {
self.list_calls.fetch_add(1, Ordering::SeqCst);
Ok(vec![
VersionInfo {
version: "1.0.0".to_string(),
created_at: Some("2024-01-01".to_string()),
..Default::default()
},
VersionInfo {
version: "2.0.0".to_string(),
created_at: Some("2025-01-01".to_string()),
..Default::default()
},
])
Ok(self.remote_versions.clone())
}

async fn latest_stable_version(
Expand Down Expand Up @@ -2286,9 +2328,60 @@ mod latest_version_tests {
}

#[tokio::test]
async fn test_date_filtered_latest_bypasses_latest_stable_version() {
async fn test_date_filtered_latest_uses_stable_when_not_newer() {
let config = Config::get().await.unwrap();
let backend =
LatestBackend::new("test-latest-before-date-allowed").with_stable_result(Some("1.0.0"));
backend
.get_remote_version_cache()
.lock()
.await
.clear()
.unwrap();
let before = crate::duration::parse_into_timestamp("2024-06-01").unwrap();

assert_eq!(
backend
.latest_version(&config, Some("latest".to_string()), Some(before))
.await
.unwrap()
.as_deref(),
Some("1.0.0")
);
assert_eq!(backend.stable_calls(), 1);
assert_eq!(backend.list_calls(), 1);
}

#[tokio::test]
async fn test_date_filtered_latest_falls_back_when_stable_is_newer() {
let config = Config::get().await.unwrap();
let backend =
LatestBackend::new("test-latest-before-date-newer").with_stable_result(Some("2.0.0"));
backend
.get_remote_version_cache()
.lock()
.await
.clear()
.unwrap();
let before = crate::duration::parse_into_timestamp("2024-06-01").unwrap();

assert_eq!(
backend
.latest_version(&config, Some("latest".to_string()), Some(before))
.await
.unwrap()
.as_deref(),
Some("1.0.0")
);
assert_eq!(backend.stable_calls(), 1);
assert_eq!(backend.list_calls(), 1);
}

#[tokio::test]
async fn test_date_filtered_latest_falls_back_when_stable_metadata_is_missing() {
let config = Config::get().await.unwrap();
let backend = LatestBackend::new("test-latest-before-date");
let backend = LatestBackend::new("test-latest-before-date-missing-metadata")
.with_stable_result(Some("3.0.0"));
backend
.get_remote_version_cache()
.lock()
Expand All @@ -2305,10 +2398,37 @@ mod latest_version_tests {
.as_deref(),
Some("1.0.0")
);
assert_eq!(backend.stable_calls(), 0);
assert_eq!(backend.stable_calls(), 1);
assert_eq!(backend.list_calls(), 1);
}

#[test]
fn test_latest_stable_candidate_rejects_unverified_cutoff_metadata() {
let before = crate::duration::parse_into_timestamp("2024-06-01").unwrap();

assert!(!latest_stable_candidate_allowed_by_before_date(
"1.0.0", None, before
));
assert!(!latest_stable_candidate_allowed_by_before_date(
"1.0.0",
Some(&VersionInfo {
version: "1.0.0".to_string(),
created_at: None,
..Default::default()
}),
before
));
assert!(!latest_stable_candidate_allowed_by_before_date(
"1.0.0",
Some(&VersionInfo {
version: "1.0.0".to_string(),
created_at: Some("not-a-date".to_string()),
..Default::default()
}),
before
));
}

#[tokio::test]
async fn test_offline_remote_versions_use_cache_without_fetching() {
let config = Config::get().await.unwrap();
Expand Down Expand Up @@ -2435,6 +2555,7 @@ mod latest_version_tests {
let backend = LatestBackend {
ba: Arc::new(ba),
stable_result: Some("9.9.9".to_string()),
remote_versions: vec![],
stable_calls: AtomicUsize::new(0),
list_calls: AtomicUsize::new(0),
};
Expand All @@ -2450,7 +2571,8 @@ mod latest_version_tests {
let config = Config::get().await.unwrap();
// The test fixture has a `tiny` config entry without install_before.
// Inline backend opts must still win when a config entry exists.
let backend = LatestBackend::new("tiny[install_before=2024-06-01]");
let backend =
LatestBackend::new("tiny[install_before=2024-06-01]").with_stable_result(Some("2.0.0"));
backend
.get_remote_version_cache()
.lock()
Expand All @@ -2466,7 +2588,7 @@ mod latest_version_tests {
.as_deref(),
Some("1.0.0")
);
assert_eq!(backend.stable_calls(), 0);
assert_eq!(backend.stable_calls(), 1);
assert_eq!(backend.list_calls(), 1);
}
}
Expand Down
Loading