diff --git a/e2e/lockfile/test_lockfile_locked_mode b/e2e/lockfile/test_lockfile_locked_mode new file mode 100644 index 0000000000..8257cf4e8d --- /dev/null +++ b/e2e/lockfile/test_lockfile_locked_mode @@ -0,0 +1,40 @@ +#!/usr/bin/env bash + +# Test --locked mode enforcement during install and dry-run + +export MISE_LOCKFILE=1 + +detect_platform +PLATFORM="$MISE_PLATFORM" + +# Use jq via aqua backend, which supports lockfile URLs +cat <<'EOF' >mise.toml +[tools] +jq = "1.7.1" +EOF + +# Uninstall jq so it's not already present +mise uninstall jq@1.7.1 2>/dev/null || true + +# --- Test 1: --locked fails when no lockfile URL exists --- +cat <mise.lock +[[tools.jq]] +version = "1.7.1" +backend = "aqua:jqlang/jq" +EOF + +assert_fail_contains "mise install --locked 2>&1" "No lockfile URL found" + +# --- Test 2: --locked --dry-run also fails when no lockfile URL exists --- +# This is the key behavior: locked validation must run before dry-run short-circuit +assert_fail_contains "mise install --locked --dry-run 2>&1" "No lockfile URL found" + +# --- Test 3: --locked --dry-run succeeds when lockfile URL exists --- +cat <mise.lock +[[tools.jq]] +version = "1.7.1" +backend = "aqua:jqlang/jq" +"platforms.$PLATFORM" = { url = "https://example.com/jq-1.7.1.tar.gz" } +EOF + +assert_contains "mise install --locked --dry-run 2>&1" "would install" diff --git a/src/backend/mod.rs b/src/backend/mod.rs index 8089e5f040..10a43f4c85 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -861,6 +861,27 @@ pub trait Backend: Debug + Send + Sync { ctx: InstallContext, tv: ToolVersion, ) -> eyre::Result { + // Check for --locked mode: if enabled and no lockfile URL exists, fail early + // Exempt tool stubs from lockfile requirements since they are ephemeral + // Also exempt backends that don't support URL locking (e.g., Rust uses rustup) + // This must run before the dry-run check so that --locked --dry-run still validates + if ctx.locked && !tv.request.source().is_tool_stub() && self.supports_lockfile_url() { + let platform_key = self.get_platform_key(); + let has_lockfile_url = tv + .lock_platforms + .get(&platform_key) + .and_then(|p| p.url.as_ref()) + .is_some(); + if !has_lockfile_url { + bail!( + "No lockfile URL found for {} on platform {} (--locked mode)\n\ + hint: Run `mise lock` to generate lockfile URLs, or disable locked mode", + tv.style(), + platform_key + ); + } + } + // Handle dry-run mode early to avoid plugin installation if ctx.dry_run { use crate::ui::progress_report::ProgressIcon; @@ -896,25 +917,6 @@ pub trait Backend: Debug + Send + Sync { } else if self.is_version_installed(&ctx.config, &tv, true) { return Ok(tv); } - // Check for --locked mode: if enabled and no lockfile URL exists, fail early - // Exempt tool stubs from lockfile requirements since they are ephemeral - // Also exempt backends that don't support URL locking (e.g., Rust uses rustup) - if ctx.locked && !tv.request.source().is_tool_stub() && self.supports_lockfile_url() { - let platform_key = self.get_platform_key(); - let has_lockfile_url = tv - .lock_platforms - .get(&platform_key) - .and_then(|p| p.url.as_ref()) - .is_some(); - if !has_lockfile_url { - bail!( - "No lockfile URL found for {} on platform {} (--locked mode)\n\ - hint: Run `mise lock` to generate lockfile URLs, or disable locked mode", - tv.style(), - platform_key - ); - } - } // Track the installation asynchronously (fire-and-forget) // Do this before install so the request has time to complete during installation