Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
20 changes: 20 additions & 0 deletions docs/dev-tools/mise-lock.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,26 @@ Each tool entry (`[[tools.name]]`) can contain:
- **`options`** (optional): Backend-specific options that identify the artifact (e.g., `{exe = "rg", matching = "musl"}`)
- **`platforms`** (optional): Platform-specific metadata (checksums, URLs, sizes)

A tool can have several entries for the same version when its artifact identity
depends on more than the platform key. Swift, for example, publishes a different
Linux tarball per distro, so its entries record which one they describe:

```toml
[[tools.swift]]
version = "6.3.1"
backend = "core:swift"
options = { swift_platform = "ubuntu24.04" }

[[tools.swift]]
version = "6.3.1"
backend = "core:swift"
options = { swift_platform = "fedora39" }
```

Entries are matched on options exactly, so a machine only verifies against the
entry written for its own distro. Pin `swift.platform` to make every machine
resolve the same artifact, and commit the entry it produces.

### Platform Keys

The platform key format is generally `os-arch` but can be customized by backends:
Expand Down
58 changes: 58 additions & 0 deletions e2e/lockfile/test_lockfile_swift_distro
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
#!/usr/bin/env bash

# Regression test for https://github.com/jdx/mise/discussions/11295
# Swift's Linux tarballs differ per distro (ubuntu24.04, fedora39, ubi9, …) but
# the lock platform key is just linux-x64, so an entry written on one distro used
# to be matched on another and its checksum verified against a different
# tarball. Entries now record the distro in their options.
#
# Locking swift resolves URLs only — it never downloads the ~1GB toolchain.

if [[ "$(uname -s)" != Linux ]]; then
echo "skipping: swift artifacts are only distro-specific on linux"
exit 0
fi

export MISE_LOCKFILE=1

write_config() {
cat <<EOF >mise.toml
[settings]
swift.platform = "$1"
[tools]
"core:swift" = "6.3.1"
EOF
}

write_config "ubi9"
mise lock swift --platform linux-x64
assert_contains "cat mise.lock" 'swift_platform = "ubi9"'
assert_contains "cat mise.lock" 'swift-6.3.1-RELEASE-ubi9.tar.gz'

# Changing the pinned distro must produce its own entry rather than silently
# keeping the previous distro's artifact data.
write_config "ubuntu24.04"
mise lock swift --platform linux-x64
assert_contains "cat mise.lock" 'swift_platform = "ubuntu24.04"'
assert_contains "cat mise.lock" 'swift-6.3.1-RELEASE-ubuntu24.04.tar.gz'

# An entry written before the distro was recorded still pins the version, but
# its checksum — which describes an unknown distro's tarball — is not reused.
cat <<EOF >mise.lock
[[tools.swift]]
version = "6.3.1"
backend = "core:swift"
[tools.swift."platforms.linux-x64"]
checksum = "blake3:8a95707e406fa824266e95319f9f906415871df8f8dce47304a90302ec9977e8"
EOF
cat <<EOF >mise.toml
[settings]
swift.platform = "ubi9"
[tools]
"core:swift" = "6"
EOF
assert_contains "mise install --dry-run 2>&1" "swift@6.3.1"

