diff --git a/Cargo.lock b/Cargo.lock index 67f5c23b5c1..93ae47f3a39 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -306,7 +306,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -317,7 +317,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1480,7 +1480,7 @@ dependencies = [ "bitflags 2.13.1", "cexpr", "clang-sys", - "itertools 0.10.5", + "itertools 0.13.0", "log", "prettyplease", "proc-macro2", @@ -2204,7 +2204,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3011,7 +3011,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3300,7 +3300,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4267,7 +4267,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a791e6620676a875f362f3156ed213e73ca099a09bf992c18812abe65cc37b1" dependencies = [ "bstr", - "hashbrown 0.15.5", + "hashbrown 0.17.1", ] [[package]] @@ -5857,7 +5857,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "160f2eade097f30263b548aae5deb12ad349c909baa710fa24b92c9090b2e006" dependencies = [ "scopeguard", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5910,7 +5910,7 @@ version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a1886916523694cd6ea3d175f03a1e5010699a2a4cc13696d83d7bea1d80638" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6728,7 +6728,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6972,7 +6972,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7248,7 +7248,7 @@ dependencies = [ "aes-gcm", "aes-kw", "argon2", - "base64 0.21.7", + "base64 0.22.1", "bitfields", "block-padding", "blowfish", @@ -7772,7 +7772,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7907,9 +7907,9 @@ dependencies = [ [[package]] name = "rattler" -version = "0.48.1" +version = "0.48.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24b9d30445441401c04b874996cfcd1dfbb617a28b76a5a98367ad58e121c3a7" +checksum = "61bfdcb39e1719edbb27e562d40405c1a8130eb3fff8b11b489672b642a488af" dependencies = [ "anyhow", "astral-reqwest-middleware", @@ -7954,9 +7954,9 @@ dependencies = [ [[package]] name = "rattler_cache" -version = "0.10.4" +version = "0.10.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc91045d53da3c63c81b7666119a1f73ed2a69473e7b451649b62397d9148b77" +checksum = "b1ea7ff3474f2c5aceb5fe741061ad744f164e050de40dc25608c5ae0e66753c" dependencies = [ "ahash", "anyhow", @@ -7989,9 +7989,9 @@ dependencies = [ [[package]] name = "rattler_conda_types" -version = "0.49.0" +version = "0.49.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "776dcc9348ae7e1843e1320ad1c1afc375bd2b73faeaa1bb49b1e002575f7791" +checksum = "058c739d6ea6b3f429443f9d7117022330049bdb1cdde7b46622662463bd5cc0" dependencies = [ "ahash", "core-foundation 0.10.1", @@ -8061,9 +8061,9 @@ dependencies = [ [[package]] name = "rattler_menuinst" -version = "0.2.72" +version = "0.2.73" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68a6bc4214923ccd4ae3d2eab36c3bf8d4a91552ad83cdc4a67f713346dace9a" +checksum = "b6269634b94b5a9be52cf45bf9368819b33268a4372e76f9c8f9c64c9275a64a" dependencies = [ "configparser", "dirs", @@ -8092,9 +8092,9 @@ dependencies = [ [[package]] name = "rattler_networking" -version = "0.30.3" +version = "0.30.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1789dae98aabeedffa2d28f0fd218da55ca21746d7f4b8e909f80525cf3bef5" +checksum = "e668ff6375b364d8835cf15d51179ac7366aced2a39d60877041d099a421fd06" dependencies = [ "ambient-id", "anyhow", @@ -8120,9 +8120,9 @@ dependencies = [ [[package]] name = "rattler_package_streaming" -version = "0.26.9" +version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2bb89525bd5df28e9abcbba31e5c97b029175e66433a43ed9087792d5b0bdb7" +checksum = "e7ca685c8e3a5ffecd6ad1e9c938753993ed05220c050a070a847ae541797a2e" dependencies = [ "astral-reqwest-middleware", "astral-tokio-tar", @@ -8130,6 +8130,7 @@ dependencies = [ "astral_async_zip", "async-compression", "async-spooled-tempfile", + "bytes", "bzip2", "filetime", "fs-err", @@ -8184,9 +8185,9 @@ dependencies = [ [[package]] name = "rattler_repodata_gateway" -version = "0.31.0" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a562674a44022e676a4608f29f2f7d35e71de0386148dc5bfa72765e72676684" +checksum = "466a4335b63701b2eeb0888e66c82e034589998007ee68eceb30f75d5bc643b7" dependencies = [ "ahash", "anyhow", @@ -8247,9 +8248,9 @@ dependencies = [ [[package]] name = "rattler_shell" -version = "0.27.12" +version = "0.27.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06e484e0ac0106175cf7f2ae19a7c6d3da2af4b0434b5fb629ebe7b9dcc54542" +checksum = "27e6d9e4b73759112b0560c85c3fe3c9619386ed11700daa7b068d02fcd0a766" dependencies = [ "anyhow", "enum_dispatch", @@ -8268,9 +8269,9 @@ dependencies = [ [[package]] name = "rattler_solve" -version = "9.0.0" +version = "9.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47428b4adefb08d4b7b915f29a3ea716f6a329b75723355ea1eb63fc0a4eaac2" +checksum = "15139fa95d92a22fe4ba6b00ee89ed6b2375550f1c51036b202c51f771c0d29a" dependencies = [ "futures", "hex", @@ -8287,9 +8288,9 @@ dependencies = [ [[package]] name = "rattler_virtual_packages" -version = "4.1.0" +version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5bb5ca36e7e3c22c77ebb49737401d1ab4dd47a40fcdab19adb4a0319767b5" +checksum = "3fbade435086e26b977cd2e2a06afa6e5815de03c34af6b39e5a309cbc731196" dependencies = [ "archspec", "libloading 0.9.0", @@ -8299,8 +8300,11 @@ dependencies = [ "rattler_conda_types", "regex", "serde", + "serde_json", + "tempfile", "thiserror 2.0.19", "tracing", + "windows-sys 0.61.2", "winver", ] @@ -8780,7 +8784,7 @@ dependencies = [ "errno 0.3.14", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -8839,7 +8843,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -9508,7 +9512,7 @@ version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ - "errno 0.2.8", + "errno 0.3.14", "libc", ] @@ -9822,7 +9826,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -10177,10 +10181,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -10248,7 +10252,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -11361,7 +11365,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 5fd5f0156b3..037c849319b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -151,14 +151,14 @@ phf = "0.14" rand = "0.10" rattler = { version = "0.48", default-features = false } rattler_conda_types = { version = "0.49", default-features = false } -rattler_repodata_gateway = { version = "0.31", default-features = false, features = [ +rattler_repodata_gateway = { version = "0.32", default-features = false, features = [ "gateway", ] } rattler_solve = { version = "9", default-features = false, features = [ "resolvo", ] } -rattler_package_streaming = { version = "0.26", default-features = false } -rattler_virtual_packages = { version = "4", default-features = false } +rattler_package_streaming = { version = "0.27", default-features = false } +rattler_virtual_packages = { version = "5", default-features = false } reflink-copy = "0.1" regex = "1" plist = "1" diff --git a/e2e/lockfile/test_lockfile_conda_cross_platform b/e2e/lockfile/test_lockfile_conda_cross_platform new file mode 100644 index 00000000000..ae0e2236899 --- /dev/null +++ b/e2e/lockfile/test_lockfile_conda_cross_platform @@ -0,0 +1,21 @@ +#!/usr/bin/env bash + +# Regression test for https://github.com/jdx/mise/discussions/11944. +# Foreign-platform conda solves need usable virtual package defaults, and a +# failed re-solve must not delete an existing platform's dependency metadata. + +export MISE_LOCKFILE=1 + +cat <mise.toml +[tools] +"conda:ffmpeg" = "7.1.1" +EOF + +mise lock --platform linux-x64 +assert_contains "cat mise.lock" 'platforms.linux-x64' +assert_contains "cat mise.lock" 'conda_deps = [' +assert_contains "cat mise.lock" '[conda-packages.linux-x64.' + +cp mise.lock mise.lock.before +assert_fail "CONDA_OVERRIDE_GLIBC=0 mise lock --platform linux-x64" +assert_succeed "cmp -s mise.lock.before mise.lock" diff --git a/src/backend/conda.rs b/src/backend/conda.rs index ee660a9f611..fa84e44d5f0 100644 --- a/src/backend/conda.rs +++ b/src/backend/conda.rs @@ -116,10 +116,14 @@ impl CondaBackend { } } - fn detect_virtual_packages(platform: CondaPlatform) -> Vec { - VirtualPackages::detect_for_platform(platform, &VirtualPackageOverrides::default()) - .map(|vp| vp.into_generic_virtual_packages().collect()) - .unwrap_or_default() + fn detect_virtual_packages(platform: CondaPlatform) -> Result> { + VirtualPackages::detect_for_platform( + platform, + &VirtualPackageOverrides::from_env(), + Some(&dirs::CACHE.join("conda")), + ) + .map(|vp| vp.into_generic_virtual_packages().collect()) + .map_err(|e| eyre::eyre!("failed to detect conda virtual packages: {e}")) } /// Deduplicate records that share the same archive identifier @@ -162,7 +166,7 @@ impl CondaBackend { .to_vec(); let flat_records = Self::flatten_repodata(&repodata); - let virtual_packages = Self::detect_virtual_packages(platform); + let virtual_packages = Self::detect_virtual_packages(platform)?; let task = SolverTask { available_packages: [flat_records.as_slice()], @@ -917,28 +921,15 @@ impl Backend for CondaBackend { let tool_name = self.tool_name(); let spec_str = format!("{}=={}", tool_name, tv.version); - let match_spec = match MatchSpec::from_str(&spec_str, ParseStrictness::Lenient) { - Ok(s) => s, - Err(e) => { - debug!("invalid conda spec '{}': {}", spec_str, e); - return Ok(PlatformInfo::default()); - } - }; + let match_spec = MatchSpec::from_str(&spec_str, ParseStrictness::Lenient) + .map_err(|e| eyre::eyre!("invalid conda spec '{spec_str}': {e}"))?; let raw_opts = tv.request.options(); let opts = CondaOptions::new(&raw_opts); - let records = match self.solve_packages(vec![match_spec], platform, opts).await { - Ok(r) => r, - Err(e) => { - debug!( - "failed to resolve {} for {}: {}", - tool_name, - target.to_key(), - e - ); - return Ok(PlatformInfo::default()); - } - }; + let records = self + .solve_packages(vec![match_spec], platform, opts) + .await + .wrap_err_with(|| format!("failed to solve {tool_name} for {}", target.to_key()))?; let tool_name_norm = tool_name.to_lowercase(); let mut main_record = None; @@ -952,17 +943,20 @@ impl Backend for CondaBackend { } } - match main_record { - Some(main) => Ok(PlatformInfo { - url: Some(main.url.to_string()), - checksum: Self::format_sha256(&main), - size: None, - url_api: None, - conda_deps: Some(dep_basenames), - ..Default::default() - }), - None => Ok(PlatformInfo::default()), - } + let main = main_record.ok_or_else(|| { + eyre::eyre!( + "conda solve for {tool_name} on {} did not return the requested package", + target.to_key() + ) + })?; + Ok(PlatformInfo { + url: Some(main.url.to_string()), + checksum: Self::format_sha256(&main), + size: None, + url_api: None, + conda_deps: Some(dep_basenames), + ..Default::default() + }) } async fn list_bin_paths( diff --git a/src/cli/lock.rs b/src/cli/lock.rs index 8839af987c8..9ee7781b041 100644 --- a/src/cli/lock.rs +++ b/src/cli/lock.rs @@ -120,12 +120,28 @@ struct LockTaskResult { status: LockTaskStatus, } +#[derive(Debug, Eq, PartialEq)] enum LockTaskStatus { Updated, Unresolved, + Failed, ProvenanceFailed, } +fn classify_lock_result( + resolution_error: Option, + error_is_fatal: bool, + applied: bool, +) -> (LockTaskStatus, Option) { + if let Some(error) = resolution_error.filter(|_| error_is_fatal) { + (LockTaskStatus::Failed, Some(error)) + } else if applied { + (LockTaskStatus::Updated, None) + } else { + (LockTaskStatus::Unresolved, None) + } +} + impl Lock { pub async fn run(self) -> Result<()> { let settings = Settings::get(); @@ -179,7 +195,7 @@ impl Lock { let lockfile_targets = self.get_lockfile_targets(&config, effective_config_files, &scoped_config_paths); let mut has_lock_targets = false; - let mut all_provenance_errors: Vec = Vec::new(); + let mut all_resolution_errors: Vec = Vec::new(); let mut all_platform_regressions: Vec = Vec::new(); let mut all_changes: Vec = Vec::new(); @@ -315,10 +331,10 @@ impl Lock { // compare against old version entries. Actual pruning happens after. let stale_versions = self.stale_versions_if_pruned(&lockfile, &tools); - let (results, provenance_errors) = self + let (results, resolution_errors) = self .process_tools(&settings, &tools, &target_platforms, &mut lockfile) .await?; - all_provenance_errors.extend(provenance_errors); + all_resolution_errors.extend(resolution_errors); let platform_regressions = self.platform_regression_errors(&lockfile, &stale_versions, &results); @@ -331,7 +347,7 @@ impl Lock { self.prune_stale_versions(&mut lockfile, &tools); self.show_stale_version_prune_message(lockfile_path, &stale_versions, false)?; - // Save lockfile before raising provenance errors so non-regressing + // Save lockfile before raising resolution errors so non-regressing // tools' entries are preserved lockfile.write(lockfile_path)?; @@ -402,7 +418,7 @@ impl Lock { miseprintln!("{}", serde_json::to_string_pretty(&all_changes)?); } - all_platform_regressions.extend(all_provenance_errors); + all_platform_regressions.extend(all_resolution_errors); if !all_platform_regressions.is_empty() { return Err(eyre::eyre!(all_platform_regressions.join("\n"))); } @@ -1119,10 +1135,10 @@ impl Lock { } // Collect all results - // Defer provenance errors until after all results are applied so unaffected + // Defer resolution errors until after all results are applied so unaffected // tools' entries aren't lost. let mut completed = 0; - let mut provenance_errors: Vec = Vec::new(); + let mut resolution_errors: Vec = Vec::new(); while let Some(result) = jset.join_next().await { completed += 1; match result { @@ -1130,15 +1146,16 @@ impl Lock { let short = resolution.0.clone(); let version = resolution.1.clone(); let platform_key = resolution.3.to_key(); - let ok = resolution.4.is_ok(); - if let Err(msg) = &resolution.4 { + let resolution_error = resolution.4.as_ref().err().cloned(); + if let Some(msg) = &resolution_error { debug!("{msg}"); } + let error_is_fatal = resolution.8; pr.set_message(format!("{}@{} {}", short, version, platform_key)); pr.set_position(completed); match lockfile::apply_lock_result(lockfile, resolution) { Err(e) => { - provenance_errors.push(e.to_string()); + resolution_errors.push(e.to_string()); results.push(LockTaskResult { short, version, @@ -1149,16 +1166,19 @@ impl Lock { // 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(LockTaskResult { - short, - version, - platform: platform_key, - status: if ok && applied { - LockTaskStatus::Updated - } else { - LockTaskStatus::Unresolved - }, - }), + Ok(applied) => { + let (status, resolution_error) = + classify_lock_result(resolution_error, error_is_fatal, applied); + if let Some(error) = resolution_error { + resolution_errors.push(error); + } + results.push(LockTaskResult { + short, + version, + platform: platform_key, + status, + }); + } } } Err(e) => { @@ -1174,7 +1194,7 @@ impl Lock { .count(); pr.finish_with_message(format!("{} platform entries", updated)); - Ok((results, provenance_errors)) + Ok((results, resolution_errors)) } } @@ -1195,9 +1215,10 @@ static AFTER_LONG_HELP: &str = color_print::cstr!( #[cfg(test)] mod tests { - use super::{Lock, LockTaskResult, LockTaskStatus}; + use super::{Lock, LockTaskResult, LockTaskStatus, classify_lock_result}; use crate::cli::args::ToolArg; - use crate::lockfile::{Lockfile, PlatformInfo}; + use crate::lockfile::{Lockfile, PlatformInfo, apply_lock_result}; + use crate::platform::Platform; use crate::toolset::{ToolRequest, ToolSource, ToolVersion}; use std::collections::BTreeMap; use std::str::FromStr; @@ -1236,6 +1257,64 @@ mod tests { lockfile } + #[test] + fn test_resolution_error_is_fatal_instead_of_skipped() { + let error = "conda solve failed".to_string(); + let (status, returned_error) = classify_lock_result(Some(error.clone()), true, false); + + assert_eq!(status, LockTaskStatus::Failed); + assert_eq!(returned_error, Some(error)); + } + + #[test] + fn test_conda_package_resolution_error_is_fatal_and_not_applied() { + let error = "failed to resolve conda packages".to_string(); + let resolution = ( + "ffmpeg".to_string(), + "7.1.1".to_string(), + "conda:ffmpeg".to_string(), + Platform::parse("linux-x64").unwrap(), + Err(error.clone()), + BTreeMap::new(), + BTreeMap::new(), + BTreeMap::new(), + true, + ); + let mut lockfile = Lockfile::default(); + let resolution_error = resolution.4.as_ref().err().cloned(); + let error_is_fatal = resolution.8; + + let applied = apply_lock_result(&mut lockfile, resolution).unwrap(); + let (status, returned_error) = + classify_lock_result(resolution_error, error_is_fatal, applied); + + assert_eq!(status, LockTaskStatus::Failed); + assert_eq!(returned_error, Some(error)); + assert!(!lockfile.tools().contains_key("ffmpeg")); + assert!( + lockfile + .get_conda_package("linux-x64", "incomplete-package") + .is_none() + ); + } + + #[test] + fn test_empty_success_remains_an_unsupported_platform_skip() { + let (status, returned_error) = classify_lock_result(None, false, false); + + assert_eq!(status, LockTaskStatus::Unresolved); + assert_eq!(returned_error, None); + } + + #[test] + fn test_nonfatal_resolution_error_remains_an_unsupported_platform_skip() { + let (status, returned_error) = + classify_lock_result(Some("no URL for target".to_string()), false, false); + + assert_eq!(status, LockTaskStatus::Unresolved); + assert_eq!(returned_error, None); + } + fn lockfile_with_legacy_aqua_jq() -> Lockfile { let mut lockfile = Lockfile::default(); lockfile.set_platform_info( @@ -1442,6 +1521,43 @@ mod tests { assert!(errors[0].contains("locked version(s) 1.0.0")); } + #[test] + fn test_platform_regression_rejects_empty_stub_version_bump() { + let cmd = lock_cmd(&[]); + let mut lockfile = lockfile_with_dummy(); + let resolution = ( + "dummy".to_string(), + "2.0.0".to_string(), + "asdf:dummy".to_string(), + Platform::parse("linux-x64").unwrap(), + Ok(PlatformInfo::default()), + BTreeMap::new(), + BTreeMap::new(), + BTreeMap::new(), + false, + ); + + let applied = apply_lock_result(&mut lockfile, resolution).unwrap(); + let (status, error) = classify_lock_result(None, false, applied); + assert!(!applied); + assert_eq!(status, LockTaskStatus::Unresolved); + assert!(error.is_none()); + + let stale_versions = BTreeMap::from([("dummy".to_string(), vec!["1.0.0".to_string()])]); + let results = vec![LockTaskResult { + short: "dummy".to_string(), + version: "2.0.0".to_string(), + platform: "linux-x64".to_string(), + status, + }]; + + assert_eq!( + cmd.platform_regression_errors(&lockfile, &stale_versions, &results) + .len(), + 1 + ); + } + #[test] fn test_platform_regression_allows_new_unsupported_platform() { let cmd = lock_cmd(&[]); diff --git a/src/lockfile.rs b/src/lockfile.rs index a0ba7ed1063..12f7c3990e0 100644 --- a/src/lockfile.rs +++ b/src/lockfile.rs @@ -2597,9 +2597,11 @@ fn deferred_provenance_resolution_error( /// Result type for lock resolution tasks (shared by `mise lock` and auto-lock). /// -/// Fields: (short_name, version, backend_full, platform, info_or_error, options, conda_packages). +/// Fields: (short_name, version, backend_full, platform, info_or_error, options, +/// conda_packages, pkgx_packages, error_is_fatal). /// The `info_or_error` field is `Ok(info)` on success or `Err(message)` on failure, -/// allowing callers to log at the appropriate level. +/// allowing callers to log at the appropriate level. `error_is_fatal` distinguishes +/// genuine conda solve failures from backends that use errors to skip unsupported targets. pub type LockResolutionResult = ( String, String, @@ -2609,11 +2611,13 @@ pub type LockResolutionResult = ( BTreeMap, BTreeMap, BTreeMap, + bool, ); /// Resolve lock info for a single tool/platform combination. /// -/// Returns a tuple of (short_name, version, backend_full, platform, info_or_error, options, conda_packages). +/// Returns a tuple of (short_name, version, backend_full, platform, info_or_error, options, +/// conda_packages, pkgx_packages, error_is_fatal). /// Does not log errors — callers decide the appropriate log level. pub async fn resolve_tool_lock_info( ba: crate::cli::args::BackendArg, @@ -2622,6 +2626,9 @@ pub async fn resolve_tool_lock_info( backend: Option, ) -> LockResolutionResult { let target = PlatformTarget::new(platform.clone()); + let error_is_fatal = backend + .as_ref() + .is_some_and(|backend| backend.get_type() == BackendType::Conda); let (info, options, conda_packages, pkgx_packages) = if let Some(backend) = backend { let options = match backend.resolve_lockfile_options(&tv.request, &target) { @@ -2636,6 +2643,7 @@ pub async fn resolve_tool_lock_info( BTreeMap::new(), BTreeMap::new(), BTreeMap::new(), + error_is_fatal, ); } }; @@ -2646,13 +2654,22 @@ pub async fn resolve_tool_lock_info( match conda_backend.resolve_conda_packages(&tv, &target).await { Ok(packages) => packages, Err(e) => { - debug!( - "failed to resolve conda packages for {} on {}: {}", - ba.short, - platform.to_key(), - e + return ( + ba.short.clone(), + tv.version.clone(), + ba.stored_full(), + platform, + Err(format!( + "failed to resolve conda packages for {} on {}: {}", + ba.short, + target.to_key(), + e + )), + options, + BTreeMap::new(), + BTreeMap::new(), + error_is_fatal, ); - BTreeMap::new() } } } else { @@ -2677,6 +2694,7 @@ pub async fn resolve_tool_lock_info( options, BTreeMap::new(), BTreeMap::new(), + error_is_fatal, ); } } @@ -2715,6 +2733,7 @@ pub async fn resolve_tool_lock_info( options, conda_packages, pkgx_packages, + error_is_fatal, ) } @@ -2722,14 +2741,25 @@ pub async fn resolve_tool_lock_info( /// 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. +/// resolve metadata without installing falls back to an empty `PlatformInfo`. +/// This still creates a tool/version entry when one does not exist, but it does +/// not overwrite an existing entry's platform metadata or count as a resolved +/// platform. /// /// 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 { - let (short, version, backend, platform, info, options, conda_packages, pkgx_packages) = result; + let ( + short, + version, + backend, + platform, + info, + options, + conda_packages, + pkgx_packages, + _error_is_fatal, + ) = result; let platform_key = platform.to_key(); let mut applied = false; if let Ok(ref info) = info { @@ -2743,15 +2773,33 @@ pub fn apply_lock_result(lockfile: &mut Lockfile, result: LockResolutionResult) ) { return Err(eyre!("{err}")); } - applied |= !info.is_empty(); - lockfile.set_platform_info( - &short, - &version, - Some(&backend), - &options, - &platform_key, - info.clone(), - ); + if info.is_empty() { + let tool_exists = lockfile.tools.get(&short).is_some_and(|tools| { + tools + .iter() + .any(|tool| tool.version == version && tool.options == options) + }); + if !tool_exists { + lockfile.set_platform_info( + &short, + &version, + Some(&backend), + &options, + &platform_key, + info.clone(), + ); + } + } else { + applied = true; + lockfile.set_platform_info( + &short, + &version, + Some(&backend), + &options, + &platform_key, + info.clone(), + ); + } } for (basename, pkg_info) in conda_packages { applied = true; @@ -3615,6 +3663,70 @@ mod tests { ); } + #[test] + fn empty_resolution_preserves_existing_conda_dependencies() { + let platform_key = "linux-x64"; + let dependency = "libfoo-1.0-h123_0"; + let mut lockfile = Lockfile::default(); + lockfile.tools.insert( + "ffmpeg".to_string(), + vec![tool_with_conda_dep( + "7.1.1", + "conda:ffmpeg", + platform_key, + dependency, + )], + ); + add_test_conda_package(&mut lockfile, platform_key, dependency); + + let result = ( + "ffmpeg".to_string(), + "7.1.1".to_string(), + "conda:ffmpeg".to_string(), + Platform::parse(platform_key).unwrap(), + Ok(PlatformInfo::default()), + BTreeMap::new(), + BTreeMap::new(), + BTreeMap::new(), + false, + ); + + assert!(!apply_lock_result(&mut lockfile, result).unwrap()); + lockfile.cleanup_unreferenced_conda_packages(); + + let platform = &lockfile.tools["ffmpeg"][0].platforms[platform_key]; + assert_eq!( + platform.conda_deps.as_deref(), + Some(&[dependency.to_string()][..]) + ); + assert!( + lockfile + .get_conda_package(platform_key, dependency) + .is_some() + ); + } + + #[test] + fn empty_resolution_creates_new_tool_entry_without_platform_metadata() { + let mut lockfile = Lockfile::default(); + let result = ( + "dummy".to_string(), + "1.0.0".to_string(), + "asdf:dummy".to_string(), + Platform::parse("linux-x64").unwrap(), + Ok(PlatformInfo::default()), + BTreeMap::new(), + BTreeMap::new(), + BTreeMap::new(), + false, + ); + + assert!(!apply_lock_result(&mut lockfile, result).unwrap()); + let tool = &lockfile.tools["dummy"][0]; + assert_eq!(tool.version, "1.0.0"); + assert!(tool.platforms.is_empty()); + } + #[test] fn test_array_format_required() { // Test that multi-version (array) format is read correctly