mise lock swift --platform linux-x64
assert "grep -c '^\[\[tools.swift\]\]' mise.lock" "2"
assert_contains "cat mise.lock" 'swift-6.3.1-RELEASE-ubi9.tar.gz'
61 changes: 54 additions & 7 deletions src/backend/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ use crate::{
use crate::{dirs, env, file, hash, versions_host};
use async_trait::async_trait;
use backend_type::BackendType;
use eyre::{Result, bail, eyre};
use eyre::{Result, WrapErr, bail, eyre};
use indexmap::IndexSet;
use itertools::Itertools;
use platform_target::PlatformTarget;
Expand Down Expand Up @@ -1197,6 +1197,19 @@ pub trait Backend: Debug + Send + Sync {
true
}

/// Whether this backend's artifact-identity options describe the machine
/// that wrote a lock entry rather than the tool request.
///
/// Swift's Linux artifacts differ per distro, so its options record which
/// distro an entry describes. An entry written before those options existed
/// can't be attributed to a distro, and guessing the local one would apply
/// a foreign artifact's checksum to a different tarball. Backends that
/// return true keep such an entry's version pin but ignore its
/// checksum/URL, and are excluded from legacy option rekeying.
fn lockfile_options_are_host_specific(&self) -> bool {
false
}

async fn description(&self) -> Option<String> {
None
}
Expand Down Expand Up @@ -2826,23 +2839,42 @@ pub trait Backend: Debug + Send + Sync {
let platform_key = self.get_platform_key();

// Get or create asset info for this platform
let platform_info = tv.lock_platforms.entry(platform_key.clone()).or_default();
let locked = tv
.lock_platforms
.entry(platform_key.clone())
.or_default()
.clone();

if let Some(checksum) = &platform_info.checksum {
if let Some(checksum) = &locked.checksum {
ctx.pr.set_message(format!("checksum {filename}"));
if let Some((algo, check)) = checksum.split_once(':') {
hash::ensure_checksum(file, check, Some(ctx.pr.as_ref()), algo)?;
hash::ensure_checksum(file, check, Some(ctx.pr.as_ref()), algo).wrap_err_with(
|| {
// Name the lock entry the expectation came from: a mismatch
// usually means the entry describes a different artifact
// than the one this machine downloads (e.g. a Swift build
// for another Linux distro).
let entry = self.describe_lock_entry(tv, &platform_key);
match &locked.url {
Some(url) => format!("{entry} locks {url}"),
None => entry,
}
},
)?;
} else {
bail!("Invalid checksum: {checksum}");
}
} else if lockfile_enabled {
ctx.pr.set_message(format!("generate checksum {filename}"));
let hash = hash::file_hash_blake3(file, Some(ctx.pr.as_ref()))?;
platform_info.checksum = Some(format!("blake3:{hash}"));
tv.lock_platforms
.entry(platform_key.clone())
.or_default()
.checksum = Some(format!("blake3:{hash}"));
}

// Handle size verification and generation
if let Some(expected_size) = platform_info.size {
if let Some(expected_size) = locked.size {
ctx.pr.set_message(format!("verify size {filename}"));
let actual_size = file.metadata()?.len();
if actual_size != expected_size {
Expand All @@ -2854,11 +2886,26 @@ pub trait Backend: Debug + Send + Sync {
);
}
} else if lockfile_enabled {
platform_info.size = Some(file.metadata()?.len());
tv.lock_platforms.entry(platform_key).or_default().size = Some(file.metadata()?.len());
}
Ok(())
}

/// Human-readable identity of the lock entry consulted for `tv` on
/// `platform_key`, including the options that distinguish artifact variants.
fn describe_lock_entry(&self, tv: &ToolVersion, platform_key: &str) -> String {
let options = self
.resolve_lockfile_options(&tv.request, &PlatformTarget::from_current())
.unwrap_or_default();
let entry = format!("lockfile entry for {} on {platform_key}", tv.style());
if options.is_empty() {
entry
} else {
let options = options.iter().map(|(k, v)| format!("{k}={v}")).join(", ");
format!("{entry} [{options}]")
}
}

async fn outdated_info(
&self,
_config: &Arc<Config>,
Expand Down
18 changes: 12 additions & 6 deletions src/cli/lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1052,11 +1052,15 @@ impl Lock {
}
pr.set_message(format!("{}@{} {}", short, version, platform_key));
pr.set_position(completed);
if let Err(e) = lockfile::apply_lock_result(lockfile, resolution) {
provenance_errors.push(e.to_string());
results.push((short, platform_key, false));
} else {
results.push((short, platform_key, ok));
match lockfile::apply_lock_result(lockfile, resolution) {
Err(e) => {
provenance_errors.push(e.to_string());
results.push((short, platform_key, false));
}
// A resolution that wrote nothing is a skip, not an
// update — backends that can't resolve metadata without
// installing return empty info rather than an error.
Ok(applied) => results.push((short, platform_key, ok && applied)),
}
}
Err(e) => {
Expand All @@ -1065,7 +1069,9 @@ impl Lock {
}
}

pr.finish_with_message(format!("{} platform entries", total_tasks));
// Report entries actually written, not tasks attempted
let updated = results.iter().filter(|(_, _, ok)| *ok).count();
pr.finish_with_message(format!("{} platform entries", updated));

Ok((results, provenance_errors))
}
Expand Down
84 changes: 76 additions & 8 deletions src/lockfile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,28 @@ impl PlatformInfo {
&& self.additional_artifacts.is_empty()
}

/// Drop the fields that describe one specific downloaded artifact, keeping
/// the fields that describe how the tool is built or what it depends on.
/// Used when an entry is known to be for the right tool version but not
/// necessarily the right artifact.
pub fn without_artifact_data(&self) -> Self {
PlatformInfo {
install: self.install.clone(),
conda_deps: self.conda_deps.clone(),
pkgx_deps: self.pkgx_deps.clone(),
pkgx_provides: self.pkgx_provides.clone(),
pkgx_runtime_env: self.pkgx_runtime_env.clone(),
checksum: None,
size: None,
url: None,
url_api: None,
provenance: None,
provenance_verified: false,
github_attestations: None,
additional_artifacts: Default::default(),
}
}

/// True when the lockfile has checksum-backed, successfully verified provenance.
pub fn has_checksum_and_verified_provenance(&self) -> bool {
self.checksum.is_some() && self.provenance.is_some() && self.provenance_verified
Expand Down Expand Up @@ -2643,11 +2665,17 @@ pub async fn resolve_tool_lock_info(
/// Apply a lock resolution result to a lockfile, updating platform info and conda packages.
/// Only applies data when the resolution succeeded (info is `Ok`).
///
/// Returns whether the resolution contributed any data. A backend that can't
/// resolve metadata without installing falls back to an empty `PlatformInfo`,
/// which is `Ok` but writes no entry — callers report those as skipped rather
/// than claiming an update they didn't make.
///
/// Returns an error if a github backend tool loses provenance on version upgrade,
/// which could indicate a supply chain attack.
pub fn apply_lock_result(lockfile: &mut Lockfile, result: LockResolutionResult) -> Result<()> {
pub fn apply_lock_result(lockfile: &mut Lockfile, result: LockResolutionResult) -> Result<bool> {
let (short, version, backend, platform, info, options, conda_packages, pkgx_packages) = result;
let platform_key = platform.to_key();
let mut applied = false;
if let Ok(ref info) = info {
if let Some(err) = check_single_tool_provenance(
lockfile.tools.get(&short).map(Vec::as_slice),
Expand All @@ -2659,6 +2687,7 @@ pub fn apply_lock_result(lockfile: &mut Lockfile, result: LockResolutionResult)
) {
return Err(eyre!("{err}"));
}
applied |= !info.is_empty();
lockfile.set_platform_info(
&short,
&version,
Expand All @@ -2669,12 +2698,14 @@ pub fn apply_lock_result(lockfile: &mut Lockfile, result: LockResolutionResult)
);
}
for (basename, pkg_info) in conda_packages {
applied = true;
lockfile.set_conda_package(&platform_key, &basename, pkg_info);
}
for (id, pkg_info) in pkgx_packages {
applied = true;
lockfile.set_pkgx_package(&platform_key, &id, pkg_info);
}
Ok(())
Ok(applied)
}

type RekeyDecisions = HashMap<(String, String), Option<BTreeMap<String, String>>>;
Expand Down Expand Up @@ -2723,6 +2754,9 @@ fn build_rekey_decisions(
for platform_key in platforms {
let resolved = Platform::parse(platform_key).ok().and_then(|platform| {
let backend = tv.request.backend().ok()?;
if backend.lockfile_options_are_host_specific() {
return None;
}
backend
.resolve_lockfile_options(&tv.request, &PlatformTarget::new(platform))
.ok()
Expand Down Expand Up @@ -3030,13 +3064,19 @@ pub(crate) fn read_lockfile_for_tool_source(
.clone())
}

/// `legacy_options_fallback` lets a backend whose options describe the writing
/// host (see `Backend::lockfile_options_are_host_specific`) fall back to an
/// entry written before those options existed. The version pin is honored — it
/// is the only record of what this project resolved to — but the artifact data
/// is dropped, since there's no way to tell which variant it describes.
pub fn get_locked_version(
config: &Config,
path: Option<&Path>,
short: &str,
prefix: &str,
require_prefix_boundary: bool,
request_options: &BTreeMap<String, String>,
legacy_options_fallback: bool,
) -> Result<Option<LockfileTool>> {
let settings = Settings::get();
if !settings.lockfile_enabled() {
Expand All @@ -3058,15 +3098,15 @@ pub fn get_locked_version(
};

if let Some(tools) = lockfile.tools.get(short) {
let version_matches = |v: &LockfileTool| {
lockfile_version_matches(prefix, &v.version)
&& (!require_prefix_boundary
|| lockfile_version_matches_prefix_boundary(prefix, &v.version))
};
// Filter by version prefix and options
let mut matching: Vec<_> = tools
.iter()
.filter(|v| {
lockfile_version_matches(prefix, &v.version)
&& (!require_prefix_boundary
|| lockfile_version_matches_prefix_boundary(prefix, &v.version))
&& &v.options == request_options
})
.filter(|v| version_matches(v) && &v.options == request_options)
.collect();

// Only sort when prefix is "latest" and we have multiple matches
Expand All @@ -3081,6 +3121,34 @@ pub fn get_locked_version(
trace!("[{short}@{prefix}] found {} in lockfile", found.version);
return Ok(Some((*found).clone()));
}

if legacy_options_fallback && !request_options.is_empty() {
let mut legacy: Vec<_> = tools
.iter()
.filter(|v| version_matches(v) && v.options.is_empty())
.collect();
if prefix == "latest" && legacy.len() > 1 {
legacy.sort_by(|a, b| {
versions::Versioning::new(&b.version)
.cmp(&versions::Versioning::new(&a.version))
});
}
if let Some(found) = legacy.first() {
trace!(
"[{short}@{prefix}] found {} in lockfile without options, keeping the version pin and dropping its artifact data",
found.version
);
let mut found = (*found).clone();
found.options = request_options.clone();
found.platforms = found
.platforms
.into_iter()
.map(|(key, info)| (key, info.without_artifact_data()))
.filter(|(_, info)| !info.is_empty())
.collect();
return Ok(Some(found));
}
}
}

Ok(None)
Expand Down
Loading
Loading