From 7dde6e73c98865cdc72c7fd16279b785b4d5bc62 Mon Sep 17 00:00:00 2001 From: Denis Pyshev <3505278+gemelen@users.noreply.github.com> Date: Sat, 1 Aug 2026 15:30:57 -0300 Subject: [PATCH 01/21] fix: Allow build on illumos for rattler_pty (#2635) --- crates/rattler_pty/src/unix/pty_process.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/rattler_pty/src/unix/pty_process.rs b/crates/rattler_pty/src/unix/pty_process.rs index 83425bbd31..4557011220 100644 --- a/crates/rattler_pty/src/unix/pty_process.rs +++ b/crates/rattler_pty/src/unix/pty_process.rs @@ -27,9 +27,10 @@ use tokio::{ #[cfg(any(target_os = "linux", target_os = "android"))] use nix::pty::ptsname_r; -#[cfg(target_os = "netbsd")] +#[cfg(any(target_os = "netbsd", target_os = "illumos"))] /// NetBSD has `ptsname_r` in libc but apparently the `nix` crate does not expose it for NetBSD. /// Call `libc::ptsname_r` directly. +/// The same is applicable for illumos fn ptsname_r(fd: &PtyMaster) -> nix::Result { use std::ffi::CStr; From 92fec492d1a58b2ce146923e6617ae5e452cbb0d Mon Sep 17 00:00:00 2001 From: Hofer-Julian <30049909+Hofer-Julian@users.noreply.github.com> Date: Mon, 3 Aug 2026 09:57:35 +0200 Subject: [PATCH 02/21] feat: add `lfs` field to git source locations in the lock file (#2633) --- crates/rattler_lock/src/lib.rs | 1 + .../src/parse/models/v6/source_data.rs | 1 + .../src/parse/models/v7/source_data.rs | 7 ++ ..._lock__test__v7__sources-lfs-lock.yml.snap | 35 ++++++++++ crates/rattler_lock/src/source.rs | 26 +++++++- crates/rattler_lock/src/source_identifier.rs | 65 +++++++++++++++++++ test-data/conda-lock/v7/sources-lfs-lock.yml | 31 +++++++++ 7 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 crates/rattler_lock/src/snapshots/rattler_lock__test__v7__sources-lfs-lock.yml.snap create mode 100644 test-data/conda-lock/v7/sources-lfs-lock.yml diff --git a/crates/rattler_lock/src/lib.rs b/crates/rattler_lock/src/lib.rs index 5a225008c4..8fe75a229c 100644 --- a/crates/rattler_lock/src/lib.rs +++ b/crates/rattler_lock/src/lib.rs @@ -986,6 +986,7 @@ mod test { #[case::v7_conda_source_path("v7/conda-path-lock.yml")] #[case::v7_derived_channel("v7/derived-channel-lock.yml")] #[case::v7_sources("v7/sources-lock.yml")] + #[case::v7_sources_lfs("v7/sources-lfs-lock.yml")] #[case::v7_options("v7/options-lock.yml")] #[case::v7_pixi_build_pinned_source("v7/pixi-build-pinned-source-lock.yml")] #[case::v7_pixi_build_url_source("v7/pixi-build-url-source-lock.yml")] diff --git a/crates/rattler_lock/src/parse/models/v6/source_data.rs b/crates/rattler_lock/src/parse/models/v6/source_data.rs index 5dae14f674..fdf69da94c 100644 --- a/crates/rattler_lock/src/parse/models/v6/source_data.rs +++ b/crates/rattler_lock/src/parse/models/v6/source_data.rs @@ -171,6 +171,7 @@ impl<'a> TryFrom> for SourceLocation { git, rev, subdirectory: subdirectory.map(Cow::into_owned), + lfs: None, })) } else { unreachable!("we already checked that exactly one of url, path or git is set") diff --git a/crates/rattler_lock/src/parse/models/v7/source_data.rs b/crates/rattler_lock/src/parse/models/v7/source_data.rs index 89449b4c1b..5385a6051f 100644 --- a/crates/rattler_lock/src/parse/models/v7/source_data.rs +++ b/crates/rattler_lock/src/parse/models/v7/source_data.rs @@ -32,6 +32,8 @@ struct SourceLocationData<'a> { pub tag: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub subdirectory: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub lfs: Option, #[serde(skip_serializing_if = "Option::is_none")] pub path: Option>, @@ -58,6 +60,7 @@ impl<'a> From<&'a UrlSourceLocation> for SourceLocationData<'a> { branch: None, tag: None, subdirectory: value.subdirectory.as_deref().map(Cow::Borrowed), + lfs: None, path: None, } } @@ -86,6 +89,7 @@ impl<'a> From<&'a GitSourceLocation> for SourceLocationData<'a> { None }, subdirectory: value.subdirectory.as_deref().map(Cow::Borrowed), + lfs: value.lfs, path: None, } } @@ -102,6 +106,7 @@ impl<'a> From<&'a PathSourceLocation> for SourceLocationData<'a> { branch: None, tag: None, subdirectory: None, + lfs: None, path: Some(Cow::Borrowed(value.path.as_str())), } } @@ -133,6 +138,7 @@ impl<'a> TryFrom> for SourceLocation { branch, tag, subdirectory, + lfs, } = value; let count = [url.is_some(), path.is_some(), git.is_some()] @@ -172,6 +178,7 @@ impl<'a> TryFrom> for SourceLocation { git, rev, subdirectory: subdirectory.map(Cow::into_owned), + lfs, })) } else { unreachable!("we already checked that exactly one of url, path or git is set") diff --git a/crates/rattler_lock/src/snapshots/rattler_lock__test__v7__sources-lfs-lock.yml.snap b/crates/rattler_lock/src/snapshots/rattler_lock__test__v7__sources-lfs-lock.yml.snap new file mode 100644 index 0000000000..7f7949a853 --- /dev/null +++ b/crates/rattler_lock/src/snapshots/rattler_lock__test__v7__sources-lfs-lock.yml.snap @@ -0,0 +1,35 @@ +--- +source: crates/rattler_lock/src/lib.rs +expression: conda_lock +--- +version: 7 +platforms: + - name: win-64 +environments: + default: + channels: + - url: "https://conda.anaconda.org/conda-forge/" + packages: + win-64: + - conda_source: "child-package[87dc0b8a] @ child-package" +packages: + - conda_source: "child-package[87dc0b8a] @ child-package" + version: 0.1.0 + build: pyhbf21a9e_0 + subdir: noarch + depends: + - git_lfs + - git_no_lfs + - git_plain + source_depends: + git_lfs: + git: "https://github.com/example/baz.git" + branch: foobar + lfs: true + git_no_lfs: + git: "https://github.com/example/baz.git" + tag: v0.1.0 + lfs: false + git_plain: + git: "https://github.com/example/baz.git" + rev: deadbeaf diff --git a/crates/rattler_lock/src/source.rs b/crates/rattler_lock/src/source.rs index 3bfd111f04..7ca5f3abf1 100644 --- a/crates/rattler_lock/src/source.rs +++ b/crates/rattler_lock/src/source.rs @@ -1,6 +1,8 @@ //! Provides data types that are used to describe the location of a source //! package. +use std::hash::{Hash, Hasher}; + use rattler_digest::{Md5Hash, Sha256Hash}; use typed_path::Utf8TypedPathBuf; use url::Url; @@ -35,7 +37,7 @@ pub struct UrlSourceLocation { } /// A specification of source from a git repository. -#[derive(Clone, Debug, PartialEq, Eq, Hash)] +#[derive(Clone, Debug, PartialEq, Eq)] pub struct GitSourceLocation { /// The git url of the package which can contain git+ prefixes. pub git: Url, @@ -45,6 +47,28 @@ pub struct GitSourceLocation { /// The git subdirectory of the package pub subdirectory: Option, + + /// Whether git LFS files should be fetched + pub lfs: Option, +} + +impl Hash for GitSourceLocation { + fn hash(&self, state: &mut H) { + let GitSourceLocation { + git, + rev, + subdirectory, + lfs, + } = self; + git.hash(state); + rev.hash(state); + subdirectory.hash(state); + // An absent `lfs` field is skipped entirely so that source identifier + // hashes of lock files written before the field existed stay stable. + if let Some(lfs) = lfs { + lfs.hash(state); + } + } } /// A reference to a specific commit in a git repository. diff --git a/crates/rattler_lock/src/source_identifier.rs b/crates/rattler_lock/src/source_identifier.rs index 96ca4cf57d..36a462c2e2 100644 --- a/crates/rattler_lock/src/source_identifier.rs +++ b/crates/rattler_lock/src/source_identifier.rs @@ -692,6 +692,71 @@ mod tests { insta::assert_yaml_snapshot!(hashes); } + /// The identifier hash of a package with git sources must not depend on + /// an absent `lfs` field: lock files written before the field existed + /// carry the same hash. The pinned literal below is that pre-`lfs` hash; + /// if this test fails, the hash algorithm changed in a way that would + /// alter existing lock files (see also [`compute_test_data_hashes`]). + #[test] + fn test_git_source_identifier_hash_ignores_absent_lfs() { + use std::collections::BTreeMap; + + use rattler_conda_types::{PackageRecord, VersionWithSource}; + use url::Url; + + use crate::CondaSourceData; + use crate::source::{GitSourceLocation, SourceLocation}; + + fn git_source_identifier(lfs: Option) -> String { + let name = PackageName::from_str("git-package").unwrap(); + let mut package_record = PackageRecord::new( + name, + VersionWithSource::from_str("1.0.0").unwrap(), + "pyhbf21a9e_0".to_string(), + ); + package_record.subdir = "noarch".to_string(); + + let sources = BTreeMap::from([( + "dependency".to_string(), + SourceLocation::Git(GitSourceLocation { + git: Url::parse("https://github.com/example/dependency.git").unwrap(), + rev: None, + subdirectory: None, + lfs, + }), + )]); + + let source_data = CondaSourceData::full( + UrlOrPath::from_str("git-package").unwrap(), + None, + BTreeMap::new(), + None, + package_record, + sources, + ); + + SourceIdentifier::from_source_data(&source_data) + .hash() + .to_string() + } + + assert_eq!(git_source_identifier(None), "9f510e4a"); + + // The three states must stay distinguishable from each other. + assert_ne!( + git_source_identifier(Some(true)), + git_source_identifier(None) + ); + assert_ne!( + git_source_identifier(Some(false)), + git_source_identifier(None) + ); + assert_ne!( + git_source_identifier(Some(true)), + git_source_identifier(Some(false)) + ); + } + #[test] fn test_into_full_returns_none_for_partial() { use std::collections::BTreeMap; diff --git a/test-data/conda-lock/v7/sources-lfs-lock.yml b/test-data/conda-lock/v7/sources-lfs-lock.yml new file mode 100644 index 0000000000..40c407cc29 --- /dev/null +++ b/test-data/conda-lock/v7/sources-lfs-lock.yml @@ -0,0 +1,31 @@ +version: 7 +platforms: + - name: win-64 +environments: + default: + channels: + - url: https://conda.anaconda.org/conda-forge/ + packages: + win-64: + - conda_source: child-package[87dc0b8a] @ child-package +packages: + - conda_source: child-package[87dc0b8a] @ child-package + version: 0.1.0 + build: pyhbf21a9e_0 + subdir: noarch + depends: + - git_lfs + - git_no_lfs + - git_plain + source_depends: + git_lfs: + git: https://github.com/example/baz.git + branch: foobar + lfs: true + git_no_lfs: + git: https://github.com/example/baz.git + tag: v0.1.0 + lfs: false + git_plain: + git: https://github.com/example/baz.git + rev: deadbeaf From 4e5e69c943fae4c29d002de1b502f3d03b79bb4e Mon Sep 17 00:00:00 2001 From: "octo-sts[bot]" <157150467+octo-sts[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 08:25:26 +0000 Subject: [PATCH 03/21] chore: release (#2626) Co-authored-by: octo-sts[bot] <157150467+octo-sts[bot]@users.noreply.github.com> --- Cargo.lock | 182 +++++++++++++-------------- Cargo.toml | 12 +- crates/rattler-bin/CHANGELOG.md | 6 + crates/rattler-bin/Cargo.toml | 2 +- crates/rattler/CHANGELOG.md | 6 + crates/rattler/Cargo.toml | 2 +- crates/rattler_index/CHANGELOG.md | 6 + crates/rattler_index/Cargo.toml | 2 +- crates/rattler_lock/CHANGELOG.md | 6 + crates/rattler_lock/Cargo.toml | 2 +- crates/rattler_menuinst/CHANGELOG.md | 6 + crates/rattler_menuinst/Cargo.toml | 2 +- crates/rattler_pty/CHANGELOG.md | 6 + crates/rattler_pty/Cargo.toml | 2 +- crates/rattler_shell/CHANGELOG.md | 6 + crates/rattler_shell/Cargo.toml | 2 +- py-rattler/Cargo.lock | 12 +- 17 files changed, 152 insertions(+), 110 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 295e4f503a..08d3424440 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -208,7 +208,7 @@ checksum = "98e1c6be25cfbf1bb4fea1a9da51bc05d3259a9062df4e53f54e5607895e33c9" dependencies = [ "anyhow", "async-trait", - "http 1.4.2", + "http 1.5.0", "reqwest", "serde", "thiserror 2.0.19", @@ -226,7 +226,7 @@ dependencies = [ "async-trait", "futures", "getrandom 0.2.17", - "http 1.4.2", + "http 1.5.0", "hyper", "reqwest", "retry-policies", @@ -288,9 +288,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.42" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" dependencies = [ "compression-codecs", "compression-core", @@ -377,7 +377,7 @@ dependencies = [ "bytes", "fastrand", "hex", - "http 1.4.2", + "http 1.5.0", "p256", "rand 0.8.7", "sha1 0.10.7", @@ -445,7 +445,7 @@ dependencies = [ "bytes-utils", "fastrand", "http 0.2.12", - "http 1.4.2", + "http 1.5.0", "http-body 0.4.6", "http-body 1.1.0", "percent-encoding", @@ -481,7 +481,7 @@ dependencies = [ "hex", "hmac 0.13.0", "http 0.2.12", - "http 1.4.2", + "http 1.5.0", "http-body 1.1.0", "lru", "percent-encoding", @@ -512,7 +512,7 @@ dependencies = [ "bytes", "fastrand", "http 0.2.12", - "http 1.4.2", + "http 1.5.0", "regex-lite", "tracing", ] @@ -538,7 +538,7 @@ dependencies = [ "bytes", "fastrand", "http 0.2.12", - "http 1.4.2", + "http 1.5.0", "regex-lite", "tracing", ] @@ -564,7 +564,7 @@ dependencies = [ "bytes", "fastrand", "http 0.2.12", - "http 1.4.2", + "http 1.5.0", "regex-lite", "tracing", ] @@ -591,7 +591,7 @@ dependencies = [ "aws-types", "fastrand", "http 0.2.12", - "http 1.4.2", + "http 1.5.0", "regex-lite", "tracing", ] @@ -613,7 +613,7 @@ dependencies = [ "hex", "hmac 0.13.0", "http 0.2.12", - "http 1.4.2", + "http 1.5.0", "p256", "percent-encoding", "sha2 0.11.0", @@ -645,7 +645,7 @@ dependencies = [ "bytes", "crc-fast", "hex", - "http 1.4.2", + "http 1.5.0", "http-body 1.1.0", "http-body-util", "md-5", @@ -679,7 +679,7 @@ dependencies = [ "bytes-utils", "futures-core", "futures-util", - "http 1.4.2", + "http 1.5.0", "http-body 1.1.0", "http-body-util", "percent-encoding", @@ -698,7 +698,7 @@ dependencies = [ "aws-smithy-runtime-api", "aws-smithy-types", "h2", - "http 1.4.2", + "http 1.5.0", "hyper", "hyper-rustls", "hyper-util", @@ -761,7 +761,7 @@ dependencies = [ "bytes", "fastrand", "http 0.2.12", - "http 1.4.2", + "http 1.5.0", "http-body 0.4.6", "http-body 1.1.0", "http-body-util", @@ -782,7 +782,7 @@ dependencies = [ "aws-smithy-types", "bytes", "http 0.2.12", - "http 1.4.2", + "http 1.5.0", "pin-project-lite", "tokio", "tracing", @@ -808,7 +808,7 @@ checksum = "7d56e0a4e53127a632224e43633b0fe045fa9e1e3cfc68b9830f1115e103f910" dependencies = [ "aws-smithy-runtime-api", "aws-smithy-types", - "http 1.4.2", + "http 1.5.0", ] [[package]] @@ -822,7 +822,7 @@ dependencies = [ "bytes-utils", "futures-core", "http 0.2.12", - "http 1.4.2", + "http 1.5.0", "http-body 0.4.6", "http-body 1.1.0", "http-body-util", @@ -874,7 +874,7 @@ dependencies = [ "bytes", "form_urlencoded", "futures-util", - "http 1.4.2", + "http 1.5.0", "http-body 1.1.0", "http-body-util", "hyper", @@ -904,7 +904,7 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http 1.4.2", + "http 1.5.0", "http-body 1.1.0", "http-body-util", "mime", @@ -1275,9 +1275,9 @@ dependencies = [ [[package]] name = "clang-sys" -version = "1.8.1" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" dependencies = [ "glob", "libc", @@ -1286,9 +1286,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.4" +version = "4.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" +checksum = "301b56658598e48f3648647ac6fc887be7e7108eddfa4e9b63fcf3ec58c0cadf" dependencies = [ "clap_builder", "clap_derive", @@ -1307,9 +1307,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.2" +version = "4.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +checksum = "94a65403d1a1bd28f7dc68eb8506e8874808ee5eecb59298de588e2e1407a078" dependencies = [ "anstream", "anstyle", @@ -1973,13 +1973,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -2566,9 +2566,9 @@ dependencies = [ [[package]] name = "google-cloud-auth" -version = "1.14.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3494870d06f3cbbb3561ada6f234982549e3a2fb31e719ef258e6eadb9ae09a" +checksum = "f54aab44c16b8463ae11b165a87c3d484780231f157bb1ed65843d591beb5abd" dependencies = [ "async-trait", "base64 0.22.1", @@ -2577,7 +2577,7 @@ dependencies = [ "google-cloud-gax", "hex", "hmac 0.13.0", - "http 1.4.2", + "http 1.5.0", "reqwest", "rustc_version", "rustls", @@ -2593,15 +2593,15 @@ dependencies = [ [[package]] name = "google-cloud-gax" -version = "1.12.0" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3103a4a9013f1aed573ca56e19a9680b0211643a99ea85caf524b397d6be8be3" +checksum = "b9a46dd0fd026bbc4a5d84e6ab0c941cee6e3b057976a0bb107fdb5238ce598f" dependencies = [ "bytes", "futures", "google-cloud-rpc", "google-cloud-wkt", - "http 1.4.2", + "http 1.5.0", "pin-project", "rand 0.10.2", "serde", @@ -2625,9 +2625,9 @@ dependencies = [ [[package]] name = "google-cloud-wkt" -version = "1.6.0" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46df1fcc3ab69164af3f4199ed21f45b5dbc56d9f03211eb4fa20116d442364b" +checksum = "7fccf98cfd5481a5f5a285181ab0c62123d7d47cd2bb7299448440649349e4e7" dependencies = [ "base64 0.22.1", "bytes", @@ -2661,7 +2661,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http 1.4.2", + "http 1.5.0", "indexmap 2.14.0", "slab", "tokio", @@ -2800,9 +2800,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -2835,7 +2835,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", - "http 1.4.2", + "http 1.5.0", ] [[package]] @@ -2846,7 +2846,7 @@ checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" dependencies = [ "bytes", "futures-core", - "http 1.4.2", + "http 1.5.0", "http-body 1.1.0", "pin-project-lite", ] @@ -2857,7 +2857,7 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0603b99309a1e404c2c0945650c0f775b50bb72ac90ae570cb93f9ff05d9efe7" dependencies = [ - "http 1.4.2", + "http 1.5.0", "http-serde", "reqwest", "serde", @@ -2882,7 +2882,7 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f056c8559e3757392c8d091e796416e4649d8e49e88b8d76df6c002f05027fd" dependencies = [ - "http 1.4.2", + "http 1.5.0", "serde", ] @@ -2921,9 +2921,9 @@ checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" [[package]] name = "hybrid-array" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" dependencies = [ "typenum", ] @@ -2939,7 +2939,7 @@ dependencies = [ "futures-channel", "futures-core", "h2", - "http 1.4.2", + "http 1.5.0", "http-body 1.1.0", "httparse", "httpdate", @@ -2956,7 +2956,7 @@ version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "http 1.4.2", + "http 1.5.0", "hyper", "hyper-util", "rustls", @@ -2992,7 +2992,7 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http 1.4.2", + "http 1.5.0", "http-body 1.1.0", "hyper", "ipnet", @@ -3209,9 +3209,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.12.0" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" [[package]] name = "is-docker" @@ -3453,9 +3453,9 @@ dependencies = [ [[package]] name = "lazy-regex" -version = "3.6.0" +version = "3.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bae91019476d3ec7147de9aa291cadb6d870abf2f3015d2da73a90325ac1496" +checksum = "4994ba703f78b083e2f7946dac9251abd83fd43a0365f030e99b69be5b4b9ef9" dependencies = [ "lazy-regex-proc_macros", "once_cell", @@ -3464,9 +3464,9 @@ dependencies = [ [[package]] name = "lazy-regex-proc_macros" -version = "3.6.0" +version = "3.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4de9c1e1439d8b7b3061b2d209809f447ca33241733d9a3c01eabf2dc8d94358" +checksum = "fd97232314824e6dbef1918a871bb93f51070455e3715bf26e19a6d01aa977a0" dependencies = [ "proc-macro2", "quote", @@ -3533,9 +3533,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa" dependencies = [ "libc", ] @@ -3638,9 +3638,9 @@ dependencies = [ [[package]] name = "mea" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2640d335e7273dacdcf51044026139b2e269c3bb0dfc3f8cb3496b85e3f6a42c" +checksum = "31fc7d159de0085ab6dd7ff145a9819442cfd3d098f783263120503c3f3e58b0" dependencies = [ "slab", ] @@ -3743,7 +3743,7 @@ dependencies = [ "bytes", "colored", "futures-core", - "http 1.4.2", + "http 1.5.0", "http-body 1.1.0", "http-body-util", "hyper", @@ -3953,7 +3953,7 @@ dependencies = [ "base64 0.22.1", "chrono", "getrandom 0.2.17", - "http 1.4.2", + "http 1.5.0", "rand 0.8.7", "serde", "serde_json", @@ -4089,7 +4089,7 @@ dependencies = [ "base64 0.22.1", "bytes", "futures", - "http 1.4.2", + "http 1.5.0", "http-body 1.1.0", "jiff", "log", @@ -4141,7 +4141,7 @@ dependencies = [ "base64 0.22.1", "bytes", "crc32c", - "http 1.4.2", + "http 1.5.0", "log", "md-5", "opendal-core", @@ -4164,7 +4164,7 @@ dependencies = [ "dyn-clone", "ed25519-dalek", "hmac 0.12.1", - "http 1.4.2", + "http 1.5.0", "itertools 0.10.5", "log", "oauth2", @@ -4884,7 +4884,7 @@ dependencies = [ [[package]] name = "rattler" -version = "0.48.0" +version = "0.48.1" dependencies = [ "anyhow", "assert_matches", @@ -4950,7 +4950,7 @@ dependencies = [ [[package]] name = "rattler-bin" -version = "0.2.6" +version = "0.2.7" dependencies = [ "anyhow", "astral-reqwest-middleware", @@ -5148,7 +5148,7 @@ dependencies = [ [[package]] name = "rattler_index" -version = "0.30.10" +version = "0.30.11" dependencies = [ "ahash", "anyhow", @@ -5201,7 +5201,7 @@ dependencies = [ [[package]] name = "rattler_lock" -version = "0.31.7" +version = "0.32.0" dependencies = [ "ahash", "file_url", @@ -5241,7 +5241,7 @@ dependencies = [ [[package]] name = "rattler_menuinst" -version = "0.2.71" +version = "0.2.72" dependencies = [ "configparser", "dirs", @@ -5292,7 +5292,7 @@ dependencies = [ "getrandom 0.4.3", "google-cloud-auth", "hex", - "http 1.4.2", + "http 1.5.0", "http-auth", "insta", "itertools 0.15.0", @@ -5336,7 +5336,7 @@ dependencies = [ "getrandom 0.2.17", "getrandom 0.4.3", "hex", - "http 1.4.2", + "http 1.5.0", "insta", "jiff", "num_cpus", @@ -5377,7 +5377,7 @@ dependencies = [ [[package]] name = "rattler_pty" -version = "0.2.15" +version = "0.2.16" dependencies = [ "libc", "nix", @@ -5421,7 +5421,7 @@ dependencies = [ "hashbrown 0.17.1", "hex", "hex-literal", - "http 1.4.2", + "http 1.5.0", "http-cache-semantics", "humansize", "humantime", @@ -5497,7 +5497,7 @@ dependencies = [ [[package]] name = "rattler_shell" -version = "0.27.11" +version = "0.27.12" dependencies = [ "anyhow", "enum_dispatch", @@ -5725,7 +5725,7 @@ dependencies = [ "bytes", "form_urlencoded", "hex", - "http 1.4.2", + "http 1.5.0", "log", "percent-encoding", "quick-xml 0.41.0", @@ -5749,7 +5749,7 @@ dependencies = [ "futures", "hex", "hmac 0.13.0", - "http 1.4.2", + "http 1.5.0", "jiff", "log", "percent-encoding", @@ -5782,7 +5782,7 @@ dependencies = [ "futures-core", "futures-util", "h2", - "http 1.4.2", + "http 1.5.0", "http-body 1.1.0", "http-body-util", "hyper", @@ -6009,9 +6009,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.42" +version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ "aws-lc-rs", "log", @@ -7134,9 +7134,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.54" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", "js-sys", @@ -7247,13 +7247,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.1" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -7305,9 +7305,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.3+spec-1.1.0" +version = "1.1.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" dependencies = [ "indexmap 2.14.0", "serde_core", @@ -7403,7 +7403,7 @@ dependencies = [ "bytes", "futures-core", "futures-util", - "http 1.4.2", + "http 1.5.0", "http-body 1.1.0", "http-body-util", "pin-project-lite", @@ -7426,7 +7426,7 @@ dependencies = [ "bytes", "futures-core", "futures-util", - "http 1.4.2", + "http 1.5.0", "http-body 1.1.0", "http-body-util", "http-range-header", @@ -7544,9 +7544,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "trybuild" -version = "1.0.118" +version = "1.0.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06649c6f63d86604ba0c8950d5a1829fc9a17afd70fc6629f481d75b6a624c78" +checksum = "1e605bf6b39357663d8ba4e984f8be8da8df6bb32e81031d6889024ea8fd68e4" dependencies = [ "dissimilar", "glob", diff --git a/Cargo.toml b/Cargo.toml index 056386e089..0097a91b8e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -210,26 +210,26 @@ zstd = { version = "0.13", default-features = false } coalesced_map = { path = "crates/coalesced_map", version = "=0.1.4", default-features = false } file_url = { path = "crates/file_url", version = "=0.3.2", default-features = false } path_resolver = { path = "crates/path_resolver", version = "=0.2.12", default-features = false } -rattler = { path = "crates/rattler", version = "=0.48.0", default-features = false } +rattler = { path = "crates/rattler", version = "=0.48.1", default-features = false } rattler_cache = { path = "crates/rattler_cache", version = "=0.10.4", default-features = false } rattler_conda_types = { path = "crates/rattler_conda_types", version = "=0.49.0", default-features = false } rattler_config = { path = "crates/rattler_config", version = "=0.6.2", default-features = false } rattler_digest = { path = "crates/rattler_digest", version = "=1.3.2", default-features = false } rattler_git = { path = "crates/rattler_git", version = "=0.2.1", default-features = false } -rattler_index = { path = "crates/rattler_index", version = "=0.30.10", default-features = false } +rattler_index = { path = "crates/rattler_index", version = "=0.30.11", default-features = false } rattler_libsolv_c = { path = "crates/rattler_libsolv_c", version = "=1.4.1", default-features = false } -rattler_lock = { path = "crates/rattler_lock", version = "=0.31.7", default-features = false } +rattler_lock = { path = "crates/rattler_lock", version = "=0.32.0", default-features = false } rattler_macros = { path = "crates/rattler_macros", version = "=1.1.2", default-features = false } -rattler_menuinst = { path = "crates/rattler_menuinst", version = "=0.2.71", default-features = false } +rattler_menuinst = { path = "crates/rattler_menuinst", version = "=0.2.72", default-features = false } rattler_networking = { path = "crates/rattler_networking", version = "=0.30.3", default-features = false } rattler_package_streaming = { path = "crates/rattler_package_streaming", version = "=0.26.9", default-features = false } rattler_prefix_guard = { path = "crates/rattler_prefix_guard", version = "=0.1.2", default-features = false } -rattler_pty = { path = "crates/rattler_pty", version = "=0.2.15", default-features = false } +rattler_pty = { path = "crates/rattler_pty", version = "=0.2.16", default-features = false } rattler_redaction = { path = "crates/rattler_redaction", version = "=0.2.2", default-features = false } rattler_repodata_gateway = { path = "crates/rattler_repodata_gateway", version = "=0.31.0", default-features = false } rattler_s3 = { path = "crates/rattler_s3", version = "=0.2.9", default-features = false } rattler_sandbox = { path = "crates/rattler_sandbox", version = "=0.2.26", default-features = false } -rattler_shell = { path = "crates/rattler_shell", version = "=0.27.11", default-features = false } +rattler_shell = { path = "crates/rattler_shell", version = "=0.27.12", default-features = false } rattler_solve = { path = "crates/rattler_solve", version = "=9.0.0", default-features = false } rattler_upload = { path = "crates/rattler_upload", version = "=0.10.1", default-features = false } rattler_virtual_packages = { path = "crates/rattler_virtual_packages", version = "=4.1.0", default-features = false } diff --git a/crates/rattler-bin/CHANGELOG.md b/crates/rattler-bin/CHANGELOG.md index 8baa2a46f4..a8b3baf624 100644 --- a/crates/rattler-bin/CHANGELOG.md +++ b/crates/rattler-bin/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.7](https://github.com/conda/rattler/compare/rattler-bin-v0.2.6...rattler-bin-v0.2.7) - 2026-08-03 + +### Other + +- updated the following local packages: rattler_index, rattler_shell, rattler_menuinst, rattler + ## [0.2.6](https://github.com/conda/rattler/compare/rattler-bin-v0.2.5...rattler-bin-v0.2.6) - 2026-07-28 ### Other diff --git a/crates/rattler-bin/Cargo.toml b/crates/rattler-bin/Cargo.toml index b7c44db220..7cdbd53e7b 100644 --- a/crates/rattler-bin/Cargo.toml +++ b/crates/rattler-bin/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rattler-bin" -version = "0.2.6" +version = "0.2.7" edition.workspace = true authors = ["Bas Zalmstra "] description = "Rust binary for common Conda operations" diff --git a/crates/rattler/CHANGELOG.md b/crates/rattler/CHANGELOG.md index 2b463c8394..ca8277cb27 100644 --- a/crates/rattler/CHANGELOG.md +++ b/crates/rattler/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.48.1](https://github.com/conda/rattler/compare/rattler-v0.48.0...rattler-v0.48.1) - 2026-08-03 + +### Other + +- updated the following local packages: rattler_shell, rattler_menuinst + ## [0.48.0](https://github.com/conda/rattler/compare/rattler-v0.47.1...rattler-v0.48.0) - 2026-07-24 ### Added diff --git a/crates/rattler/Cargo.toml b/crates/rattler/Cargo.toml index 73de917d75..2d27a031ef 100644 --- a/crates/rattler/Cargo.toml +++ b/crates/rattler/Cargo.toml @@ -1,7 +1,7 @@ [package] exclude = ["**/snapshots/**"] name = "rattler" -version = "0.48.0" +version = "0.48.1" edition.workspace = true authors = ["Bas Zalmstra "] description = "Rust library to install conda environments" diff --git a/crates/rattler_index/CHANGELOG.md b/crates/rattler_index/CHANGELOG.md index e0d122fc9b..db19e29c80 100644 --- a/crates/rattler_index/CHANGELOG.md +++ b/crates/rattler_index/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.30.11](https://github.com/conda/rattler/compare/rattler_index-v0.30.10...rattler_index-v0.30.11) - 2026-08-03 + +### Other + +- update Cargo.lock dependencies + ## [0.30.10](https://github.com/conda/rattler/compare/rattler_index-v0.30.9...rattler_index-v0.30.10) - 2026-07-24 ### Other diff --git a/crates/rattler_index/Cargo.toml b/crates/rattler_index/Cargo.toml index fa142ad175..fec40cb1cb 100644 --- a/crates/rattler_index/Cargo.toml +++ b/crates/rattler_index/Cargo.toml @@ -1,7 +1,7 @@ [package] exclude = ["tests/**"] name = "rattler_index" -version = "0.30.10" +version = "0.30.11" edition.workspace = true authors = [] description = "A crate to index conda channels and create a repodata.json file." diff --git a/crates/rattler_lock/CHANGELOG.md b/crates/rattler_lock/CHANGELOG.md index 2299b24afd..b613da9dc9 100644 --- a/crates/rattler_lock/CHANGELOG.md +++ b/crates/rattler_lock/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.32.0](https://github.com/conda/rattler/compare/rattler_lock-v0.31.7...rattler_lock-v0.32.0) - 2026-08-03 + +### Added + +- add `lfs` field to git source locations in the lock file ([#2633](https://github.com/conda/rattler/pull/2633)) + ## [0.31.7](https://github.com/conda/rattler/compare/rattler_lock-v0.31.6...rattler_lock-v0.31.7) - 2026-07-28 ### Other diff --git a/crates/rattler_lock/Cargo.toml b/crates/rattler_lock/Cargo.toml index e80599ae51..6c77ab6dcf 100644 --- a/crates/rattler_lock/Cargo.toml +++ b/crates/rattler_lock/Cargo.toml @@ -1,7 +1,7 @@ [package] exclude = ["**/snapshots/**"] name = "rattler_lock" -version = "0.31.7" +version = "0.32.0" edition.workspace = true authors = ["Bas Zalmstra "] description = "Rust data types for conda lock" diff --git a/crates/rattler_menuinst/CHANGELOG.md b/crates/rattler_menuinst/CHANGELOG.md index 0fe297a6b8..b1731c877d 100644 --- a/crates/rattler_menuinst/CHANGELOG.md +++ b/crates/rattler_menuinst/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.72](https://github.com/conda/rattler/compare/rattler_menuinst-v0.2.71...rattler_menuinst-v0.2.72) - 2026-08-03 + +### Other + +- updated the following local packages: rattler_shell + ## [0.2.71](https://github.com/conda/rattler/compare/rattler_menuinst-v0.2.70...rattler_menuinst-v0.2.71) - 2026-07-24 ### Other diff --git a/crates/rattler_menuinst/Cargo.toml b/crates/rattler_menuinst/Cargo.toml index 6cad060100..305f29403e 100644 --- a/crates/rattler_menuinst/Cargo.toml +++ b/crates/rattler_menuinst/Cargo.toml @@ -1,7 +1,7 @@ [package] exclude = ["**/snapshots/**", "test-data/**"] name = "rattler_menuinst" -version = "0.2.71" +version = "0.2.72" edition.workspace = true authors = ["Wolf Vollprecht "] description = "Install menu entries for a Conda package" diff --git a/crates/rattler_pty/CHANGELOG.md b/crates/rattler_pty/CHANGELOG.md index c06e25bac9..b5a9e0c32e 100644 --- a/crates/rattler_pty/CHANGELOG.md +++ b/crates/rattler_pty/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.16](https://github.com/conda/rattler/compare/rattler_pty-v0.2.15...rattler_pty-v0.2.16) - 2026-08-03 + +### Fixed + +- Allow build on illumos for rattler_pty ([#2635](https://github.com/conda/rattler/pull/2635)) + ## [0.2.15](https://github.com/conda/rattler/compare/rattler_pty-v0.2.14...rattler_pty-v0.2.15) - 2026-07-14 ### Other diff --git a/crates/rattler_pty/Cargo.toml b/crates/rattler_pty/Cargo.toml index 228d313ada..20b149eafc 100644 --- a/crates/rattler_pty/Cargo.toml +++ b/crates/rattler_pty/Cargo.toml @@ -1,7 +1,7 @@ [package] exclude = ["tests/**"] name = "rattler_pty" -version = "0.2.15" +version = "0.2.16" description = "A crate to create pty" categories.workspace = true homepage.workspace = true diff --git a/crates/rattler_shell/CHANGELOG.md b/crates/rattler_shell/CHANGELOG.md index d22e76e84b..7197467f50 100644 --- a/crates/rattler_shell/CHANGELOG.md +++ b/crates/rattler_shell/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.27.12](https://github.com/conda/rattler/compare/rattler_shell-v0.27.11...rattler_shell-v0.27.12) - 2026-08-03 + +### Other + +- updated the following local packages: rattler_pty + ## [0.27.11](https://github.com/conda/rattler/compare/rattler_shell-v0.27.10...rattler_shell-v0.27.11) - 2026-07-24 ### Fixed diff --git a/crates/rattler_shell/Cargo.toml b/crates/rattler_shell/Cargo.toml index 23035749f5..2864267726 100644 --- a/crates/rattler_shell/Cargo.toml +++ b/crates/rattler_shell/Cargo.toml @@ -1,7 +1,7 @@ [package] exclude = ["**/snapshots/**"] name = "rattler_shell" -version = "0.27.11" +version = "0.27.12" edition.workspace = true authors = ["Wolf Vollprecht "] description = "A crate to help with activation and deactivation of a conda environment" diff --git a/py-rattler/Cargo.lock b/py-rattler/Cargo.lock index 2522d56abd..6dfbd15c45 100644 --- a/py-rattler/Cargo.lock +++ b/py-rattler/Cargo.lock @@ -3967,7 +3967,7 @@ dependencies = [ [[package]] name = "rattler" -version = "0.48.0" +version = "0.48.1" dependencies = [ "anyhow", "astral-reqwest-middleware", @@ -4122,7 +4122,7 @@ dependencies = [ [[package]] name = "rattler_index" -version = "0.30.10" +version = "0.30.11" dependencies = [ "ahash", "anyhow", @@ -4160,7 +4160,7 @@ dependencies = [ [[package]] name = "rattler_lock" -version = "0.31.7" +version = "0.32.0" dependencies = [ "ahash", "file_url", @@ -4195,7 +4195,7 @@ dependencies = [ [[package]] name = "rattler_menuinst" -version = "0.2.71" +version = "0.2.72" dependencies = [ "configparser", "dirs", @@ -4301,7 +4301,7 @@ dependencies = [ [[package]] name = "rattler_pty" -version = "0.2.15" +version = "0.2.16" dependencies = [ "libc", "nix", @@ -4398,7 +4398,7 @@ dependencies = [ [[package]] name = "rattler_shell" -version = "0.27.11" +version = "0.27.12" dependencies = [ "anyhow", "enum_dispatch", From d482a490c04ee411e3b6b85cde52bc491451ac0c Mon Sep 17 00:00:00 2001 From: Bas Zalmstra <4995967+baszalmstra@users.noreply.github.com> Date: Tue, 4 Aug 2026 22:41:33 +0200 Subject: [PATCH 04/21] feat: add PackageArchive for sparse reads from conda packages (#2632) --- Cargo.lock | 2 + crates/rattler_package_streaming/Cargo.toml | 2 + .../rattler_package_streaming/src/archive.rs | 1543 +++++++++++++++++ crates/rattler_package_streaming/src/lib.rs | 20 + .../src/reqwest/fetch.rs | 173 +- .../src/reqwest/full_download.rs | 2 +- .../src/reqwest/mod.rs | 4 +- .../src/reqwest/sparse.rs | 144 +- .../src/reqwest/test_server.rs | 61 +- .../src/tokio/async_read.rs | 63 +- py-rattler/Cargo.lock | 1 + py-rattler/Cargo.toml | 4 +- .../rattler/package_streaming/__init__.py | 249 ++- py-rattler/src/lib.rs | 3 + py-rattler/src/package_streaming/archive.rs | 307 ++++ py-rattler/src/package_streaming/mod.rs | 4 +- py-rattler/tests/unit/test_package_archive.py | 157 ++ test-data/sparse/dotslash-test-1.0.0-0.conda | Bin 0 -> 548 bytes test-data/sparse/generate.py | 118 ++ test-data/sparse/info-only-1.0.0-0.conda | Bin 0 -> 297 bytes test-data/sparse/sparse-test-1.0.0-0.conda | Bin 0 -> 152197 bytes test-data/sparse/symlink-test-1.0.0-0.conda | Bin 0 -> 600 bytes test-data/sparse/zip64-test-1.0.0-0.conda | Bin 0 -> 576 bytes 23 files changed, 2557 insertions(+), 300 deletions(-) create mode 100644 crates/rattler_package_streaming/src/archive.rs create mode 100644 py-rattler/src/package_streaming/archive.rs create mode 100644 py-rattler/tests/unit/test_package_archive.py create mode 100644 test-data/sparse/dotslash-test-1.0.0-0.conda create mode 100644 test-data/sparse/generate.py create mode 100644 test-data/sparse/info-only-1.0.0-0.conda create mode 100644 test-data/sparse/sparse-test-1.0.0-0.conda create mode 100644 test-data/sparse/symlink-test-1.0.0-0.conda create mode 100644 test-data/sparse/zip64-test-1.0.0-0.conda diff --git a/Cargo.lock b/Cargo.lock index 08d3424440..5a49645671 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5327,7 +5327,9 @@ dependencies = [ "astral_async_zip", "async-compression", "async-spooled-tempfile", + "async-trait", "axum", + "bytes", "bzip2", "filetime", "fs-err", diff --git a/crates/rattler_package_streaming/Cargo.toml b/crates/rattler_package_streaming/Cargo.toml index 8639730b83..0a1ca66608 100644 --- a/crates/rattler_package_streaming/Cargo.toml +++ b/crates/rattler_package_streaming/Cargo.toml @@ -49,6 +49,7 @@ astral_async_zip = { workspace = true, features = [ "tokio-fs", ] } astral-tokio-tar = { workspace = true } +bytes = { workspace = true } async-compression = { workspace = true } zstd = { workspace = true, default-features = false } async-spooled-tempfile = { version = "0.1" } @@ -81,6 +82,7 @@ features = ["reqwest"] [dev-dependencies] assert_matches = { workspace = true } +async-trait = { workspace = true } tokio = { workspace = true, features = ["rt", "macros", "rt-multi-thread"] } tools = { path = "../tools" } walkdir = { workspace = true } diff --git a/crates/rattler_package_streaming/src/archive.rs b/crates/rattler_package_streaming/src/archive.rs new file mode 100644 index 0000000000..69fef8ecf0 --- /dev/null +++ b/crates/rattler_package_streaming/src/archive.rs @@ -0,0 +1,1543 @@ +//! Read individual files from conda packages, local or remote, with as few +//! HTTP requests as possible. +//! +//! A [`PackageArchive`] is opened once and queried many times. Remote +//! `.conda` archives on range-capable servers are opened with a single +//! request for the archive tail (ZIP central directory and usually the whole +//! info section); reads then cost at most one streaming ranged request per +//! touched section, aborted once the last requested file has been read. +//! `.tar.bz2` archives and servers without range support transparently fall +//! back to downloading the archive once into a temporary spool file. +//! +//! Reads are not retried internally: a network error mid-read surfaces as an +//! [`ExtractError`] (check [`ExtractError::should_retry`]) and the call can +//! simply be repeated. Symbolic links inside the archive are surfaced but +//! never followed; reading one is an error. +//! +//! # Example +//! +//! ```rust,no_run +//! # #[tokio::main] +//! # async fn main() { +//! use rattler_conda_types::package::PathsJson; +//! use rattler_package_streaming::archive::PackageArchive; +//! use reqwest::Client; +//! use reqwest_middleware::ClientWithMiddleware; +//! use url::Url; +//! +//! let client = ClientWithMiddleware::from(Client::new()); +//! let url = Url::parse("https://conda.anaconda.org/conda-forge/linux-64/python-3.12.7-hc5c86c4_0_cpython.conda").unwrap(); +//! +//! // One HTTP range request. +//! let archive = PackageArchive::from_url(client, url).await.unwrap(); +//! +//! // Usually free: the info section often sits inside the cached tail. +//! let paths: PathsJson = archive.read_package_file().await.unwrap(); +//! +//! // One streaming pass over the payload, aborted after the last hit. +//! let files = archive +//! .read_files(paths.paths.iter().map(|entry| entry.relative_path.clone())) +//! .await +//! .unwrap(); +//! # drop(files); +//! # } +//! ``` + +use std::collections::{HashMap, HashSet}; +use std::io::SeekFrom; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use async_compression::tokio::bufread::{BzDecoder, ZstdDecoder}; +use async_http_range_reader::{ + AsyncHttpRangeReader, AsyncHttpRangeReaderError, CheckSupportMethod, +}; +use async_zip::Compression; +use async_zip::base::read::seek::ZipFileReader; +use futures_util::TryStreamExt; +use http::HeaderMap; +use http::header::{ETAG, IF_RANGE, LAST_MODIFIED, RANGE}; +use rattler_conda_types::package::{CondaArchiveType, PackageFile}; +use reqwest_middleware::ClientWithMiddleware; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncSeekExt, AsyncWriteExt}; +use tokio_util::compat::TokioAsyncReadCompatExt; +use tokio_util::io::StreamReader; +use tracing::debug; +use url::Url; + +use crate::ExtractError; + +/// Bytes fetched from the end of a remote archive on open: enough for the +/// ZIP central directory, with the surplus acting as a cache that often +/// contains the entire info section. +const TAIL_SIZE: u64 = 64 * 1024; + +/// Buffer size used for the decompression pipelines. +const STREAM_BUF_SIZE: usize = 128 * 1024; + +/// Signature of a ZIP local file header (`PK\x03\x04`). +const LOCAL_HEADER_MAGIC: [u8; 4] = [0x50, 0x4b, 0x03, 0x04]; + +/// Cap for upfront buffer allocations based on (untrusted) tar header sizes. +const MAX_PREALLOC: u64 = 4 * 1024 * 1024; + +/// The two sections of a conda package. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Section { + /// Package metadata: everything under `info/`. Stored in the + /// `info-*.tar.zst` member of a `.conda` archive. + Info, + /// The package payload. Stored in the `pkg-*.tar.zst` member of a + /// `.conda` archive. + Content, +} + +impl Section { + /// Returns the section a path inside the package belongs to. + pub(crate) fn containing(path: &Path) -> Section { + let first = path + .components() + .find(|c| !matches!(c, std::path::Component::CurDir)); + match first { + Some(std::path::Component::Normal(first)) if first == "info" => Section::Info, + _ => Section::Content, + } + } + + /// The file name prefix of the ZIP member holding this section. + pub(crate) fn zip_prefix(self) -> &'static str { + match self { + Section::Info => "info-", + Section::Content => "pkg-", + } + } +} + +/// How a remote archive should be opened. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum SparsePolicy { + /// Prefer sparse access and fall back to a spooled download. + #[default] + Prefer, + /// Require sparse access and fail when the server does not support it. + Require, + /// Skip the range probe and download the archive immediately. + Disable, +} + +/// Options for opening a remote package archive. +#[derive(Debug, Clone, Default)] +pub struct RemoteArchiveOptions { + sparse_policy: SparsePolicy, + max_spool_size: Option, +} + +impl RemoteArchiveOptions { + /// Creates options using the default sparse-access policy. + pub const fn new() -> Self { + Self { + sparse_policy: SparsePolicy::Prefer, + max_spool_size: None, + } + } + + /// Sets how HTTP range support and full-download fallback are handled. + pub const fn with_sparse_policy(mut self, policy: SparsePolicy) -> Self { + self.sparse_policy = policy; + self + } + + /// Limits the number of bytes that may be downloaded for a spooled + /// fallback. By default there is no limit. + pub const fn with_max_spool_size(mut self, max_size: u64) -> Self { + self.max_spool_size = Some(max_size); + self + } +} + +/// How a [`PackageArchive`] accesses the underlying archive. +/// +/// This is diagnostic information. Use [`RemoteArchiveOptions`] to control +/// whether a remote archive may fall back to a spooled download. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum ArchiveAccess { + /// Remote archive read sparsely with HTTP range requests. + Sparse, + /// Local file on disk. + Local, + /// Remote archive that was downloaded once into a temporary spool file + /// (server without range support, or a `.tar.bz2` archive). + Spooled, +} + +/// Byte span of a stored ZIP member inside a `.conda` archive. +/// +/// `end` is the offset of the next member's local header (or the central +/// directory for the last member), which is a robust upper bound for the +/// member's data regardless of local-header extra field quirks. +#[derive(Debug, Clone)] +struct MemberSpan { + name: String, + /// Offset of the member's local file header. + header_offset: u64, + /// Size of the stored (uncompressed) member data. + size: u64, + /// Exclusive upper bound of the member's bytes in the archive. + end: u64, +} + +enum Backend { + Conda { + source: CondaSource, + members: Vec, + }, + TarBz2 { + path: PathBuf, + temp: Option, + }, +} + +/// Where the bytes of a `.conda` archive come from. +enum CondaSource { + Sparse { + client: ClientWithMiddleware, + url: Url, + /// Strong `ETag` (or `Last-Modified`) captured at open time; sent as + /// `If-Range` on section requests so servers that honor it reject + /// reads from a concurrently republished archive. Best-effort: + /// servers may ignore `If-Range`. + validator: Option, + tail_offset: u64, + tail: bytes::Bytes, + }, + Local { + path: PathBuf, + /// Present when the archive was spooled from a remote; keeps the + /// temporary file alive and distinguishes `Spooled` from `Local`. + temp: Option, + }, +} + +/// A conda package archive that can be opened once and read many times. +/// +/// Cloning is cheap; clones share the parsed archive index and (for spooled +/// archives) the temporary file. +#[derive(Clone)] +pub struct PackageArchive { + backend: Arc, +} + +/// A boxed reader used for the section decompression pipelines. +type DynReader = Box; +type RawSectionEntry = tokio_tar::Entry>; + +/// The kind of an entry in a package archive. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum ArchiveEntryKind { + /// A regular file. + File, + /// A directory. + Directory, + /// A symbolic link. + Symlink, + /// A hard link. + Hardlink, + /// Another tar entry type. + Other, +} + +impl ArchiveEntryKind { + /// Returns whether this entry is a symbolic or hard link. + pub fn is_link(self) -> bool { + matches!(self, Self::Symlink | Self::Hardlink) + } +} + +/// An entry yielded by [`SectionStream::next_entry`]. +/// +/// The underlying tar implementation is intentionally hidden so it can be +/// changed without affecting callers. +pub struct SectionEntry { + inner: RawSectionEntry, + path: PathBuf, +} + +impl SectionEntry { + /// Returns the normalized package-relative path of this entry. + pub fn path(&self) -> &Path { + &self.path + } + + /// Returns the entry kind. + pub fn kind(&self) -> ArchiveEntryKind { + let kind = self.inner.header().entry_type(); + if kind.is_file() { + ArchiveEntryKind::File + } else if kind.is_dir() { + ArchiveEntryKind::Directory + } else if kind.is_symlink() { + ArchiveEntryKind::Symlink + } else if kind.is_hard_link() { + ArchiveEntryKind::Hardlink + } else { + ArchiveEntryKind::Other + } + } + + /// Returns the declared entry size. + pub fn size(&self) -> Result { + Ok(self.inner.header().size()?) + } + + /// Returns the raw link target for a symbolic or hard link. + pub fn link_target(&self) -> Result, ExtractError> { + Ok(self.inner.link_name()?.map(std::borrow::Cow::into_owned)) + } + + /// Reads the complete entry body. + /// + /// Links are surfaced by the iterator but are never followed. + pub async fn read(&mut self) -> Result, ExtractError> { + if let Some(link) = describe_link(self)? { + return Err(ExtractError::LinksNotFollowed(vec![link])); + } + read_raw_entry_contents(&mut self.inner).await + } +} + +impl PackageArchive { + /// Opens a remote package archive with a single range request, falling + /// back to a one-time spooled download for `.tar.bz2` archives and + /// servers without range support. + pub async fn from_url(client: ClientWithMiddleware, url: Url) -> Result { + Self::from_url_with_options(client, url, RemoteArchiveOptions::default()).await + } + + /// Opens a remote package archive using the supplied access policy. + pub async fn from_url_with_options( + client: ClientWithMiddleware, + url: Url, + options: RemoteArchiveOptions, + ) -> Result { + let archive_type = CondaArchiveType::try_from(Path::new(url.path())) + .ok_or(ExtractError::UnsupportedArchiveType)?; + + if archive_type == CondaArchiveType::Conda && options.sparse_policy != SparsePolicy::Disable + { + if let Some(archive) = Self::try_open_sparse(client.clone(), url.clone()).await? { + return Ok(archive); + } + if options.sparse_policy == SparsePolicy::Require { + return Err(ExtractError::SparseAccessUnsupported); + } + } else if options.sparse_policy == SparsePolicy::Require { + return Err(ExtractError::SparseAccessUnsupported); + } + + Self::open_spooled(client, url, archive_type, options.max_spool_size).await + } + + /// Opens a package archive from a local file. + /// + /// ```rust,no_run + /// # #[tokio::main] + /// # async fn main() { + /// use rattler_package_streaming::archive::PackageArchive; + /// + /// let archive = PackageArchive::from_path("numpy-2.1.3-py312h58c1407_0.conda") + /// .await + /// .unwrap(); + /// # drop(archive); + /// # } + /// ``` + pub async fn from_path(path: impl AsRef) -> Result { + let path = path.as_ref(); + let archive_type = + CondaArchiveType::try_from(path).ok_or(ExtractError::UnsupportedArchiveType)?; + Self::open_local(path.to_owned(), archive_type, None).await + } + + /// Returns how this handle accesses the archive. + pub fn access(&self) -> ArchiveAccess { + let temp = match &*self.backend { + Backend::Conda { + source: CondaSource::Sparse { .. }, + .. + } => return ArchiveAccess::Sparse, + Backend::Conda { + source: CondaSource::Local { temp, .. }, + .. + } + | Backend::TarBz2 { temp, .. } => temp, + }; + if temp.is_some() { + ArchiveAccess::Spooled + } else { + ArchiveAccess::Local + } + } + + /// Reads a single file from the package, or `None` if the path does not + /// exist. + /// + /// Contents are not cached: every call streams the containing section + /// again up to the requested file. Prefer [`PackageArchive::read_files`] + /// with one batch over repeated calls. Requesting a path that is a + /// symbolic or hard link is an error; links are not followed. + /// + /// ```rust,no_run + /// # #[tokio::main] + /// # async fn main() { + /// # let archive = rattler_package_streaming::archive::PackageArchive::from_path("pkg.conda").await.unwrap(); + /// match archive.read_file("info/recipe/meta.yaml").await.unwrap() { + /// Some(bytes) => println!("recipe: {}", String::from_utf8_lossy(&bytes)), + /// None => println!("package has no recipe"), + /// } + /// # } + /// ``` + pub async fn read_file(&self, path: impl AsRef) -> Result>, ExtractError> { + let path = normalize(path.as_ref())?.into_owned(); + let mut result = self.read_files([path.clone()]).await?; + Ok(result.remove(&path).flatten()) + } + + /// Reads multiple files in one pass per touched section (sections are + /// fetched concurrently), aborting each stream after its last requested + /// file. Maps every requested path to its contents, or `None` when + /// absent. + /// + /// Calls are independent and may run concurrently, but contents are not + /// cached: a repeated call streams its sections again, so batch all + /// needed paths into a single call where possible. Requesting a path + /// that is a symbolic or hard link is an error; links are not followed. + /// + /// ```rust,no_run + /// # #[tokio::main] + /// # async fn main() { + /// # let archive = rattler_package_streaming::archive::PackageArchive::from_path("pkg.conda").await.unwrap(); + /// // One pass over the payload, one over info, fetched concurrently. + /// let files = archive + /// .read_files(["info/index.json", "lib/libfoo.so", "bin/foo"]) + /// .await + /// .unwrap(); + /// for (path, contents) in &files { + /// match contents { + /// Some(bytes) => println!("{}: {} bytes", path.display(), bytes.len()), + /// None => println!("{}: not in archive", path.display()), + /// } + /// } + /// # } + /// ``` + pub async fn read_files( + &self, + paths: impl IntoIterator>, + ) -> Result>>, ExtractError> { + let paths: Vec = paths + .into_iter() + .map(|path| { + let path: PathBuf = path.into(); + normalize(&path).map(std::borrow::Cow::into_owned) + }) + .collect::>()?; + if paths.is_empty() { + return Ok(HashMap::new()); + } + + // A .tar.bz2 archive is one flat tar: serve everything in a single + // unfiltered pass. Grouping per section here would decompress the + // whole bz2 stream once per section. + if let Backend::TarBz2 { path, .. } = &*self.backend { + let mut stream = Self::tar_bz2_stream(path, None).await?; + return scan_stream(&mut stream, paths).await; + } + + let mut groups: HashMap> = HashMap::new(); + for path in paths { + groups + .entry(Section::containing(&path)) + .or_default() + .push(path); + } + + let passes = groups.into_iter().map(|(section, group)| async move { + match self.stream(section).await { + Ok(mut stream) => scan_stream(&mut stream, group).await, + // A section that is absent from the archive simply does not + // contain any of the requested paths. + Err(ExtractError::MissingComponent) => { + Ok(group.into_iter().map(|path| (path, None)).collect()) + } + Err(err) => Err(err), + } + }); + let results = futures::future::try_join_all(passes).await?; + + Ok(results.into_iter().flatten().collect()) + } + + /// Reads and parses a typed [`PackageFile`], or `None` when the file is + /// not present in the package (common for `run_exports.json`). + pub async fn try_read_package_file(&self) -> Result, ExtractError> { + match self.read_file(P::package_path()).await? { + None => Ok(None), + Some(bytes) => parse_package_file(&bytes).map(Some), + } + } + + /// Reads and parses a typed [`PackageFile`] (e.g. `IndexJson`, + /// `PathsJson`) from the package. + /// + /// ```rust,no_run + /// # #[tokio::main] + /// # async fn main() { + /// # let archive = rattler_package_streaming::archive::PackageArchive::from_path("pkg.conda").await.unwrap(); + /// use rattler_conda_types::package::IndexJson; + /// + /// let index: IndexJson = archive.read_package_file().await.unwrap(); + /// println!("{} {}", index.name.as_normalized(), index.version); + /// # } + /// ``` + pub async fn read_package_file(&self) -> Result { + self.try_read_package_file() + .await? + .ok_or(ExtractError::MissingComponent) + } + + /// Lists the paths of all files (including symbolic links) in one + /// section. + /// + /// For [`Section::Info`] this is usually served from the cached archive + /// tail without extra requests. For [`Section::Content`] it streams the + /// entire section; prefer reading `info/paths.json` when only paths are + /// needed. + /// + /// ```rust,no_run + /// # #[tokio::main] + /// # async fn main() { + /// # let archive = rattler_package_streaming::archive::PackageArchive::from_path("pkg.conda").await.unwrap(); + /// use rattler_package_streaming::archive::Section; + /// + /// // Usually free: the info section tends to sit in the cached tail. + /// for path in archive.list_files(Section::Info).await.unwrap() { + /// println!("{}", path.display()); + /// } + /// # } + /// ``` + pub async fn list_files(&self, section: Section) -> Result, ExtractError> { + let mut stream = self.stream(section).await?; + let mut paths = Vec::new(); + while let Some(entry) = stream.next_entry().await? { + if matches!( + entry.kind(), + ArchiveEntryKind::File | ArchiveEntryKind::Symlink | ArchiveEntryKind::Hardlink + ) { + paths.push(entry.path().to_owned()); + } + } + Ok(paths) + } + + /// Streams the tar entries of one section. Unread entries are skipped + /// cheaply; dropping the stream aborts any underlying HTTP transfer. + /// + /// Every call opens a new independent forward-only stream (for remote + /// archives: a new request). + /// + /// ```rust,no_run + /// # #[tokio::main] + /// # async fn main() { + /// # let archive = rattler_package_streaming::archive::PackageArchive::from_path("pkg.conda").await.unwrap(); + /// use rattler_package_streaming::archive::Section; + /// + /// let mut stream = archive.stream(Section::Content).await.unwrap(); + /// while let Some(mut entry) = stream.next_entry().await.unwrap() { + /// let path = entry.path().to_owned(); + /// if path.extension().is_some_and(|ext| ext == "so") { + /// let bytes = entry.read().await.unwrap(); + /// println!("{}: {} bytes", path.display(), bytes.len()); + /// } // entries that are not read are skipped cheaply + /// } + /// # } + /// ``` + pub async fn stream(&self, section: Section) -> Result { + match &*self.backend { + Backend::Conda { source, members } => { + let span = find_section_member(members, section)?; + let raw = Self::conda_member_reader(source, span).await?; + let decoder = + ZstdDecoder::new(tokio::io::BufReader::with_capacity(STREAM_BUF_SIZE, raw)); + Ok(SectionStream::new(Box::new(decoder), None)) + } + // `read_files` bypasses the filter with `tar_bz2_stream(None)` + // to serve both sections from a single pass. + Backend::TarBz2 { path, .. } => Self::tar_bz2_stream(path, Some(section)).await, + } + } + + // --------------------------------------------------------------------- + // opening + // --------------------------------------------------------------------- + + /// Opens a remote `.conda` archive sparsely, or `None` when the server + /// does not support the required range requests and the caller should + /// fall back to a full download. + pub(crate) async fn try_open_sparse( + client: ClientWithMiddleware, + url: Url, + ) -> Result, ExtractError> { + match Self::open_sparse(client, url).await { + Ok(archive) => Ok(Some(archive)), + Err(err) if sparse_unsupported(&err) => { + debug!("sparse access unavailable ({err}), falling back to full download"); + Ok(None) + } + Err(err) => Err(err), + } + } + + /// Opens a remote `.conda` archive sparsely, without full-download fallback. + pub(crate) async fn open_sparse( + client: ClientWithMiddleware, + url: Url, + ) -> Result { + // One suffix range request: fetches the last TAIL_SIZE bytes and + // reveals the total archive size. + let (reader, headers) = AsyncHttpRangeReader::new( + client.clone(), + url.clone(), + CheckSupportMethod::NegativeRangeRequest(TAIL_SIZE), + HeaderMap::default(), + ) + .await?; + // A weak ETag must not be sent in `If-Range` (RFC 9110 §13.1.5); + // fall back to `Last-Modified` in that case. + let validator = headers + .get(ETAG) + .filter(|v| !v.as_bytes().starts_with(b"W/")) + .or_else(|| headers.get(LAST_MODIFIED)) + .cloned(); + let size = reader.len(); + debug!("opened remote archive ({size} bytes) with a {TAIL_SIZE} byte tail request"); + + // Parse the central directory. The needed bytes are already cached + // from the tail request; if the central directory is unusually large + // the range reader transparently fetches the difference. + let buf_reader = futures::io::BufReader::new(reader.compat()); + let zip = ZipFileReader::new(buf_reader).await?; + let members = collect_members(zip.file(), size)?; + + // Recover the range reader and keep a copy of the tail bytes so + // members that live inside the tail (usually the info section) can be + // served without further requests. + let mut reader = zip.into_inner().into_inner().into_inner(); + let tail_offset = size.saturating_sub(TAIL_SIZE); + let mut tail = vec![0u8; (size - tail_offset) as usize]; + reader.seek(SeekFrom::Start(tail_offset)).await?; + reader.read_exact(&mut tail).await?; + + Ok(Self { + backend: Arc::new(Backend::Conda { + source: CondaSource::Sparse { + client, + url, + validator, + tail_offset, + tail: tail.into(), + }, + members, + }), + }) + } + + async fn open_spooled( + client: ClientWithMiddleware, + url: Url, + archive_type: CondaArchiveType, + max_spool_size: Option, + ) -> Result { + let response = client + .get(url.clone()) + .send() + .await? + .error_for_status() + .map_err(|e| ExtractError::ReqwestError(e.into()))?; + + if let (Some(limit), Some(content_length)) = (max_spool_size, response.content_length()) + && content_length > limit + { + return Err(ExtractError::SpoolLimitExceeded { limit }); + } + + // Spool to disk rather than memory: packages can be arbitrarily + // large (multi-GB), so an in-memory copy is not an option. + let temp = tempfile::NamedTempFile::new()?; + let (file, temp_path) = temp.into_parts(); + let mut file = tokio::fs::File::from_std(file); + let body = StreamReader::new(response.bytes_stream().map_err(std::io::Error::other)); + let copied = if let Some(limit) = max_spool_size { + let mut body = body.take(limit.saturating_add(1)); + tokio::io::copy(&mut body, &mut file).await? + } else { + let mut body = body; + tokio::io::copy(&mut body, &mut file).await? + }; + if let Some(limit) = max_spool_size + && copied > limit + { + return Err(ExtractError::SpoolLimitExceeded { limit }); + } + file.flush().await?; + + Self::open_local(temp_path.to_path_buf(), archive_type, Some(temp_path)).await + } + + async fn open_local( + path: PathBuf, + archive_type: CondaArchiveType, + temp: Option, + ) -> Result { + let backend = match archive_type { + CondaArchiveType::Conda => { + let file = tokio::fs::File::open(&path).await?; + let size = file.metadata().await?.len(); + let buf_reader = + futures::io::BufReader::new(tokio::io::BufReader::new(file).compat()); + let zip = ZipFileReader::new(buf_reader).await?; + let members = collect_members(zip.file(), size)?; + Backend::Conda { + source: CondaSource::Local { path, temp }, + members, + } + } + CondaArchiveType::TarBz2 => Backend::TarBz2 { path, temp }, + }; + Ok(Self { + backend: Arc::new(backend), + }) + } + + // --------------------------------------------------------------------- + // section readers + // --------------------------------------------------------------------- + + /// Returns a reader over the stored bytes of a ZIP member. + async fn conda_member_reader( + source: &CondaSource, + span: &MemberSpan, + ) -> Result { + match source { + CondaSource::Sparse { + client, + url, + validator, + tail_offset, + tail, + } => { + // Serve from the cached tail when the whole member is inside it. + if span.header_offset >= *tail_offset { + let rel = (span.header_offset - tail_offset) as usize; + if let Some(range) = member_data_range(&tail[rel..], span.size) { + debug!("serving member {} from the cached tail", span.name); + let data = tail.slice(rel + range.start..rel + range.end); + return Ok(Box::new(std::io::Cursor::new(data))); + } + } + + // One bounded streaming ranged GET for the member. Dropping + // the returned reader aborts the transfer. + debug!( + "requesting range {}-{} for member {}", + span.header_offset, + span.end - 1, + span.name + ); + let mut request = client + .get(url.clone()) + .header( + RANGE, + format!("bytes={}-{}", span.header_offset, span.end - 1), + ) + // Forbid content-coding: byte math relies on the exact + // stored representation. + .header(http::header::ACCEPT_ENCODING, "identity"); + if let Some(validator) = validator { + request = request.header(IF_RANGE, validator); + } + let response = request + .send() + .await? + .error_for_status() + .map_err(|e| ExtractError::ReqwestError(e.into()))?; + if response.status() != ::reqwest::StatusCode::PARTIAL_CONTENT { + // An honored `If-Range` mismatch (the archive changed since + // it was opened) or a server that stopped honoring ranges. + return Err(ExtractError::RemoteArchiveChanged); + } + let mut reader = + StreamReader::new(response.bytes_stream().map_err(std::io::Error::other)); + skip_local_header(&mut reader).await?; + Ok(Box::new(reader.take(span.size))) + } + CondaSource::Local { path, .. } => { + let mut file = tokio::fs::File::open(path).await?; + file.seek(SeekFrom::Start(span.header_offset)).await?; + let mut reader = tokio::io::BufReader::new(file); + skip_local_header(&mut reader).await?; + Ok(Box::new(reader.take(span.size))) + } + } + } + + /// Opens a (optionally section-filtered) stream over a `.tar.bz2` archive. + async fn tar_bz2_stream( + path: &Path, + section: Option
, + ) -> Result { + let file = tokio::fs::File::open(path).await?; + let decoder = BzDecoder::new(tokio::io::BufReader::with_capacity(STREAM_BUF_SIZE, file)); + Ok(SectionStream::new(Box::new(decoder), section)) + } +} + +/// A streaming view over the tar entries of one package section. +pub struct SectionStream { + entries: tokio_tar::Entries, + /// For `.tar.bz2` archives (one flat tar), entries are filtered to the + /// requested section. `None` yields every entry. + filter: Option
, +} + +impl SectionStream { + fn new(reader: DynReader, filter: Option
) -> Self { + let mut archive = tokio_tar::Archive::new(reader); + let entries = archive + .entries() + .expect("entries() cannot fail on a fresh archive"); + Self { entries, filter } + } + + /// Advances to the next tar entry of the section, or `None` at the end of + /// the section. + pub async fn next_entry(&mut self) -> Result, ExtractError> { + use futures_util::StreamExt; + while let Some(entry) = self.entries.next().await { + let entry = entry?; + let path = { + let path = entry.path()?; + normalize(&path)?.into_owned() + }; + if let Some(section) = self.filter + && Section::containing(&path) != section + { + continue; + } + return Ok(Some(SectionEntry { inner: entry, path })); + } + Ok(None) + } +} + +/// Reads the requested paths out of a section stream, aborting as soon as the +/// last one has been found. +async fn scan_stream( + stream: &mut SectionStream, + paths: Vec, +) -> Result>>, ExtractError> { + let mut remaining: HashSet = paths.into_iter().collect(); + let mut out = HashMap::with_capacity(remaining.len()); + let mut links: Vec = Vec::new(); + while !remaining.is_empty() { + let Some(mut entry) = stream.next_entry().await? else { + break; + }; + let path = entry.path().to_owned(); + if remaining.remove(&path) { + // Finish the scan so the error names every offending link + // instead of discarding the whole batch on the first one. + if let Some(link) = describe_link(&entry)? { + links.push(link); + continue; + } + let buf = entry.read().await?; + out.insert(path, Some(buf)); + } + } + if !links.is_empty() { + return Err(ExtractError::LinksNotFollowed(links)); + } + for path in remaining { + out.insert(path, None); + } + Ok(out) +} + +/// Collects the member spans of a `.conda` ZIP archive from its parsed +/// central directory. The exclusive end bound of each member is the offset of +/// the next member (or the end of the archive), which over-approximates by at +/// most the size of the central directory for the last member. +fn collect_members( + zip: &async_zip::ZipFile, + archive_size: u64, +) -> Result, ExtractError> { + let entries = zip.entries(); + let mut members = Vec::with_capacity(entries.len()); + for entry in entries { + let name = entry + .filename() + .as_str() + .map_err(|e| { + ExtractError::IoError(std::io::Error::new(std::io::ErrorKind::InvalidData, e)) + })? + .to_owned(); + if name.ends_with(".tar.zst") && entry.compression() != Compression::Stored { + return Err(ExtractError::UnsupportedCompressionMethod); + } + members.push(MemberSpan { + name, + header_offset: entry.header_offset(), + size: entry.compressed_size(), + end: archive_size, + }); + } + // Bound each member by the next member's local header offset. + members.sort_unstable_by_key(|m| m.header_offset); + for i in 1..members.len() { + members[i - 1].end = members[i].header_offset; + } + Ok(members) +} + +fn find_section_member( + members: &[MemberSpan], + section: Section, +) -> Result<&MemberSpan, ExtractError> { + let prefix = section.zip_prefix(); + members + .iter() + .find(|m| m.name.starts_with(prefix) && m.name.ends_with(".tar.zst")) + .ok_or(ExtractError::MissingComponent) +} + +/// Describes a link entry for [`ExtractError::LinksNotFollowed`], or `None` +/// for regular entries. +fn describe_link(entry: &SectionEntry) -> Result, ExtractError> { + if !entry.kind().is_link() { + return Ok(None); + } + let target = entry + .link_target()? + .map(|target| target.display().to_string()) + .unwrap_or_default(); + Ok(Some(format!( + "'{}' (links to '{target}')", + entry.path().display() + ))) +} + +/// Reads the contents of a raw tar entry while capping the upfront allocation +/// derived from its untrusted header. +pub(crate) async fn read_raw_entry_contents( + entry: &mut tokio_tar::Entry, +) -> Result, ExtractError> { + let kind = entry.header().entry_type(); + if kind.is_symlink() || kind.is_hard_link() { + let path = normalize(&entry.path()?)?.into_owned(); + let target = entry + .link_name()? + .map(std::borrow::Cow::into_owned) + .unwrap_or_default(); + return Err(ExtractError::LinksNotFollowed(vec![format!( + "'{}' (links to '{}')", + path.display(), + target.display() + )])); + } + + let size = entry.header().size()?; + let mut buf = Vec::with_capacity(size.min(MAX_PREALLOC) as usize); + entry.read_to_end(&mut buf).await?; + Ok(buf) +} + +/// Parses the raw bytes of a typed [`PackageFile`]. +pub(crate) fn parse_package_file(bytes: &[u8]) -> Result { + P::from_slice(bytes) + .map_err(|e| ExtractError::ArchiveMemberParseError(P::package_path().to_owned(), e)) +} + +/// Validates a package-relative path and strips `.` components. +/// +/// Package paths may not be empty, absolute, or contain parent components. +pub(crate) fn normalize(path: &Path) -> Result, ExtractError> { + let mut needs_normalization = false; + let mut has_component = false; + for component in path.components() { + match component { + std::path::Component::Normal(_) => has_component = true, + std::path::Component::CurDir => needs_normalization = true, + std::path::Component::ParentDir + | std::path::Component::RootDir + | std::path::Component::Prefix(_) => { + return Err(ExtractError::InvalidArchivePath(path.to_owned())); + } + } + } + if !has_component { + return Err(ExtractError::InvalidArchivePath(path.to_owned())); + } + if needs_normalization { + Ok(std::borrow::Cow::Owned( + path.components() + .filter(|component| !matches!(component, std::path::Component::CurDir)) + .collect(), + )) + } else { + Ok(std::borrow::Cow::Borrowed(path)) + } +} + +/// Parses a ZIP local file header at the start of `buf` and returns the +/// range of the member data if `buf` contains all of it. +fn member_data_range(buf: &[u8], size: u64) -> Option> { + if buf.len() < 30 || buf[0..4] != LOCAL_HEADER_MAGIC { + return None; + } + let name_len = u16::from_le_bytes([buf[26], buf[27]]) as usize; + let extra_len = u16::from_le_bytes([buf[28], buf[29]]) as usize; + let data_start = 30 + name_len + extra_len; + let data_end = data_start.checked_add(size as usize)?; + (data_end <= buf.len()).then_some(data_start..data_end) +} + +/// Reads and skips a ZIP local file header from a stream, leaving the reader +/// positioned at the start of the member data. +async fn skip_local_header(reader: &mut R) -> Result<(), ExtractError> { + let mut header = [0u8; 30]; + reader.read_exact(&mut header).await?; + if header[0..4] != LOCAL_HEADER_MAGIC { + return Err(ExtractError::IoError(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "expected a ZIP local file header", + ))); + } + let name_len = u64::from(u16::from_le_bytes([header[26], header[27]])); + let extra_len = u64::from(u16::from_le_bytes([header[28], header[29]])); + let mut skip = reader.take(name_len + extra_len); + tokio::io::copy(&mut skip, &mut tokio::io::sink()).await?; + Ok(()) +} + +/// Returns true for errors that mean "sparse access is unavailable" and the +/// caller should fall back to a full download. +fn sparse_unsupported(err: &ExtractError) -> bool { + match err { + // Servers that ignore the `Range` header answer with a plain `200 OK` + // that carries no `Content-Range` header. + ExtractError::AsyncHttpRangeReaderError( + AsyncHttpRangeReaderError::HttpRangeRequestUnsupported + | AsyncHttpRangeReaderError::ContentRangeMissing, + ) => true, + // JFrog Artifactory returns 416 when querying more than the object length. + ExtractError::AsyncHttpRangeReaderError(AsyncHttpRangeReaderError::HttpError(err)) => { + err.status() == Some(::reqwest::StatusCode::RANGE_NOT_SATISFIABLE) + } + _ => false, + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use rattler_conda_types::package::{AboutJson, IndexJson}; + + use super::*; + use crate::reqwest::test_server; + + fn conda_test_file() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../test-data/clobber/clobber-fd-1-0.1.0-h4616a5c_0.conda") + } + + fn tar_bz2_test_file() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../test-data/clobber/clobber-1-0.1.0-h4616a5c_0.tar.bz2") + } + + /// A middleware that counts the HTTP requests going through a client. + struct RequestCounter(Arc); + + #[async_trait::async_trait] + impl reqwest_middleware::Middleware for RequestCounter { + async fn handle( + &self, + req: ::reqwest::Request, + extensions: &mut http::Extensions, + next: reqwest_middleware::Next<'_>, + ) -> reqwest_middleware::Result<::reqwest::Response> { + self.0.fetch_add(1, Ordering::Relaxed); + next.run(req, extensions).await + } + } + + fn counting_client() -> (ClientWithMiddleware, Arc) { + let counter = Arc::new(AtomicUsize::new(0)); + let client = reqwest_middleware::ClientBuilder::new(::reqwest::Client::new()) + .with(RequestCounter(counter.clone())) + .build(); + (client, counter) + } + + #[tokio::test] + async fn test_sparse_conda_round_trip() { + let url = test_server::serve_file(conda_test_file()).await; + let (client, requests) = counting_client(); + + let archive = PackageArchive::from_url(client, url).await.unwrap(); + assert_eq!(archive.access(), ArchiveAccess::Sparse); + assert_eq!(requests.load(Ordering::Relaxed), 1, "open = 1 request"); + + // Typed metadata reads: the test package is tiny, so everything is + // served from the cached tail without further requests. + let index: IndexJson = archive.read_package_file().await.unwrap(); + assert_eq!(index.name.as_normalized(), "clobber-fd-1"); + let _about: AboutJson = archive.read_package_file().await.unwrap(); + assert_eq!( + requests.load(Ordering::Relaxed), + 1, + "metadata reads served from the tail cache" + ); + + // Payload + metadata in one batched call. + let files = archive + .read_files(["clobber", "info/index.json", "does/not/exist"]) + .await + .unwrap(); + assert_eq!( + String::from_utf8(files[Path::new("clobber")].clone().unwrap()).unwrap(), + "clobber-fd-1\n" + ); + assert!(files[Path::new("info/index.json")].is_some()); + assert!(files[Path::new("does/not/exist")].is_none()); + assert_eq!( + requests.load(Ordering::Relaxed), + 1, + "tiny package: payload also served from the tail cache" + ); + } + + #[tokio::test] + async fn test_stream_section() { + let url = test_server::serve_file(conda_test_file()).await; + let (client, _) = counting_client(); + + let archive = PackageArchive::from_url(client, url).await.unwrap(); + let mut names = Vec::new(); + let mut stream = archive.stream(Section::Info).await.unwrap(); + while let Some(entry) = stream.next_entry().await.unwrap() { + names.push(entry.path().display().to_string()); + } + assert!(names.iter().any(|n| n == "info/index.json"), "{names:?}"); + } + + #[tokio::test] + async fn test_tar_bz2_spooled() { + let url = test_server::serve_file(tar_bz2_test_file()).await; + let (client, requests) = counting_client(); + + let archive = PackageArchive::from_url(client, url).await.unwrap(); + assert_eq!(archive.access(), ArchiveAccess::Spooled); + assert_eq!(requests.load(Ordering::Relaxed), 1, "one full download"); + + let files = archive + .read_files(["info/index.json", "clobber.txt"]) + .await + .unwrap(); + assert!(files[Path::new("info/index.json")].is_some()); + assert!(files[Path::new("clobber.txt")].is_some()); + + let index: IndexJson = archive.read_package_file().await.unwrap(); + assert_eq!(index.name.as_normalized(), "clobber-1"); + assert_eq!( + requests.load(Ordering::Relaxed), + 1, + "spooled archive is downloaded exactly once" + ); + + // Section streaming filters the flat tar by prefix. + let mut stream = archive.stream(Section::Content).await.unwrap(); + let mut names = Vec::new(); + while let Some(entry) = stream.next_entry().await.unwrap() { + names.push(entry.path().display().to_string()); + } + assert!(names.iter().all(|n| !n.starts_with("info/")), "{names:?}"); + assert!(names.iter().any(|n| n == "clobber.txt"), "{names:?}"); + } + + #[tokio::test] + async fn test_conda_no_range_support_fallback() { + let url = test_server::serve_file_no_ranges(conda_test_file()).await; + let (client, requests) = counting_client(); + + let archive = PackageArchive::from_url(client, url).await.unwrap(); + assert_eq!(archive.access(), ArchiveAccess::Spooled); + assert_eq!( + requests.load(Ordering::Relaxed), + 2, + "one failed range probe + one full download" + ); + + let index: IndexJson = archive.read_package_file().await.unwrap(); + assert_eq!(index.name.as_normalized(), "clobber-fd-1"); + let content = archive.read_file("clobber").await.unwrap().unwrap(); + assert_eq!(String::from_utf8(content).unwrap(), "clobber-fd-1\n"); + assert_eq!( + requests.load(Ordering::Relaxed), + 2, + "all reads served from the spool file" + ); + } + + /// A package larger than the 64 KiB tail: payload reads must go through + /// the ranged member-GET path (local header skip, `If-Range`, end bound). + #[tokio::test] + async fn test_sparse_large_package() { + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../test-data/sparse/sparse-test-1.0.0-0.conda"); + let url = test_server::serve_file(fixture).await; + let (client, requests) = counting_client(); + + let archive = PackageArchive::from_url(client, url).await.unwrap(); + assert_eq!(requests.load(Ordering::Relaxed), 1, "open = 1 request"); + + // The info member sits inside the tail: no extra request. Leading + // `./` components are normalized away. + let index = archive + .read_file("./info/index.json") + .await + .unwrap() + .expect("index.json should exist"); + assert!(!index.is_empty()); + assert_eq!(requests.load(Ordering::Relaxed), 1); + + // The payload member lies outside the tail: exactly one ranged GET, + // shared by both files. + let files = archive + .read_files(["bin/first-file.txt", "share/last-file.txt"]) + .await + .unwrap(); + assert_eq!( + files[Path::new("bin/first-file.txt")].as_deref(), + Some(b"first payload file\n".as_slice()) + ); + assert_eq!( + files[Path::new("share/last-file.txt")].as_deref(), + Some(b"last payload file\n".as_slice()) + ); + assert_eq!( + requests.load(Ordering::Relaxed), + 2, + "payload batch = 1 ranged request" + ); + + let names = archive.list_files(Section::Content).await.unwrap(); + assert_eq!(names.len(), 3, "{names:?}"); + } + + #[tokio::test] + async fn test_list_files() { + let archive = PackageArchive::from_path(conda_test_file()).await.unwrap(); + let info = archive.list_files(Section::Info).await.unwrap(); + assert!( + info.iter().any(|p| p == Path::new("info/index.json")), + "{info:?}" + ); + let content = archive.list_files(Section::Content).await.unwrap(); + assert_eq!(content, vec![PathBuf::from("clobber")]); + } + + #[tokio::test] + async fn test_symlinks_surfaced_not_followed() { + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../test-data/sparse/symlink-test-1.0.0-0.conda"); + let archive = PackageArchive::from_path(&fixture).await.unwrap(); + + // Symbolic links show up in listings... + let files = archive.list_files(Section::Content).await.unwrap(); + assert!( + files.contains(&PathBuf::from("lib/liblink.so")), + "{files:?}" + ); + assert!( + files.contains(&PathBuf::from("lib/libhard.so")), + "{files:?}" + ); + assert!( + files.contains(&PathBuf::from("lib/libreal.so.1")), + "{files:?}" + ); + + // ...their targets read fine... + let real = archive.read_file("lib/libreal.so.1").await.unwrap(); + assert_eq!(real.as_deref(), Some(b"real library bytes".as_slice())); + + let mut stream = archive.stream(Section::Content).await.unwrap(); + let mut kinds = HashMap::new(); + while let Some(entry) = stream.next_entry().await.unwrap() { + kinds.insert(entry.path().to_owned(), entry.kind()); + } + assert_eq!( + kinds[Path::new("lib/liblink.so")], + ArchiveEntryKind::Symlink + ); + assert_eq!( + kinds[Path::new("lib/libhard.so")], + ArchiveEntryKind::Hardlink + ); + + // ...but reading a link itself is an error, for both link kinds. + for link in ["lib/liblink.so", "lib/libhard.so"] { + let err = archive.read_file(link).await.unwrap_err(); + assert!(err.to_string().contains("links are not followed"), "{err}"); + } + } + + #[tokio::test] + async fn test_missing_section_reads_as_none() { + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../test-data/sparse/info-only-1.0.0-0.conda"); + let archive = PackageArchive::from_path(&fixture).await.unwrap(); + + // A path in an absent section is simply not in the archive. + let files = archive + .read_files(["bin/missing", "info/index.json"]) + .await + .unwrap(); + assert!(files[Path::new("bin/missing")].is_none()); + assert!(files[Path::new("info/index.json")].is_some()); + + // Asking for the section itself is still an error. + assert!(matches!( + archive.stream(Section::Content).await, + Err(ExtractError::MissingComponent) + )); + } + + /// The fixture uses zip64 local headers; the reader must skip their + /// zip64 extra fields correctly (sizes themselves come from the central + /// directory). + #[tokio::test] + async fn test_zip64_local_headers() { + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../test-data/sparse/zip64-test-1.0.0-0.conda"); + let url = test_server::serve_file(fixture).await; + let (client, _) = counting_client(); + + let archive = PackageArchive::from_url(client, url).await.unwrap(); + let content = archive.read_file("bin/hello.txt").await.unwrap(); + assert_eq!(content.as_deref(), Some(b"zip64 payload\n".as_slice())); + } + + #[tokio::test] + async fn test_tar_bz2_list_files() { + let archive = PackageArchive::from_path(tar_bz2_test_file()) + .await + .unwrap(); + let info = archive.list_files(Section::Info).await.unwrap(); + assert!(info.contains(&PathBuf::from("info/index.json")), "{info:?}"); + let content = archive.list_files(Section::Content).await.unwrap(); + assert!( + content.contains(&PathBuf::from("clobber.txt")), + "{content:?}" + ); + assert!( + content.iter().all(|p| !p.starts_with("info")), + "{content:?}" + ); + } + + #[tokio::test] + async fn test_try_read_package_file_absent() { + use rattler_conda_types::package::RunExportsJson; + let archive = PackageArchive::from_path(conda_test_file()).await.unwrap(); + let run_exports: Option = archive.try_read_package_file().await.unwrap(); + assert!(run_exports.is_none()); + } + + /// `JFrog` Artifactory answers suffix ranges beyond the object length with + /// 416; the handle must fall back to spooling. + #[tokio::test] + async fn test_conda_416_suffix_fallback() { + let url = test_server::serve_file_416_suffix(conda_test_file()).await; + let (client, requests) = counting_client(); + + let archive = PackageArchive::from_url(client, url).await.unwrap(); + assert_eq!(archive.access(), ArchiveAccess::Spooled); + assert_eq!( + requests.load(Ordering::Relaxed), + 2, + "rejected range probe + one full download" + ); + let content = archive.read_file("clobber").await.unwrap().unwrap(); + assert_eq!(String::from_utf8(content).unwrap(), "clobber-fd-1\n"); + } + + /// A republished archive must fail loudly, not yield garbage — even on + /// servers (like this test server) that ignore `If-Range`. + #[tokio::test] + async fn test_archive_replaced_mid_read_errors() { + let dir = tempfile::tempdir().unwrap(); + let served = dir.path().join("replaced-test-1.0.0-0.conda"); + std::fs::copy( + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../test-data/sparse/sparse-test-1.0.0-0.conda"), + &served, + ) + .unwrap(); + let url = test_server::serve_file(&served).await; + let (client, _) = counting_client(); + + let archive = PackageArchive::from_url(client, url).await.unwrap(); + + // Replace the archive on the server with a different package. + std::fs::copy(conda_test_file(), &served).unwrap(); + + // The payload member no longer matches the parsed index; the read + // must error rather than return bytes from the wrong archive. + assert!(archive.read_file("bin/first-file.txt").await.is_err()); + } + + /// Entries stored with a leading `./` (as `tar -C dir -c .` produces) + /// must round-trip between `list_files` and `read_file`. + #[tokio::test] + async fn test_dot_slash_entries_round_trip() { + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../test-data/sparse/dotslash-test-1.0.0-0.conda"); + let archive = PackageArchive::from_path(&fixture).await.unwrap(); + + let files = archive.list_files(Section::Content).await.unwrap(); + assert_eq!(files, vec![PathBuf::from("lib/data.txt")]); + + for spelling in ["lib/data.txt", "./lib/data.txt"] { + let content = archive.read_file(spelling).await.unwrap(); + assert_eq!( + content.as_deref(), + Some(b"dot slash payload\n".as_slice()), + "{spelling}" + ); + } + assert!( + archive + .read_file("info/index.json") + .await + .unwrap() + .is_some() + ); + } + + /// A link in a batch fails the read, but only after the scan completes, + /// with an error naming the offending path. + #[tokio::test] + async fn test_link_error_names_offending_path() { + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../test-data/sparse/symlink-test-1.0.0-0.conda"); + let archive = PackageArchive::from_path(&fixture).await.unwrap(); + + let err = archive + .read_files(["lib/libreal.so.1", "lib/liblink.so"]) + .await + .unwrap_err(); + let message = err.to_string(); + assert!(message.contains("'lib/liblink.so'"), "{message}"); + assert!( + !message.contains("'lib/libreal.so.1'"), + "the regular file must not be reported as offending: {message}" + ); + } + + #[test] + fn test_section_containing() { + assert_eq!( + Section::containing(Path::new("info/index.json")), + Section::Info + ); + assert_eq!( + Section::containing(Path::new("./info/index.json")), + Section::Info + ); + assert_eq!(Section::containing(Path::new("info")), Section::Info); + assert_eq!( + Section::containing(Path::new("info-custom.txt")), + Section::Content + ); + assert_eq!( + Section::containing(Path::new("information/file")), + Section::Content + ); + assert_eq!( + Section::containing(Path::new("lib/libz.so")), + Section::Content + ); + } + + #[tokio::test] + async fn test_remote_access_policy() { + let url = test_server::serve_file_no_ranges(conda_test_file()).await; + let (client, requests) = counting_client(); + let options = RemoteArchiveOptions::new().with_sparse_policy(SparsePolicy::Require); + let error = match PackageArchive::from_url_with_options(client, url, options).await { + Ok(_) => panic!("range support should have been required"), + Err(error) => error, + }; + assert!(matches!(error, ExtractError::SparseAccessUnsupported)); + assert_eq!(requests.load(Ordering::Relaxed), 1); + + let url = test_server::serve_file(conda_test_file()).await; + let (client, requests) = counting_client(); + let options = RemoteArchiveOptions::new().with_sparse_policy(SparsePolicy::Disable); + let archive = PackageArchive::from_url_with_options(client, url, options) + .await + .unwrap(); + assert_eq!(archive.access(), ArchiveAccess::Spooled); + assert_eq!(requests.load(Ordering::Relaxed), 1); + } + + #[tokio::test] + async fn test_spool_size_limit() { + let url = test_server::serve_file_no_ranges(conda_test_file()).await; + let (client, _) = counting_client(); + let options = RemoteArchiveOptions::new().with_max_spool_size(1); + let error = match PackageArchive::from_url_with_options(client, url, options).await { + Ok(_) => panic!("the spool limit should have rejected the download"), + Err(error) => error, + }; + assert!(matches!( + error, + ExtractError::SpoolLimitExceeded { limit: 1 } + )); + } + + #[tokio::test] + async fn test_invalid_archive_paths() { + let archive = PackageArchive::from_path(conda_test_file()).await.unwrap(); + for path in ["", ".", "../clobber", "/clobber"] { + assert!(matches!( + archive.read_file(path).await, + Err(ExtractError::InvalidArchivePath(_)) + )); + } + } + + #[tokio::test] + async fn test_local_conda() { + let archive = PackageArchive::from_path(conda_test_file()).await.unwrap(); + assert_eq!(archive.access(), ArchiveAccess::Local); + let index: IndexJson = archive.read_package_file().await.unwrap(); + assert_eq!(index.name.as_normalized(), "clobber-fd-1"); + let content = archive.read_file("clobber").await.unwrap().unwrap(); + assert_eq!(String::from_utf8(content).unwrap(), "clobber-fd-1\n"); + } +} diff --git a/crates/rattler_package_streaming/src/lib.rs b/crates/rattler_package_streaming/src/lib.rs index 34d9247f18..9ac39c26e3 100644 --- a/crates/rattler_package_streaming/src/lib.rs +++ b/crates/rattler_package_streaming/src/lib.rs @@ -11,6 +11,8 @@ use rattler_digest::{Md5Hash, Sha256Hash}; #[cfg(feature = "reqwest")] use rattler_redaction::Redact; +#[cfg(all(feature = "reqwest", not(target_arch = "wasm32")))] +pub mod archive; pub mod read; pub mod seek; @@ -52,6 +54,24 @@ pub enum ExtractError { #[error("a component is missing from the Conda archive")] MissingComponent, + #[error("invalid package-relative archive path: {0}")] + InvalidArchivePath(PathBuf), + + #[cfg(feature = "reqwest")] + #[error("the server does not support sparse package access")] + SparseAccessUnsupported, + + #[cfg(feature = "reqwest")] + #[error("spooled package download exceeds the configured {limit} byte limit")] + SpoolLimitExceeded { limit: u64 }, + + #[cfg(feature = "reqwest")] + #[error("the remote archive changed after it was opened; reopen the archive")] + RemoteArchiveChanged, + + #[error("cannot read {}: links are not followed", .0.join(", "))] + LinksNotFollowed(Vec), + #[error("unsupported compression method")] UnsupportedCompressionMethod, diff --git a/crates/rattler_package_streaming/src/reqwest/fetch.rs b/crates/rattler_package_streaming/src/reqwest/fetch.rs index e0d09ddf11..10bc618383 100644 --- a/crates/rattler_package_streaming/src/reqwest/fetch.rs +++ b/crates/rattler_package_streaming/src/reqwest/fetch.rs @@ -1,70 +1,21 @@ -//! High-level helpers to fetch files from remote Conda packages. +//! High-level helpers to fetch single files from remote conda packages. //! -//! These helpers first try the sparse HTTP range-request path from [`super::sparse`] -//! and automatically fall back to streaming the full archive through -//! [`super::full_download`] when range requests are unsupported or the archive type -//! cannot be handled sparsely. -//! -//! Use this module when you want a single entry point that works for both typed -//! [`PackageFile`] members and arbitrary file paths inside `.conda` or `.tar.bz2` -//! packages. -//! -//! # Example -//! -//! ```rust,no_run -//! # #[tokio::main] -//! # async fn main() { -//! use rattler_conda_types::package::IndexJson; -//! use rattler_package_streaming::reqwest::fetch::fetch_package_file_from_remote_url; -//! use reqwest::Client; -//! use reqwest_middleware::ClientWithMiddleware; -//! use url::Url; -//! -//! let client = ClientWithMiddleware::from(Client::new()); -//! let url = Url::parse("https://conda.anaconda.org/conda-forge/linux-64/python-3.10.8-h4a9ceb5_0_cpython.conda").unwrap(); -//! -//! let index_json: IndexJson = fetch_package_file_from_remote_url(client, url) -//! .await -//! .unwrap(); -//! -//! println!("Package: {}", index_json.name.as_normalized()); -//! # } -//! ``` +//! Sparse range requests where supported (via +//! [`crate::archive::PackageArchive`]), with a streaming full-download +//! fallback that aborts once the target file has been read. To read multiple +//! files from one package, open a [`crate::archive::PackageArchive`] instead. -use async_http_range_reader::AsyncHttpRangeReaderError; -use rattler_conda_types::package::PackageFile; +use rattler_conda_types::package::{CondaArchiveType, PackageFile}; use reqwest_middleware::ClientWithMiddleware; -use tracing::debug; use url::Url; pub use super::full_download::{ fetch_file_from_remote_full_download, fetch_package_file_full_download, }; -use super::sparse::fetch_package_file_sparse; use crate::ExtractError; -use crate::reqwest::sparse::fetch_file_from_remote_sparse; +use crate::archive::{PackageArchive, parse_package_file}; -/// Fetch and parse a specific [`PackageFile`] from a remote package. -/// -/// The function first attempts the sparse range-request path, which usually only -/// downloads the bytes needed to reach the requested file inside a `.conda` -/// archive. -/// -/// If the server does not support range requests, or if the archive type cannot -/// be handled by the sparse implementation, it falls back to the streaming -/// full-download path. -/// -/// For lower-level access, see [`super::sparse::fetch_file_from_remote_sparse`] -/// and [`super::full_download::fetch_file_from_remote_full_download`]. -/// -/// # Arguments -/// -/// * `client` - The HTTP client to use for requests -/// * `url` - The URL of the package -/// -/// # Returns -/// -/// The parsed package file (e.g., `IndexJson`, `AboutJson`, etc.) +/// Fetch and parse a typed [`PackageFile`] from a remote package. /// /// # Example /// @@ -89,59 +40,30 @@ pub async fn fetch_package_file_from_remote_url( client: ClientWithMiddleware, url: Url, ) -> Result { - match fetch_package_file_sparse::

(client.clone(), url.clone()).await { - Ok(result) => return Ok(result), - Err(ExtractError::UnsupportedArchiveType) => { - debug!("archive type not supported for range requests, falling back to full download"); - } - Err(ExtractError::AsyncHttpRangeReaderError( - AsyncHttpRangeReaderError::HttpRangeRequestUnsupported, - )) => { - debug!("server does not support range requests, falling back to full download"); - } - Err(ExtractError::AsyncHttpRangeReaderError(AsyncHttpRangeReaderError::HttpError(err))) - if err.status() == Some(reqwest::StatusCode::RANGE_NOT_SATISFIABLE) => - { - // this can happen with JFrog Artifactory when you query more than the object length - debug!("server returned range not satisfiable, falling back to full download"); - } - Err(e) => return Err(e), - } - - fetch_package_file_full_download::

(&client, &url).await + let bytes = fetch_file_from_remote_url(client, url, P::package_path()) + .await? + .ok_or(ExtractError::MissingComponent)?; + parse_package_file(&bytes) } -/// Fetch the raw bytes for an arbitrary file path inside a remote package. -/// -/// The function first attempts the sparse range-request path for `.conda` -/// packages and falls back to streaming the full archive when sparse access is -/// unavailable. -/// -/// Returns `Ok(None)` when the target path does not exist in the archive. +/// Fetch the raw bytes for a file path inside a remote package. +/// Returns `Ok(None)` when the path does not exist in the archive. pub async fn fetch_file_from_remote_url( client: ClientWithMiddleware, url: Url, target_path: &std::path::Path, ) -> Result>, ExtractError> { - match fetch_file_from_remote_sparse(client.clone(), url.clone(), target_path).await { - Ok(result) => return Ok(result), - Err(ExtractError::UnsupportedArchiveType) => { - debug!("archive type not supported for range requests, falling back to full download"); - } - Err(ExtractError::AsyncHttpRangeReaderError( - AsyncHttpRangeReaderError::HttpRangeRequestUnsupported, - )) => { - debug!("server does not support range requests, falling back to full download"); - } - Err(ExtractError::AsyncHttpRangeReaderError(AsyncHttpRangeReaderError::HttpError(err))) - if err.status() == Some(reqwest::StatusCode::RANGE_NOT_SATISFIABLE) => - { - // this can happen with JFrog Artifactory when you query more than the object length - debug!("server returned range not satisfiable, falling back to full download"); - } - Err(e) => return Err(e), + let archive_type = CondaArchiveType::try_from(std::path::Path::new(url.path())) + .ok_or(ExtractError::UnsupportedArchiveType)?; + + if archive_type == CondaArchiveType::Conda + && let Some(archive) = PackageArchive::try_open_sparse(client.clone(), url.clone()).await? + { + return archive.read_file(target_path).await; } + // One-shot read: stream the body and abort once the file is found, + // rather than spooling the whole archive like `PackageArchive` does. fetch_file_from_remote_full_download(&client, &url, target_path).await } @@ -183,8 +105,7 @@ mod tests { insta::assert_yaml_snapshot!(about_json); } - /// tar.bz2 is unsupported by the sparse path, so `fetch_package_file_from_remote_url` - /// falls through to `fetch_package_file_full_download` (streaming). + /// tar.bz2 is unsupported by the sparse path and falls back to streaming. #[tokio::test] async fn test_fetch_full_download_tar_bz2() { use rattler_conda_types::package::IndexJson; @@ -229,6 +150,52 @@ mod tests { assert!(!raw.is_empty()); } + /// `./`-prefixed entries resolve identically on the sparse path and the + /// streaming fallback. + #[tokio::test] + async fn test_dot_slash_on_both_paths() { + let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../test-data/sparse/dotslash-test-1.0.0-0.conda"); + let client = reqwest_middleware::ClientWithMiddleware::from(reqwest::Client::new()); + + for url in [ + test_server::serve_file(&fixture).await, + test_server::serve_file_no_ranges(&fixture).await, + ] { + let raw = fetch_file_from_remote_url( + client.clone(), + url, + std::path::Path::new("lib/data.txt"), + ) + .await + .unwrap() + .expect("entry stored as ./lib/data.txt must resolve"); + assert_eq!(raw, b"dot slash payload\n"); + } + } + + /// Both the sparse path and the streaming fallback must reject links + /// with the same contract. + #[tokio::test] + async fn test_link_rejected_on_both_paths() { + let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../test-data/sparse/symlink-test-1.0.0-0.conda"); + let link = std::path::Path::new("lib/liblink.so"); + let client = reqwest_middleware::ClientWithMiddleware::from(reqwest::Client::new()); + + let sparse_url = test_server::serve_file(&fixture).await; + let err = fetch_file_from_remote_url(client.clone(), sparse_url, link) + .await + .unwrap_err(); + assert!(err.to_string().contains("links are not followed"), "{err}"); + + let no_range_url = test_server::serve_file_no_ranges(&fixture).await; + let err = fetch_file_from_remote_url(client, no_range_url, link) + .await + .unwrap_err(); + assert!(err.to_string().contains("links are not followed"), "{err}"); + } + #[tokio::test] async fn test_fetch_file_from_remote_tar_bz2_fallback() { let tar_bz2 = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) diff --git a/crates/rattler_package_streaming/src/reqwest/full_download.rs b/crates/rattler_package_streaming/src/reqwest/full_download.rs index 6f449d796b..64ac89b956 100644 --- a/crates/rattler_package_streaming/src/reqwest/full_download.rs +++ b/crates/rattler_package_streaming/src/reqwest/full_download.rs @@ -65,7 +65,7 @@ pub async fn fetch_file_from_remote_full_download( let mut zip_reader = ZipFileReader::new(&mut buf_reader); let mut found: Option> = None; - let prefix = crate::tokio::async_read::conda_entry_prefix(target_path); + let prefix = crate::archive::Section::containing(target_path).zip_prefix(); while let Some(mut entry) = zip_reader .next_with_entry() diff --git a/crates/rattler_package_streaming/src/reqwest/mod.rs b/crates/rattler_package_streaming/src/reqwest/mod.rs index 46f41c546c..c127f231fa 100644 --- a/crates/rattler_package_streaming/src/reqwest/mod.rs +++ b/crates/rattler_package_streaming/src/reqwest/mod.rs @@ -1,8 +1,10 @@ //! Functionality to stream and extract packages directly from a [`reqwest::Url`]. +#[cfg(not(target_arch = "wasm32"))] pub mod fetch; pub mod full_download; +#[cfg(not(target_arch = "wasm32"))] pub mod sparse; pub mod tokio; #[cfg(test)] -mod test_server; +pub(crate) mod test_server; diff --git a/crates/rattler_package_streaming/src/reqwest/sparse.rs b/crates/rattler_package_streaming/src/reqwest/sparse.rs index fc38ccca23..cb323c1193 100644 --- a/crates/rattler_package_streaming/src/reqwest/sparse.rs +++ b/crates/rattler_package_streaming/src/reqwest/sparse.rs @@ -1,152 +1,42 @@ //! Sparse remote access to files inside `.conda` archives. //! -//! This module uses HTTP range requests to avoid downloading the full archive. -//! It opens the outer ZIP container, locates the relevant `info-*.tar.zst` or -//! `pkg-*.tar.zst` member, and streams only the bytes needed to read a target -//! path from that inner tarball. -//! -//! Only `.conda` archives on servers that support range requests are supported. -//! For higher-level APIs that fall back to full downloads, see -//! [`super::fetch::fetch_package_file_from_remote_url`] and -//! [`super::fetch::fetch_file_from_remote_url`]. -//! -//! # Example -//! -//! ```rust,no_run -//! # #[tokio::main] -//! # async fn main() { -//! use rattler_conda_types::package::IndexJson; -//! use rattler_package_streaming::reqwest::sparse::fetch_package_file_sparse; -//! use reqwest::Client; -//! use reqwest_middleware::ClientWithMiddleware; -//! use url::Url; -//! -//! let client = ClientWithMiddleware::from(Client::new()); -//! let url = Url::parse("https://conda.anaconda.org/conda-forge/linux-64/python-3.10.8-h4a9ceb5_0_cpython.conda").unwrap(); -//! -//! let index_json: IndexJson = fetch_package_file_sparse(client, url).await.unwrap(); -//! # } -//! ``` +//! Thin wrappers around [`crate::archive::PackageArchive`] that require range +//! request support and never fall back to a full download. For fallback +//! behavior see [`super::fetch`]; to read multiple files from one package, +//! use [`crate::archive::PackageArchive`] directly. use std::path::Path; -use async_compression::tokio::bufread::ZstdDecoder; -use async_http_range_reader::{AsyncHttpRangeReader, CheckSupportMethod}; -use async_zip::base::read::seek::ZipFileReader; -use http::HeaderMap; use rattler_conda_types::package::{CondaArchiveType, PackageFile}; use rattler_redaction::{DEFAULT_REDACTION_STR, redact_known_secrets_from_url}; use reqwest_middleware::ClientWithMiddleware; -use tokio_util::compat::{FuturesAsyncReadCompatExt, TokioAsyncReadCompatExt}; -use tracing::{debug, instrument}; +use tracing::instrument; use url::Url; use crate::ExtractError; -use crate::tokio::async_read::{conda_entry_prefix, get_file_from_tar_archive}; - -/// Default number of bytes to fetch from the end of the file. -/// 64KB should be enough for most packages to include the EOCD, Central Directory, -/// and often the entire info archive. -const DEFAULT_TAIL_SIZE: u64 = 64 * 1024; +use crate::archive::PackageArchive; /// Fetch the raw bytes of a single file from a remote `.conda` package using -/// HTTP range requests. +/// HTTP range requests. Returns `Ok(None)` if the file is not in the archive. /// -/// Streams the zstd data directly from the server through an async decompressor -/// and tar reader, stopping as soon as the target file is found. Only the bytes -/// needed to reach the target file are downloaded and decompressed. -/// -/// Returns `Ok(None)` if the file is not found in the archive. -/// Returns an error if the URL does not point to a `.conda` archive or the -/// server does not support range requests. +/// Only the bytes needed to reach the target file are downloaded. Errors if +/// the URL is not a `.conda` archive or the server does not support ranges. #[instrument(skip_all, fields(url = %redact_known_secrets_from_url(&url, DEFAULT_REDACTION_STR).as_ref().unwrap_or(&url), path = %target_path.display()))] pub async fn fetch_file_from_remote_sparse( client: ClientWithMiddleware, url: Url, target_path: &Path, ) -> Result>, ExtractError> { - let archive_type = CondaArchiveType::try_from(std::path::Path::new(url.path())) - .ok_or(ExtractError::UnsupportedArchiveType)?; - - if archive_type != CondaArchiveType::Conda { + if CondaArchiveType::try_from(Path::new(url.path())) != Some(CondaArchiveType::Conda) { return Err(ExtractError::UnsupportedArchiveType); } - - // Create range reader (fetches last 64KB on construction) - let (reader, _headers) = AsyncHttpRangeReader::new( - client, - url, - CheckSupportMethod::NegativeRangeRequest(DEFAULT_TAIL_SIZE), - HeaderMap::default(), - ) - .await?; - - // Wrap for async_zip (tokio → futures traits + buffering) - let buf_reader = futures::io::BufReader::new(reader.compat()); - - // Open ZIP (parses EOCD + central directory, data already cached from range request) - let mut zip_reader = ZipFileReader::new(buf_reader).await?; - - // Find the tar.zst entry that contains the target path - let prefix = conda_entry_prefix(target_path); - let (index, _) = zip_reader - .file() - .entries() - .iter() - .enumerate() - .find(|(_, e)| { - e.filename() - .as_str() - .is_ok_and(|f| f.starts_with(prefix) && f.ends_with(".tar.zst")) - }) - .ok_or(ExtractError::MissingComponent)?; - - // Prefetch the entire info entry in a single HTTP request so the - // streaming pipeline doesn't trigger many small range requests. - let entry = &zip_reader.file().entries()[index]; - let offset = entry.header_offset(); - let size = entry.header_size() + entry.compressed_size(); - zip_reader - .inner_mut() - .get_mut() - .get_mut() - .prefetch(offset..offset + size) - .await; - - // Get a streaming reader for the ZIP entry (futures::io::AsyncRead). - // This does NOT buffer the entire entry — bytes are fetched on demand - // via HTTP range requests as the downstream decompressor/tar reader - // consumes them. - let entry_reader = zip_reader.reader_without_entry(index).await?; - - // Pipeline: async ZIP entry reader -> tokio compat -> buffered -> zstd decoder -> tar - let tokio_reader = entry_reader.compat(); - let buf_reader = tokio::io::BufReader::new(tokio_reader); - let zstd_decoder = ZstdDecoder::new(buf_reader); - let mut tar = tokio_tar::Archive::new(zstd_decoder); - - let result = get_file_from_tar_archive(&mut tar, target_path).await?; - - debug!( - "Requested ranges: {:?}", - zip_reader - .inner_mut() - .get_mut() - .get_mut() - .requested_ranges() - .await - ); - - Ok(result) + let archive = PackageArchive::open_sparse(client, url).await?; + archive.read_file(target_path).await } /// Fetch and parse a typed [`PackageFile`] from a remote `.conda` package /// using HTTP range requests. /// -/// This is a thin typed wrapper around [`fetch_file_from_remote_sparse`]. It -/// only works for `.conda` archives on servers that support range requests and -/// does not perform any full-download fallback. -/// /// # Example /// /// ```rust,no_run @@ -169,11 +59,11 @@ pub async fn fetch_package_file_sparse( client: ClientWithMiddleware, url: Url, ) -> Result { - let bytes = fetch_file_from_remote_sparse(client, url, P::package_path()) - .await? - .ok_or(ExtractError::MissingComponent)?; - P::from_slice(&bytes) - .map_err(|e| ExtractError::ArchiveMemberParseError(P::package_path().to_owned(), e)) + if CondaArchiveType::try_from(Path::new(url.path())) != Some(CondaArchiveType::Conda) { + return Err(ExtractError::UnsupportedArchiveType); + } + let archive = PackageArchive::open_sparse(client, url).await?; + archive.read_package_file().await } #[cfg(test)] diff --git a/crates/rattler_package_streaming/src/reqwest/test_server.rs b/crates/rattler_package_streaming/src/reqwest/test_server.rs index 3f5552bdc0..c9a36b939e 100644 --- a/crates/rattler_package_streaming/src/reqwest/test_server.rs +++ b/crates/rattler_package_streaming/src/reqwest/test_server.rs @@ -12,16 +12,41 @@ use url::Url; /// Returns the URL to the file (e.g. `http://127.0.0.1:12345/file.conda`). pub async fn serve_file(file_path: impl AsRef) -> Url { let file_path = file_path.as_ref(); - let file_name = file_path.file_name().unwrap().to_string_lossy().to_string(); - let dir = file_path.parent().unwrap(); let file_size = std::fs::metadata(file_path).unwrap().len(); - - let app = axum::Router::new() - .fallback_service(ServeDir::new(dir)) - .layer(middleware::from_fn_with_state( + serve(file_path, |router| { + router.layer(middleware::from_fn_with_state( file_size, clamp_suffix_range, - )); + )) + }) + .await +} + +/// Spawn a local file server that does NOT support range requests: incoming +/// `Range` headers are stripped, so every response is a full `200 OK`. +pub async fn serve_file_no_ranges(file_path: impl AsRef) -> Url { + serve(file_path.as_ref(), |router| { + router.layer(middleware::from_fn(strip_range)) + }) + .await +} + +/// Spawn a local file server that answers any suffix range (`bytes=-N`) with +/// `416 Range Not Satisfiable`, mimicking `JFrog` Artifactory when the range +/// exceeds the object length. +pub async fn serve_file_416_suffix(file_path: impl AsRef) -> Url { + serve(file_path.as_ref(), |router| { + router.layer(middleware::from_fn(reject_suffix_range)) + }) + .await +} + +/// Serves the directory containing `file_path` with the given middleware +/// applied, returning the URL of the file. +async fn serve(file_path: &Path, layer: impl FnOnce(axum::Router) -> axum::Router) -> Url { + let file_name = file_path.file_name().unwrap().to_string_lossy().to_string(); + let dir = file_path.parent().unwrap(); + let app = layer(axum::Router::new().fallback_service(ServeDir::new(dir))); let addr = SocketAddr::new([127, 0, 0, 1].into(), 0); let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); @@ -35,6 +60,28 @@ pub async fn serve_file(file_path: impl AsRef) -> Url { .unwrap() } +async fn reject_suffix_range(req: Request, next: Next) -> Response { + let is_suffix = req + .headers() + .get(http::header::RANGE) + .and_then(|value| value.to_str().ok()) + .is_some_and(|range| range.starts_with("bytes=-")); + if is_suffix { + return Response::builder() + .status(http::StatusCode::RANGE_NOT_SATISFIABLE) + .body(axum::body::Body::empty()) + .unwrap(); + } + next.run(req).await +} + +async fn strip_range(mut req: Request, next: Next) -> Response { + req.headers_mut().remove(http::header::RANGE); + let mut response = next.run(req).await; + response.headers_mut().remove(http::header::ACCEPT_RANGES); + response +} + /// Clamp suffix ranges (`bytes=-N`) that exceed the file size so `ServeDir` /// doesn't return 416. Per RFC 9110 §14.1.2, a suffix range exceeding the /// representation length should select the entire representation. diff --git a/crates/rattler_package_streaming/src/tokio/async_read.rs b/crates/rattler_package_streaming/src/tokio/async_read.rs index c95f80c2ea..f9014ce37e 100644 --- a/crates/rattler_package_streaming/src/tokio/async_read.rs +++ b/crates/rattler_package_streaming/src/tokio/async_read.rs @@ -8,8 +8,6 @@ use async_spooled_tempfile::SpooledTempFile; use async_zip::base::read::stream::ZipFileReader; #[cfg(feature = "reqwest")] use futures_util::StreamExt; -#[cfg(feature = "reqwest")] -use tokio::io::AsyncReadExt; use tokio::io::{AsyncRead, AsyncSeekExt}; use tokio_util::compat::{FuturesAsyncReadCompatExt, TokioAsyncReadCompatExt}; @@ -230,17 +228,6 @@ pub async fn extract_conda_via_buffering( }) } -/// Returns the ZIP entry prefix (`"info-"` or `"pkg-"`) that contains the -/// given `target_path` inside a `.conda` archive. -#[cfg(feature = "reqwest")] -pub(crate) fn conda_entry_prefix(target_path: &Path) -> &'static str { - if target_path.starts_with("info") { - "info-" - } else { - "pkg-" - } -} - /// Async equivalent of [`crate::seek::get_file_from_archive`]. /// /// Iterates entries of a tar archive, returning the contents of the first @@ -251,56 +238,18 @@ pub(crate) async fn get_file_from_tar_archive( archive: &mut tokio_tar::Archive, file_name: &Path, ) -> Result>, ExtractError> { + let target = crate::archive::normalize(file_name)?; let mut entries = archive.entries().map_err(ExtractError::IoError)?; while let Some(entry) = entries.next().await { let mut entry = entry.map_err(ExtractError::IoError)?; let path = entry.path().map_err(ExtractError::IoError)?; - if path.as_ref() == file_name { - let size = entry.header().size().map_err(ExtractError::IoError)?; - let mut buf = Vec::with_capacity(size as usize); - entry - .read_to_end(&mut buf) + // Normalized comparison, matching the sparse path in `crate::archive`. + if crate::archive::normalize(&path)? == target { + drop(path); + return crate::archive::read_raw_entry_contents(&mut entry) .await - .map_err(ExtractError::IoError)?; - return Ok(Some(buf)); + .map(Some); } } Ok(None) } - -#[cfg(all(test, feature = "reqwest"))] -mod tests { - use super::conda_entry_prefix; - use std::path::Path; - - #[test] - fn test_conda_entry_prefix_info_files() { - assert_eq!(conda_entry_prefix(Path::new("info/index.json")), "info-"); - assert_eq!(conda_entry_prefix(Path::new("info/about.json")), "info-"); - assert_eq!(conda_entry_prefix(Path::new("info/paths.json")), "info-"); - assert_eq!( - conda_entry_prefix(Path::new("info/nested/deep/file.txt")), - "info-" - ); - } - - #[test] - fn test_conda_entry_prefix_pkg_files() { - assert_eq!(conda_entry_prefix(Path::new("lib/libz.so")), "pkg-"); - assert_eq!(conda_entry_prefix(Path::new("bin/python")), "pkg-"); - assert_eq!(conda_entry_prefix(Path::new("clobber")), "pkg-"); - } - - #[test] - fn test_conda_entry_prefix_info_bare() { - // Path::starts_with works on components, so "info" matches "info/" - assert_eq!(conda_entry_prefix(Path::new("info")), "info-"); - } - - #[test] - fn test_conda_entry_prefix_info_like_but_not_info_dir() { - // Paths that textually start with "info" but are not under the info/ directory - assert_eq!(conda_entry_prefix(Path::new("info-custom.txt")), "pkg-"); - assert_eq!(conda_entry_prefix(Path::new("information/file")), "pkg-"); - } -} diff --git a/py-rattler/Cargo.lock b/py-rattler/Cargo.lock index 6dfbd15c45..227863b156 100644 --- a/py-rattler/Cargo.lock +++ b/py-rattler/Cargo.lock @@ -4270,6 +4270,7 @@ dependencies = [ "astral_async_zip", "async-compression", "async-spooled-tempfile", + "bytes", "bzip2", "filetime", "fs-err", diff --git a/py-rattler/Cargo.toml b/py-rattler/Cargo.toml index 16f1870c22..73f644b627 100644 --- a/py-rattler/Cargo.toml +++ b/py-rattler/Cargo.toml @@ -54,7 +54,7 @@ rattler_solve = { path = "../crates/rattler_solve", default-features = false, fe ] } rattler_index = { path = "../crates/rattler_index" } rattler_lock = { path = "../crates/rattler_lock", default-features = false } -rattler_package_streaming = { path = "../crates/rattler_package_streaming", default-features = false } +rattler_package_streaming = { path = "../crates/rattler_package_streaming", default-features = false, features = ["reqwest"] } pyo3 = { version = "0.29", features = [ "abi3-py310", "extension-module", @@ -63,7 +63,7 @@ pyo3 = { version = "0.29", features = [ ] } pyo3-async-runtimes = { version = "0.29", features = ["tokio-runtime"] } pythonize = "0.29" -tokio = { version = "1" } +tokio = { version = "1", features = ["sync"] } reqwest = { version = "0.13", default-features = false } reqwest-middleware = { package = "astral-reqwest-middleware", version = "0.5" } diff --git a/py-rattler/rattler/package_streaming/__init__.py b/py-rattler/rattler/package_streaming/__init__.py index 22fd6315f9..d938c07c82 100644 --- a/py-rattler/rattler/package_streaming/__init__.py +++ b/py-rattler/rattler/package_streaming/__init__.py @@ -1,7 +1,14 @@ +from __future__ import annotations + from os import PathLike -from typing import Optional, Tuple +from typing import AsyncIterator, Dict, Iterable, List, Literal, Optional, Tuple from rattler.networking.client import Client +from rattler.package.about_json import AboutJson +from rattler.package.index_json import IndexJson +from rattler.package.paths_json import PathsJson +from rattler.package.run_exports_json import RunExportsJson +from rattler.rattler import PyArchiveEntry, PyPackageArchive from rattler.rattler import download_bytes as py_download_bytes from rattler.rattler import download_to_path as py_download_to_path from rattler.rattler import download_to_writer as py_download_to_writer @@ -64,5 +71,245 @@ async def fetch_raw_package_file_from_url(client: Client, url: str, path: str) - """ Fetch raw bytes for a file inside a remote `.conda` package using sparse range requests. + + When reading more than one file from the same package, prefer + `PackageArchive`, which opens the package once and shares the work + between reads. """ return await py_fetch_raw_package_file_from_url(client._client, url, path) + + +class ArchiveEntry: + """ + One tar entry yielded while streaming a section of a package archive. + + Call `read()` to get the entry contents *before* advancing the stream; + not calling it skips the entry cheaply. Reading a link entry raises an + `OSError`; links are surfaced but never followed. + """ + + def __init__(self, inner: PyArchiveEntry) -> None: + self._inner = inner + + @property + def name(self) -> str: + """The path of the entry inside the package.""" + return self._inner.name + + @property + def size(self) -> int: + """The size of the entry contents in bytes.""" + return self._inner.size + + @property + def is_file(self) -> bool: + """True if the entry is a regular file (not a directory or link).""" + return self._inner.is_file + + @property + def is_link(self) -> bool: + """True if the entry is a symbolic or hard link.""" + return self._inner.is_link + + @property + def is_symlink(self) -> bool: + """True if the entry is a symbolic link.""" + return self._inner.is_symlink + + @property + def is_hardlink(self) -> bool: + """True if the entry is a hard link.""" + return self._inner.is_hardlink + + @property + def link_target(self) -> Optional[str]: + """The target of a link entry, or `None` for other entries.""" + return self._inner.link_target + + async def read(self) -> bytes: + """Reads the contents of this entry. Raises `OSError` for links.""" + return await self._inner.read() + + def __repr__(self) -> str: + return f"ArchiveEntry(name={self.name!r}, size={self.size})" + + +class PackageArchive: + """ + A conda package archive (local or remote) that is opened once and can + then be read many times. + + For remote `.conda` archives on servers that support HTTP range requests, + opening costs a single range request and reads only download the bytes + they need. `.tar.bz2` archives and servers without range support + transparently fall back to downloading the archive once into a temporary + file. + + Reads are not retried internally: a network error mid-read raises and the + call can simply be repeated. Symbolic links inside the archive are + surfaced but never followed; reading one raises an `OSError`. Paths are + exchanged as UTF-8 strings. + + Examples + -------- + ```python + pkg = await PackageArchive.from_url(client, url) + paths = await pkg.paths_json() + libs = [p.relative_path for p in paths.paths if str(p.relative_path).endswith(".so")] + files = await pkg.read_files(libs) + ``` + """ + + _inner: PyPackageArchive + + def __init__(self, inner: PyPackageArchive) -> None: + self._inner = inner + + @staticmethod + async def from_url( + client: Client, + url: str, + *, + sparse: Literal["prefer", "require", "disable"] = "prefer", + max_spool_size: Optional[int] = None, + ) -> PackageArchive: + """ + Opens a remote package archive. + + `sparse="prefer"` uses range requests when possible and otherwise + spools one full download. Use `"require"` to reject servers without + range support, or `"disable"` to skip the range probe. Setting + `max_spool_size` limits any fallback download. + """ + return PackageArchive(await PyPackageArchive.from_url(client._client, url, sparse, max_spool_size)) + + @staticmethod + async def from_path(path: PathLike[str] | str) -> PackageArchive: + """ + Opens a package archive from a local file. + + Examples + -------- + ```python + pkg = await PackageArchive.from_path("numpy-2.1.3-py312h58c1407_0.conda") + index = await pkg.index_json() + ``` + """ + return PackageArchive(await PyPackageArchive.from_path(path)) + + @property + def access(self) -> Literal["sparse", "local", "spooled", "unknown"]: + """How the archive is accessed.""" + return self._inner.access() + + async def read_file(self, path: str) -> Optional[bytes]: + """ + Reads a single file from the package. Returns `None` if the path does + not exist in the archive. + + Contents are not cached: every call streams the containing section + again up to the requested file. When reading more than one file, + prefer a single `read_files` call. Requesting a path that is a link + raises an `OSError`; links are not followed. + + Examples + -------- + ```python + recipe = await pkg.read_file("info/recipe/meta.yaml") + if recipe is None: + print("package has no recipe") + ``` + """ + return await self._inner.read_file(path) + + async def read_files(self, paths: Iterable[str]) -> Dict[str, Optional[bytes]]: + """ + Reads multiple files from the package with the minimum amount of + work: paths are grouped per section and each touched section is + streamed at most once, aborting as soon as its last requested file + has been read. The result maps every requested path to its contents, + or `None` when the path does not exist. + + Calls are independent and may run concurrently, but contents are not + cached: a repeated call streams its sections again, so batch all + needed paths into a single call where possible. Requesting a path + that is a link raises an `OSError`; links are not followed. + + Examples + -------- + ```python + # One pass over the payload, one over info, fetched concurrently. + files = await pkg.read_files(["info/index.json", "lib/libfoo.so", "bin/foo"]) + for path, contents in files.items(): + if contents is None: + print(f"{path}: not in archive") + ``` + """ + return await self._inner.read_files(list(paths)) + + async def index_json(self) -> IndexJson: + """Reads and parses `info/index.json`.""" + return IndexJson._from_py_index_json(await self._inner.index_json()) + + async def about_json(self) -> AboutJson: + """Reads and parses `info/about.json`.""" + return AboutJson._from_py_about_json(await self._inner.about_json()) + + async def paths_json(self) -> PathsJson: + """Reads and parses `info/paths.json`.""" + return PathsJson._from_py_paths_json(await self._inner.paths_json()) + + async def run_exports_json(self) -> Optional[RunExportsJson]: + """ + Reads and parses `info/run_exports.json`, or returns `None` when the + package has none. + """ + value = await self._inner.run_exports_json() + if value is None: + return None + return RunExportsJson._from_py_run_exports_json(value) + + async def list_files(self, section: Literal["info", "pkg"] = "pkg") -> List[str]: + """ + Lists the paths of all files (including symbolic links) in one + section. + + For `"info"` this is usually served from the cached archive tail. For + `"pkg"` it streams the entire section; prefer `paths_json()` when only + paths are needed. + + Examples + -------- + ```python + # Usually free: the info section tends to sit in the cached tail. + for path in await pkg.list_files("info"): + print(path) + ``` + """ + return await self._inner.list_files(section) + + async def stream(self, section: Literal["info", "pkg"] = "pkg") -> AsyncIterator[ArchiveEntry]: + """ + Streams the tar entries of one section of the package. + + Every call opens a new independent forward-only iterator (for remote + archives: a new request). Entries that are not `read()` are skipped + cheaply, and abandoning the iterator aborts any underlying transfer. + If an iteration step is cancelled (e.g. by a timeout), discard the + iterator: the underlying stream position is no longer well-defined. + + Examples + -------- + ```python + async for entry in pkg.stream("pkg"): + if entry.name.endswith(".so"): + data = await entry.read() # read before advancing + # entries that are not read are skipped cheaply + ``` + """ + inner = await self._inner.stream(section) + async for entry in inner: + yield ArchiveEntry(entry) + + def __repr__(self) -> str: + return f"PackageArchive(access={self.access!r})" diff --git a/py-rattler/src/lib.rs b/py-rattler/src/lib.rs index a8c386aa90..e9eaeec82a 100644 --- a/py-rattler/src/lib.rs +++ b/py-rattler/src/lib.rs @@ -197,6 +197,9 @@ fn rattler<'py>(py: Python<'py>, m: Bound<'py, PyModule>) -> PyResult<()> { m.add_function( wrap_pyfunction!(package_streaming::fetch_raw_package_file_from_url, &m).unwrap(), )?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; // Explicit environment specification m.add_class::()?; diff --git a/py-rattler/src/package_streaming/archive.rs b/py-rattler/src/package_streaming/archive.rs new file mode 100644 index 0000000000..d5abc2082e --- /dev/null +++ b/py-rattler/src/package_streaming/archive.rs @@ -0,0 +1,307 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use pyo3::exceptions::{PyRuntimeError, PyStopAsyncIteration, PyValueError}; +use pyo3::prelude::*; +use pyo3::types::{PyBytes, PyDict}; +use pyo3_async_runtimes::tokio::future_into_py; +use rattler_conda_types::package::{AboutJson, IndexJson, PathsJson, RunExportsJson}; +use rattler_package_streaming::archive::{ + ArchiveAccess, ArchiveEntryKind, PackageArchive, RemoteArchiveOptions, Section, SectionEntry, + SectionStream, SparsePolicy, +}; +use url::Url; + +use super::io_error; +use crate::about_json::PyAboutJson; +use crate::index_json::PyIndexJson; +use crate::networking::client::PyClientWithMiddleware; +use crate::paths_json::PyPathsJson; +use crate::run_exports_json::PyRunExportsJson; + +fn parse_sparse_policy(policy: &str) -> PyResult { + match policy { + "prefer" => Ok(SparsePolicy::Prefer), + "require" => Ok(SparsePolicy::Require), + "disable" => Ok(SparsePolicy::Disable), + _ => Err(PyValueError::new_err(format!( + "invalid sparse policy {policy:?}: expected 'prefer', 'require' or 'disable'" + ))), + } +} + +fn parse_section(section: &str) -> PyResult

{ + match section { + "info" => Ok(Section::Info), + "pkg" => Ok(Section::Content), + _ => Err(PyValueError::new_err(format!( + "invalid section {section:?}: expected 'info' or 'pkg'" + ))), + } +} + +/// A conda package archive (local or remote) that is opened once and can then +/// be read many times. +#[pyclass(skip_from_py_object)] +#[derive(Clone)] +pub struct PyPackageArchive { + inner: PackageArchive, +} + +#[pymethods] +impl PyPackageArchive { + /// Opens a remote package archive. For `.conda` archives on servers with + /// range support this costs a single HTTP range request. + #[staticmethod] + pub fn from_url<'a>( + py: Python<'a>, + client: PyClientWithMiddleware, + url: String, + sparse: String, + max_spool_size: Option, + ) -> PyResult> { + let url = + Url::parse(&url).map_err(|e| PyValueError::new_err(format!("Invalid URL: {e}")))?; + let mut options = + RemoteArchiveOptions::new().with_sparse_policy(parse_sparse_policy(&sparse)?); + if let Some(max_spool_size) = max_spool_size { + options = options.with_max_spool_size(max_spool_size); + } + future_into_py(py, async move { + let inner = PackageArchive::from_url_with_options(client.into(), url, options) + .await + .map_err(io_error)?; + Ok(Self { inner }) + }) + } + + /// Opens a package archive from a local file. + #[staticmethod] + pub fn from_path(py: Python<'_>, path: PathBuf) -> PyResult> { + future_into_py(py, async move { + let inner = PackageArchive::from_path(&path).await.map_err(io_error)?; + Ok(Self { inner }) + }) + } + + /// Returns how the archive is accessed: "sparse", "local" or "spooled". + pub fn access(&self) -> &'static str { + match self.inner.access() { + ArchiveAccess::Sparse => "sparse", + ArchiveAccess::Local => "local", + ArchiveAccess::Spooled => "spooled", + _ => "unknown", + } + } + + /// Reads a single file from the package; `None` if it does not exist. + pub fn read_file<'a>(&self, py: Python<'a>, path: String) -> PyResult> { + let inner = self.inner.clone(); + future_into_py(py, async move { + let content = inner.read_file(&path).await.map_err(io_error)?; + Python::attach(|py| { + Ok(match content { + Some(bytes) => PyBytes::new(py, &bytes).into_any().unbind(), + None => py.None(), + }) + }) + }) + } + + /// Reads multiple files, grouped per section with one early-aborted + /// streaming pass per section. Returns a dict mapping every requested + /// path to its contents (or `None` when absent). + pub fn read_files<'a>(&self, py: Python<'a>, paths: Vec) -> PyResult> { + let inner = self.inner.clone(); + future_into_py(py, async move { + let result = inner.read_files(paths).await.map_err(io_error)?; + Python::attach(|py| { + let dict = PyDict::new(py); + for (path, content) in result { + let key = path.to_string_lossy().into_owned(); + match content { + Some(bytes) => dict.set_item(key, PyBytes::new(py, &bytes))?, + None => dict.set_item(key, py.None())?, + } + } + Ok(dict.unbind()) + }) + }) + } + + /// Reads and parses `info/index.json`. + pub fn index_json<'a>(&self, py: Python<'a>) -> PyResult> { + let inner = self.inner.clone(); + future_into_py(py, async move { + let value: IndexJson = inner.read_package_file().await.map_err(io_error)?; + Ok(PyIndexJson::from(value)) + }) + } + + /// Reads and parses `info/about.json`. + pub fn about_json<'a>(&self, py: Python<'a>) -> PyResult> { + let inner = self.inner.clone(); + future_into_py(py, async move { + let value: AboutJson = inner.read_package_file().await.map_err(io_error)?; + Ok(PyAboutJson::from(value)) + }) + } + + /// Reads and parses `info/paths.json`. + pub fn paths_json<'a>(&self, py: Python<'a>) -> PyResult> { + let inner = self.inner.clone(); + future_into_py(py, async move { + let value: PathsJson = inner.read_package_file().await.map_err(io_error)?; + Ok(PyPathsJson::from(value)) + }) + } + + /// Reads and parses `info/run_exports.json`; `None` when absent. + pub fn run_exports_json<'a>(&self, py: Python<'a>) -> PyResult> { + let inner = self.inner.clone(); + future_into_py(py, async move { + let value: Option = + inner.try_read_package_file().await.map_err(io_error)?; + Ok(value.map(PyRunExportsJson::from)) + }) + } + + /// Lists the paths of all files in one section ("info" or "pkg"). + pub fn list_files<'a>(&self, py: Python<'a>, section: String) -> PyResult> { + let inner = self.inner.clone(); + let section = parse_section(§ion)?; + future_into_py(py, async move { + let paths = inner.list_files(section).await.map_err(io_error)?; + Ok(paths + .into_iter() + .map(|p| p.to_string_lossy().into_owned()) + .collect::>()) + }) + } + + /// Opens a stream over the tar entries of one section ("info" or "pkg"). + pub fn stream<'a>(&self, py: Python<'a>, section: String) -> PyResult> { + let inner = self.inner.clone(); + let section = parse_section(§ion)?; + future_into_py(py, async move { + let stream = inner.stream(section).await.map_err(io_error)?; + Ok(PySectionStream { + state: Arc::new(tokio::sync::Mutex::new(StreamState { + stream, + current: None, + generation: 0, + })), + }) + }) + } +} + +struct StreamState { + stream: SectionStream, + /// The most recently yielded entry. Kept here (not inside the Python + /// entry object) because the tar stream only allows reading the current + /// entry before advancing. + current: Option, + generation: u64, +} + +/// An async iterator over the tar entries of one package section. +#[pyclass] +pub struct PySectionStream { + state: Arc>, +} + +#[pymethods] +impl PySectionStream { + fn __aiter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> { + slf + } + + fn __anext__<'a>(&self, py: Python<'a>) -> PyResult> { + let state = self.state.clone(); + future_into_py(py, async move { + let mut guard = state.lock().await; + // Advancing discards the previous entry; its unread body is + // skipped by the tar reader. + guard.current = None; + match guard.stream.next_entry().await.map_err(io_error)? { + None => Err(PyStopAsyncIteration::new_err(())), + Some(entry) => { + let name = entry.path().to_string_lossy().into_owned(); + let size = entry.size().map_err(io_error)?; + let kind = entry.kind(); + let is_file = kind == ArchiveEntryKind::File; + let is_symlink = kind == ArchiveEntryKind::Symlink; + let is_hardlink = kind == ArchiveEntryKind::Hardlink; + let is_link = kind.is_link(); + let link_target = entry + .link_target() + .map_err(io_error)? + .map(|target| target.to_string_lossy().into_owned()); + guard.generation += 1; + let generation = guard.generation; + guard.current = Some(entry); + drop(guard); + Ok(PyArchiveEntry { + name, + size, + is_file, + is_link, + is_symlink, + is_hardlink, + link_target, + generation, + state: state.clone(), + }) + } + } + }) + } +} + +/// One tar entry yielded by a section stream. Call `read()` to get the entry +/// contents before advancing the stream; not calling it skips the entry. +#[pyclass] +pub struct PyArchiveEntry { + #[pyo3(get)] + name: String, + #[pyo3(get)] + size: u64, + #[pyo3(get)] + is_file: bool, + #[pyo3(get)] + is_link: bool, + #[pyo3(get)] + is_symlink: bool, + #[pyo3(get)] + is_hardlink: bool, + #[pyo3(get)] + link_target: Option, + generation: u64, + state: Arc>, +} + +#[pymethods] +impl PyArchiveEntry { + /// Reads the contents of this entry. Reading a link is an error; links + /// are not followed. + pub fn read<'a>(&self, py: Python<'a>) -> PyResult> { + let state = self.state.clone(); + let generation = self.generation; + future_into_py(py, async move { + let mut guard = state.lock().await; + if guard.generation != generation { + return Err(PyRuntimeError::new_err( + "entry is no longer readable because the stream has advanced past it", + )); + } + let entry = guard + .current + .as_mut() + .ok_or_else(|| PyRuntimeError::new_err("entry has already been read"))?; + let buf = entry.read().await.map_err(io_error)?; + guard.current = None; + Python::attach(|py| Ok(PyBytes::new(py, &buf).unbind())) + }) + } +} diff --git a/py-rattler/src/package_streaming/mod.rs b/py-rattler/src/package_streaming/mod.rs index ef722c0a24..b05957028f 100644 --- a/py-rattler/src/package_streaming/mod.rs +++ b/py-rattler/src/package_streaming/mod.rs @@ -1,3 +1,5 @@ +pub mod archive; + use futures::StreamExt; use pyo3::{prelude::*, types::PyBytes}; use pyo3_async_runtimes::tokio::future_into_py; @@ -21,7 +23,7 @@ fn parse_url(url: &str) -> PyResult { .map_err(|e| PyErr::new::(format!("Invalid URL: {e}"))) } -fn io_error(error: E) -> PyErr { +pub(crate) fn io_error(error: E) -> PyErr { PyErr::new::(error.to_string()) } diff --git a/py-rattler/tests/unit/test_package_archive.py b/py-rattler/tests/unit/test_package_archive.py new file mode 100644 index 0000000000..2a01359096 --- /dev/null +++ b/py-rattler/tests/unit/test_package_archive.py @@ -0,0 +1,157 @@ +import http.server +import os +import threading + +import pytest + +from rattler.package_streaming import PackageArchive + + +@pytest.fixture +def conda_package(test_data_dir: str) -> str: + return os.path.join(test_data_dir, "clobber/clobber-fd-1-0.1.0-h4616a5c_0.conda") + + +@pytest.fixture +def tar_bz2_package(test_data_dir: str) -> str: + return os.path.join(test_data_dir, "clobber/clobber-1-0.1.0-h4616a5c_0.tar.bz2") + + +@pytest.mark.asyncio +async def test_read_files(conda_package: str) -> None: + archive = await PackageArchive.from_path(conda_package) + assert archive.access == "local" + + files = await archive.read_files(["info/index.json", "clobber", "missing"]) + assert files["clobber"] == b"clobber-fd-1\n" + assert files["info/index.json"] is not None + assert files["missing"] is None + + assert await archive.read_file("clobber") == b"clobber-fd-1\n" + assert await archive.read_file("missing") is None + + +@pytest.mark.asyncio +async def test_typed_metadata(conda_package: str) -> None: + archive = await PackageArchive.from_path(conda_package) + index = await archive.index_json() + assert index.name.normalized == "clobber-fd-1" + about = await archive.about_json() + assert about is not None + + +@pytest.mark.asyncio +async def test_stream(conda_package: str) -> None: + archive = await PackageArchive.from_path(conda_package) + + names = [entry.name async for entry in archive.stream("info")] + assert "info/index.json" in names + + async for entry in archive.stream("pkg"): + if entry.name == "clobber": + assert await entry.read() == b"clobber-fd-1\n" + break + + +@pytest.mark.asyncio +async def test_tar_bz2(tar_bz2_package: str) -> None: + archive = await PackageArchive.from_path(tar_bz2_package) + + files = await archive.read_files(["info/index.json", "clobber.txt"]) + assert files["info/index.json"] is not None + assert files["clobber.txt"] is not None + + names = [entry.name async for entry in archive.stream("pkg")] + assert "clobber.txt" in names + assert not any(name.startswith("info/") for name in names) + + +@pytest.mark.asyncio +async def test_list_files(conda_package: str) -> None: + archive = await PackageArchive.from_path(conda_package) + info = await archive.list_files("info") + assert "info/index.json" in info + content = await archive.list_files("pkg") + assert content == ["clobber"] + + +@pytest.mark.asyncio +async def test_run_exports_json_absent(conda_package: str) -> None: + archive = await PackageArchive.from_path(conda_package) + assert await archive.run_exports_json() is None + + +@pytest.mark.asyncio +async def test_symlinks_surfaced_not_followed(test_data_dir: str) -> None: + archive = await PackageArchive.from_path(os.path.join(test_data_dir, "sparse/symlink-test-1.0.0-0.conda")) + + files = await archive.list_files("pkg") + assert "lib/liblink.so" in files and "lib/libreal.so.1" in files + assert "lib/libhard.so" in files + + assert await archive.read_file("lib/libreal.so.1") == b"real library bytes" + with pytest.raises(OSError, match="links are not followed"): + await archive.read_file("lib/liblink.so") + + async for entry in archive.stream("pkg"): + if entry.name == "lib/liblink.so": + assert entry.is_link and entry.is_symlink + assert not entry.is_hardlink and not entry.is_file + assert entry.link_target == "libreal.so.1" + with pytest.raises(OSError, match="links are not followed"): + await entry.read() + + async for entry in archive.stream("pkg"): + if entry.name == "lib/libhard.so": + assert entry.is_link and entry.is_hardlink + assert not entry.is_symlink and not entry.is_file + assert entry.link_target == "lib/libreal.so.1" + + +@pytest.mark.asyncio +async def test_invalid_archive_paths(conda_package: str) -> None: + archive = await PackageArchive.from_path(conda_package) + for path in ["", "../clobber", "/clobber"]: + with pytest.raises(OSError, match="invalid package-relative archive path"): + await archive.read_file(path) + + +@pytest.mark.asyncio +async def test_from_url_spooled_fallback(conda_package: str) -> None: + """python's http.server has no Range support, exercising the fallback.""" + directory = os.path.dirname(conda_package) + handler = lambda *args: http.server.SimpleHTTPRequestHandler(*args, directory=directory) # noqa: E731 + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + from rattler.networking.client import Client + + url = f"http://127.0.0.1:{server.server_port}/{os.path.basename(conda_package)}" + archive = await PackageArchive.from_url(Client(), url) + assert archive.access == "spooled" + assert await archive.read_file("clobber") == b"clobber-fd-1\n" + finally: + server.shutdown() + server.server_close() + + +@pytest.mark.asyncio +async def test_remote_fallback_policy(conda_package: str) -> None: + """Callers can reject or limit a full-download fallback.""" + directory = os.path.dirname(conda_package) + handler = lambda *args: http.server.SimpleHTTPRequestHandler(*args, directory=directory) # noqa: E731 + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + from rattler.networking.client import Client + + url = f"http://127.0.0.1:{server.server_port}/{os.path.basename(conda_package)}" + with pytest.raises(OSError, match="does not support sparse"): + await PackageArchive.from_url(Client(), url, sparse="require") + with pytest.raises(OSError, match="exceeds the configured 1 byte limit"): + await PackageArchive.from_url(Client(), url, max_spool_size=1) + finally: + server.shutdown() + server.server_close() diff --git a/test-data/sparse/dotslash-test-1.0.0-0.conda b/test-data/sparse/dotslash-test-1.0.0-0.conda new file mode 100644 index 0000000000000000000000000000000000000000..f4b64a2394dcbf3e5c88baa58b42c9ea5b8acb5f GIT binary patch literal 548 zcmWIWW@Zs#00H;(uQA^_;=>bxY!Fri;)3jS-IV;2;+(|d4Be8{;u2j$Jp(-hT?4(6 z#3H?_;u4Lm`hOD`)b}zm7#-mgo3Qm|K<@n2%v1LimZx6!x2mkU-s{cAE3!%Sz~ipA z)v-pUf@h|E$+kN5Tx{Y0V5xtz8+2x@;7Qa8X5wVH%Kd;DA}u*@M6i9WG&uFyH)&-ZdAR_pI@M` zi zIl}b7#v;l7CW8QPMkWzv+yM^s6Bslwf+&K)k8T`#_&^L}U}#`m0AvyltpIOUHjo}B MAe;}R^TEyl0Q*I&GXMYp literal 0 HcmV?d00001 diff --git a/test-data/sparse/generate.py b/test-data/sparse/generate.py new file mode 100644 index 0000000000..1b3a2fe346 --- /dev/null +++ b/test-data/sparse/generate.py @@ -0,0 +1,118 @@ +"""Regenerates the .conda fixtures in this directory. + +Run from the repository root: python test-data/sparse/generate.py +Requires the `zstandard` package. Output is deterministic (seeded RNG, +fixed mtimes) up to the zstd library version. +""" + +import io +import json +import random +import tarfile +import zipfile +from pathlib import Path + +import zstandard + +OUT = Path(__file__).parent + + +def tar_zst(entries, level=3): + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w") as tar: + for name, data, link in entries: + info = tarfile.TarInfo(name) + info.mtime = 0 + if link: + kind, target = link + info.type = kind + info.linkname = target + else: + info.size = len(data) + tar.addfile(info, io.BytesIO(data) if not link else None) + return zstandard.ZstdCompressor(level=level).compress(buf.getvalue()) + + +def index_json(name): + return json.dumps( + {"name": name, "version": "1.0.0", "build": "0", "build_number": 0} + ).encode() + + +def write_conda(stem, pkg_entries, info_entries, force_zip64=False): + path = OUT / f"{stem}.conda" + with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_STORED) as zf: + members = [] + if pkg_entries is not None: + members.append((f"pkg-{stem}.tar.zst", tar_zst(pkg_entries))) + members.append((f"info-{stem}.tar.zst", tar_zst(info_entries))) + for name, data in members: + if force_zip64: + with zf.open(zipfile.ZipInfo(name), "w", force_zip64=True) as f: + f.write(data) + else: + zf.writestr(name, data) + print(f"{path}: {path.stat().st_size} bytes") + + +# Larger than the 64 KiB tail so the payload member requires a ranged GET. +rng = random.Random(2252) +blob = bytes(rng.getrandbits(8) for _ in range(150_000)) +write_conda( + "sparse-test-1.0.0-0", + [ + ("bin/first-file.txt", b"first payload file\n", None), + ("lib/blob.bin", blob, None), + ("share/last-file.txt", b"last payload file\n", None), + ], + [ + ("info/index.json", index_json("sparse-test"), None), + ( + "info/paths.json", + json.dumps( + { + "paths_version": 1, + "paths": [ + {"_path": "bin/first-file.txt", "path_type": "hardlink", "size_in_bytes": 19}, + {"_path": "lib/blob.bin", "path_type": "hardlink", "size_in_bytes": 150000}, + {"_path": "share/last-file.txt", "path_type": "hardlink", "size_in_bytes": 18}, + ], + } + ).encode(), + None, + ), + ], +) + +# A payload containing a symbolic and a hard link. +write_conda( + "symlink-test-1.0.0-0", + [ + ("lib/libreal.so.1", b"real library bytes", None), + ("lib/liblink.so", b"", (tarfile.SYMTYPE, "libreal.so.1")), + ("lib/libhard.so", b"", (tarfile.LNKTYPE, "lib/libreal.so.1")), + ], + [("info/index.json", index_json("symlink-test"), None)], +) + +# No pkg-*.tar.zst member at all. +write_conda( + "info-only-1.0.0-0", + None, + [("info/index.json", index_json("info-only"), None)], +) + +# zip64 local headers (sizes deferred to the zip64 extra field). +write_conda( + "zip64-test-1.0.0-0", + [("bin/hello.txt", b"zip64 payload\n", None)], + [("info/index.json", index_json("zip64-test"), None)], + force_zip64=True, +) + +# Tar entries stored with a leading `./`, as produced by `tar -C dir -c .`. +write_conda( + "dotslash-test-1.0.0-0", + [("./lib/data.txt", b"dot slash payload\n", None)], + [("./info/index.json", index_json("dotslash-test"), None)], +) diff --git a/test-data/sparse/info-only-1.0.0-0.conda b/test-data/sparse/info-only-1.0.0-0.conda new file mode 100644 index 0000000000000000000000000000000000000000..4f0a7488ad44aa9594a29e362542ef6606d0a8a7 GIT binary patch literal 297 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 osj067Z1 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 Date: Wed, 5 Aug 2026 11:23:21 +0200 Subject: [PATCH 05/21] ci: unblock pixi setup and source package builds (#2642) --- crates/rattler-bin/pixi.toml | 2 +- crates/rattler_index/pixi.toml | 2 +- pixi.toml | 8 ++++++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/rattler-bin/pixi.toml b/crates/rattler-bin/pixi.toml index 3c185c7ae1..4895c4c35c 100644 --- a/crates/rattler-bin/pixi.toml +++ b/crates/rattler-bin/pixi.toml @@ -3,7 +3,7 @@ name = "rattler" [package.build.backend] name = "pixi-build-rust" -version = "0.4.*" +version = "0.5.*" channels = [ "https://prefix.dev/pixi-build-backends", "https://prefix.dev/conda-forge" diff --git a/crates/rattler_index/pixi.toml b/crates/rattler_index/pixi.toml index de00b0a216..17812f7ce6 100644 --- a/crates/rattler_index/pixi.toml +++ b/crates/rattler_index/pixi.toml @@ -3,7 +3,7 @@ name = "rattler_index" [package.build.backend] name = "pixi-build-rust" -version = "0.4.*" +version = "0.5.*" channels = [ "https://prefix.dev/pixi-build-backends", "https://prefix.dev/conda-forge" diff --git a/pixi.toml b/pixi.toml index c95083d580..14732c68df 100644 --- a/pixi.toml +++ b/pixi.toml @@ -18,6 +18,14 @@ requires-pixi = ">=0.67.0" # Use the same Rust version everywhere rust_compiler_version = ["1.95.0"] +# The build backend has to match the build API version that pixi hands out, so +# the workspace cutoff cannot hold it back. On Windows the MSVC runtime it links +# against has to come along. +[exclude-newer] +pixi-build-rust = "0d" +vc14_runtime = "0d" +vcomp14 = "0d" + [tasks] build = "cargo build" check = "cargo check" From b8d90e255e9a9ac06893d8889789a3f6636b3ac5 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 09:36:52 +0000 Subject: [PATCH 06/21] chore(ci): Update Rust crate anyhow to v1.0.104 (#2638) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- py-rattler/Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/py-rattler/Cargo.lock b/py-rattler/Cargo.lock index 227863b156..e75284df14 100644 --- a/py-rattler/Cargo.lock +++ b/py-rattler/Cargo.lock @@ -122,9 +122,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.103" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "apple-native-keyring-store" From 7aa5e8b110833ea4192f88ba7f06554e97590989 Mon Sep 17 00:00:00 2001 From: Pavel Zwerschke Date: Wed, 5 Aug 2026 16:18:16 +0200 Subject: [PATCH 07/21] fix: Failing test (#2647) --- .../src/gateway/mod.rs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/crates/rattler_repodata_gateway/src/gateway/mod.rs b/crates/rattler_repodata_gateway/src/gateway/mod.rs index c29aa5ab3c..6c36dcc38a 100644 --- a/crates/rattler_repodata_gateway/src/gateway/mod.rs +++ b/crates/rattler_repodata_gateway/src/gateway/mod.rs @@ -1431,14 +1431,24 @@ mod test { .await .unwrap(); - let total_records: usize = records.iter().map(RepoData::len).sum(); - assert_eq!(total_records, 19); - + // Only consider records that were published before a fixed cutoff. New + // `openssl` builds appear in conda-forge all the time, so without a cutoff + // the expected record count would have to be bumped over and over again. + let exclude_newer: jiff::Timestamp = "2026-01-01T00:00:00Z".parse().unwrap(); let mut repodata_records = records .iter() - .flat_map(|r| r.iter().cloned()) + .flat_map(RepoData::iter) + .filter(|record| { + record + .package_record + .timestamp + .is_some_and(|timestamp| timestamp < exclude_newer) + }) + .cloned() .collect::>(); + assert_eq!(repodata_records.len(), 16); + assert!(run_exports_missing(&repodata_records)); gateway From 7d92b330957ad5c02a44182224ce4ed53a47b481 Mon Sep 17 00:00:00 2001 From: Pavel Zwerschke Date: Wed, 5 Aug 2026 16:50:27 +0200 Subject: [PATCH 08/21] feat: Improve `rattler solve` output (#2644) --- crates/rattler-bin/src/commands/solve.rs | 187 +++++++++++++++++++---- 1 file changed, 158 insertions(+), 29 deletions(-) diff --git a/crates/rattler-bin/src/commands/solve.rs b/crates/rattler-bin/src/commands/solve.rs index 9179d19b5d..c0535d8a01 100644 --- a/crates/rattler-bin/src/commands/solve.rs +++ b/crates/rattler-bin/src/commands/solve.rs @@ -20,6 +20,7 @@ use rattler_solve::{ resolvo, }; use rattler_virtual_packages::{VirtualPackageOverrides, VirtualPackages}; +use url::Url; use crate::{ commands::progress::{wrap_in_async_progress, wrap_in_progress}, @@ -75,6 +76,10 @@ pub struct Opt { /// When using a date, packages from the entire day are included. #[clap(long)] exclude_newer: Option, + + /// Output in JSON format + #[clap(long)] + json: bool, } #[derive(Debug, Clone, Copy, ValueEnum)] @@ -112,7 +117,9 @@ pub async fn solve(opt: Opt, offline: bool) -> miette::Result<()> { let channel_config = ChannelConfig::default_with_root_dir(env::current_dir().into_diagnostic()?); - println!("Solving for platform: {}", opt.platform); + // All progress information goes to stderr so that stdout only contains the + // solved package set. + eprintln!("Solving for platform: {}", opt.platform); let match_spec_options = ParseMatchSpecOptions::strict() .with_extras(true) @@ -169,10 +176,10 @@ pub async fn solve(opt: Opt, offline: bool) -> miette::Result<()> { .context("failed to load repodata")?; let total_records: usize = repo_data.iter().map(RepoData::len).sum(); - println!( - "Loaded {} records in {:?}", + eprintln!( + "Loaded {} records in {}", total_records, - start_load_repo_data.elapsed() + format_elapsed(start_load_repo_data.elapsed()) ); let virtual_packages = wrap_in_progress("determining virtual packages", || { @@ -185,7 +192,7 @@ pub async fn solve(opt: Opt, offline: bool) -> miette::Result<()> { } })?; - println!( + eprintln!( "Virtual packages:\n{}\n", virtual_packages .iter() @@ -201,11 +208,13 @@ pub async fn solve(opt: Opt, offline: bool) -> miette::Result<()> { ..SolverTask::from_iter(&repo_data) }; + let start_solve = Instant::now(); let solver_result = wrap_in_progress("solving", || match opt.solver.unwrap_or_default() { Solver::Resolvo => resolvo::Solver.solve(solver_task), Solver::LibSolv => libsolv_c::Solver.solve(solver_task), }) .into_diagnostic()?; + let solve_duration = start_solve.elapsed(); let mut solved_packages: Vec = solver_result.records; @@ -215,16 +224,57 @@ pub async fn solve(opt: Opt, offline: bool) -> miette::Result<()> { solved_packages.retain(|r| !specs.iter().any(|s| s.matches(&r.package_record))); } + // The solver returns records in the order it decided on them, which is an + // implementation detail and differs between backends. Sort by name so the + // output is stable and diffable between runs. + solved_packages.sort_by(|a, b| { + a.package_record + .name + .as_normalized() + .cmp(b.package_record.name.as_normalized()) + }); + if solved_packages.is_empty() { - println!("No packages solved"); + eprintln!("No packages solved"); + if opt.json { + println!("[]"); + } + return Ok(()); + } + + if opt.json { + println!( + "{}", + serde_json::to_string_pretty(&solved_packages).into_diagnostic()? + ); } else { - println!("Solved {} packages:", solved_packages.len()); - print_records(&solved_packages, solver_result.extras); + eprintln!( + "Solved {} package{} in {}:", + solved_packages.len(), + if solved_packages.len() == 1 { "" } else { "s" }, + format_elapsed(solve_duration) + ); + print_records( + &solved_packages, + &solver_result.extras, + &specs, + &channel_config, + ); } Ok(()) } +/// Formats a duration in a compact, human readable way. +fn format_elapsed(duration: Duration) -> String { + let millis = duration.as_millis(); + if millis < 1000 { + format!("{millis}ms") + } else { + format!("{:.2}s", duration.as_secs_f64()) + } +} + fn parse_virtual_packages( virtual_packages: &[String], ) -> miette::Result> { @@ -244,28 +294,107 @@ fn parse_virtual_packages( .collect::>>() } -fn print_records(records: &[RepoDataRecord], features: HashMap>) { +/// Prints the solved records as a table with aligned columns. +/// +/// Packages that are explicitly requested through one of the input specs are +/// highlighted to distinguish them from the transitive dependencies that were +/// pulled in by the solver. +fn print_records( + records: &[RepoDataRecord], + extras: &HashMap>, + specs: &[MatchSpec], + channel_config: &ChannelConfig, +) { + let header = [ + "Package".to_string(), + "Version".to_string(), + "Build".to_string(), + "Channel".to_string(), + ]; + + // These initial widths match the header column lengths. + let mut widths: [usize; 4] = header.clone().map(|field| field.len()); + let mut rows = Vec::with_capacity(records.len()); for record in records { - let direct_url_print = record.channel.clone().unwrap_or_default(); - if let Some(features) = features.get(&record.package_record.name) { - println!( - "{}[{}] {} {} {} {}", - record.package_record.name.as_normalized(), - features.join(", "), - record.package_record.version, - record.package_record.build, - record.package_record.subdir, - direct_url_print, - ); - } else { - println!( - "{} {} {} {} {}", - record.package_record.name.as_normalized(), - record.package_record.version, - record.package_record.build, - record.package_record.subdir, - direct_url_print, - ); + let mut name = record.package_record.name.as_normalized().to_string(); + if let Some(extras) = extras.get(&record.package_record.name) { + name.push('['); + name.push_str(&extras.join(",")); + name.push(']'); + } + + let fields = [ + name, + record.package_record.version.to_string(), + record.package_record.build.clone(), + format_channel(record, channel_config), + ]; + for (width, field) in widths.iter_mut().zip(&fields) { + *width = (*width).max(field.chars().count()); + } + + let explicit = specs + .iter() + .any(|spec| spec.matches(&record.package_record)); + rows.push((fields, explicit)); + } + + // Separates the table from the status messages on stderr. + eprintln!(); + let styled_header = header + .clone() + .map(|field| console::style(field).bold().to_string()); + print_row(&styled_header, &widths, &header); + for (fields, explicit) in &rows { + let styled = [ + if *explicit { + console::style(&fields[0]).green().bold().to_string() + } else { + fields[0].clone() + }, + fields[1].clone(), + console::style(&fields[2]).dim().to_string(), + console::style(&fields[3]).dim().to_string(), + ]; + print_row(&styled, &widths, fields); + } +} + +/// Prints a single table row, padding each column to `widths`. +/// +/// `styled` holds the fields as they should be displayed, `plain` the same +/// fields without any styling. Padding is computed from `plain` because ANSI +/// escape codes in `styled` do not occupy any terminal columns but would +/// otherwise be counted by the formatter. +fn print_row(styled: &[String; 4], widths: &[usize; 4], plain: &[String; 4]) { + let mut line = String::new(); + for (i, field) in styled.iter().enumerate() { + line.push_str(field); + // Don't pad the last column, that would only add trailing whitespace. + if i + 1 < styled.len() { + let padding = widths[i].saturating_sub(plain[i].chars().count()); + // Two spaces as inter-column padding. + line.push_str(&" ".repeat(padding + 2)); } } + println!("{}", line.trim_end()); +} + +/// Formats the channel of a record as `/`. +/// +/// Records that come from a channel under the configured channel alias are +/// shortened to just their name (e.g. `conda-forge/noarch`), anything else +/// keeps its full URL so it stays unambiguous. +fn format_channel(record: &RepoDataRecord, channel_config: &ChannelConfig) -> String { + let subdir = &record.package_record.subdir; + let Some(channel) = &record.channel else { + return subdir.clone(); + }; + + let name = Url::parse(channel) + .ok() + .and_then(|url| channel_config.strip_channel_alias(&url)) + .unwrap_or_else(|| channel.trim_end_matches('/').to_string()); + + format!("{name}/{subdir}") } From 501ec08a9ec5e7aa2e2b3b9b688721a5ab97c681 Mon Sep 17 00:00:00 2001 From: Bas Zalmstra <4995967+baszalmstra@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:53:15 +0200 Subject: [PATCH 09/21] fix(repodata_gateway): test remote run_exports against a local server (#2648) --- .../src/gateway/mod.rs | 106 +++++++++++++----- 1 file changed, 75 insertions(+), 31 deletions(-) diff --git a/crates/rattler_repodata_gateway/src/gateway/mod.rs b/crates/rattler_repodata_gateway/src/gateway/mod.rs index 6c36dcc38a..b6cedec0b9 100644 --- a/crates/rattler_repodata_gateway/src/gateway/mod.rs +++ b/crates/rattler_repodata_gateway/src/gateway/mod.rs @@ -1404,26 +1404,37 @@ mod test { #[tokio::test] async fn test_ensure_run_exports_remote_conda_forge() { - // conda-forge's sharded repodata now embeds `run_exports` directly in the - // records. Disable sharded repodata so that the records are fetched from - // `repodata.json` (which does not contain `run_exports`). This ensures the - // records start out without `run_exports` and allows us to exercise - // `ensure_run_exports`. - let gateway = Gateway::builder() - .with_channel_config(crate::ChannelConfig { - default: SourceConfig { - sharded_enabled: false, - ..SourceConfig::default() - }, - ..crate::ChannelConfig::default() - }) - .finish(); + // Serve a copy of the pinned conda-forge snapshot over HTTP so this test + // exercises the remote code path of `ensure_run_exports` without + // depending on live conda-forge data (which drifts and previously broke + // the record count assertion). + let channel_dir = tempfile::tempdir().unwrap(); + for subdir in ["linux-64", "noarch"] { + let repodata = tools::fetch_test_conda_forge_repodata_async(subdir) + .await + .unwrap(); + let subdir_dir = channel_dir.path().join(subdir); + std::fs::create_dir_all(&subdir_dir).unwrap(); + + // Remove the `base_url` so that the record urls (and with that the + // `run_exports.json` lookups) resolve relative to the local server + // instead of conda.anaconda.org. + let mut repodata: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(repodata).unwrap()).unwrap(); + repodata["info"].as_object_mut().unwrap().remove("base_url"); + std::fs::write( + subdir_dir.join("repodata.json"), + serde_json::to_string(&repodata).unwrap(), + ) + .unwrap(); + } + + let server = SimpleChannelServer::new(channel_dir.path()).await; + let gateway = Gateway::new(); let records = gateway .query( - vec![Channel::from_url( - Url::parse("https://conda.anaconda.org/conda-forge/").unwrap(), - )], + vec![server.channel()], vec![Platform::Linux64, Platform::NoArch], vec![MatchSpec::from_str("openssl=3.*=*_1", Lenient).unwrap()].into_iter(), ) @@ -1431,32 +1442,65 @@ mod test { .await .unwrap(); - // Only consider records that were published before a fixed cutoff. New - // `openssl` builds appear in conda-forge all the time, so without a cutoff - // the expected record count would have to be bumped over and over again. - let exclude_newer: jiff::Timestamp = "2026-01-01T00:00:00Z".parse().unwrap(); + let total_records: usize = records.iter().map(RepoData::len).sum(); + assert_eq!(total_records, 3); + let mut repodata_records = records .iter() - .flat_map(RepoData::iter) - .filter(|record| { - record - .package_record - .timestamp - .is_some_and(|timestamp| timestamp < exclude_newer) - }) - .cloned() + .flat_map(|r| r.iter().cloned()) .collect::>(); - assert_eq!(repodata_records.len(), 16); - assert!(run_exports_missing(&repodata_records)); + // Serve a `run_exports.json` that covers the matched records. The run + // exports carry a marker value that the real packages do not contain, so + // we can verify below that they were fetched from the served file rather + // than extracted from the packages themselves. + for subdir in ["linux-64", "noarch"] { + let mut packages = serde_json::Map::new(); + let mut conda_packages = serde_json::Map::new(); + for record in repodata_records + .iter() + .filter(|record| record.package_record.subdir == subdir) + { + let file_name = record.identifier.to_file_name(); + let entry = serde_json::json!({ + "run_exports": { "weak": ["from-run-exports-json"] } + }); + if file_name.ends_with(".conda") { + conda_packages.insert(file_name, entry); + } else { + packages.insert(file_name, entry); + } + } + let run_exports = serde_json::json!({ + "packages": packages, + "packages.conda": conda_packages, + }); + std::fs::write( + channel_dir.path().join(subdir).join("run_exports.json"), + serde_json::to_string(&run_exports).unwrap(), + ) + .unwrap(); + } + gateway .ensure_run_exports(repodata_records.iter_mut(), None) .await .unwrap(); assert!(run_exports_in_place(&repodata_records)); + + // The run exports must originate from the served `run_exports.json`, not + // from the package download fallback. + for record in &repodata_records { + assert_eq!( + record.package_record.run_exports.as_ref().unwrap().weak, + vec!["from-run-exports-json".to_string()], + "run_exports of {} should come from the served run_exports.json", + record.identifier + ); + } } /// A mock `RepoDataSource` for testing custom source functionality. From 935526e6631a1b70819ce67485e816c55b23cc5c Mon Sep 17 00:00:00 2001 From: Dylan Jenkins Date: Thu, 6 Aug 2026 06:05:28 +1000 Subject: [PATCH 10/21] fix: follow the OCI registry's WWW-Authenticate challenge (#2628) --- crates/rattler-bin/src/commands/client.rs | 5 +- .../rattler_networking/src/oci_middleware.rs | 541 ++++++++++++++++-- py-rattler/rattler/networking/middleware.py | 3 + py-rattler/src/networking/client.rs | 7 +- 4 files changed, 519 insertions(+), 37 deletions(-) diff --git a/crates/rattler-bin/src/commands/client.rs b/crates/rattler-bin/src/commands/client.rs index 6897a16d15..477d444a59 100644 --- a/crates/rattler-bin/src/commands/client.rs +++ b/crates/rattler-bin/src/commands/client.rs @@ -49,7 +49,10 @@ pub fn create_client_with_middleware( ))) .with_arc(Arc::new(AuthChallengeMiddleware::default())); - let client = client.with(rattler_networking::OciMiddleware::new(download_client)); + let client = client.with( + rattler_networking::OciMiddleware::new(download_client) + .with_authentication_storage(authentication_storage.clone()), + ); #[cfg(feature = "s3")] let client = client.with(rattler_networking::S3Middleware::new( HashMap::new(), diff --git a/crates/rattler_networking/src/oci_middleware.rs b/crates/rattler_networking/src/oci_middleware.rs index c0177ac191..cb08aea644 100644 --- a/crates/rattler_networking/src/oci_middleware.rs +++ b/crates/rattler_networking/src/oci_middleware.rs @@ -2,18 +2,23 @@ use std::{ collections::HashMap, fmt::{Display, Formatter}, + sync::{Arc, Mutex}, }; +use base64::{Engine, prelude::BASE64_STANDARD}; use http::{ Extensions, header::{ACCEPT, AUTHORIZATION}, }; -use reqwest::{Request, Response}; +use reqwest::{Request, Response, StatusCode, header::HeaderValue}; use reqwest_middleware::{Middleware, Next}; use serde::Deserialize; use url::{ParseError, Url}; -use crate::{LazyClient, mirror_middleware::create_404_response}; +use crate::{ + Authentication, AuthenticationStorage, Challenge, LazyClient, + challenge_middleware::parse_challenges, mirror_middleware::create_404_response, +}; #[derive(thiserror::Error, Debug)] enum OciMiddlewareError { @@ -34,14 +39,31 @@ enum OciMiddlewareError { #[error("Invalid OCI URL '{0}': {1}")] InvalidUrl(Url, &'static str), + + #[error("OCI registry requested authentication")] + AuthenticationRequired(Vec), } /// Middleware to handle `oci://` URLs +/// +/// Authentication follows the registry's `WWW-Authenticate` challenge: a +/// `Bearer` challenge is exchanged for a scoped token at its realm, anything +/// else sends stored credentials directly. Anonymous without +/// [`with_authentication_storage`](Self::with_authentication_storage), which is +/// enough for public registries. #[derive(Debug, Clone)] pub struct OciMiddleware { /// Shared HTTP client reused across all OCI requests to avoid creating a /// new connection pool on every token fetch or manifest pull. client: LazyClient, + + /// Credentials for private registries. Without a storage the middleware + /// only ever accesses registries anonymously. + auth_storage: Option, + + /// What [`OciMiddleware::registry_auth`] discovered about each registry + /// host so far, keyed by host. + registry_auth_cache: Arc>>, } impl OciMiddleware { @@ -49,10 +71,277 @@ impl OciMiddleware { pub fn new(client: impl Into) -> Self { Self { client: client.into(), + auth_storage: None, + registry_auth_cache: Arc::new(Mutex::new(HashMap::new())), + } + } + + /// Use `storage` to look up credentials for private registries. + #[must_use] + pub fn with_authentication_storage(mut self, storage: AuthenticationStorage) -> Self { + self.auth_storage = Some(storage); + self + } + + /// Credentials stored for the registry, if any. [`AuthenticationStorage`] + /// resolves by host, so the `oci://` scheme is not a problem. + async fn stored_credentials(&self, url: &Url) -> Option { + match self + .auth_storage + .as_ref()? + .get_by_url_refreshed(url.clone()) + .await + { + Ok((_, credentials)) => credentials, + Err(e) => { + tracing::warn!("OCI Mirror: could not look up credentials for {url}: {e}"); + None + } + } + } + + /// How `host` wants to be authenticated, probing it at most once per + /// process instead of once per artifact. + async fn registry_auth(&self, host: &str) -> RegistryAuth { + let cached = self + .registry_auth_cache + .lock() + .expect("OCI registry auth cache poisoned") + .get(host) + .cloned(); + if let Some(cached) = cached { + return cached; + } + + // Don't cache a failed probe in case it's a temporary error. + let Some(auth) = self.probe_registry_auth(host).await else { + return RegistryAuth::Direct; + }; + + // Concurrent first requests to the same host can both probe it. The + // answer is the same either way, so one duplicate request is cheaper + // than single-flight machinery. + self.registry_auth_cache + .lock() + .expect("OCI registry auth cache poisoned") + .insert(host.to_string(), auth.clone()); + + auth + } + + /// Ask the registry how it wants to be authenticated with the OCI API + /// version check (`GET /v2/`). + /// + /// `None` when the registry did not answer, which is not the same as it + /// answering that it wants no negotiation. + async fn probe_registry_auth(&self, host: &str) -> Option { + let Ok(url) = format!("https://{host}/v2/").parse::() else { + return Some(RegistryAuth::Direct); + }; + + match self.client.client().get(url.clone()).send().await { + Ok(response) => { + let status = response.status(); + let challenges = parse_challenges(response.headers()); + let result = registry_auth_from_probe(status, &challenges); + if result.is_none() { + // Do not let a transient response permanently poison the + // host-wide auth cache as `Direct`. + tracing::debug!( + "OCI Mirror: auth probe {url} returned unusable status {status} or challenge" + ); + } + result + } + Err(e) => { + // The artifact request that follows reports a better error than + // we could here, so a failed probe must not be fatal. + tracing::debug!("OCI Mirror: could not probe {url} for its auth challenge: {e}"); + None + } + } + } + + /// The `Authorization` header to send for `oci_url`, honouring the + /// registry's challenge. + async fn authorization_header( + &self, + oci_url: &OCIUrl, + action: OciAction, + ) -> Result, OciMiddlewareError> { + let credentials = self.stored_credentials(&oci_url.url).await; + + // A stored bearer token already *is* a registry token, so there is + // nothing to exchange it for. + if matches!( + credentials, + Some(Authentication::BearerToken(_) | Authentication::OAuth { .. }) + ) { + return Ok(credentials_header(credentials.as_ref())); + } + + match self.registry_auth(&oci_url.host).await { + RegistryAuth::TokenExchange { realm, service } => { + let token_url = token_url(&realm, service.as_deref(), &oci_url.path, action); + let token = get_token(&self.client, &token_url, credentials.as_ref()).await?; + let mut header = HeaderValue::from_str(&format!("Bearer {token}"))?; + header.set_sensitive(true); + Ok(Some(header)) + } + RegistryAuth::Direct => Ok(credentials_header(credentials.as_ref())), + } + } + + /// Resolve a challenge returned by a concrete manifest or blob request. + /// Unlike the `/v2/` probe, this is authoritative for the requested + /// repository and therefore replaces the cached host-wide answer. + async fn authorization_from_challenges( + &self, + oci_url: &OCIUrl, + challenges: &[Challenge], + ) -> Result, OciMiddlewareError> { + let auth = registry_auth_from_challenges(challenges); + self.registry_auth_cache + .lock() + .expect("OCI registry auth cache poisoned") + .insert(oci_url.host.clone(), auth.clone()); + + let credentials = self.stored_credentials(&oci_url.url).await; + match auth { + RegistryAuth::TokenExchange { realm, service } => { + let token_url = + token_url(&realm, service.as_deref(), &oci_url.path, OciAction::Pull); + let token = get_token(&self.client, &token_url, credentials.as_ref()).await?; + let mut header = HeaderValue::from_str(&format!("Bearer {token}"))?; + header.set_sensitive(true); + Ok(Some(header)) + } + RegistryAuth::Direct => Ok(credentials_header(credentials.as_ref())), + } + } + + /// Turn an `oci://` request into a request for the registry blob that holds + /// the artifact. + async fn rewrite_to_blob_request( + &self, + oci_url: &OCIUrl, + req: &mut Request, + ) -> Result<(), OciMiddlewareError> { + let authorization = self.authorization_header(oci_url, OciAction::Pull).await?; + match oci_url + .set_blob_url(&self.client, req, authorization.as_ref()) + .await + { + Err(OciMiddlewareError::AuthenticationRequired(challenges)) => { + let authorization = self + .authorization_from_challenges(oci_url, &challenges) + .await?; + oci_url + .set_blob_url(&self.client, req, authorization.as_ref()) + .await + } + result => result, } } } +/// The authentication a registry asks for in its `GET /v2/` response. +#[derive(Clone, Debug, PartialEq, Eq)] +enum RegistryAuth { + /// A `Bearer` challenge: exchange credentials for a scoped registry token + /// at the challenge's realm (`https://ghcr.io/token` for ghcr.io). + TokenExchange { realm: Url, service: Option }, + + /// Everything else: a `Basic` challenge (Amazon ECR), no challenge at all, + /// or a scheme we don't implement. There is nothing to negotiate, so send + /// the stored credentials as they are — or nothing, and let the registry + /// answer with its own error. + Direct, +} + +/// Interpret the result of the best-effort `GET /v2/` probe. +/// +/// Unexpected statuses and a `401` without a parseable challenge are not +/// cached: both can be transient and neither tells us how the registry wants +/// the concrete repository request authenticated. +fn registry_auth_from_probe(status: StatusCode, challenges: &[Challenge]) -> Option { + if status == StatusCode::UNAUTHORIZED { + return (!challenges.is_empty()).then(|| registry_auth_from_challenges(challenges)); + } + status + .is_success() + .then(|| registry_auth_from_challenges(challenges)) +} + +/// Pick the authentication flow from a registry's challenges. +/// +/// A `Bearer` challenge whose `realm` is missing, unparsable, or not HTTPS +/// degrades to [`RegistryAuth::Direct`] rather than erroring: a registry we +/// cannot negotiate with may still accept stored credentials directly. +fn registry_auth_from_challenges(challenges: &[Challenge]) -> RegistryAuth { + for challenge in challenges { + if !challenge.scheme.eq_ignore_ascii_case("bearer") { + continue; + } + let Some(realm) = challenge + .params + .get("realm") + .and_then(|realm| Url::parse(realm).ok()) + .filter(|realm| realm.scheme() == "https") + else { + // Basic credentials are forwarded to this endpoint during token + // exchange. Never allow a challenge to downgrade them to cleartext. + tracing::debug!("OCI Mirror: ignoring Bearer challenge without a usable HTTPS realm"); + continue; + }; + return RegistryAuth::TokenExchange { + realm, + service: challenge.params.get("service").cloned(), + }; + } + RegistryAuth::Direct +} + +/// The Docker-style token exchange URL: the challenge's realm, plus the service +/// it named and the scope for this artifact. +fn token_url(realm: &Url, service: Option<&str>, path: &str, action: OciAction) -> Url { + let mut url = realm.clone(); + let mut query = url.query_pairs_mut(); + if let Some(service) = service { + query.append_pair("service", service); + } + query.append_pair("scope", &format!("repository:{path}:{action}")); + drop(query); + url +} + +/// The `Authorization` header for stored credentials. `None` when there are +/// none, or when they are not something an HTTP header can carry: conda tokens +/// live in the URL and S3 credentials sign the request instead. +fn credentials_header(credentials: Option<&Authentication>) -> Option { + let value = match credentials? { + Authentication::BasicHTTP { username, password } => { + format!( + "Basic {}", + BASE64_STANDARD.encode(format!("{username}:{password}")) + ) + } + Authentication::BearerToken(token) => format!("Bearer {token}"), + Authentication::OAuth { access_token, .. } => format!("Bearer {access_token}"), + Authentication::CondaToken(_) | Authentication::S3Credentials { .. } => return None, + }; + + // Never log the value itself, not even in the error case. + let Ok(mut header) = HeaderValue::from_str(&value) else { + tracing::warn!( + "OCI Mirror: stored credentials are not a valid header value, continuing without them" + ); + return None; + }; + header.set_sensitive(true); + Some(header) +} + /// The action to perform on the OCI registry pub enum OciAction { /// Pull an artifact @@ -81,12 +370,18 @@ impl Display for OciAction { // [oci://ghcr.io/channel-mirrors/conda-forge]/[osx-arm64/xtensor] async fn get_token( client: &LazyClient, - url: &OCIUrl, - action: OciAction, + token_url: &Url, + credentials: Option<&Authentication>, ) -> Result { - let token_url = url.token_url(action)?; + let mut request = client.client().get(token_url.clone()); - let response = client.client().get(token_url.clone()).send().await?; + // Like Docker, present stored credentials to the token endpoint: an + // anonymous exchange only ever yields a token for public repositories. + if let Some(header) = credentials_header(credentials) { + request = request.header(AUTHORIZATION, header); + } + + let response = request.send().await?; match response.error_for_status() { Ok(response) => { @@ -135,14 +430,6 @@ impl OCIUrl { .parse() } - pub fn token_url(&self, action: OciAction) -> Result { - format!( - "https://{}/token?scope=repository:{}:{}", - self.host, self.path, action - ) - .parse() - } - pub fn blob_url(&self, sha256: &str) -> Result { format!("https://{}/v2/{}/blobs/{}", self.host, self.path, sha256).parse() } @@ -221,17 +508,17 @@ impl OCIUrl { Ok(res) } - pub async fn get_blob_url( + /// Point `req` at the blob holding this artifact, authenticated with + /// `authorization` (which the registry's challenge decided upon). + pub async fn set_blob_url( + &self, client: &LazyClient, req: &mut Request, + authorization: Option<&HeaderValue>, ) -> Result<(), OciMiddlewareError> { - let oci_url = OCIUrl::new(req.url())?; - let token = get_token(client, &oci_url, OciAction::Pull).await?; - - let mut header = reqwest::header::HeaderValue::from_str(&format!("Bearer {token}"))?; - header.set_sensitive(true); - - req.headers_mut().insert(AUTHORIZATION, header); + if let Some(header) = authorization { + req.headers_mut().insert(AUTHORIZATION, header.clone()); + } // if we know the hash, we can pull the artifact directly // if we don't, we need to pull the manifest and then pull the artifact @@ -240,32 +527,40 @@ impl OCIUrl { .get("X-Expected-Sha256") .and_then(|s| s.to_str().ok()) { - *req.url_mut() = oci_url.blob_url(&format!("sha256:{expected_sha_hash}"))?; + *req.url_mut() = self.blob_url(&format!("sha256:{expected_sha_hash}"))?; } else { // get the tag from the URL retrieve the manifest - let manifest_url = oci_url.manifest_url()?; // TODO: handle error + let manifest_url = self.manifest_url()?; // TODO: handle error - let manifest = client + let mut manifest_request = client .client() .get(manifest_url) - .bearer_auth(&token) - .header(ACCEPT, "application/vnd.oci.image.manifest.v1+json") - .send() - .await?; + .header(ACCEPT, "application/vnd.oci.image.manifest.v1+json"); + if let Some(header) = authorization { + manifest_request = manifest_request.header(AUTHORIZATION, header.clone()); + } - let manifest: Manifest = manifest.json().await?; + let manifest = manifest_request.send().await?; + if manifest.status() == StatusCode::UNAUTHORIZED { + let challenges = parse_challenges(manifest.headers()); + if !challenges.is_empty() { + return Err(OciMiddlewareError::AuthenticationRequired(challenges)); + } + } + + let manifest: Manifest = manifest.error_for_status()?.json().await?; let layer = if let Some(layer) = manifest .layers .iter() - .find(|l| l.media_type == oci_url.media_type) + .find(|l| l.media_type == self.media_type) { layer } else { return Err(OciMiddlewareError::LayerNotFound); }; - *req.url_mut() = oci_url.blob_url(&layer.digest)?; + *req.url_mut() = self.blob_url(&layer.digest)?; } Ok(()) @@ -306,10 +601,38 @@ impl Middleware for OciMiddleware { return next.run(req, extensions).await; } - let res = OCIUrl::get_blob_url(&self.client, &mut req).await; + let oci_url = match OCIUrl::new(req.url()) { + Ok(url) => url, + Err(e) => return Err(reqwest_middleware::Error::Middleware(e.into())), + }; + let res = self.rewrite_to_blob_request(&oci_url, &mut req).await; match res { - Ok(_) => next.run(req, extensions).await, + Ok(_) => { + // Keep one copy so a repository-specific challenge can be + // answered and replayed exactly once. + let Some(mut retry_req) = req.try_clone() else { + return next.run(req, extensions).await; + }; + let response = next.clone().run(req, extensions).await?; + if response.status() != StatusCode::UNAUTHORIZED { + return Ok(response); + } + + let challenges = parse_challenges(response.headers()); + if challenges.is_empty() { + return Ok(response); + } + let authorization = self + .authorization_from_challenges(&oci_url, &challenges) + .await + .map_err(|e| reqwest_middleware::Error::Middleware(e.into()))?; + let Some(authorization) = authorization else { + return Ok(response); + }; + retry_req.headers_mut().insert(AUTHORIZATION, authorization); + next.run(retry_req, extensions).await + } Err(e) => match e { OciMiddlewareError::LayerNotFound => { return Ok(create_404_response( @@ -329,7 +652,155 @@ impl Middleware for OciMiddleware { mod tests { use sha2::{Digest, Sha256}; - use crate::OciMiddleware; + use super::{ + Authentication, OciAction, RegistryAuth, StatusCode, credentials_header, parse_challenges, + registry_auth_from_challenges, registry_auth_from_probe, token_url, + }; + use crate::{Challenge, OciMiddleware}; + + /// The challenges of a registry that answers `GET /v2/` with `header`. + fn challenges(header: &str) -> Vec { + let mut headers = http::HeaderMap::new(); + headers.insert( + http::header::WWW_AUTHENTICATE, + http::HeaderValue::from_str(header).unwrap(), + ); + parse_challenges(&headers) + } + + /// ghcr.io's challenge must keep producing exactly the token URL the + /// middleware used to hardcode. + #[test] + fn bearer_challenge_builds_scoped_token_url() { + let auth = registry_auth_from_challenges(&challenges( + r#"Bearer realm="https://ghcr.io/token",service="ghcr.io""#, + )); + let RegistryAuth::TokenExchange { realm, service } = auth else { + panic!("a Bearer challenge with a realm must yield a token exchange, got {auth:?}"); + }; + + let url = token_url( + &realm, + service.as_deref(), + "channel-mirrors/conda-forge/noarch/xtensor", + OciAction::Pull, + ); + + assert_eq!(url.path(), "/token"); + // `scope` is percent-encoded here but not in the old hand-built URL; + // registries decode the query either way. + let query: Vec<_> = url.query_pairs().collect(); + assert_eq!( + query, + vec![ + ("service".into(), "ghcr.io".into()), + ( + "scope".into(), + "repository:channel-mirrors/conda-forge/noarch/xtensor:pull".into() + ), + ] + ); + } + + /// A registry offering both schemes is negotiated with, even when `Basic` + /// comes first, and a realm-only challenge is still usable. + #[test] + fn bearer_is_preferred_and_service_is_optional() { + let auth = registry_auth_from_challenges(&challenges( + r#"Basic realm="https://registry.example/", Bearer realm="https://registry.example/token""#, + )); + assert_eq!( + auth, + RegistryAuth::TokenExchange { + realm: "https://registry.example/token".parse().unwrap(), + service: None, + } + ); + } + + /// Amazon ECR only offers `Basic`: there is nothing to exchange, the stored + /// credentials go straight onto the request. + #[test] + fn basic_challenge_sends_stored_credentials() { + let auth = registry_auth_from_challenges(&challenges( + r#"Basic realm="https://1234.dkr.ecr.eu-west-1.amazonaws.com/",service="ecr.amazonaws.com""#, + )); + assert_eq!(auth, RegistryAuth::Direct); + + let header = credentials_header(Some(&Authentication::BasicHTTP { + username: "AWS".to_string(), + password: "secret".to_string(), + })) + .expect("basic credentials are a valid header value"); + + assert_eq!(header, "Basic QVdTOnNlY3JldA=="); + assert!(header.is_sensitive()); + } + + /// Without credentials we send no `Authorization` header at all and let the + /// registry answer with its own error. + #[test] + fn no_credentials_means_no_authorization_header() { + assert!(credentials_header(None).is_none()); + // Neither of these can travel in an `Authorization` header. + assert!(credentials_header(Some(&Authentication::CondaToken("t".to_string()))).is_none()); + assert!( + credentials_header(Some(&Authentication::S3Credentials { + access_key_id: "k".to_string(), + secret_access_key: "s".to_string(), + session_token: None, + })) + .is_none() + ); + } + + /// A challenge we cannot act on must degrade to the direct path instead of + /// failing the download. + #[test] + fn unusable_challenges_degrade_to_direct() { + for header in [ + r#"Bearer service="ghcr.io""#, + r#"Bearer realm="not a url""#, + r#"Bearer realm="http://registry.example/token""#, + r#"Digest realm="https://registry.example/token""#, + "%%% ###", + ] { + assert_eq!( + registry_auth_from_challenges(&challenges(header)), + RegistryAuth::Direct, + "{header} should not be negotiated with" + ); + } + // A 2xx `GET /v2/` carries no challenge at all. + assert_eq!(registry_auth_from_challenges(&[]), RegistryAuth::Direct); + } + + #[test] + fn transient_probe_responses_are_not_cached_as_direct() { + assert_eq!( + registry_auth_from_probe(StatusCode::TOO_MANY_REQUESTS, &[]), + None + ); + assert_eq!( + registry_auth_from_probe(StatusCode::SERVICE_UNAVAILABLE, &[]), + None + ); + assert_eq!( + registry_auth_from_probe(StatusCode::UNAUTHORIZED, &[]), + None + ); + assert_eq!( + registry_auth_from_probe(StatusCode::OK, &[]), + Some(RegistryAuth::Direct) + ); + assert!(matches!( + registry_auth_from_probe( + StatusCode::UNAUTHORIZED, + &challenges(r#"Bearer realm="https://registry.example/token""#) + ), + Some(RegistryAuth::TokenExchange { .. }) + )); + } // test pulling an image from OCI registry #[cfg(any(feature = "rustls", feature = "native-tls"))] diff --git a/py-rattler/rattler/networking/middleware.py b/py-rattler/rattler/networking/middleware.py index 6a791de516..41cb3e0b56 100644 --- a/py-rattler/rattler/networking/middleware.py +++ b/py-rattler/rattler/networking/middleware.py @@ -108,6 +108,9 @@ def __repr__(self) -> str: class OciMiddleware: """ Middleware to handle `oci://` URLs + + Authenticates by following the registry's `WWW-Authenticate` challenge, using any + credentials stored for the registry's host in the authentication storage. """ def __init__(self) -> None: diff --git a/py-rattler/src/networking/client.rs b/py-rattler/src/networking/client.rs index 1c6580cb4e..65dc3f3900 100644 --- a/py-rattler/src/networking/client.rs +++ b/py-rattler/src/networking/client.rs @@ -79,7 +79,12 @@ impl PyClientWithMiddleware { client = client.with(RetryTransientMiddleware::new_with_policy(policy)); } PyMiddleware::Oci(_middleware) => { - client = client.with(OciMiddleware::new(reqwest_client.clone())); + client = client.with( + OciMiddleware::new(reqwest_client.clone()).with_authentication_storage( + AuthenticationStorage::from_env_and_defaults() + .map_err(PyRattlerError::from)?, + ), + ); } PyMiddleware::Gcs(middleware) => { client = client.with(GCSMiddleware::from(middleware)); From cfc6bf4d7c2692e588ca31aa85e60ba814243b5d Mon Sep 17 00:00:00 2001 From: Ash Date: Thu, 6 Aug 2026 02:02:56 +0530 Subject: [PATCH 11/21] fix(shell): escape env var values in activation script (#2621) --- crates/rattler_shell/src/shell/mod.rs | 42 +++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/crates/rattler_shell/src/shell/mod.rs b/crates/rattler_shell/src/shell/mod.rs index 5bdf1fff96..ed59fa6523 100644 --- a/crates/rattler_shell/src/shell/mod.rs +++ b/crates/rattler_shell/src/shell/mod.rs @@ -472,6 +472,7 @@ pub struct Zsh; impl Shell for Zsh { fn set_env_var(&self, f: &mut impl Write, env_var: &str, value: &str) -> ShellResult { validate_env_var_name(env_var)?; + let value = escape_double_quoted(value); Ok(writeln!(f, "export {env_var}=\"{value}\"")?) } @@ -533,6 +534,7 @@ pub struct Xonsh; impl Shell for Xonsh { fn set_env_var(&self, f: &mut impl Write, env_var: &str, value: &str) -> ShellResult { validate_env_var_name(env_var)?; + let value = escape_double_quoted(value); Ok(writeln!(f, "${env_var} = \"{value}\"")?) } @@ -592,7 +594,7 @@ impl Shell for Xonsh { /// Parses newline-separated `KEY=VALUE` output, as emitted by the /// `print_env` of shells whose environment dump is line-based -/// (cmd.exe's `@SET`, PowerShell's `dir env:`). +/// (`cmd.exe`'s `@SET`, `PowerShell`'s `dir env:`). fn parse_env_lines(env: &str) -> HashMap<&str, &str> { env.lines() .filter_map(|line| { @@ -730,6 +732,7 @@ impl Shell for PowerShell { fn set_env_var(&self, f: &mut impl Write, env_var: &str, value: &str) -> ShellResult { validate_env_var_name(env_var)?; + let value = escape_powershell_double_quoted(value); Ok(writeln!(f, "${{Env:{env_var}}} = \"{value}\"")?) } @@ -791,6 +794,7 @@ pub struct Fish; impl Shell for Fish { fn set_env_var(&self, f: &mut impl Write, env_var: &str, value: &str) -> ShellResult { validate_env_var_name(env_var)?; + let value = escape_double_quoted(value); Ok(writeln!(f, "set -gx {env_var} \"{value}\"")?) } @@ -855,6 +859,19 @@ impl Shell for Fish { fn escape_backslashes(s: &str) -> String { s.replace('\\', "\\\\") } + +/// Escapes `value` for inclusion inside a double-quoted string in shells that +/// use backslash escaping (zsh, fish, xonsh, nushell). Backslashes are escaped +/// first so the escapes added for the double quotes are not themselves doubled. +fn escape_double_quoted(s: &str) -> String { + escape_backslashes(s).replace('"', "\\\"") +} + +/// Escapes `value` for a `PowerShell` double-quoted string, where the backtick is +/// the escape character rather than the backslash. +fn escape_powershell_double_quoted(s: &str) -> String { + s.replace('`', "``").replace('"', "`\"") +} fn quote_if_required(s: &str) -> Cow<'_, str> { if s.contains(|c: char| !c.is_ascii_alphanumeric() && c != '_' && c != '-') { Cow::Owned(format!("\"{s}\"")) @@ -869,13 +886,13 @@ pub struct NuShell; impl Shell for NuShell { fn set_env_var(&self, f: &mut impl Write, env_var: &str, value: &str) -> ShellResult { - // escape backslashes for Windows (make them double backslashes) + // escape backslashes and double quotes so the value stays inside the string validate_env_var_name(env_var)?; Ok(writeln!( f, "$env.{} = \"{}\"", quote_if_required(env_var), - escape_backslashes(value) + escape_double_quoted(value) )?) } @@ -1294,6 +1311,25 @@ mod tests { use super::*; + #[test] + fn test_set_env_var_escapes_special_chars() { + // A value containing a literal double quote (or backslash) must stay inside + // the generated string rather than terminating it early, which would break + // the activation script or allow command injection. Bash is the reference. + fn line(sh: S, value: &str) -> String { + let mut out = String::new(); + sh.set_env_var(&mut out, "FOO", value).unwrap(); + out.trim_end().to_string() + } + let val = r#"a"b\c"#; + assert_eq!(line(Zsh, val), r#"export FOO="a\"b\\c""#); + assert_eq!(line(Fish, val), r#"set -gx FOO "a\"b\\c""#); + assert_eq!(line(Xonsh, val), r#"$FOO = "a\"b\\c""#); + assert_eq!(line(NuShell, val), r#"$env.FOO = "a\"b\\c""#); + // PowerShell uses the backtick as its escape character; backslash is literal. + assert_eq!(line(PowerShell::default(), val), r#"${Env:FOO} = "a`"b\c""#); + } + #[test] fn test_bash() { let mut script = ShellScript::new(Bash::default(), Platform::Linux64); From 4a46ed5a0455bd3deefb3cdd9d09a9f996bd52e3 Mon Sep 17 00:00:00 2001 From: Bas Zalmstra <4995967+baszalmstra@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:39:38 +0200 Subject: [PATCH 12/21] fix(rattler_solve): rate candidates by their most restrictive requirement (#2649) --- .../src/resolvo/conda_sorting.rs | 34 +++++----- crates/rattler_solve/tests/backends/main.rs | 17 +++++ .../tests/backends/sorting_tests.rs | 68 +++++++++++++++++++ 3 files changed, 103 insertions(+), 16 deletions(-) create mode 100644 crates/rattler_solve/tests/backends/sorting_tests.rs diff --git a/crates/rattler_solve/src/resolvo/conda_sorting.rs b/crates/rattler_solve/src/resolvo/conda_sorting.rs index 9dd5d38e1c..3f06005746 100644 --- a/crates/rattler_solve/src/resolvo/conda_sorting.rs +++ b/crates/rattler_solve/src/resolvo/conda_sorting.rs @@ -276,25 +276,27 @@ impl<'a, 'repo> SolvableSorter<'a, 'repo> { .sorted_by_key(|name| self.pool().resolve_package_name(*name)) .collect_vec(); - // A closure that locates the highest version of a dependency for a solvable. - let mut find_highest_version_for_set = |version_set_ids: &Vec| { + // The best version of a dependency that this solvable can end up with. + // + // A solvable can require the same package more than once: a recipe lists a bare + // `nodejs` next to the `nodejs >=26.5.1,<27.0a0` pin from its run-export. Only + // versions matching all of the requirements can be selected, so take the lowest + // of their highest versions. Taking the highest would score every build by the + // bare requirement, which matches everything and separates nothing. + // + // This is an approximation. It does not intersect the requirements, only takes + // the lowest of their individual maxima. Enough to order candidates. + let mut find_best_selectable_version = |version_set_ids: &Vec| { version_set_ids .iter() .filter_map(|id| find_highest_version(*id, self.solver, version_cache)) .map(|v| TrackedFeatureVersion::new(v.0, v.1)) - .fold(None, |init, version| { - if let Some(init) = init { - Some( - if version.compare_with_strategy(&init, CompareStrategy::Default) - == Ordering::Less - { - version - } else { - init - }, - ) + .reduce(|a, b| { + // Better sorts first, so `Greater` means `a` is the more restrictive. + if a.compare_with_strategy(&b, CompareStrategy::Default) == Ordering::Greater { + a } else { - Some(version) + b } }) }; @@ -305,10 +307,10 @@ impl<'a, 'repo> SolvableSorter<'a, 'repo> { for &name in sorted_unique_names.iter() { let a_version = id_and_deps .get(&(*a, name)) - .and_then(&mut find_highest_version_for_set); + .and_then(&mut find_best_selectable_version); let b_version = id_and_deps .get(&(*b, name)) - .and_then(&mut find_highest_version_for_set); + .and_then(&mut find_best_selectable_version); // Deal with the case where resolving the version set doesn't actually select a // version diff --git a/crates/rattler_solve/tests/backends/main.rs b/crates/rattler_solve/tests/backends/main.rs index e639839603..448547c99e 100644 --- a/crates/rattler_solve/tests/backends/main.rs +++ b/crates/rattler_solve/tests/backends/main.rs @@ -22,6 +22,7 @@ mod extras_tests; mod helpers; mod min_age_tests; mod solver_case_tests; +mod sorting_tests; mod strategy_tests; mod variant_flags_tests; @@ -1352,6 +1353,22 @@ mod resolvo { "###); } + // Candidate ordering tests (resolvo-specific) + + #[test] + fn test_prefers_build_with_highest_dependency() { + crate::sorting_tests::solve_prefers_build_with_highest_dependency::< + rattler_solve::resolvo::Solver, + >(); + } + + #[test] + fn test_prefers_build_with_highest_dependency_with_bare_requirement() { + crate::sorting_tests::solve_prefers_build_with_highest_dependency_with_bare_requirement::< + rattler_solve::resolvo::Solver, + >(); + } + // Strategy tests (resolvo-specific) #[test] diff --git a/crates/rattler_solve/tests/backends/sorting_tests.rs b/crates/rattler_solve/tests/backends/sorting_tests.rs new file mode 100644 index 0000000000..6f43088f36 --- /dev/null +++ b/crates/rattler_solve/tests/backends/sorting_tests.rs @@ -0,0 +1,68 @@ +//! Tests for how candidates of the same version and build number are ordered. + +use super::helpers::{PackageBuilder, SolverCase}; +use rattler_conda_types::RepoDataRecord; +use rattler_solve::SolverImpl; + +/// Two builds of `pkg 1.0` with the same build number that pin different majors +/// of `dep`. Mirrors conda-forge, where `pnpm 11.19.0` exists both pinned to +/// `nodejs 24.*` and pinned to `nodejs 26.*`. +/// +/// With `bare_requirement` both builds also carry an unpinned `dep` next to the +/// pin, which is what a recipe listing `dep` in its run requirements produces. +fn duplicate_dependency_repository(bare_requirement: bool) -> Vec { + let dep_24 = PackageBuilder::new("dep").version("24.19.0").build(); + let dep_26 = PackageBuilder::new("dep").version("26.6.0").build(); + + let requirements = |pin: &str| { + if bare_requirement { + vec!["dep".to_string(), pin.to_string()] + } else { + vec![pin.to_string()] + } + }; + + // The build pinned to the older `dep` is built last, so it wins any tie that + // falls through to the timestamp. + let pkg_dep24 = PackageBuilder::new("pkg") + .version("1.0") + .build_number(0) + .build_string("h_dep24_0") + .depends(requirements("dep >=24.18.0,<25.0a0")) + .timestamp("2026-07-31T18:34:43Z") + .build(); + let pkg_dep26 = PackageBuilder::new("pkg") + .version("1.0") + .build_number(0) + .build_string("h_dep26_0") + .depends(requirements("dep >=26.5.1,<27.0a0")) + .timestamp("2026-07-31T18:34:41Z") + .build(); + + vec![dep_24, dep_26, pkg_dep24, pkg_dep26] +} + +/// One pin per build: the build allowing the newest `dep` wins, even though the +/// other one has a newer timestamp. +pub(super) fn solve_prefers_build_with_highest_dependency() { + SolverCase::new("build pinning the newest dependency wins over a newer build timestamp") + .repository(duplicate_dependency_repository(false)) + .specs(["pkg"]) + .expect_present([("pkg", "1.0", "h_dep26_0")]) + .expect_present([("dep", "26.6.0")]) + .run::(); +} + +/// Same, but with a bare `dep` next to each pin. The bare requirement matches +/// every `dep`, so scoring a build by its least restrictive requirement leaves +/// the timestamp to decide, and that only says which variant built last. +pub(super) fn solve_prefers_build_with_highest_dependency_with_bare_requirement< + T: SolverImpl + Default, +>() { + SolverCase::new("a bare requirement next to a pin does not mask the pin") + .repository(duplicate_dependency_repository(true)) + .specs(["pkg"]) + .expect_present([("pkg", "1.0", "h_dep26_0")]) + .expect_present([("dep", "26.6.0")]) + .run::(); +} From 0b9a6773f477477061f1058fbd4fe3b082a1271b Mon Sep 17 00:00:00 2001 From: Wolf Vollprecht Date: Wed, 5 Aug 2026 23:26:45 +0200 Subject: [PATCH 13/21] feat: add CEP-6 channel notice support (#2639) Co-authored-by: Bas Zalmstra <4995967+baszalmstra@users.noreply.github.com> --- .../rattler_conda_types/src/channel_notice.rs | 83 +++++ crates/rattler_conda_types/src/lib.rs | 2 + crates/rattler_config/src/config/index.rs | 40 ++- ...t__parse__deprecated-and-unknown.toml.snap | 1 + .../compat__parse__kitchen-sink.toml.snap | 2 + .../compat__parse__override-layer.toml.snap | 1 + ...ompat__parse__snake-case-aliases.toml.snap | 1 + crates/rattler_index/src/lib.rs | 63 +++- .../tests/integration/basic_indexing.rs | 17 +- .../src/gateway/builder.rs | 2 + .../src/gateway/channel_notices.rs | 300 ++++++++++++++++++ .../src/gateway/mod.rs | 236 ++++++++++++++ .../src/gateway/query.rs | 158 +++++++-- crates/rattler_repodata_gateway/src/lib.rs | 8 +- .../rattler_repodata_gateway/src/reporter.rs | 5 + js-rattler/crate/gateway.rs | 87 ++++- js-rattler/src/Gateway.ts | 55 +++- py-rattler/rattler/__init__.py | 6 + py-rattler/rattler/repo_data/__init__.py | 11 +- py-rattler/rattler/repo_data/gateway.py | 92 +++++- py-rattler/src/lib.rs | 3 +- py-rattler/src/repo_data/gateway.rs | 88 ++++- py-rattler/tests/unit/test_gateway.py | 56 ++++ 23 files changed, 1254 insertions(+), 63 deletions(-) create mode 100644 crates/rattler_conda_types/src/channel_notice.rs create mode 100644 crates/rattler_repodata_gateway/src/gateway/channel_notices.rs diff --git a/crates/rattler_conda_types/src/channel_notice.rs b/crates/rattler_conda_types/src/channel_notice.rs new file mode 100644 index 0000000000..70accd8d35 --- /dev/null +++ b/crates/rattler_conda_types/src/channel_notice.rs @@ -0,0 +1,83 @@ +//! Data types for [CEP-6] channel notices. +//! +//! [CEP-6]: https://github.com/conda/ceps/blob/main/cep-0006.md + +use jiff::Timestamp; +use serde::{Deserialize, Serialize}; + +/// The importance of a channel notice. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum ChannelNoticeLevel { + /// General information. + #[default] + Info, + /// A warning that may require action from the user. + Warning, + /// A critical notice, such as a security advisory. + Critical, +} + +/// A notice published by a channel in its `notices.json` file. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct ChannelNotice { + /// A stable identifier for the notice. + pub id: String, + /// The message to display to users. + pub message: String, + /// The importance of the notice. + #[serde(default)] + pub level: ChannelNoticeLevel, + /// When the notice was created. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub created_at: Option, + /// When the notice expires. + /// + /// `expired_at` is accepted as an alias for compatibility with older + /// conda implementations, but CEP-6 calls this field `expires_at`. + #[serde(default, alias = "expired_at", skip_serializing_if = "Option::is_none")] + pub expires_at: Option, + /// The requested interval between displaying the notice. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub interval: Option, +} + +/// The contents of a CEP-6 `notices.json` file. +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] +pub struct ChannelNotices { + /// Notices published by the channel. + #[serde(default)] + pub notices: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_cep6_notices() { + let notices: ChannelNotices = serde_json::from_str( + r#"{ + "notices": [{ + "id": "security-1", + "message": "Please update demo", + "level": "critical", + "created_at": "2025-01-01T12:00:00+00:00", + "expires_at": "2025-02-01T12:00:00+00:00", + "interval": 24 + }] + }"#, + ) + .unwrap(); + + assert_eq!(notices.notices.len(), 1); + assert_eq!(notices.notices[0].level, ChannelNoticeLevel::Critical); + assert!(notices.notices[0].expires_at.is_some()); + assert_eq!(notices.notices[0].interval, Some(24)); + assert!( + serde_json::to_value(¬ices).unwrap()["notices"][0] + .get("expires_at") + .is_some() + ); + } +} diff --git a/crates/rattler_conda_types/src/lib.rs b/crates/rattler_conda_types/src/lib.rs index 49b39f503f..826d6ceab1 100644 --- a/crates/rattler_conda_types/src/lib.rs +++ b/crates/rattler_conda_types/src/lib.rs @@ -7,6 +7,7 @@ pub mod backup; mod build_spec; mod channel; mod channel_data; +mod channel_notice; mod explicit_environment_spec; mod flags; pub mod match_spec; @@ -38,6 +39,7 @@ use std::path::{Path, PathBuf}; pub use build_spec::{BuildNumber, BuildNumberSpec, OrdOperator, ParseBuildNumberSpecError}; pub use channel::{Channel, ChannelConfig, ChannelUrl, NamedChannelOrUrl, ParseChannelError}; pub use channel_data::{ChannelData, ChannelDataPackage}; +pub use channel_notice::{ChannelNotice, ChannelNoticeLevel, ChannelNotices}; pub use environment_yaml::{EnvironmentYaml, MatchSpecOrSubSection}; pub use explicit_environment_spec::{ ExplicitEnvironmentEntry, ExplicitEnvironmentSpec, PackageArchiveHash, diff --git a/crates/rattler_config/src/config/index.rs b/crates/rattler_config/src/config/index.rs index 1fb28ae68a..7042be2da2 100644 --- a/crates/rattler_config/src/config/index.rs +++ b/crates/rattler_config/src/config/index.rs @@ -22,12 +22,21 @@ //! [index-config."s3://my-bucket/staging".channel-relations] //! base = "../conda-forge" //! +//! [[index-config."s3://my-bucket/staging".notices]] +//! id = "security-1" +//! message = "Please update the affected package" +//! level = "critical" +//! created_at = "2025-01-01T12:00:00Z" +//! expires_at = "2025-02-01T12:00:00Z" +//! //! [index-config."/srv/conda/internal"] //! base-url = "../packages/" //! ``` use std::{collections::HashMap, str::FromStr}; -use rattler_conda_types::{ChannelRelations, RepodataRevision, RepodataRevisionInfo}; +use rattler_conda_types::{ + ChannelNotice, ChannelRelations, RepodataRevision, RepodataRevisionInfo, +}; use serde::{Deserialize, Deserializer, Serialize, de::Error as DeError}; use crate::config::{Config, MergeError, ValidationError}; @@ -92,6 +101,13 @@ pub struct IndexChannelConfig { #[serde(default, skip_serializing_if = "Option::is_none")] pub base_url: Option, + /// CEP-6 notices to write to the channel's root `notices.json`. + /// + /// When unset, an existing notices file is left untouched. An empty list + /// explicitly writes a notices file with no notices. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub notices: Option>, + /// `info.channel_relations` value written to generated repodata. #[serde(default, skip_serializing_if = "Option::is_none")] pub channel_relations: Option, @@ -105,6 +121,7 @@ impl IndexChannelConfig { && self.repodata_revisions.is_none() && self.package_revision_assignment.is_none() && self.base_url.is_none() + && self.notices.is_none() && self.channel_relations.is_none() } @@ -120,6 +137,7 @@ impl IndexChannelConfig { .package_revision_assignment .or(self.package_revision_assignment), base_url: other.base_url.or_else(|| self.base_url.clone()), + notices: other.notices.or_else(|| self.notices.clone()), channel_relations: other .channel_relations .or_else(|| self.channel_relations.clone()), @@ -307,6 +325,26 @@ base = "../conda-forge" assert!(cfg.per_channel.is_empty()); } + #[test] + fn parses_channel_notices() { + let cfg = parse( + r#" +[[notices]] +id = "security-1" +message = "Please update demo" +level = "critical" +created_at = "2025-01-01T12:00:00Z" +expires_at = "2025-02-01T12:00:00Z" +interval = 24 +"#, + ); + + let notices = cfg.default.notices.unwrap(); + assert_eq!(notices.len(), 1); + assert_eq!(notices[0].id, "security-1"); + assert_eq!(notices[0].interval, Some(24)); + } + #[test] fn parses_per_channel_entries() { let cfg = parse( diff --git a/crates/rattler_config/tests/snapshots/compat__parse__deprecated-and-unknown.toml.snap b/crates/rattler_config/tests/snapshots/compat__parse__deprecated-and-unknown.toml.snap index e0a41e88ba..3d2a3e47a1 100644 --- a/crates/rattler_config/tests/snapshots/compat__parse__deprecated-and-unknown.toml.snap +++ b/crates/rattler_config/tests/snapshots/compat__parse__deprecated-and-unknown.toml.snap @@ -95,6 +95,7 @@ expression: "(unused, normalized(config))" repodata_revisions: None, package_revision_assignment: None, base_url: None, + notices: None, channel_relations: None, }, per_channel: {}, diff --git a/crates/rattler_config/tests/snapshots/compat__parse__kitchen-sink.toml.snap b/crates/rattler_config/tests/snapshots/compat__parse__kitchen-sink.toml.snap index c16deeeba4..bf99928f21 100644 --- a/crates/rattler_config/tests/snapshots/compat__parse__kitchen-sink.toml.snap +++ b/crates/rattler_config/tests/snapshots/compat__parse__kitchen-sink.toml.snap @@ -217,6 +217,7 @@ expression: "(unused, normalized(config))" repodata_revisions: None, package_revision_assignment: None, base_url: None, + notices: None, channel_relations: None, }, per_channel: { @@ -228,6 +229,7 @@ expression: "(unused, normalized(config))" base_url: Some( "../packages/", ), + notices: None, channel_relations: None, }, }, diff --git a/crates/rattler_config/tests/snapshots/compat__parse__override-layer.toml.snap b/crates/rattler_config/tests/snapshots/compat__parse__override-layer.toml.snap index f0d2caef56..464cb886a4 100644 --- a/crates/rattler_config/tests/snapshots/compat__parse__override-layer.toml.snap +++ b/crates/rattler_config/tests/snapshots/compat__parse__override-layer.toml.snap @@ -143,6 +143,7 @@ expression: "(unused, normalized(config))" repodata_revisions: None, package_revision_assignment: None, base_url: None, + notices: None, channel_relations: None, }, per_channel: {}, diff --git a/crates/rattler_config/tests/snapshots/compat__parse__snake-case-aliases.toml.snap b/crates/rattler_config/tests/snapshots/compat__parse__snake-case-aliases.toml.snap index 1d94eb42e0..2ca0c49ecb 100644 --- a/crates/rattler_config/tests/snapshots/compat__parse__snake-case-aliases.toml.snap +++ b/crates/rattler_config/tests/snapshots/compat__parse__snake-case-aliases.toml.snap @@ -75,6 +75,7 @@ expression: "(unused, normalized(config))" repodata_revisions: None, package_revision_assignment: None, base_url: None, + notices: None, channel_relations: None, }, per_channel: {}, diff --git a/crates/rattler_index/src/lib.rs b/crates/rattler_index/src/lib.rs index be5c547bb2..debba4e044 100644 --- a/crates/rattler_index/src/lib.rs +++ b/crates/rattler_index/src/lib.rs @@ -29,8 +29,9 @@ use opendal::layers::RetryLayer; use opendal::services::S3Config; use opendal::{Configurator, Operator, services::FsConfig}; use rattler_conda_types::{ - ChannelInfo, ChannelRelations, PackageRecord, PatchInstructions, Platform, RepoData, Shard, - ShardedRepodata, ShardedSubdirInfo, UrlOrPath, V3Packages, WhlPackageRecord, + ChannelInfo, ChannelNotice, ChannelNotices, ChannelRelations, PackageRecord, PatchInstructions, + Platform, RepoData, Shard, ShardedRepodata, ShardedSubdirInfo, UrlOrPath, V3Packages, + WhlPackageRecord, package::{ CondaArchiveType, DistArchiveIdentifier, DistArchiveType, IndexJson, PackageFile, RunExportsJson, WheelArchiveType, @@ -57,17 +58,22 @@ use tracing::Instrument; #[cfg(feature = "s3")] use url::Url; -/// Channel metadata written into generated repodata. +/// Metadata published while indexing a channel. /// -/// Distinct from [`IndexChannelConfig`] — that type describes the indexer's -/// behavior knobs (zst, shards, revisions, ...). `ChannelMetadata` is just the -/// data that ends up under `info` in the generated repodata. +/// Distinct from [`IndexChannelConfig`] — that type also describes indexer +/// behavior knobs (zst, shards, revisions, ...). This type contains metadata +/// written to generated repodata and the channel-root `notices.json` file. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct ChannelMetadata { /// The `info.base_url` value written to `repodata.json`. pub base_url: Option, /// The `info.channel_relations` value written to `repodata.json`. pub channel_relations: Option, + /// CEP-6 notices to write to the channel root. + /// + /// `None` leaves an existing `notices.json` untouched, while `Some` writes + /// the supplied notices (including an explicitly empty list). + pub notices: Option>, } impl ChannelMetadata { @@ -79,6 +85,7 @@ impl ChannelMetadata { .channel_relations .clone() .filter(|relations| !relations.is_empty()), + notices: config.notices.clone(), } } } @@ -134,6 +141,7 @@ pub struct IndexStats { const REPODATA_FROM_PACKAGES: &str = "repodata_from_packages.json"; const REPODATA: &str = "repodata.json"; const REPODATA_SHARDS: &str = "repodata_shards.msgpack.zst"; +const CHANNEL_NOTICES: &str = "notices.json"; const ZSTD_REPODATA_COMPRESSION_LEVEL: i32 = 19; const CACHE_CONTROL_IMMUTABLE: &str = "public, max-age=31536000, immutable"; const CACHE_CONTROL_REPODATA: &str = "public, max-age=300"; // 5 minutes @@ -1577,6 +1585,11 @@ pub async fn index_with_channel_metadata( precondition_checks: PreconditionChecks, channel_metadata: ChannelMetadata, ) -> anyhow::Result { + let notices_metadata = if channel_metadata.notices.is_some() { + Some(RepodataFileMetadata::new(&op, CHANNEL_NOTICES, precondition_checks).await?) + } else { + None + }; let entries = op.list_with("").await?; // If requested `target_platform` subdir does not exist, we create it. @@ -1681,9 +1694,47 @@ pub async fn index_with_channel_metadata( } } } + + // Publish notices only after all repodata updates succeeded, so a failed + // indexing operation cannot partially update channel-level messaging. + if let (Some(notices), Some(metadata)) = (&channel_metadata.notices, notices_metadata.as_ref()) + { + write_channel_notices_with_metadata(&op, notices, metadata).await?; + } + Ok(stats) } +/// Write CEP-6 channel notices to the channel root. +pub async fn write_channel_notices(op: &Operator, notices: &[ChannelNotice]) -> anyhow::Result<()> { + let metadata = + RepodataFileMetadata::new(op, CHANNEL_NOTICES, PreconditionChecks::Disabled).await?; + write_channel_notices_with_metadata(op, notices, &metadata).await +} + +async fn write_channel_notices_with_metadata( + op: &Operator, + notices: &[ChannelNotice], + metadata: &RepodataFileMetadata, +) -> anyhow::Result<()> { + let bytes = serde_json::to_vec_pretty(&ChannelNotices { + notices: notices.to_vec(), + })?; + let mut writer = op + .write_with(CHANNEL_NOTICES, bytes) + .content_type("application/json") + .cache_control(CACHE_CONTROL_REPODATA); + if metadata.precondition_checks.is_enabled() { + if let Some(etag) = &metadata.etag { + writer = writer.if_match(etag); + } else if !metadata.file_existed { + writer = writer.if_not_exists(true); + } + } + writer.await?; + Ok(()) +} + /// Ensures that a channel has a valid `noarch/repodata.json` file. /// /// If `noarch/repodata.json` doesn't exist, creates an empty one. diff --git a/crates/rattler_index/tests/integration/basic_indexing.rs b/crates/rattler_index/tests/integration/basic_indexing.rs index c2062b6b0a..20f4fcc7a5 100644 --- a/crates/rattler_index/tests/integration/basic_indexing.rs +++ b/crates/rattler_index/tests/integration/basic_indexing.rs @@ -5,7 +5,8 @@ use std::{ }; use rattler_conda_types::{ - ChannelRelations, Platform, ShardedRepodata, compression_level::CompressionLevel, + ChannelNotice, ChannelNoticeLevel, ChannelRelations, Platform, ShardedRepodata, + compression_level::CompressionLevel, }; use rattler_index::{ ChannelMetadata, IndexFsConfig, PackageRevisionAssignment, RepodataRevision, @@ -388,6 +389,14 @@ async fn test_index_writes_channel_metadata() { base: Some("../conda-forge".to_string()), overrides: Some("../fallback".to_string()), }), + notices: Some(vec![ChannelNotice { + id: "security-1".to_string(), + message: "Please update demo".to_string(), + level: ChannelNoticeLevel::Critical, + created_at: None, + expires_at: None, + interval: Some(24), + }]), }; index_fs_with_channel_metadata( @@ -458,6 +467,12 @@ async fn test_index_writes_channel_metadata() { shard_index.info.repodata_revisions[&RepodataRevision::V3].n_packages, Some(0) ); + + let notices_json: Value = + serde_json::from_reader(File::open(temp_dir.path().join("notices.json")).unwrap()).unwrap(); + assert_eq!(notices_json["notices"][0]["id"], "security-1"); + assert_eq!(notices_json["notices"][0]["level"], "critical"); + assert_eq!(notices_json["notices"][0]["interval"], 24); } /// Regression test: sharded repodata must be reproducible. diff --git a/crates/rattler_repodata_gateway/src/gateway/builder.rs b/crates/rattler_repodata_gateway/src/gateway/builder.rs index fb3518915b..aab5b88075 100644 --- a/crates/rattler_repodata_gateway/src/gateway/builder.rs +++ b/crates/rattler_repodata_gateway/src/gateway/builder.rs @@ -193,6 +193,8 @@ impl GatewayBuilder { subdirs: CoalescedMap::new(), client, channel_config: self.channel_config, + notices: dashmap::DashMap::new(), + notice_fetch_locks: dashmap::DashMap::new(), #[cfg(not(target_arch = "wasm32"))] cache, #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/rattler_repodata_gateway/src/gateway/channel_notices.rs b/crates/rattler_repodata_gateway/src/gateway/channel_notices.rs new file mode 100644 index 0000000000..7610e27f56 --- /dev/null +++ b/crates/rattler_repodata_gateway/src/gateway/channel_notices.rs @@ -0,0 +1,300 @@ +//! Fetching and caching of CEP-6 channel notices. + +use std::{collections::HashSet, sync::Arc, time::Duration}; + +#[cfg(not(target_arch = "wasm32"))] +use std::time::Instant; +#[cfg(target_arch = "wasm32")] +use wasmtimer::std::Instant; + +use futures::{TryStreamExt, future::OptionFuture}; +use rattler_conda_types::{Channel, ChannelNotice, ChannelUrl}; +use rattler_redaction::Redact; +use reqwest::StatusCode; +use serde::Deserialize; + +use crate::{Reporter, reporter::ResponseReporterExt}; + +use super::GatewayInner; + +const NOTICES_FILENAME: &str = "notices.json"; +const MAX_NOTICES_SIZE: usize = 1024 * 1024; +const EMPTY_NOTICES_TTL: Duration = Duration::from_secs(24 * 60 * 60); +const FAILED_NOTICES_TTL: Duration = Duration::from_secs(5 * 60); + +/// A channel notice together with the channel that published it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChannelNoticeResult { + /// The channel that published the notice. + pub channel: ChannelUrl, + /// The published CEP-6 notice. + pub notice: ChannelNotice, +} + +pub(super) struct CachedChannelNotices { + notices: Arc>, + refresh_at: Instant, +} + +impl CachedChannelNotices { + fn is_fresh(&self) -> bool { + Instant::now() < self.refresh_at + } +} + +struct NoticeFetch { + notices: Vec, + ttl: Duration, +} + +impl NoticeFetch { + fn failed() -> Self { + Self { + notices: Vec::new(), + ttl: FAILED_NOTICES_TTL, + } + } + + fn empty() -> Self { + Self { + notices: Vec::new(), + ttl: EMPTY_NOTICES_TTL, + } + } + + fn from_notices(mut notices: Vec) -> Self { + let now = jiff::Timestamp::now(); + let had_expired_notice = notices + .iter() + .any(|notice| notice.expires_at.is_some_and(|expires| expires <= now)); + notices.retain(|notice| notice.expires_at.is_none_or(|expires| expires > now)); + + let ttl = notices + .iter() + .filter_map(|notice| notice.expires_at) + .filter_map(|expires| Duration::try_from(expires.duration_since(now)).ok()) + .min() + .unwrap_or(if had_expired_notice { + FAILED_NOTICES_TTL + } else { + EMPTY_NOTICES_TTL + }); + + Self { notices, ttl } + } +} + +impl GatewayInner { + /// Fetch notices for the channels. Notice failures are intentionally + /// non-fatal: a missing or malformed `notices.json` must never prevent + /// repodata from being used. + pub(super) async fn get_channel_notices<'a>( + &self, + channels: impl IntoIterator, + reporter: Option<&dyn Reporter>, + ) -> Vec { + let mut seen = HashSet::new(); + let channels: Vec<_> = channels + .into_iter() + .filter(|channel| seen.insert(channel.base_url.clone())) + .cloned() + .collect(); + + let results = futures::future::join_all(channels.iter().map(|channel| async move { + let notices = self.get_one_channel_notices(channel, reporter).await; + (channel.base_url.clone(), notices) + })) + .await; + + results + .into_iter() + .flat_map(|(channel, notices)| { + notices + .as_ref() + .clone() + .into_iter() + .map(move |notice| ChannelNoticeResult { + channel: channel.clone(), + notice, + }) + }) + .collect() + } + + async fn get_one_channel_notices( + &self, + channel: &Channel, + reporter: Option<&dyn Reporter>, + ) -> Arc> { + if let Some(cached) = self.notices.get(&channel.base_url) + && cached.is_fresh() + { + return cached.notices.clone(); + } + + // Notice requests are much smaller than repodata requests but still + // need to be coalesced when multiple queries start simultaneously. + let lock = self + .notice_fetch_locks + .entry(channel.base_url.clone()) + .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))) + .clone(); + let _guard = lock.lock().await; + + // Another waiter may have refreshed the entry while this task waited. + if let Some(cached) = self.notices.get(&channel.base_url) + && cached.is_fresh() + { + return cached.notices.clone(); + } + + let fetched = self.fetch_channel_notices(channel, reporter).await; + let notices = Arc::new(fetched.notices); + let now = Instant::now(); + self.notices.insert( + channel.base_url.clone(), + Arc::new(CachedChannelNotices { + notices: notices.clone(), + refresh_at: now + .checked_add(fetched.ttl) + .unwrap_or_else(|| now + EMPTY_NOTICES_TTL), + }), + ); + notices + } + + pub(super) fn report_channel_notices( + reporter: Option<&dyn Reporter>, + notices: &[ChannelNoticeResult], + ) { + if let Some(reporter) = reporter { + for notice in notices { + reporter.on_channel_notice(notice); + } + } + } + + async fn fetch_channel_notices( + &self, + channel: &Channel, + reporter: Option<&dyn Reporter>, + ) -> NoticeFetch { + let Ok(url) = channel.base_url.url().join(NOTICES_FILENAME) else { + return NoticeFetch::failed(); + }; + + #[cfg(not(target_arch = "wasm32"))] + if url.scheme() == "file" { + let Ok(path) = url.to_file_path() else { + return NoticeFetch::failed(); + }; + return match fs_err::read(path) { + Ok(bytes) if bytes.len() <= MAX_NOTICES_SIZE => parse_notices(&bytes), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => NoticeFetch::empty(), + Ok(_) | Err(_) => NoticeFetch::failed(), + }; + } + + if !matches!(url.scheme(), "http" | "https") { + return NoticeFetch::empty(); + } + + // Notice downloads share the gateway's request budget with repodata, + // package, and run-export downloads. + let _request_permit = OptionFuture::from( + self.concurrent_requests_semaphore + .clone() + .map(tokio::sync::Semaphore::acquire_owned), + ) + .await + .transpose() + .expect("gateway request semaphore was closed"); + + let request = self.client.client().get(url.clone()); + #[cfg(not(target_arch = "wasm32"))] + let response = request.timeout(Duration::from_secs(5)).send().await; + #[cfg(target_arch = "wasm32")] + let response = match wasmtimer::tokio::timeout(Duration::from_secs(5), request.send()).await + { + Ok(response) => response, + Err(_) => { + tracing::debug!(url = %url.clone().redact(), "timed out fetching channel notices"); + return NoticeFetch::failed(); + } + }; + let response = match response { + Ok(response) if response.status() == StatusCode::NOT_FOUND => { + return NoticeFetch::empty(); + } + Ok(response) => match response.error_for_status() { + Ok(response) => response, + Err(err) => { + tracing::debug!(url = %url.clone().redact(), "failed to fetch channel notices: {err}"); + return NoticeFetch::failed(); + } + }, + Err(err) => { + tracing::debug!(url = %url.clone().redact(), "failed to fetch channel notices: {err}"); + return NoticeFetch::failed(); + } + }; + + if response + .content_length() + .is_some_and(|length| length > MAX_NOTICES_SIZE as u64) + { + tracing::debug!(url = %url.clone().redact(), "channel notices response is too large"); + return NoticeFetch::failed(); + } + + let download = reporter + .and_then(Reporter::download_reporter) + .map(|download| (download, download.on_download_start(&url))); + let mut stream = std::pin::pin!(response.byte_stream_with_progress(download)); + let mut bytes = Vec::new(); + let result = loop { + match stream.try_next().await { + Ok(Some(chunk)) + if bytes + .len() + .checked_add(chunk.len()) + .is_some_and(|size| size <= MAX_NOTICES_SIZE) => + { + bytes.extend_from_slice(&chunk); + } + Ok(Some(_)) | Err(_) => break NoticeFetch::failed(), + Ok(None) => break parse_notices(&bytes), + } + }; + if let Some((download, index)) = download { + download.on_download_complete(&url, index); + } + result + } +} + +fn parse_notices(bytes: &[u8]) -> NoticeFetch { + #[derive(Deserialize)] + struct RawNotices { + #[serde(default)] + notices: Vec, + } + + let Ok(raw) = serde_json::from_slice::(bytes) else { + return NoticeFetch::failed(); + }; + + // A malformed notice should not hide unrelated valid notices from the + // same channel. + let had_entries = !raw.notices.is_empty(); + let notices: Vec<_> = raw + .notices + .into_iter() + .filter_map(|notice| serde_json::from_value(notice).ok()) + .collect(); + if had_entries && notices.is_empty() { + NoticeFetch::failed() + } else { + NoticeFetch::from_notices(notices) + } +} diff --git a/crates/rattler_repodata_gateway/src/gateway/mod.rs b/crates/rattler_repodata_gateway/src/gateway/mod.rs index b6cedec0b9..a6684034f4 100644 --- a/crates/rattler_repodata_gateway/src/gateway/mod.rs +++ b/crates/rattler_repodata_gateway/src/gateway/mod.rs @@ -2,6 +2,7 @@ mod barrier_cell; mod builder; mod channel_config; mod channel_expander; +mod channel_notices; mod channel_relations; #[cfg(not(target_arch = "wasm32"))] mod direct_url_query; @@ -27,6 +28,8 @@ pub use barrier_cell::BarrierCell; pub use builder::{GatewayBuilder, MaxConcurrency}; pub use channel_config::{ChannelConfig, SourceConfig}; pub use channel_expander::{ChannelRelationsMode, ChannelRelationsWarning}; +use channel_notices::CachedChannelNotices; +pub use channel_notices::ChannelNoticeResult; pub use channel_relations::DEFAULT_CHANNEL_RELATIONS_MAX_DEPTH; use coalesced_map::{CoalescedGetError, CoalescedMap}; pub use error::GatewayError; @@ -189,6 +192,18 @@ impl Gateway { ) } + /// Return the cached or freshly fetched CEP-6 notices for the given + /// channels. + /// + /// Fetch and parse failures are non-fatal and are retried after a short + /// cache interval. + pub async fn channel_notices<'a>( + &self, + channels: impl IntoIterator, + ) -> Vec { + self.inner.get_channel_notices(channels, None).await + } + /// Returns the [CEP-42] `channel_relations` declared by the given /// `(channel, platform)` subdirectory, or `None` if none were /// declared or the subdirectory doesn't exist. @@ -274,6 +289,8 @@ impl Gateway { self.inner.subdirs.retain(|key, _| { key.0.base_url != channel.base_url || !subdirs.contains(key.1.as_str()) }); + self.inner.notices.remove(&channel.base_url); + self.inner.notice_fetch_locks.remove(&channel.base_url); #[cfg(not(target_arch = "wasm32"))] if mode == CacheClearMode::InMemoryAndDisk { @@ -324,6 +341,13 @@ struct GatewayInner { /// The channel configuration channel_config: ChannelConfig, + /// In-memory notices cache, keyed by channel URL. + notices: dashmap::DashMap>, + + /// Per-channel locks used to coalesce notice refreshes. + notice_fetch_locks: + dashmap::DashMap>>, + /// The directory to store any cache #[cfg(not(target_arch = "wasm32"))] cache: std::path::PathBuf, @@ -651,6 +675,178 @@ mod test { assert!(messages.is_empty()); } + #[tokio::test] + async fn test_channel_notices_are_returned_and_reported() { + #[derive(Default)] + struct NoticeReporter(Mutex>); + + impl Reporter for Arc { + fn download_reporter(&self) -> Option<&dyn DownloadReporter> { + None + } + + fn on_channel_notice(&self, notice: &crate::ChannelNoticeResult) { + self.0.lock().unwrap().push(notice.notice.id.clone()); + } + } + + let tempdir = tempfile::tempdir().unwrap(); + let noarch = tempdir.path().join("noarch"); + fs_err::create_dir_all(&noarch).unwrap(); + fs_err::write(noarch.join("repodata.json"), make_repodata("demo", "1.0")).unwrap(); + fs_err::write( + tempdir.path().join("notices.json"), + r#"{"notices":[ + {"id":"security-1","message":"Update demo","level":"critical","expires_at":"2099-01-01T00:00:00Z"}, + {"id":42,"message":"malformed notice"} + ]}"#, + ) + .unwrap(); + + let channel = Channel::try_from_directory(tempdir.path()).unwrap(); + let reporter = Arc::new(NoticeReporter::default()); + let output = Gateway::new() + .query( + vec![channel.clone()], + vec![Platform::NoArch], + vec![PackageName::from_str("demo").unwrap()], + ) + .channel_notices(true) + .with_reporter(reporter.clone()) + .await + .unwrap(); + + assert_eq!(output.notices.len(), 1); + assert_eq!(output.notices[0].notice.id, "security-1"); + assert_eq!(reporter.0.lock().unwrap().as_slice(), ["security-1"]); + + let output = Gateway::new() + .query( + vec![channel], + vec![Platform::NoArch], + vec![PackageName::from_str("demo").unwrap()], + ) + .await + .unwrap(); + assert!(output.notices.is_empty()); + } + + #[tokio::test] + async fn test_channel_notices_refresh_at_expiration() { + let tempdir = tempfile::tempdir().unwrap(); + let channel = Channel::try_from_directory(tempdir.path()).unwrap(); + let expiry = jiff::Timestamp::now() + jiff::SignedDuration::from_millis(500); + fs_err::write( + tempdir.path().join("notices.json"), + format!(r#"{{"notices":[{{"id":"old","message":"Old","expires_at":"{expiry}"}}]}}"#), + ) + .unwrap(); + + let gateway = Gateway::new(); + assert_eq!( + gateway.channel_notices([&channel]).await[0].notice.id, + "old" + ); + + tokio::time::sleep(std::time::Duration::from_millis(550)).await; + fs_err::write( + tempdir.path().join("notices.json"), + r#"{"notices":[{"id":"new","message":"New","expires_at":"2099-01-01T00:00:00Z"}]}"#, + ) + .unwrap(); + assert_eq!( + gateway.channel_notices([&channel]).await[0].notice.id, + "new" + ); + } + + #[tokio::test] + #[cfg(not(target_arch = "wasm32"))] + async fn test_channel_notice_requests_are_coalesced_and_size_limited() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + let calls = Arc::new(AtomicUsize::new(0)); + let calls_for_route = calls.clone(); + let app = axum::Router::new().route( + "/notices.json", + axum::routing::get(move || { + let calls = calls_for_route.clone(); + async move { + calls.fetch_add(1, Ordering::SeqCst); + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + r#"{"notices":[{"id":"one","message":"One","expires_at":"2099-01-01T00:00:00Z"}]}"# + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let channel = Channel::from_url(Url::parse(&format!("http://{address}/")).unwrap()); + let gateway = Gateway::new(); + + let results = futures::future::join_all( + (0..8).map(|_| gateway.channel_notices(std::iter::once(&channel))), + ) + .await; + assert!(results.iter().all(|notices| notices.len() == 1)); + assert_eq!(calls.load(Ordering::SeqCst), 1); + server.abort(); + + let app = axum::Router::new().route( + "/notices.json", + axum::routing::get(|| async { "x".repeat(1024 * 1024 + 1) }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let channel = Channel::from_url(Url::parse(&format!("http://{address}/")).unwrap()); + let gateway = Gateway::new(); + assert!(gateway.channel_notices([&channel]).await.is_empty()); + server.abort(); + } + + #[tokio::test] + #[cfg(not(target_arch = "wasm32"))] + async fn test_channel_notices_respect_max_concurrent_requests() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + let active = Arc::new(AtomicUsize::new(0)); + let maximum = Arc::new(AtomicUsize::new(0)); + let handler = { + let active = active.clone(); + let maximum = maximum.clone(); + move || { + let active = active.clone(); + let maximum = maximum.clone(); + async move { + let current = active.fetch_add(1, Ordering::SeqCst) + 1; + maximum.fetch_max(current, Ordering::SeqCst); + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + active.fetch_sub(1, Ordering::SeqCst); + r#"{"notices":[]}"# + } + } + }; + let app = axum::Router::new() + .route("/a/notices.json", axum::routing::get(handler.clone())) + .route("/b/notices.json", axum::routing::get(handler)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let root = Url::parse(&format!("http://{address}/")).unwrap(); + let channels = [ + Channel::from_url(root.join("a/").unwrap()), + Channel::from_url(root.join("b/").unwrap()), + ]; + let gateway = Gateway::builder() + .with_max_concurrent_requests(1_usize) + .finish(); + + gateway.channel_notices(channels.iter()).await; + assert_eq!(maximum.load(Ordering::SeqCst), 1); + server.abort(); + } + #[tokio::test] #[cfg(not(target_arch = "wasm32"))] async fn test_direct_url_spec_from_gateway() { @@ -2870,6 +3066,46 @@ mod test { assert!(!results[1].is_empty(), "bioconda bucket non-empty"); } + /// Notices include channels discovered through CEP-42 relations. + #[tokio::test] + async fn test_cep42_discovered_channel_notices_are_returned() { + let dir = tempfile::tempdir().unwrap(); + let cf_root = dir.path().join("conda-forge"); + let bc_root = dir.path().join("bioconda"); + write_test_subdir(&cf_root, "shared", "1.0.0", None, None); + write_test_subdir(&bc_root, "shared", "2.0.0", Some("../conda-forge"), None); + std::fs::write( + cf_root.join("notices.json"), + r#"{"notices":[{"id":"base","message":"Base notice"}]}"#, + ) + .unwrap(); + std::fs::write( + bc_root.join("notices.json"), + r#"{"notices":[{"id":"declaring","message":"Declaring notice"}]}"#, + ) + .unwrap(); + + let server = SimpleChannelServer::new(dir.path()).await; + let bioconda = Channel::from_url(server.url().join("bioconda/").unwrap()); + let output = Gateway::new() + .query( + [bioconda], + [Platform::Linux64], + [MatchSpec::from_str("shared", Strict).unwrap()], + ) + .channel_notices(true) + .execute() + .await + .unwrap(); + + let ids: std::collections::HashSet<_> = output + .notices + .iter() + .map(|notice| notice.notice.id.as_str()) + .collect(); + assert_eq!(ids, std::collections::HashSet::from(["base", "declaring"])); + } + /// `Disabled` ignores declared relations. #[tokio::test] async fn test_cep42_disabled_mode_ignores_relations() { diff --git a/crates/rattler_repodata_gateway/src/gateway/query.rs b/crates/rattler_repodata_gateway/src/gateway/query.rs index e22af81dc2..e6344c54b5 100644 --- a/crates/rattler_repodata_gateway/src/gateway/query.rs +++ b/crates/rattler_repodata_gateway/src/gateway/query.rs @@ -12,7 +12,7 @@ use rattler_conda_types::{ use url::Url; use super::{ - BarrierCell, GatewayError, GatewayInner, GatewayWarning, RepoData, + BarrierCell, ChannelNoticeResult, GatewayError, GatewayInner, GatewayWarning, RepoData, channel_expander::{ChannelExpander, ChannelRelationsMode, ChannelRelationsWarning}, channel_relations::DEFAULT_CHANNEL_RELATIONS_MAX_DEPTH, source::{CustomSourceClient, Source}, @@ -31,6 +31,9 @@ pub struct RepoDataQueryOutput { /// next to the channel that introduced them; caller-supplied /// sources keep their positions. pub repodata: Vec, + /// CEP-6 notices published by the queried channels. Also streamed to + /// [`Reporter::on_channel_notice`]. + pub notices: Vec, /// Non-fatal warnings encountered during the query. Also streamed /// to [`Reporter::on_gateway_warning`] as they are recorded. pub warnings: Vec, @@ -71,6 +74,9 @@ impl<'a> IntoIterator for &'a RepoDataQueryOutput { pub struct NamesQueryOutput { /// Distinct package names contributed by all queried subdirs. pub names: Vec, + /// CEP-6 notices published by the queried channels. Also streamed to + /// [`Reporter::on_channel_notice`]. + pub notices: Vec, /// Non-fatal warnings encountered during the query. Also streamed /// to [`Reporter::on_gateway_warning`] as they are recorded. pub warnings: Vec, @@ -133,6 +139,9 @@ pub struct RepoDataQuery { /// The reporter to use by the query. reporter: Option>, + /// Whether to fetch CEP-6 notices for this query. + channel_notices: bool, + /// CEP-42 channel relations handling mode. channel_relations_mode: ChannelRelationsMode, @@ -228,6 +237,7 @@ impl RepoDataQuery { recursive: false, reporter: None, + channel_notices: false, channel_relations_mode: ChannelRelationsMode::default(), channel_relations_max_depth: DEFAULT_CHANNEL_RELATIONS_MAX_DEPTH, } @@ -254,6 +264,15 @@ impl RepoDataQuery { } } + /// Enable or disable fetching CEP-6 channel notices. Disabled by default. + #[must_use] + pub fn channel_notices(self, enabled: bool) -> Self { + Self { + channel_notices: enabled, + ..self + } + } + /// Sets whether the query should be recursive. If recursive is set to true /// the query will also recursively fetch the dependencies of the packages /// that match the root specs. @@ -332,6 +351,63 @@ struct QueryExecutor { /// CEP-42 expansion state. expander: ChannelExpander, + + /// CEP-6 notice collection state. + notices: NoticeCollector, +} + +/// Collects CEP-6 notices while a query runs. Fetches are queued as channels +/// enter the query — user-supplied and CEP-42-discovered alike — and their +/// futures are driven concurrently with the query's subdir and record +/// fetches. Notice failures are non-fatal by construction: +/// [`GatewayInner::get_channel_notices`] never errors. +struct NoticeCollector { + /// Whether notice fetching is enabled for the query. + enabled: bool, + /// Channels for which a fetch was already queued; guards against + /// queuing one fetch per platform. + seen: HashSet, + /// In-flight notice fetches. + pending: FuturesUnordered>>, + /// Notices collected so far. + collected: Vec, +} + +impl NoticeCollector { + fn new(enabled: bool) -> Self { + Self { + enabled, + seen: HashSet::new(), + pending: FuturesUnordered::new(), + collected: Vec::new(), + } + } + + /// Queue a notice fetch for `channel` unless notices are disabled or a + /// fetch for the channel was already queued. + fn queue( + &mut self, + gateway: &Arc, + url: &ChannelUrl, + channel: Arc, + reporter: Option>, + ) { + if !self.enabled || !self.seen.insert(url.clone()) { + return; + } + let gateway = gateway.clone(); + self.pending.push(box_future(async move { + gateway + .get_channel_notices(std::iter::once(channel.as_ref()), reporter.as_deref()) + .await + })); + } + + /// Record a completed batch, streaming it to the reporter. + fn collect(&mut self, reporter: Option<&dyn Reporter>, batch: Vec) { + GatewayInner::report_channel_notices(reporter, &batch); + self.collected.extend(batch); + } } impl QueryExecutor { @@ -345,6 +421,7 @@ impl QueryExecutor { specs, recursive, reporter, + channel_notices, channel_relations_mode, channel_relations_max_depth, } = query; @@ -419,6 +496,7 @@ impl QueryExecutor { let total_handles = sources_with_idx.len() * platforms.len(); let mut subdir_handles = Vec::with_capacity(total_handles); let pending_subdirs = FuturesUnordered::new(); + let mut notices = NoticeCollector::new(channel_notices); for (caller_idx, source) in sources_with_idx { for &platform in &platforms { @@ -428,6 +506,7 @@ impl QueryExecutor { let (kind, pending) = match source_clone { Source::Channel(channel) => { let (url, channel) = expander.register_user_channel(channel); + notices.queue(&gateway, &url, channel.clone(), reporter.clone()); let kind = SubdirKind::Channel { url: url.clone(), platform, @@ -486,6 +565,7 @@ impl QueryExecutor { pending_subdirs, pending_records: FuturesUnordered::new(), expander, + notices, }) } @@ -853,6 +933,11 @@ impl QueryExecutor { self.accumulate_records(target, pkg.records, &request); } + // Handle any CEP-6 notices that were fetched + batch = self.notices.pending.select_next_some() => { + self.notices.collect(self.reporter.as_deref(), batch); + } + // All futures have been handled, all subdirectories have been loaded and all // repodata records have been fetched complete => { @@ -890,6 +975,8 @@ impl QueryExecutor { channel: Arc, platform: Platform, ) { + self.notices + .queue(&self.gateway, &url, channel.clone(), self.reporter.clone()); let barrier = Arc::new(BarrierCell::new()); let policy = if self.expander.strict() { @@ -1008,6 +1095,7 @@ impl QueryExecutor { repodata.extend(handles.into_iter().map(|h| h.data)); Ok(RepoDataQueryOutput { repodata, + notices: self.notices.collected, warnings: self .expander .take_warnings() @@ -1197,6 +1285,9 @@ pub struct NamesQuery { /// The reporter to use by the query. reporter: Option>, + /// Whether to fetch CEP-6 notices for this query. + channel_notices: bool, + /// CEP-42 channel relations handling mode. channel_relations_mode: ChannelRelationsMode, @@ -1218,11 +1309,21 @@ impl NamesQuery { platforms, reporter: None, + channel_notices: false, channel_relations_mode: ChannelRelationsMode::default(), channel_relations_max_depth: DEFAULT_CHANNEL_RELATIONS_MAX_DEPTH, } } + /// Enable or disable fetching CEP-6 channel notices. Disabled by default. + #[must_use] + pub fn channel_notices(self, enabled: bool) -> Self { + Self { + channel_notices: enabled, + ..self + } + } + /// Sets the reporter to use for this query. /// /// The reporter is notified of important evens during the execution of the @@ -1264,10 +1365,17 @@ impl NamesQuery { self.platforms.clone(), self.reporter.clone(), ); + let mut notices = NoticeCollector::new(self.channel_notices); let mut pending: FuturesUnordered> = FuturesUnordered::new(); for channel in self.channels { let (url, channel_arc) = expander.register_user_channel(channel); + notices.queue( + &self.gateway, + &url, + channel_arc.clone(), + self.reporter.clone(), + ); for &platform in &self.platforms { pending.push(spawn_names_fetch( self.gateway.clone(), @@ -1288,23 +1396,36 @@ impl NamesQuery { FetchErrorPolicy::SwallowAsWarning }; - while let Some(result) = pending.next().await { - let (url, platform, subdir, warning) = result?; - if let Some(w) = warning { - expander.push_warning(w); - } - if let Some(subdir_names) = subdir.package_names() { - names.extend(subdir_names); - } - for (new_url, new_channel, new_plat) in expander.observe(&url, platform, &subdir)? { - pending.push(spawn_names_fetch( - self.gateway.clone(), - new_channel, - new_plat, - new_url, - self.reporter.clone(), - policy, - )); + loop { + select_biased! { + result = pending.select_next_some() => { + let (url, platform, subdir, warning) = result?; + if let Some(w) = warning { + expander.push_warning(w); + } + if let Some(subdir_names) = subdir.package_names() { + names.extend(subdir_names); + } + for (new_url, new_channel, new_plat) in expander.observe(&url, platform, &subdir)? { + notices.queue(&self.gateway, &new_url, new_channel.clone(), self.reporter.clone()); + pending.push(spawn_names_fetch( + self.gateway.clone(), + new_channel, + new_plat, + new_url, + self.reporter.clone(), + policy, + )); + } + } + + batch = notices.pending.select_next_some() => { + notices.collect(self.reporter.as_deref(), batch); + } + + complete => { + break; + } } } @@ -1320,6 +1441,7 @@ impl NamesQuery { .collect::, _>>()?; Ok(NamesQueryOutput { names, + notices: notices.collected, warnings: expander .take_warnings() .into_iter() diff --git a/crates/rattler_repodata_gateway/src/lib.rs b/crates/rattler_repodata_gateway/src/lib.rs index 40cbae4893..5aa6b7c54b 100644 --- a/crates/rattler_repodata_gateway/src/lib.rs +++ b/crates/rattler_repodata_gateway/src/lib.rs @@ -75,10 +75,10 @@ mod gateway; #[cfg(feature = "gateway")] pub use gateway::{ - CacheClearMode, ChannelConfig, ChannelRelationsMode, ChannelRelationsWarning, - DEFAULT_CHANNEL_RELATIONS_MAX_DEPTH, Gateway, GatewayBuilder, GatewayError, GatewayWarning, - MaxConcurrency, NamesQuery, NamesQueryOutput, RepoData, RepoDataQuery, RepoDataQueryOutput, - RepoDataSource, Source, SourceConfig, SubdirSelection, + CacheClearMode, ChannelConfig, ChannelNoticeResult, ChannelRelationsMode, + ChannelRelationsWarning, DEFAULT_CHANNEL_RELATIONS_MAX_DEPTH, Gateway, GatewayBuilder, + GatewayError, GatewayWarning, MaxConcurrency, NamesQuery, NamesQueryOutput, RepoData, + RepoDataQuery, RepoDataQueryOutput, RepoDataSource, Source, SourceConfig, SubdirSelection, }; #[cfg(feature = "indicatif")] pub use gateway::{IndicatifReporter, IndicatifReporterBuilder}; diff --git a/crates/rattler_repodata_gateway/src/reporter.rs b/crates/rattler_repodata_gateway/src/reporter.rs index 30dc385d08..2e44f9a980 100644 --- a/crates/rattler_repodata_gateway/src/reporter.rs +++ b/crates/rattler_repodata_gateway/src/reporter.rs @@ -96,6 +96,11 @@ pub trait Reporter: Send + Sync { #[cfg(feature = "sparse")] fn on_unsupported_repodata_revision(&self, _message: &UnsupportedRepodataRevision) {} + /// Called for every CEP-6 channel notice found during a query. The notice + /// is also collected on the query output. + #[cfg(feature = "gateway")] + fn on_channel_notice(&self, _notice: &crate::ChannelNoticeResult) {} + /// Called once per unique non-fatal warning as the query records /// it. The warning is also collected on the query output. #[cfg(feature = "gateway")] diff --git a/js-rattler/crate/gateway.rs b/js-rattler/crate/gateway.rs index 60c2864ff1..f94fedc0e5 100644 --- a/js-rattler/crate/gateway.rs +++ b/js-rattler/crate/gateway.rs @@ -1,12 +1,12 @@ use std::{collections::HashMap, path::PathBuf, str::FromStr}; -use rattler_conda_types::{Channel, Platform}; +use rattler_conda_types::{Channel, ChannelNoticeLevel, Platform}; use rattler_repodata_gateway::{ ChannelConfig, Gateway, GatewayWarning, SourceConfig, fetch::CacheAction, }; use reqwest::Client; use reqwest_middleware::ClientWithMiddleware; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use url::Url; use wasm_bindgen::prelude::*; @@ -29,6 +29,42 @@ pub(crate) fn emit_gateway_warnings(warnings: Vec) { use crate::JsResult; +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct Notice { + channel: String, + id: String, + message: String, + level: &'static str, + created_at: Option, + expires_at: Option, + interval: Option, +} + +impl From for Notice { + fn from(result: rattler_repodata_gateway::ChannelNoticeResult) -> Self { + Self { + channel: result.channel.to_string(), + id: result.notice.id, + message: result.notice.message, + level: match result.notice.level { + ChannelNoticeLevel::Info => "info", + ChannelNoticeLevel::Warning => "warning", + ChannelNoticeLevel::Critical => "critical", + }, + created_at: result + .notice + .created_at + .map(|timestamp| timestamp.to_string()), + expires_at: result + .notice + .expires_at + .map(|timestamp| timestamp.to_string()), + interval: result.notice.interval, + } + } +} + #[wasm_bindgen] #[repr(transparent)] #[derive(Clone)] @@ -146,11 +182,29 @@ impl JsGateway { }) } + pub async fn channel_notices(&self, channels: Vec) -> Result { + let channel_config = + rattler_conda_types::ChannelConfig::default_with_root_dir(PathBuf::from("")); + let channels = channels + .into_iter() + .map(|channel| Channel::from_str(&channel, &channel_config)) + .collect::, _>>()?; + let notices: Vec<_> = self + .inner + .channel_notices(channels.iter()) + .await + .into_iter() + .map(Notice::from) + .collect(); + Ok(serde_wasm_bindgen::to_value(¬ices)?) + } + pub async fn names( &self, channels: Vec, platforms: Vec, - ) -> Result, JsError> { + channel_notices: bool, + ) -> Result { // TODO: Dont hardcode let channel_config = rattler_conda_types::ChannelConfig::default_with_root_dir(PathBuf::from("")); @@ -164,12 +218,27 @@ impl JsGateway { .map(|p| Platform::from_str(&p)) .collect::, _>>()?; - let output = self.inner.names(channels, platforms).execute().await?; + let output = self + .inner + .names(channels, platforms) + .channel_notices(channel_notices) + .execute() + .await?; emit_gateway_warnings(output.warnings); - Ok(output - .names - .into_iter() - .map(|name| name.as_source().to_string()) - .collect()) + + #[derive(Serialize)] + struct NamesOutput { + names: Vec, + notices: Vec, + } + + Ok(serde_wasm_bindgen::to_value(&NamesOutput { + names: output + .names + .into_iter() + .map(|name| name.as_source().to_string()) + .collect(), + notices: output.notices.into_iter().map(Notice::from).collect(), + })?) } } diff --git a/js-rattler/src/Gateway.ts b/js-rattler/src/Gateway.ts index ea67f5fadd..3928286c79 100644 --- a/js-rattler/src/Gateway.ts +++ b/js-rattler/src/Gateway.ts @@ -35,6 +35,28 @@ export type GatewayChannelConfig = { }; }; +export type ChannelNotice = { + channel: string; + id: string; + message: string; + level: "info" | "warning" | "critical"; + createdAt: string | null; + expiresAt: string | null; + interval: number | null; +}; + +export type GatewayQueryOptions = { + /** Whether CEP-6 channel notices are fetched. Defaults to `false`. */ + channelNotices?: boolean; +}; + +export type GatewayNamesResult = NormalizedPackageName[] & { + /** The package names. This aliases the result array for compatibility. */ + names: NormalizedPackageName[]; + /** CEP-6 notices published by queried and CEP-42-discovered channels. */ + notices: ChannelNotice[]; +}; + export type GatewayOptions = { /** * The maximum number of concurrent requests the gateway can execute. By @@ -73,20 +95,47 @@ export class Gateway { this.native = new JsGateway(options); } + /** Fetches CEP-6 notices for the given channels. */ + public async channelNotices(channels: string[]): Promise { + return (await this.native.channel_notices(channels)) as ChannelNotice[]; + } + /** * Returns the names of the package that are available for the given * channels and platforms. * * @param channels - The channels to query * @param platforms - The platforms to query + * @param options - Per-query options */ public async names( channels: string[], platforms: Platform[], - ): Promise { - return (await this.native.names( + options?: GatewayQueryOptions, + ): Promise { + const nativeNames = ( + this.native.names as unknown as ( + channels: string[], + platforms: Platform[], + channelNotices: boolean, + ) => Promise + ).bind(this.native); + const rawOutput = await nativeNames( channels, platforms, - )) as NormalizedPackageName[]; + options?.channelNotices ?? false, + ); + // Accept the old native array shape as well, so the TypeScript wrapper + // remains compatible when it is loaded with an older WASM artifact. + const output = Array.isArray(rawOutput) + ? { names: rawOutput as NormalizedPackageName[], notices: [] } + : (rawOutput as { + names: NormalizedPackageName[]; + notices: ChannelNotice[]; + }); + const result = output.names as GatewayNamesResult; + result.names = result; + result.notices = output.notices; + return result; } } diff --git a/py-rattler/rattler/__init__.py b/py-rattler/rattler/__init__.py index 13a0efdde0..148a59539f 100644 --- a/py-rattler/rattler/__init__.py +++ b/py-rattler/rattler/__init__.py @@ -2,6 +2,7 @@ from rattler.match_spec import MatchSpec, NamelessMatchSpec from rattler.repo_data import ( ChannelInfo, + ChannelNotice, ChannelRelations, PackageRecord, RepoData, @@ -10,6 +11,8 @@ PatchInstructions, SparseRepoData, Gateway, + GatewayNamesResult, + GatewayQueryResult, SourceConfig, PackageFormatSelection, RepoDataSource, @@ -59,6 +62,7 @@ "MatchSpec", "NamelessMatchSpec", "ChannelInfo", + "ChannelNotice", "ChannelRelations", "PackageRecord", "Channel", @@ -106,6 +110,8 @@ "FileMode", "IndexJson", "Gateway", + "GatewayNamesResult", + "GatewayQueryResult", "SourceConfig", "RepoDataSource", "NoArchType", diff --git a/py-rattler/rattler/repo_data/__init__.py b/py-rattler/rattler/repo_data/__init__.py index 4ba738e0fc..f03084d2d0 100644 --- a/py-rattler/rattler/repo_data/__init__.py +++ b/py-rattler/rattler/repo_data/__init__.py @@ -4,12 +4,19 @@ from rattler.repo_data.record import RepoDataRecord from rattler.repo_data.whl_package_record import WhlPackageRecord from rattler.repo_data.sparse import SparseRepoData, PackageFormatSelection -from rattler.repo_data.gateway import Gateway, SourceConfig +from rattler.repo_data.gateway import ( + ChannelNotice, + Gateway, + GatewayNamesResult, + GatewayQueryResult, + SourceConfig, +) from rattler.repo_data.source import RepoDataSource __all__ = [ "ChannelInfo", "ChannelRelations", + "ChannelNotice", "PackageRecord", "RepoData", "PatchInstructions", @@ -17,6 +24,8 @@ "WhlPackageRecord", "SparseRepoData", "Gateway", + "GatewayNamesResult", + "GatewayQueryResult", "SourceConfig", "PackageFormatSelection", "RepoDataSource", diff --git a/py-rattler/rattler/repo_data/gateway.py b/py-rattler/rattler/repo_data/gateway.py index baf5ae654a..2986ef2759 100644 --- a/py-rattler/rattler/repo_data/gateway.py +++ b/py-rattler/rattler/repo_data/gateway.py @@ -11,7 +11,7 @@ from rattler.networking.fetch_repo_data import CacheAction from rattler.package.package_name import PackageName from rattler.platform.platform import Platform, PlatformLiteral -from rattler.rattler import PyGateway, PyMatchSpec, PySourceConfig +from rattler.rattler import PyChannelNotice, PyGateway, PyMatchSpec, PySourceConfig from rattler.repo_data.record import RepoDataRecord from rattler.repo_data.repo_data import ChannelRelations @@ -127,6 +127,55 @@ def _into_py(self) -> PySourceConfig: ) +@dataclass(frozen=True) +class ChannelNotice: + """A CEP-6 notice published by a conda channel.""" + + channel: str + id: str + message: str + level: Literal["info", "warning", "critical"] + created_at: Optional[str] + expires_at: Optional[str] + interval: Optional[int] + + @classmethod + def _from_py(cls, notice: PyChannelNotice) -> ChannelNotice: + return cls( + channel=notice.channel, + id=notice.id, + message=notice.message, + level=notice.level, + created_at=notice.created_at, + expires_at=notice.expires_at, + interval=notice.interval, + ) + + +class GatewayQueryResult(list[List[RepoDataRecord]]): + """Repodata and CEP-6 notices returned by :meth:`Gateway.query`. + + This remains a list for compatibility with earlier releases. + """ + + def __init__(self, repodata: List[List[RepoDataRecord]], notices: List[ChannelNotice]) -> None: + super().__init__(repodata) + self.repodata = self + self.notices = notices + + +class GatewayNamesResult(list[PackageName]): + """Package names and CEP-6 notices returned by :meth:`Gateway.names`. + + This remains a list for compatibility with earlier releases. + """ + + def __init__(self, names: List[PackageName], notices: List[ChannelNotice]) -> None: + super().__init__(names) + self.names = self + self.notices = notices + + class Gateway: """ The gateway manages all the quircks and complex bits of efficiently acquiring @@ -194,7 +243,8 @@ async def query( recursive: bool = True, channel_relations: Optional[ChannelRelationsMode] = None, channel_relations_max_depth: Optional[int] = None, - ) -> List[List[RepoDataRecord]]: + channel_notices: bool = False, + ) -> GatewayQueryResult: """Queries the gateway for repodata from channels and custom sources. If `recursive` is `True` the gateway will recursively fetch the dependencies of the @@ -228,6 +278,7 @@ async def query( ``channel_relations``. ``None`` uses the default (10). ``0`` behaves like ``channel_relations="disabled"``. + channel_notices: Whether to fetch CEP-6 notices for this query. Returns: A list of lists of `RepoDataRecord`s. The outer list contains one entry per @@ -249,7 +300,7 @@ async def query( >>> ``` """ - py_records = await self._gateway.query( + py_records, py_notices = await self._gateway.query( sources=_convert_sources(sources), platforms=[ platform._inner if isinstance(platform, Platform) else Platform(platform)._inner @@ -260,12 +311,16 @@ async def query( for spec in specs ], recursive=recursive, + channel_notices=channel_notices, channel_relations=channel_relations, channel_relations_max_depth=channel_relations_max_depth, ) - # Convert the records into python objects - return [[RepoDataRecord._from_py_record(record) for record in records] for records in py_records] + # Convert the records and notices into Python objects. + return GatewayQueryResult( + [[RepoDataRecord._from_py_record(record) for record in records] for records in py_records], + [ChannelNotice._from_py(notice) for notice in py_notices], + ) async def names( self, @@ -273,7 +328,8 @@ async def names( platforms: Iterable[Platform | PlatformLiteral], channel_relations: Optional[ChannelRelationsMode] = None, channel_relations_max_depth: Optional[int] = None, - ) -> List[PackageName]: + channel_notices: bool = False, + ) -> GatewayNamesResult: """Queries all the names of packages in channels or custom sources. Arguments: @@ -285,6 +341,7 @@ async def names( channel_relations_max_depth: Maximum recursion depth when following ``channel_relations``. ``None`` uses the default (10). + channel_notices: Whether to fetch CEP-6 notices for this query. Returns: A list of package names that are present in the given subdirectories. @@ -301,18 +358,35 @@ async def names( ``` """ - py_package_names = await self._gateway.names( + py_package_names, py_notices = await self._gateway.names( sources=_convert_sources(sources), platforms=[ platform._inner if isinstance(platform, Platform) else Platform(platform)._inner for platform in platforms ], + channel_notices=channel_notices, channel_relations=channel_relations, channel_relations_max_depth=channel_relations_max_depth, ) - # Convert the records into python objects - return [PackageName._from_py_package_name(package_name) for package_name in py_package_names] + # Convert the names and notices into Python objects. + return GatewayNamesResult( + [PackageName._from_py_package_name(package_name) for package_name in py_package_names], + [ChannelNotice._from_py(notice) for notice in py_notices], + ) + + async def channel_notices( + self, + channels: Iterable[Channel | str], + ) -> List[ChannelNotice]: + """Fetch CEP-6 notices for the given channels. + + Results reuse the same expiration-aware cache as regular queries. + """ + py_notices = await self._gateway.channel_notices( + [channel._channel if isinstance(channel, Channel) else Channel(channel)._channel for channel in channels] + ) + return [ChannelNotice._from_py(notice) for notice in py_notices] async def channel_relations( self, diff --git a/py-rattler/src/lib.rs b/py-rattler/src/lib.rs index e9eaeec82a..0e52a458c8 100644 --- a/py-rattler/src/lib.rs +++ b/py-rattler/src/lib.rs @@ -73,7 +73,7 @@ use pyo3::prelude::*; use record::{PyLink, PyRecord}; use repo_data::{ PyChannelInfo, PyChannelRelations, PyRepoData, - gateway::{PyFetchRepoDataOptions, PyGateway, PySourceConfig}, + gateway::{PyChannelNotice, PyFetchRepoDataOptions, PyGateway, PySourceConfig}, patch_instructions::PyPatchInstructions, sparse::{PyPackageFormatSelection, PySparseRepoData}, }; @@ -146,6 +146,7 @@ fn rattler<'py>(py: Python<'py>, m: Bound<'py, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; diff --git a/py-rattler/src/repo_data/gateway.rs b/py-rattler/src/repo_data/gateway.rs index eeedd04ddb..970fdcfd77 100644 --- a/py-rattler/src/repo_data/gateway.rs +++ b/py-rattler/src/repo_data/gateway.rs @@ -9,8 +9,8 @@ use pyo3::{Borrowed, Bound, FromPyObject, PyAny, PyErr, PyResult, Python, pyclas use pyo3_async_runtimes::tokio::future_into_py; use rattler_repodata_gateway::fetch::{CacheAction, FetchRepoDataOptions, Variant}; use rattler_repodata_gateway::{ - CacheClearMode, ChannelConfig, ChannelRelationsMode, Gateway, GatewayWarning, Source, - SourceConfig, SubdirSelection, + CacheClearMode, ChannelConfig, ChannelNoticeResult, ChannelRelationsMode, Gateway, + GatewayWarning, Source, SourceConfig, SubdirSelection, }; use url::Url; @@ -31,6 +31,39 @@ pub struct PyGateway { show_progress: bool, } +/// A CEP-6 channel notice returned by the repodata gateway. +#[pyclass(get_all, from_py_object)] +#[derive(Clone)] +pub struct PyChannelNotice { + channel: String, + id: String, + message: String, + level: String, + created_at: Option, + expires_at: Option, + interval: Option, +} + +impl From for PyChannelNotice { + fn from(value: ChannelNoticeResult) -> Self { + let notice = value.notice; + Self { + channel: value.channel.to_string(), + id: notice.id, + message: notice.message, + level: match notice.level { + rattler_conda_types::ChannelNoticeLevel::Info => "info", + rattler_conda_types::ChannelNoticeLevel::Warning => "warning", + rattler_conda_types::ChannelNoticeLevel::Critical => "critical", + } + .to_string(), + created_at: notice.created_at.map(|timestamp| timestamp.to_string()), + expires_at: notice.expires_at.map(|timestamp| timestamp.to_string()), + interval: notice.interval, + } + } +} + impl From for Gateway { fn from(value: PyGateway) -> Self { value.inner @@ -180,6 +213,23 @@ impl PyGateway { }) } + /// Fetch CEP-6 notices for the given channels. + pub fn channel_notices<'a>( + &self, + py: Python<'a>, + channels: Vec, + ) -> PyResult> { + let gateway = self.inner.clone(); + future_into_py(py, async move { + Ok(gateway + .channel_notices(channels.iter().map(|channel| &channel.inner)) + .await + .into_iter() + .map(PyChannelNotice::from) + .collect::>()) + }) + } + #[pyo3(signature = (channel, subdirs, clear_disk=false))] pub fn clear_repodata_cache( &self, @@ -223,6 +273,7 @@ impl PyGateway { recursive, channel_relations=None, channel_relations_max_depth=None, + channel_notices=false, ))] #[allow(clippy::too_many_arguments)] pub fn query<'a>( @@ -234,6 +285,7 @@ impl PyGateway { recursive: bool, channel_relations: Option>, channel_relations_max_depth: Option, + channel_notices: bool, ) -> PyResult> { // Convert Python sources to Rust Source enum let rust_sources: Vec = sources @@ -246,7 +298,8 @@ impl PyGateway { future_into_py(py, async move { let mut query = gateway .query(rust_sources, platforms.into_iter().map(|p| p.inner), specs) - .recursive(recursive); + .recursive(recursive) + .channel_notices(channel_notices); if let Some(mode) = channel_relations { query = query.channel_relations(mode.0); @@ -264,7 +317,7 @@ impl PyGateway { emit_gateway_warnings(output.warnings)?; // Convert the records into a list of lists (Arc clone, not deep copy) - Ok(output + let records = output .repodata .into_iter() .map(|r| { @@ -272,7 +325,13 @@ impl PyGateway { .map(|arc| PyRecord::from(arc.clone())) .collect::>() }) - .collect::>()) + .collect::>(); + let notices = output + .notices + .into_iter() + .map(PyChannelNotice::from) + .collect::>(); + Ok((records, notices)) }) } @@ -281,6 +340,7 @@ impl PyGateway { platforms, channel_relations=None, channel_relations_max_depth=None, + channel_notices=false, ))] pub fn names<'a>( &self, @@ -289,6 +349,7 @@ impl PyGateway { platforms: Vec, channel_relations: Option>, channel_relations_max_depth: Option, + channel_notices: bool, ) -> PyResult> { // Convert Python sources to Rust Source enum let rust_sources: Vec = sources @@ -317,8 +378,11 @@ impl PyGateway { let mut all_names: std::collections::HashSet = std::collections::HashSet::new(); + let mut notices = Vec::new(); if !channels.is_empty() { - let mut query = gateway.names(channels, platforms_vec.iter().copied()); + let mut query = gateway + .names(channels, platforms_vec.iter().copied()) + .channel_notices(channel_notices); if let Some(mode) = channel_relations { query = query.channel_relations(mode.0); @@ -336,6 +400,7 @@ impl PyGateway { let output = query.execute().await.map_err(PyRattlerError::from)?; emit_gateway_warnings(output.warnings)?; all_names.extend(output.names); + notices.extend(output.notices.into_iter().map(PyChannelNotice::from)); } // Collect names from custom sources directly @@ -351,10 +416,13 @@ impl PyGateway { } // Convert to list of PyPackageName - Ok(all_names - .into_iter() - .map(PyPackageName::from) - .collect::>()) + Ok(( + all_names + .into_iter() + .map(PyPackageName::from) + .collect::>(), + notices, + )) }) } } diff --git a/py-rattler/tests/unit/test_gateway.py b/py-rattler/tests/unit/test_gateway.py index 6262606f17..c9eaaca1ad 100644 --- a/py-rattler/tests/unit/test_gateway.py +++ b/py-rattler/tests/unit/test_gateway.py @@ -1,3 +1,6 @@ +import json +from pathlib import Path + import pytest from rattler import Gateway, Channel, SourceConfig @@ -13,6 +16,59 @@ async def test_single_record_in_recursive_query(gateway: Gateway, conda_forge_ch assert len(python_records) == 1 +@pytest.mark.asyncio +async def test_channel_notices(tmp_path: Path) -> None: + noarch = tmp_path / "noarch" + noarch.mkdir() + (noarch / "repodata.json").write_text( + json.dumps( + { + "packages": { + "demo-1.0-0.tar.bz2": { + "name": "demo", + "version": "1.0", + "build": "0", + "build_number": 0, + "depends": [], + "subdir": "noarch", + } + } + } + ) + ) + (tmp_path / "notices.json").write_text( + json.dumps( + { + "notices": [ + { + "id": "security-1", + "message": "Please update demo", + "level": "critical", + "created_at": "2025-01-01T00:00:00Z", + "expires_at": "2099-01-01T00:00:00Z", + } + ] + } + ) + ) + + gateway = Gateway() + channel = Channel(str(tmp_path)) + notices = await gateway.channel_notices([channel]) + assert len(notices) == 1 + assert notices[0].id == "security-1" + assert notices[0].level == "critical" + assert notices[0].expires_at == "2099-01-01T00:00:00Z" + + result = await gateway.query([channel], ["noarch"], ["demo"], channel_notices=True) + assert result.repodata is result + assert result.notices == notices + + names = await gateway.names([channel], ["noarch"], channel_notices=True) + assert names.names is names + assert names.notices == notices + + def test_init_per_channel_config_key() -> None: test_source_config = SourceConfig() From 59163e170f27a62be97ffb55ff5356cc9fa07035 Mon Sep 17 00:00:00 2001 From: Wolf Vollprecht Date: Wed, 5 Aug 2026 23:34:58 +0200 Subject: [PATCH 14/21] fix(rattler_git): harden LFS checkouts and add path filters (#2640) --- crates/rattler_git/src/git.rs | 152 +++++++++++++++++++++++-------- crates/rattler_git/src/lib.rs | 2 +- crates/rattler_git/src/source.rs | 34 +++++-- crates/rattler_git/tests/lfs.rs | 107 +++++++++++++++++++++- 4 files changed, 248 insertions(+), 47 deletions(-) diff --git a/crates/rattler_git/src/git.rs b/crates/rattler_git/src/git.rs index d3a90c0c3f..452ebfb818 100644 --- a/crates/rattler_git/src/git.rs +++ b/crates/rattler_git/src/git.rs @@ -25,6 +25,10 @@ use crate::{ /// A file indicates that if present, `git reset` has been done and a repo /// checkout is ready to go. See [`GitCheckout::reset`] for why we need this. const CHECKOUT_READY_LOCK: &str = ".ok"; +/// Content of [`CHECKOUT_READY_LOCK`] indicating that LFS was requested but +/// `git-lfs` was unavailable. Such a checkout is reusable while `git-lfs` +/// remains unavailable, but must be recreated once it becomes available. +const CHECKOUT_LFS_DEGRADED: &str = "lfs-degraded"; pub const GIT_DIR: &str = "GIT_DIR"; pub const GIT_TERMINAL_PROMPT: &str = "GIT_TERMINAL_PROMPT"; pub const GIT_LFS_SKIP_SMUDGE: &str = "GIT_LFS_SKIP_SMUDGE"; @@ -100,10 +104,11 @@ fn git_output(cmd: &mut Command) -> Result { Ok(output) } -/// Value for `GIT_LFS_SKIP_SMUDGE`, or `None` to leave the var unset. -/// `Some(true)` → "0" (run smudge), `Some(false)` → "1" (skip smudge). -fn lfs_skip_smudge_env(lfs: Option) -> Option<&'static str> { - lfs.map(|on| if on { "0" } else { "1" }) +/// Value for `GIT_LFS_SKIP_SMUDGE`: only an explicit `Some(true)` enables +/// smudging. The checkout's origin is the local database, which only contains +/// LFS objects when they were explicitly requested and fetched. +fn lfs_skip_smudge_env(lfs: Option) -> &'static str { + if lfs == Some(true) { "0" } else { "1" } } /// Strategy when fetching refspecs for a [`GitReference`] @@ -287,7 +292,7 @@ impl GitRemote { reference: &GitReference, locked_rev: Option, client: &LazyClient, - lfs: Option, + options: &CheckoutOptions, ) -> Result<(GitDatabase, GitOid), GitError> { let locked_ref = locked_rev.map(|oid| GitReference::FullCommit(oid.to_string())); let reference = locked_ref.as_ref().unwrap_or(reference); @@ -300,8 +305,10 @@ impl GitRemote { }; if let Some(rev) = resolved_commit_hash { - let ready = (lfs == Some(true)) - .then(|| maybe_fetch_lfs(&mut db.repo, self.url.as_str(), rev)) + let ready = (options.lfs == Some(true)) + .then(|| { + maybe_fetch_lfs(&mut db.repo, self.url.as_str(), rev, &options.lfs_filter) + }) .flatten(); return Ok((db.with_lfs_ready(ready), rev)); } @@ -331,8 +338,8 @@ impl GitRemote { })?, }; - let ready = (lfs == Some(true)) - .then(|| maybe_fetch_lfs(&mut repo, self.url.as_str(), rev)) + let ready = (options.lfs == Some(true)) + .then(|| maybe_fetch_lfs(&mut repo, self.url.as_str(), rev, &options.lfs_filter)) .flatten(); Ok(( @@ -360,6 +367,40 @@ impl GitRemote { } } +/// Path filters passed to Git LFS. Values use the comma-separated gitignore +/// pattern syntax accepted by `git lfs fetch --include/--exclude`. +#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)] +pub struct LfsFilter { + /// Only materialize LFS objects whose paths match these patterns. + pub include: Option, + /// Do not materialize LFS objects whose paths match these patterns. + pub exclude: Option, +} + +impl LfsFilter { + pub fn is_empty(&self) -> bool { + self.include.is_none() && self.exclude.is_none() + } + + fn configure_git(&self, command: &mut Command) { + if let Some(include) = &self.include { + command.arg("-c").arg(format!("lfs.fetchinclude={include}")); + } + if let Some(exclude) = &self.exclude { + command.arg("-c").arg(format!("lfs.fetchexclude={exclude}")); + } + } + + fn configure_lfs_fetch(&self, command: &mut Command) { + if let Some(include) = &self.include { + command.arg(format!("--include={include}")); + } + if let Some(exclude) = &self.exclude { + command.arg(format!("--exclude={exclude}")); + } + } +} + /// Options controlling checkout behavior (submodules, LFS, etc.). #[derive(Debug, Clone)] pub struct CheckoutOptions { @@ -373,9 +414,13 @@ pub struct CheckoutOptions { /// * `Some(false)`: force-skip the smudge filter (`GIT_LFS_SKIP_SMUDGE=1`) /// so checkouts always contain pointer files. Callers can handle LFS /// themselves afterwards. - /// * `None`: no opinion — the `GIT_LFS_SKIP_SMUDGE` environment variable - /// is left untouched so ambient git configuration applies. + /// * `None`: LFS is not requested and the smudge filter is skipped. This + /// differs from `Some(false)` only in preserving the caller's preference. pub lfs: Option, + + /// Optional path filters limiting which LFS objects are fetched and + /// materialized. Filters have no effect unless `lfs == Some(true)`. + pub lfs_filter: LfsFilter, } impl Default for CheckoutOptions { @@ -386,6 +431,7 @@ impl Default for CheckoutOptions { // origin points at the local database, which has no LFS objects // unless `lfs == Some(true)` fetched them. lfs: Some(false), + lfs_filter: LfsFilter::default(), } } } @@ -416,8 +462,11 @@ impl GitDatabase { /// True if the LFS objects reachable from `revision` are already present /// and valid in this database (i.e. `git lfs fsck --objects` passes). /// Used to decide whether a cached DB satisfies an LFS-aware request. - pub(crate) fn contains_lfs_artifacts(&self, revision: GitOid) -> bool { - self.repo.lfs_fsck_objects(revision) + pub(crate) fn contains_lfs_artifacts(&self, revision: GitOid, filter: &LfsFilter) -> bool { + // A filtered fetch is cheap when its objects are already cached and is + // the authoritative way to apply Git LFS's path matching semantics. + // Do not claim a DB-only cache hit for filtered requests. + filter.is_empty() && self.repo.lfs_fsck_objects(revision) } /// Checkouts to a revision at `destination` from this database. @@ -435,7 +484,7 @@ impl GitDatabase { let checkout = match GitRepository::open(destination) .ok() .map(|repo| GitCheckout::new(rev, repo)) - .filter(GitCheckout::is_fresh) + .filter(|checkout| checkout.is_fresh(GIT_LFS.is_err())) { Some(co) => co, None => GitCheckout::clone_into(destination, self, rev, source_url, options)?, @@ -601,6 +650,9 @@ impl GitCheckout { // from has no LFS objects. When LFS was requested, the objects were // fetched into the database beforehand, so smudging can succeed. let mut clone_cmd = Command::new(GIT.as_ref().map_err(Clone::clone)?); + if options.lfs == Some(true) { + options.lfs_filter.configure_git(&mut clone_cmd); + } clone_cmd .arg("clone") .arg("--local") @@ -609,9 +661,7 @@ impl GitCheckout { // have a HEAD checked out. .arg(dunce::simplified(&database.repo.path).display().to_string()) .arg(dunce::simplified(into).display().to_string()); - if let Some(value) = lfs_skip_smudge_env(options.lfs) { - clone_cmd.env(GIT_LFS_SKIP_SMUDGE, value); - } + clone_cmd.env(GIT_LFS_SKIP_SMUDGE, lfs_skip_smudge_env(options.lfs)); let output = git_output(&mut clone_cmd)?; tracing::debug!("output after cloning {:?}", output); @@ -622,12 +672,17 @@ impl GitCheckout { Ok(checkout) } - /// Checks if the `HEAD` of this checkout points to the expected revision. - fn is_fresh(&self) -> bool { + /// Checks if the `HEAD` points to the expected revision and its ready + /// marker is usable. An LFS-degraded checkout is only reusable while the + /// current process still has no usable `git-lfs`. + fn is_fresh(&self, accept_lfs_degraded: bool) -> bool { match self.repo.rev_parse("HEAD") { Ok(id) if id == self.revision => { // See comments in reset() for why we check this - self.repo.path.join(CHECKOUT_READY_LOCK).exists() + match fs_err::read_to_string(self.repo.path.join(CHECKOUT_READY_LOCK)) { + Ok(contents) => contents != CHECKOUT_LFS_DEGRADED || accept_lfs_degraded, + Err(_) => false, + } } _ => false, } @@ -654,18 +709,26 @@ impl GitCheckout { let skip_smudge = lfs_skip_smudge_env(options.lfs); - // Perform the hard reset. The LFS smudge filter is controlled by - // `options.lfs`: the checkout's origin points at the local database, - // which only has LFS objects when LFS was requested and fetched. + // Perform the hard reset. Configure the filter explicitly when LFS is + // enabled so materialization does not depend on a prior global + // `git lfs install`. let mut reset_cmd = Command::new(GIT.as_ref().map_err(Clone::clone)?); + if options.lfs == Some(true) && GIT_LFS.is_ok() { + options.lfs_filter.configure_git(&mut reset_cmd); + reset_cmd + .arg("-c") + .arg("filter.lfs.smudge=git-lfs smudge -- %f") + .arg("-c") + .arg("filter.lfs.process=git-lfs filter-process") + .arg("-c") + .arg("filter.lfs.required=true"); + } reset_cmd .arg("reset") .arg("--hard") .arg(self.revision.as_str()) - .current_dir(&self.repo.path); - if let Some(value) = skip_smudge { - reset_cmd.env(GIT_LFS_SKIP_SMUDGE, value); - } + .current_dir(&self.repo.path) + .env(GIT_LFS_SKIP_SMUDGE, skip_smudge); git_output(&mut reset_cmd)?; if options.update_submodules { @@ -680,6 +743,9 @@ impl GitCheckout { // policy. Allow file:// protocol so local clones and file-based // submodule URLs work on modern Git (>= 2.38.1). let mut submodule_cmd = Command::new(GIT.as_ref().map_err(Clone::clone)?); + if options.lfs == Some(true) { + options.lfs_filter.configure_git(&mut submodule_cmd); + } submodule_cmd .args(["-c", "protocol.file.allow=always"]) .arg("submodule") @@ -687,13 +753,15 @@ impl GitCheckout { .arg("--recursive") .arg("--init") .current_dir(&self.repo.path); - if let Some(value) = skip_smudge { - submodule_cmd.env(GIT_LFS_SKIP_SMUDGE, value); - } + submodule_cmd.env(GIT_LFS_SKIP_SMUDGE, skip_smudge); git_output(&mut submodule_cmd)?; } - fs_err::File::create(ok_file)?; + if options.lfs == Some(true) && GIT_LFS.is_err() { + fs_err::write(ok_file, CHECKOUT_LFS_DEGRADED)?; + } else { + fs_err::File::create(ok_file)?; + } Ok(()) } } @@ -839,7 +907,12 @@ pub(crate) fn fetch( /// Best-effort `fetch_lfs`: warns and continues on missing git-lfs or fetch /// failure. Returns the value to record in [`GitDatabase::lfs_ready`]. -fn maybe_fetch_lfs(repo: &mut GitRepository, url: &str, revision: GitOid) -> Option { +fn maybe_fetch_lfs( + repo: &mut GitRepository, + url: &str, + revision: GitOid, + filter: &LfsFilter, +) -> Option { let lfs = if let Ok(lfs) = GIT_LFS.as_ref() { lfs } else { @@ -849,7 +922,7 @@ fn maybe_fetch_lfs(repo: &mut GitRepository, url: &str, revision: GitOid) -> Opt ); return Some(false); }; - match fetch_lfs(lfs, repo, url, revision) { + match fetch_lfs(lfs, repo, url, revision, filter) { Ok(fsck_ok) => Some(fsck_ok), Err(err) => { tracing::warn!("failed to fetch LFS objects for {url} at {revision}: {err}"); @@ -868,13 +941,15 @@ fn fetch_lfs( repo: &mut GitRepository, url: &str, revision: GitOid, + filter: &LfsFilter, ) -> Result { let remote = lfs_remote_url(url); tracing::debug!("fetching LFS objects for {remote} at {revision}"); - let output = lfs - .cmd() - .arg("fetch") + let mut command = lfs.cmd(); + command.arg("fetch"); + filter.configure_lfs_fetch(&mut command); + let output = command .arg(&*remote) .arg(revision.as_str()) .env_remove(GIT_DIR) @@ -887,7 +962,10 @@ fn fetch_lfs( } tracing::debug!("git lfs fetch output: {:?}", output); - Ok(repo.lfs_fsck_objects(revision)) + // `git lfs fsck` supports exclusions but not includes. A successful + // filtered fetch is therefore the best available validation for a subset; + // unfiltered requests retain the full object-integrity check. + Ok(!filter.is_empty() || repo.lfs_fsck_objects(revision)) } /// The remote to pass to `git lfs fetch`. git-lfs' standalone file transfer diff --git a/crates/rattler_git/src/lib.rs b/crates/rattler_git/src/lib.rs index c8aac3e05f..0df1a23d61 100644 --- a/crates/rattler_git/src/lib.rs +++ b/crates/rattler_git/src/lib.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use ::url::Url; -pub use git::CheckoutOptions; +pub use git::{CheckoutOptions, LfsFilter}; use git::{GitBinaryError, GitReference}; use sha::{GitSha, OidParseError}; diff --git a/crates/rattler_git/src/source.rs b/crates/rattler_git/src/source.rs index 4b3651bd8a..3d1aefa5c9 100644 --- a/crates/rattler_git/src/source.rs +++ b/crates/rattler_git/src/source.rs @@ -15,7 +15,7 @@ use tracing::instrument; use crate::{ GitError, GitUrl, Reporter, credentials::GIT_STORE, - git::{CheckoutOptions, GitRemote}, + git::{CheckoutOptions, GitRemote, LfsFilter}, resolver::RepositoryReference, sha::{GitOid, GitSha}, url::RepositoryUrl, @@ -23,7 +23,7 @@ use crate::{ /// Parses a tri-state LFS preference from the environment variable named /// `var_name`. Accepts `1`/`0`, `true`/`false`, `yes`/`no`, `on`/`off` -/// (case-insensitive). Unset/empty → `None` (no opinion). +/// (case-insensitive). Unset/empty → `None` (LFS is not requested). /// /// Callers pick the variable name (e.g. pixi uses `PIXI_GIT_LFS`) and pass /// the result to [`CheckoutOptions::lfs`]. @@ -103,6 +103,14 @@ impl GitSource { self } + /// Limit which LFS paths are fetched and materialized. The filter only has + /// an effect when LFS is enabled. + #[must_use] + pub fn with_lfs_filter(mut self, filter: LfsFilter) -> Self { + self.checkout_options.lfs_filter = filter; + self + } + /// Fetch the underlying Git repository at the given revision. #[instrument(skip(self), fields(repository = %self.git.repository, rev = self.git.precise.map(tracing::field::display)))] pub fn fetch(self) -> Result { @@ -141,7 +149,11 @@ impl GitSource { // requested, its LFS objects validate. Skip the regular fetch. (Some(rev), Some(db)) if db.contains(rev.into()) - && (!lfs_requested || db.contains_lfs_artifacts(rev.into())) => + && (!lfs_requested + || db.contains_lfs_artifacts( + rev.into(), + &self.checkout_options.lfs_filter, + )) => { tracing::debug!( "Using existing Git source `{}` pointed at `{}`", @@ -170,7 +182,7 @@ impl GitSource { &self.git.reference, locked_rev.map(GitOid::from), &self.client, - self.checkout_options.lfs, + &self.checkout_options, )?; (db, GitSha::from(actual_rev), task) @@ -183,12 +195,22 @@ impl GitSource { // Check out `actual_rev` from the database to a scoped location on the // filesystem. This will use hard links and such to ideally make the - // checkout operation here pretty fast. + // checkout operation here pretty fast. LFS-enabled checkouts contain + // different files than plain checkouts and must not share their path. + let checkout_name = if lfs_requested && !self.checkout_options.lfs_filter.is_empty() { + let mut hasher = DefaultHasher::new(); + self.checkout_options.lfs_filter.hash(&mut hasher); + format!("{short_id}-lfs-{:x}", hasher.finish()) + } else if lfs_requested { + format!("{short_id}-lfs") + } else { + short_id.clone() + }; let checkout_path = self .cache .join("checkouts") .join(&ident) - .join(short_id.as_str()); + .join(checkout_name); tracing::debug!( "Copying git revision `{}` to path `{}`", diff --git a/crates/rattler_git/tests/lfs.rs b/crates/rattler_git/tests/lfs.rs index c4ba0bd97b..da501dd4fa 100644 --- a/crates/rattler_git/tests/lfs.rs +++ b/crates/rattler_git/tests/lfs.rs @@ -1,12 +1,12 @@ //! Integration tests for the Git LFS fetch path. Builds a tiny fixture repo -//! with `*.bin filter=lfs` in `.gitattributes` and one binary file. +//! with `*.bin filter=lfs` in `.gitattributes` and two binary files. //! Requires `git-lfs` on the host; tests skip themselves when it's missing. use std::path::Path; use std::process::Command; use rattler_git::LazyClient; -use rattler_git::{GitUrl, sha::GitSha, source::GitSource}; +use rattler_git::{GitUrl, LfsFilter, sha::GitSha, source::GitSource}; use reqwest_middleware::ClientWithMiddleware; use url::Url; @@ -35,7 +35,7 @@ fn require_git_lfs(test: &str) -> bool { ok } -/// A tiny git repository with one LFS-tracked file (`data.bin`). +/// A tiny git repository with two LFS-tracked files. struct LfsFixture { /// Kept alive to prevent cleanup until the fixture is dropped. _tempdir: tempfile::TempDir, @@ -84,6 +84,7 @@ impl LfsFixture { b"\x00\x01\x02\x03binary payload\xff\xfe", ) .unwrap(); + fs_err::write(repo_path.join("other.bin"), b"other LFS payload").unwrap(); git(&["add", "."]); git(&["commit", "--message", "v0.1.0"]); @@ -216,3 +217,103 @@ fn cached_fetch_with_lfs_artifacts_is_ready() { assert!(second.lfs_ready()); assert_eq!(second.commit(), first.commit()); } + +/// A plain checkout followed by an LFS checkout of the same commit must not +/// reuse the pointer-only checkout directory. +#[test] +fn same_commit_plain_then_lfs_uses_distinct_checkouts() { + if !require_git_lfs("same_commit_plain_then_lfs_uses_distinct_checkouts") { + return; + } + let repo = LfsFixture::new(); + let original = fs_err::read(repo.repo_path.join("data.bin")).unwrap(); + let cache = tempfile::tempdir().unwrap(); + let git_url = GitUrl::try_from(repo.base_url.clone()).unwrap(); + + let plain = GitSource::new(git_url.clone(), panic_client(), cache.path()) + .with_lfs(Some(false)) + .fetch() + .expect("plain fetch should succeed"); + let lfs = GitSource::new(git_url, panic_client(), cache.path()) + .with_lfs(Some(true)) + .fetch() + .expect("LFS fetch should succeed"); + + assert_eq!(plain.commit(), lfs.commit()); + assert_ne!(plain.path(), lfs.path()); + assert!(is_lfs_pointer(&plain.path().join("data.bin"))); + assert_eq!(fs_err::read(lfs.path().join("data.bin")).unwrap(), original); +} + +/// The reverse order must also keep a later plain checkout from reusing or +/// modifying the materialized LFS checkout. +#[test] +fn same_commit_lfs_then_plain_uses_distinct_checkouts() { + if !require_git_lfs("same_commit_lfs_then_plain_uses_distinct_checkouts") { + return; + } + let repo = LfsFixture::new(); + let original = fs_err::read(repo.repo_path.join("data.bin")).unwrap(); + let cache = tempfile::tempdir().unwrap(); + let git_url = GitUrl::try_from(repo.base_url.clone()).unwrap(); + + let lfs = GitSource::new(git_url.clone(), panic_client(), cache.path()) + .with_lfs(Some(true)) + .fetch() + .expect("LFS fetch should succeed"); + let plain = GitSource::new(git_url, panic_client(), cache.path()) + .with_lfs(None) + .fetch() + .expect("plain fetch should succeed"); + + assert_eq!(lfs.commit(), plain.commit()); + assert_ne!(lfs.path(), plain.path()); + assert!(is_lfs_pointer(&plain.path().join("data.bin"))); + assert_eq!(fs_err::read(lfs.path().join("data.bin")).unwrap(), original); +} + +/// Include and exclude patterns limit both fetching and materialization. A +/// different filter gets a different checkout path. +#[test] +fn lfs_path_filters_materialize_only_the_requested_subset() { + if !require_git_lfs("lfs_path_filters_materialize_only_the_requested_subset") { + return; + } + let repo = LfsFixture::new(); + let data = fs_err::read(repo.repo_path.join("data.bin")).unwrap(); + let other = fs_err::read(repo.repo_path.join("other.bin")).unwrap(); + let cache = tempfile::tempdir().unwrap(); + let git_url = GitUrl::try_from(repo.base_url.clone()).unwrap(); + + let data_only = GitSource::new(git_url.clone(), panic_client(), cache.path()) + .with_lfs(Some(true)) + .with_lfs_filter(LfsFilter { + include: Some("*.bin".to_string()), + exclude: Some("other.bin".to_string()), + }) + .fetch() + .expect("filtered LFS fetch should succeed"); + + assert!(data_only.lfs_ready()); + assert_eq!( + fs_err::read(data_only.path().join("data.bin")).unwrap(), + data + ); + assert!(is_lfs_pointer(&data_only.path().join("other.bin"))); + + let other_only = GitSource::new(git_url, panic_client(), cache.path()) + .with_lfs(Some(true)) + .with_lfs_filter(LfsFilter { + include: Some("other.bin".to_string()), + exclude: None, + }) + .fetch() + .expect("second filtered LFS fetch should succeed"); + + assert_ne!(data_only.path(), other_only.path()); + assert!(is_lfs_pointer(&other_only.path().join("data.bin"))); + assert_eq!( + fs_err::read(other_only.path().join("other.bin")).unwrap(), + other + ); +} From 1e8966de18b08fd552a7798cb8497b4f3b64150a Mon Sep 17 00:00:00 2001 From: Wolf Vollprecht Date: Wed, 5 Aug 2026 23:55:05 +0200 Subject: [PATCH 15/21] fix(repodata-gateway): limit concurrent shard cache reads (#2163) --- .../src/gateway/builder.rs | 33 +++++++++++++++++++ .../src/gateway/mod.rs | 7 +++- .../src/gateway/sharded_subdir/mod.rs | 6 ++++ .../src/gateway/sharded_subdir/tokio/mod.rs | 15 ++++++++- .../src/gateway/subdir_builder.rs | 2 ++ 5 files changed, 61 insertions(+), 2 deletions(-) diff --git a/crates/rattler_repodata_gateway/src/gateway/builder.rs b/crates/rattler_repodata_gateway/src/gateway/builder.rs index aab5b88075..e1e2244225 100644 --- a/crates/rattler_repodata_gateway/src/gateway/builder.rs +++ b/crates/rattler_repodata_gateway/src/gateway/builder.rs @@ -49,6 +49,8 @@ pub struct GatewayBuilder { #[cfg(not(target_arch = "wasm32"))] package_cache: Option, max_concurrent_requests: MaxConcurrency, + #[cfg(not(target_arch = "wasm32"))] + max_concurrent_io: MaxConcurrency, } impl GatewayBuilder { @@ -133,6 +135,28 @@ impl GatewayBuilder { self } + /// Sets the maximum number of concurrent IO operations (e.g. reading shard + /// cache files from disk). This prevents exhausting the OS file-descriptor + /// limit when many packages are queried at once. + #[cfg(not(target_arch = "wasm32"))] + #[must_use] + pub fn with_max_concurrent_io(self, max_concurrent_io: impl Into) -> Self { + Self { + max_concurrent_io: max_concurrent_io.into(), + ..self + } + } + + /// Sets the maximum number of concurrent IO operations. + #[cfg(not(target_arch = "wasm32"))] + pub fn set_max_concurrent_io( + &mut self, + max_concurrent_io: impl Into, + ) -> &mut Self { + self.max_concurrent_io = max_concurrent_io.into(); + self + } + /// Apply the shared rattler configuration (see [`rattler_config`]) to /// this builder: the channel configuration is derived from /// `repodata-config` and the maximum number of concurrent requests from @@ -188,6 +212,13 @@ impl GatewayBuilder { MaxConcurrency::Semaphore(sem) => Some(sem), }; + #[cfg(not(target_arch = "wasm32"))] + let io_concurrency_semaphore = match self.max_concurrent_io { + MaxConcurrency::Unlimited => None, + MaxConcurrency::Limited(n) => Some(Arc::new(tokio::sync::Semaphore::new(n))), + MaxConcurrency::Semaphore(sem) => Some(sem), + }; + Gateway { inner: Arc::new(GatewayInner { subdirs: CoalescedMap::new(), @@ -201,6 +232,8 @@ impl GatewayBuilder { package_cache, subdir_run_exports_cache: Arc::default(), concurrent_requests_semaphore, + #[cfg(not(target_arch = "wasm32"))] + io_concurrency_semaphore, }), } } diff --git a/crates/rattler_repodata_gateway/src/gateway/mod.rs b/crates/rattler_repodata_gateway/src/gateway/mod.rs index a6684034f4..874068e499 100644 --- a/crates/rattler_repodata_gateway/src/gateway/mod.rs +++ b/crates/rattler_repodata_gateway/src/gateway/mod.rs @@ -359,8 +359,13 @@ struct GatewayInner { /// A cache for global run exports. subdir_run_exports_cache: Arc, - /// A semaphore to limit the number of concurrent requests. + /// A semaphore to limit the number of concurrent HTTP requests. concurrent_requests_semaphore: Option>, + + /// A semaphore to limit the number of concurrent IO operations (e.g. + /// reading shard files from the on-disk cache). + #[cfg(not(target_arch = "wasm32"))] + io_concurrency_semaphore: Option>, } impl GatewayInner { diff --git a/crates/rattler_repodata_gateway/src/gateway/sharded_subdir/mod.rs b/crates/rattler_repodata_gateway/src/gateway/sharded_subdir/mod.rs index 369f43d370..ceabbca23f 100644 --- a/crates/rattler_repodata_gateway/src/gateway/sharded_subdir/mod.rs +++ b/crates/rattler_repodata_gateway/src/gateway/sharded_subdir/mod.rs @@ -329,6 +329,7 @@ mod tests { }, None, None, + None, ) .await .unwrap(); @@ -397,6 +398,7 @@ mod tests { }, None, None, + None, ) .await .err() @@ -438,6 +440,7 @@ mod tests { }, None, None, + None, ) .await .unwrap(); @@ -480,6 +483,7 @@ mod tests { }, None, None, + None, ) .await .expect("the index is served, so it is cached now"); @@ -495,6 +499,7 @@ mod tests { }, None, None, + None, ) .await .expect("the index comes from the cache") @@ -524,6 +529,7 @@ mod tests { }, None, None, + None, ) .await .err() diff --git a/crates/rattler_repodata_gateway/src/gateway/sharded_subdir/tokio/mod.rs b/crates/rattler_repodata_gateway/src/gateway/sharded_subdir/tokio/mod.rs index ed8b219b9a..4649c73843 100644 --- a/crates/rattler_repodata_gateway/src/gateway/sharded_subdir/tokio/mod.rs +++ b/crates/rattler_repodata_gateway/src/gateway/sharded_subdir/tokio/mod.rs @@ -60,11 +60,13 @@ pub struct ShardedSubdir { package_base_url: Url, sharded_repodata: ShardedRepodata, concurrent_requests_semaphore: Option>, + io_concurrency_semaphore: Option>, cache_dir: PathBuf, cache_policy: ShardCachePolicy, } impl ShardedSubdir { + #[allow(clippy::too_many_arguments)] pub async fn new( channel: Channel, subdir: String, @@ -72,6 +74,7 @@ impl ShardedSubdir { cache_dir: PathBuf, cache_policy: ShardCachePolicy, concurrent_requests_semaphore: Option>, + io_concurrency_semaphore: Option>, reporter: Option<&dyn Reporter>, ) -> Result { // Construct the base url for the shards (e.g. `/`). @@ -139,6 +142,7 @@ impl ShardedSubdir { cache_dir, cache_policy, concurrent_requests_semaphore, + io_concurrency_semaphore, }) } @@ -202,8 +206,17 @@ impl SubdirClient for ShardedSubdir { .cache_dir .join(format!("{}.msgpack", hex::encode(shard))); - // Read the cached shard + // Read the cached shard. + // Acquire the IO semaphore permit before opening the file to avoid + // exhausting the OS file-descriptor limit when many shards are fetched + // concurrently (e.g. when querying for `*`). if self.cache_policy.action != CacheAction::NoCache { + let _io_permit = OptionFuture::from( + self.io_concurrency_semaphore + .as_deref() + .map(tokio::sync::Semaphore::acquire), + ) + .await; match tokio_fs::read(&shard_cache_path).await { Ok(cached_bytes) => { // Decode the cached shard diff --git a/crates/rattler_repodata_gateway/src/gateway/subdir_builder.rs b/crates/rattler_repodata_gateway/src/gateway/subdir_builder.rs index 112f530cdf..3d0db0fd5e 100644 --- a/crates/rattler_repodata_gateway/src/gateway/subdir_builder.rs +++ b/crates/rattler_repodata_gateway/src/gateway/subdir_builder.rs @@ -156,6 +156,8 @@ impl<'g> SubdirBuilder<'g> { missing_shards_are_empty: _source_config.missing_shards_are_empty, }, self.gateway.concurrent_requests_semaphore.clone(), + #[cfg(not(target_arch = "wasm32"))] + self.gateway.io_concurrency_semaphore.clone(), self.reporter.as_deref(), ) .await?; From 130e5931462a3322d5c1e54f0efba335454eb399 Mon Sep 17 00:00:00 2001 From: Dylan Jenkins Date: Thu, 6 Aug 2026 17:21:39 +1000 Subject: [PATCH 16/21] fix(rattler_networking): report a missing OCI manifest as a 404 instead of erroring (#2651) --- .../rattler_networking/src/oci_middleware.rs | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/crates/rattler_networking/src/oci_middleware.rs b/crates/rattler_networking/src/oci_middleware.rs index cb08aea644..9eff458c5c 100644 --- a/crates/rattler_networking/src/oci_middleware.rs +++ b/crates/rattler_networking/src/oci_middleware.rs @@ -37,6 +37,9 @@ enum OciMiddlewareError { #[error("Layer not found")] LayerNotFound, + #[error("Manifest request failed with status {0}")] + ManifestRequestFailed(StatusCode), + #[error("Invalid OCI URL '{0}': {1}")] InvalidUrl(Url, &'static str), @@ -548,7 +551,10 @@ impl OCIUrl { } } - let manifest: Manifest = manifest.error_for_status()?.json().await?; + if !manifest.status().is_success() { + return Err(OciMiddlewareError::ManifestRequestFailed(manifest.status())); + } + let manifest: Manifest = manifest.json().await?; let layer = if let Some(layer) = manifest .layers @@ -634,12 +640,16 @@ impl Middleware for OciMiddleware { next.run(retry_req, extensions).await } Err(e) => match e { + // Return clean 404s rather than erroring as callers safely handle them. OciMiddlewareError::LayerNotFound => { return Ok(create_404_response( req.url(), "No layer available for media type", )); } + OciMiddlewareError::ManifestRequestFailed(StatusCode::NOT_FOUND) => { + return Ok(create_404_response(req.url(), "Manifest not found")); + } _ => { return Err(reqwest_middleware::Error::Middleware(e.into())); } @@ -833,6 +843,27 @@ mod tests { ); } + /// Test that a missing package comes back as a plain 404. + #[cfg(any(feature = "rustls", feature = "native-tls"))] + #[tokio::test] + async fn test_oci_middleware_missing_package_is_404() { + let client = reqwest::Client::new(); + let middleware = OciMiddleware::new(client.clone()); + + let client_with_middleware = reqwest_middleware::ClientBuilder::new(client) + .with(middleware) + .build(); + + // Repo exists, version doesn't. + let response = client_with_middleware + .get("oci://ghcr.io/channel-mirrors/conda-forge/osx-arm64/xtensor-999.999.999-h0000000_0.conda") + .send() + .await + .unwrap(); + + assert_eq!(response.status(), 404); + } + // test pulling an image from OCI registry #[cfg(any(feature = "rustls", feature = "native-tls"))] #[tokio::test] From 5453393397e8d3fd7882a0b51b8d2e702777f852 Mon Sep 17 00:00:00 2001 From: Pavel Zwerschke Date: Thu, 6 Aug 2026 09:33:58 +0200 Subject: [PATCH 17/21] feat: Introduce default versions for virtual packages (#2646) --- .../rattler_virtual_packages/src/defaults.rs | 35 ++++++ crates/rattler_virtual_packages/src/lib.rs | 116 +++++++++++++++--- 2 files changed, 134 insertions(+), 17 deletions(-) create mode 100644 crates/rattler_virtual_packages/src/defaults.rs diff --git a/crates/rattler_virtual_packages/src/defaults.rs b/crates/rattler_virtual_packages/src/defaults.rs new file mode 100644 index 0000000000..63d149d9fc --- /dev/null +++ b/crates/rattler_virtual_packages/src/defaults.rs @@ -0,0 +1,35 @@ +//! Default virtual package versions to use when the actual version cannot be +//! detected from the host system, for example when detecting virtual packages +//! for a platform other than the current one (see +//! [`crate::VirtualPackages::detect_for_platform`]). + +use rattler_conda_types::{Platform, Version}; + +/// The default `glibc` version to use when the version cannot be detected. +/// +/// This is the `glibc` version that ships with RHEL 8 and Debian 10. +pub fn default_glibc_version() -> Version { + "2.28".parse().unwrap() +} + +/// The default Linux kernel version to use when the version cannot be +/// detected. +/// +/// This is the kernel version that ships with RHEL 8. +pub fn default_linux_version() -> Version { + "4.18".parse().unwrap() +} + +/// The default Windows version to use when the version cannot be detected. +pub fn default_windows_version() -> Version { + "10.0".parse().unwrap() +} + +/// Returns the default macOS version to use when the version cannot be +/// detected, or `None` if the given platform is not a macOS platform. +pub fn default_mac_os_version(platform: Platform) -> Option { + match platform { + Platform::Osx64 | Platform::OsxArm64 => Some("13.0".parse().unwrap()), + _ => None, + } +} diff --git a/crates/rattler_virtual_packages/src/lib.rs b/crates/rattler_virtual_packages/src/lib.rs index dc9f3b6c7d..915b28b7d7 100644 --- a/crates/rattler_virtual_packages/src/lib.rs +++ b/crates/rattler_virtual_packages/src/lib.rs @@ -33,6 +33,7 @@ //! example. pub mod cuda; +pub mod defaults; pub mod libc; pub mod linux; pub mod osx; @@ -307,14 +308,15 @@ impl VirtualPackages { /// # Cross-compilation defaults /// /// When cross-compiling (targeting a platform different from the current one), the following - /// defaults are used if no override is provided: + /// defaults are used if no override is provided (see the [`defaults`] module): /// - /// - **Windows** (`__win`): No version specified - /// - **Linux** (`__linux`): Version 0 - /// - **OSX** (`__osx`): Version 0 + /// - **Windows** (`__win`): [`defaults::default_windows_version`] + /// - **Linux** (`__linux`): [`defaults::default_linux_version`] + /// - **OSX** (`__osx`): [`defaults::default_mac_os_version`] /// - **iOS** (`__ios`): Version 0 (minimum supported iOS version) /// - **Android** (`__android`): Version 0 (minimum supported API level) - /// - **`LibC`** (`__glibc`): `glibc` with version 0 (only for Linux platforms) + /// - **`LibC`** (`__glibc`): `glibc` with [`defaults::default_glibc_version`] (only for Linux + /// platforms) /// - **CUDA** (`__cuda`): Not included (None) /// - **Archspec**: Platform-specific minimal architecture (e.g., `x86_64` for `osx-64`) pub fn detect_for_platform( @@ -328,29 +330,49 @@ impl VirtualPackages { } else { // When cross-compiling, respect overrides but fall back to defaults let win = if platform.is_windows() { - // Check override first, fall back to default (no version) - virtual_packages - .win - .or_else(|| Some(Windows { version: None })) + // Check override first, fall back to the default version + virtual_packages.win.or_else(|| { + let version = defaults::default_windows_version(); + log_default_virtual_package( + "__win", + platform, + &version, + Windows::DEFAULT_ENV_NAME, + ); + Some(Windows { + version: Some(version), + }) + }) } else { None }; let linux = if platform.is_linux() { virtual_packages.linux.or_else(|| { - Some(Linux { - version: Version::major(0), - }) + let version = defaults::default_linux_version(); + log_default_virtual_package( + "__linux", + platform, + &version, + Linux::DEFAULT_ENV_NAME, + ); + Some(Linux { version }) }) } else { None }; let osx = if platform.is_osx() { - // Check override first, fall back to version 0 + // Check override first, fall back to the default version virtual_packages.osx.or_else(|| { - Some(Osx { - version: Version::major(0), + defaults::default_mac_os_version(platform).map(|version| { + log_default_virtual_package( + "__osx", + platform, + &version, + Osx::DEFAULT_ENV_NAME, + ); + Osx { version } }) }) } else { @@ -380,11 +402,18 @@ impl VirtualPackages { }; let libc = if platform.is_linux() { - // Check override first, fall back to glibc 0 + // Check override first, fall back to the default glibc version virtual_packages.libc.or_else(|| { + let version = defaults::default_glibc_version(); + log_default_virtual_package( + "__glibc", + platform, + &version, + LibC::DEFAULT_ENV_NAME, + ); Some(LibC { family: "glibc".into(), - version: Version::major(0), + version, }) }) } else { @@ -415,6 +444,19 @@ impl VirtualPackages { } } +/// Logs that the version of a virtual package could not be detected for the +/// target platform and that a default version is assumed instead. +fn log_default_virtual_package( + name: &str, + platform: Platform, + version: &Version, + env_var_name: &str, +) { + tracing::info!( + "cannot detect the version of the virtual package '{name}' when targeting '{platform}', assuming version {version}; set the {env_var_name} environment variable to override" + ); +} + impl From for GenericVirtualPackage { fn from(package: VirtualPackage) -> Self { match package { @@ -1428,6 +1470,46 @@ mod test { assert!(win_names.contains(&"__archspec".to_string())); } + #[test] + fn test_cross_platform_default_versions() { + // When targeting a platform whose virtual packages cannot be detected + // on the host, the pixi default versions are used instead of 0. The + // host's own platform family is skipped because there the detected + // (host) versions take precedence over the defaults. + let overrides = VirtualPackageOverrides::default(); + let current = Platform::current(); + + if !current.is_linux() { + let packages = + VirtualPackages::detect_for_platform(Platform::Linux64, &overrides).unwrap(); + assert_eq!( + packages.linux.expect("__linux should be present").version, + defaults::default_linux_version() + ); + let libc = packages.libc.expect("__glibc should be present"); + assert_eq!(libc.family, "glibc"); + assert_eq!(libc.version, defaults::default_glibc_version()); + } + + if !current.is_osx() { + let packages = + VirtualPackages::detect_for_platform(Platform::OsxArm64, &overrides).unwrap(); + assert_eq!( + packages.osx.expect("__osx should be present").version, + defaults::default_mac_os_version(Platform::OsxArm64).unwrap() + ); + } + + if !current.is_windows() { + let packages = + VirtualPackages::detect_for_platform(Platform::Win64, &overrides).unwrap(); + assert_eq!( + packages.win.expect("__win should be present").version, + Some(defaults::default_windows_version()) + ); + } + } + #[test] fn test_ios_android_virtual_packages() { // Cross-compiling to an ios-* subdir yields __ios (falling back to From ac9ca8d5455c68dc3a4ae1c6fb0498ab88b38bfe Mon Sep 17 00:00:00 2001 From: Bas Zalmstra <4995967+baszalmstra@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:08:09 +0200 Subject: [PATCH 18/21] feat(rattler_conda_types): convert to and from `semver::Version` (#2641) --- .github/workflows/rust-compile.yml | 2 +- Cargo.lock | 1 + Cargo.toml | 1 + crates/rattler_conda_types/Cargo.toml | 1 + crates/rattler_conda_types/src/lib.rs | 2 + crates/rattler_conda_types/src/version/mod.rs | 5 + .../rattler_conda_types/src/version/semver.rs | 514 ++++++++++++++++++ pixi.toml | 2 +- 8 files changed, 526 insertions(+), 2 deletions(-) create mode 100644 crates/rattler_conda_types/src/version/semver.rs diff --git a/.github/workflows/rust-compile.yml b/.github/workflows/rust-compile.yml index b892188077..f8ec6a8122 100644 --- a/.github/workflows/rust-compile.yml +++ b/.github/workflows/rust-compile.yml @@ -22,7 +22,7 @@ env: RUST_BACKTRACE: 1 RUSTFLAGS: "-D warnings" CARGO_TERM_COLOR: always - DEFAULT_FEATURES: indicatif,tokio,serde,reqwest,sparse,gateway,resolvo,libsolv_c,s3,edit,rattler_config + DEFAULT_FEATURES: indicatif,tokio,serde,reqwest,sparse,gateway,resolvo,libsolv_c,s3,edit,rattler_config,rattler_conda_types/semver jobs: check-rustdoc-links: diff --git a/Cargo.lock b/Cargo.lock index 5a49645671..d61d5fc2df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5066,6 +5066,7 @@ dependencies = [ "regex", "rmp-serde", "rstest", + "semver", "serde", "serde-untagged", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 0097a91b8e..621c032702 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -149,6 +149,7 @@ rstest_reuse = "0.7" rustix = { version = "1.1", default-features = false } simd-json = { version = "0.17", features = ["serde_impl"] } self_cell = "1" +semver = "1" serde = { version = "1" } serde_bytes = { version = "0.11" } serde_ignored = "0.1" diff --git a/crates/rattler_conda_types/Cargo.toml b/crates/rattler_conda_types/Cargo.toml index 923c416982..d794ceef7a 100644 --- a/crates/rattler_conda_types/Cargo.toml +++ b/crates/rattler_conda_types/Cargo.toml @@ -57,6 +57,7 @@ url = { workspace = true, features = ["serde"] } indexmap = { workspace = true } dirs = { workspace = true } rayon = { workspace = true, optional = true } +semver = { workspace = true, optional = true } fs-err = { workspace = true } memmap2 = { workspace = true } diff --git a/crates/rattler_conda_types/src/lib.rs b/crates/rattler_conda_types/src/lib.rs index 826d6ceab1..86853f62fb 100644 --- a/crates/rattler_conda_types/src/lib.rs +++ b/crates/rattler_conda_types/src/lib.rs @@ -76,6 +76,8 @@ pub use repo_data::{ }; pub use repo_data_record::{RepoDataRecord, SolverResult}; pub use run_export::RunExportKind; +#[cfg(feature = "semver")] +pub use version::VersionToSemverError; pub use version::{ Component, ParseVersionError, ParseVersionErrorKind, StrictVersion, Version, VersionBumpError, VersionBumpType, VersionExtendError, VersionWithSource, diff --git a/crates/rattler_conda_types/src/version/mod.rs b/crates/rattler_conda_types/src/version/mod.rs index c631f7287a..7db9cf84ac 100644 --- a/crates/rattler_conda_types/src/version/mod.rs +++ b/crates/rattler_conda_types/src/version/mod.rs @@ -18,12 +18,17 @@ use smallvec::SmallVec; mod flags; pub(crate) mod parse; mod segment; +#[cfg(feature = "semver")] +mod semver; mod with_source; pub(crate) mod bump; pub use bump::{VersionBumpError, VersionBumpType}; use flags::Flags; use segment::Segment; +// Disambiguated from the `semver` crate, which is used by the module itself. +#[cfg(feature = "semver")] +pub use self::semver::VersionToSemverError; use thiserror::Error; pub use with_source::VersionWithSource; diff --git a/crates/rattler_conda_types/src/version/semver.rs b/crates/rattler_conda_types/src/version/semver.rs new file mode 100644 index 0000000000..56a750c34f --- /dev/null +++ b/crates/rattler_conda_types/src/version/semver.rs @@ -0,0 +1,514 @@ +//! Conversions between a conda [`Version`] and a [`semver::Version`]. + +use std::fmt::Write; + +use semver::{BuildMetadata, Prerelease, Version as SemverVersion}; +use thiserror::Error; + +use super::{Component, ComponentVec, SegmentVec, Version, flags::Flags, segment::Segment}; + +/// The index of the first local segment is stored in 7 bits, so at most this +/// many segments fit in front of it. +const MAX_LEADING_SEGMENTS: usize = 127; + +/// Maximum number of components a single [`Segment`] can hold. +const MAX_COMPONENTS_PER_SEGMENT: usize = (1 << 13) - 1; + +/// An error that can occur when converting a conda [`Version`] into a +/// [`semver::Version`]. +#[derive(Debug, Error)] +pub enum VersionToSemverError { + /// The version has an epoch. semver does not know about those. + #[error("a semver version cannot express the epoch of `{0}!`")] + EpochNotSupported(u64), + + /// The version has more than three release numbers. + #[error("a semver version can only hold three release numbers (`major.minor.patch`)")] + TooManyReleaseSegments, + + /// The version contains a `post` component. In conda `post` sorts after the + /// release it belongs to, in semver everything behind the `-` sorts before + /// it. There is no way to keep the ordering. + #[error("a semver version cannot express a `post` release")] + PostReleaseNotSupported, + + /// The version contains an underscore. semver does not allow those. + #[error("a semver version cannot contain an underscore")] + UnderscoreNotSupported, + + /// The components behind the release numbers do not form a valid semver + /// pre-release. + #[error("the version cannot be expressed as a semver pre-release")] + InvalidPrerelease(#[source] semver::Error), + + /// The local version part does not form valid semver build metadata. + #[error("the local version cannot be expressed as semver build metadata")] + InvalidBuildMetadata(#[source] semver::Error), +} + +impl From<&SemverVersion> for Version { + /// Converts a [`semver::Version`] into a conda [`Version`]. + /// + /// `major`, `minor` and `patch` become the first three segments, every dot + /// separated pre-release identifier becomes another segment and the build + /// metadata becomes the local version part. So `1.2.3-rc.1+build.5` turns + /// into `1.2.3.rc.1+build.5`. + /// + /// The pre-release is separated with a `.` instead of the `-` that semver + /// uses, because conda package filenames use `-` to split the name, version + /// and build string. Both compare the same in conda, separators do not + /// affect ordering. + /// + /// Ordering is preserved except where conda and semver disagree: + /// + /// * semver sorts numeric identifiers below alphanumeric ones + /// (`1.0.0-1 < 1.0.0-alpha`), conda does the opposite. + /// * conda treats `dev` and `post` as special, so `1.0.0-post` ends up + /// above `1.0.0` instead of below it. + /// + /// Two inputs have no exact representation. A number that does not fit in a + /// `u64` is kept as text, and the build metadata is dropped if the + /// pre-release has so many identifiers that there is no room left to encode + /// the local part. Neither changes the ordering, semver ignores build + /// metadata when comparing anyway. + /// + /// ``` + /// # use std::str::FromStr; + /// use rattler_conda_types::Version; + /// + /// let semver = semver::Version::parse("1.2.3-rc.1+build.5").unwrap(); + /// assert_eq!(Version::from(&semver).to_string(), "1.2.3.rc.1+build.5"); + /// ``` + fn from(value: &SemverVersion) -> Self { + let mut components = ComponentVec::new(); + let mut segments = SegmentVec::new(); + + push_number(&mut components, &mut segments, value.major, None); + push_number(&mut components, &mut segments, value.minor, Some('.')); + push_number(&mut components, &mut segments, value.patch, Some('.')); + push_identifiers( + &mut components, + &mut segments, + value.pre.as_str(), + Some('.'), + ); + + // Build metadata goes behind the `+`. Its first segment has no + // separator, the `+` already is one. + let mut flags = Flags::default(); + let local_segment_index = segments.len(); + if local_segment_index <= MAX_LEADING_SEGMENTS { + push_identifiers(&mut components, &mut segments, value.build.as_str(), None); + + // Only mark a local part if the build metadata produced segments. + if segments.len() > local_segment_index { + flags = u8::try_from(local_segment_index) + .ok() + .and_then(|index| flags.with_local_segment_index(index)) + .expect("the index was bounded above"); + } + } + + Version { + components, + segments, + flags, + } + } +} + +impl From for Version { + fn from(value: SemverVersion) -> Self { + Version::from(&value) + } +} + +/// Appends a single numeric segment. +fn push_number( + components: &mut ComponentVec, + segments: &mut SegmentVec, + number: u64, + separator: Option, +) { + components.push(Component::Numeral(number)); + segments.push( + Segment::new(1) + .expect("one component always fits") + .with_separator(separator) + .expect("`.` is a valid separator"), + ); +} + +/// Appends the dot separated `identifiers` of a pre-release or of build +/// metadata as segments. `first_separator` goes in front of the first one. +fn push_identifiers( + components: &mut ComponentVec, + segments: &mut SegmentVec, + identifiers: &str, + first_separator: Option, +) { + let start = segments.len(); + let mut separator = first_separator; + for identifier in identifiers.split('.') { + // Conda splits on `-` too, so `alpha-1` becomes two segments, the same + // as what the parser does. Empty parts like in `--` have no conda + // equivalent, skip those. + for part in identifier.split('-').filter(|part| !part.is_empty()) { + push_identifier(components, segments, part, separator); + separator = Some('-'); + } + if segments.len() > start { + separator = Some('.'); + } + } +} + +/// Appends a single non-empty semver identifier as one segment. +fn push_identifier( + components: &mut ComponentVec, + segments: &mut SegmentVec, + identifier: &str, + separator: Option, +) { + debug_assert!(!identifier.is_empty()); + + // Segments must start with a number, so one that starts with a letter gets + // an implicit `0` in front of it. + let has_implicit_default = !identifier.starts_with(|c: char| c.is_ascii_digit()); + + // An identifier with more runs than fit in one segment is stored as a + // single component. The text survives, only the ordering is off. + if identifier.len() > MAX_COMPONENTS_PER_SEGMENT { + components.push(Component::Iden( + identifier.to_ascii_lowercase().into_boxed_str(), + )); + push_segment(segments, 1, true, separator); + return; + } + + // Split into alternating runs of digits and letters, like the parser does. + let mut component_count = 0u16; + let mut rest = identifier; + while let Some(first) = rest.chars().next() { + let is_digit = first.is_ascii_digit(); + let end = rest + .find(|c: char| c.is_ascii_digit() != is_digit) + .unwrap_or(rest.len()); + let (run, remainder) = rest.split_at(end); + components.push(component_from_run(run, is_digit)); + component_count += 1; + rest = remainder; + } + + push_segment(segments, component_count, has_implicit_default, separator); +} + +/// Appends a segment covering the last `component_count` components. +fn push_segment( + segments: &mut SegmentVec, + component_count: u16, + has_implicit_default: bool, + separator: Option, +) { + segments.push( + Segment::new(component_count) + .expect("the component count was bounded above") + .with_implicit_default(has_implicit_default) + .with_separator(separator) + .expect("`.` and `-` are valid separators"), + ); +} + +/// Turns a run of digits or letters into a [`Component`], the same way the +/// version parser does. +fn component_from_run(run: &str, is_digit: bool) -> Component { + if is_digit { + // Numbers larger than a `u64` are kept as text. + run.parse() + .map_or_else(|_| Component::Iden(Box::from(run)), Component::Numeral) + } else if run.eq_ignore_ascii_case("post") { + Component::Post + } else if run.eq_ignore_ascii_case("dev") { + Component::Dev + } else if run.bytes().all(|b| b.is_ascii_lowercase()) { + Component::Iden(Box::from(run)) + } else { + Component::Iden(run.to_ascii_lowercase().into_boxed_str()) + } +} + +impl TryFrom<&Version> for SemverVersion { + type Error = VersionToSemverError; + + /// Converts a conda [`Version`] into a [`semver::Version`]. + /// + /// The inverse of the conversion above: the first three segments become + /// `major`, `minor` and `patch`, everything behind them becomes the + /// pre-release and the local version part becomes the build metadata. + /// Missing release numbers default to `0`, so `1.2` becomes `1.2.0`. + /// + /// Conda versions can express a lot more than semver versions, so this + /// fails for anything without a semver equivalent. See + /// [`VersionToSemverError`] for what gets rejected. + /// + /// ``` + /// # use std::str::FromStr; + /// use rattler_conda_types::Version; + /// + /// let version = Version::from_str("1.2.3rc1").unwrap(); + /// let semver = semver::Version::try_from(&version).unwrap(); + /// assert_eq!(semver.to_string(), "1.2.3-rc.1"); + /// ``` + fn try_from(value: &Version) -> Result { + if let Some(epoch) = value.epoch_opt().filter(|epoch| *epoch != 0) { + return Err(VersionToSemverError::EpochNotSupported(epoch)); + } + + let mut release = [0u64; 3]; + let mut release_count = 0; + let mut pre = String::new(); + let mut in_pre = false; + + for segment in value.segments() { + let mut components = strip_implicit_default(&segment).peekable(); + + if !in_pre { + match components + .peek() + .and_then(|component| component.as_number()) + { + // The next release number, e.g. the `3` of `1.2.3rc1`. + Some(number) if release_count < release.len() => { + release[release_count] = number; + release_count += 1; + components.next(); + } + // A zero behind the patch is padding, e.g. `1.2.3.0rc1`. + Some(0) => { + components.next(); + } + // Anything else would be a fourth release number. + Some(_) => return Err(VersionToSemverError::TooManyReleaseSegments), + // The rest of the version is the pre-release. + None => {} + } + in_pre = components.peek().is_some(); + } + + for component in components { + push_semver_identifier(&mut pre, component)?; + } + } + + let mut build = String::new(); + for segment in value.local_segments() { + for component in strip_implicit_default(&segment) { + push_semver_identifier(&mut build, component)?; + } + } + + Ok(SemverVersion { + major: release[0], + minor: release[1], + patch: release[2], + pre: Prerelease::new(&pre).map_err(VersionToSemverError::InvalidPrerelease)?, + build: BuildMetadata::new(&build) + .map_err(VersionToSemverError::InvalidBuildMetadata)?, + }) + } +} + +impl TryFrom for SemverVersion { + type Error = VersionToSemverError; + + fn try_from(value: Version) -> Result { + SemverVersion::try_from(&value) + } +} + +/// Iterates the components of a segment and skips the `0` that conda inserts in +/// front of segments that start with a letter. That zero belongs to the +/// internal representation, not to the version itself. +fn strip_implicit_default<'v>( + segment: &super::SegmentIter<'v>, +) -> impl Iterator { + let mut components = segment.components(); + if segment.has_implicit_default() { + components.next(); + } + components +} + +/// Appends `component` to `out` as another dot separated identifier. +fn push_semver_identifier( + out: &mut String, + component: &Component, +) -> Result<(), VersionToSemverError> { + if !out.is_empty() { + out.push('.'); + } + match component { + Component::Numeral(number) => { + write!(out, "{number}").expect("writing to a string never fails"); + } + Component::Iden(iden) => out.push_str(iden), + Component::Dev => out.push_str("dev"), + Component::Post => return Err(VersionToSemverError::PostReleaseNotSupported), + Component::UnderscoreOrDash { is_dash: true } => out.push('-'), + Component::UnderscoreOrDash { is_dash: false } => { + return Err(VersionToSemverError::UnderscoreNotSupported); + } + } + Ok(()) +} + +#[cfg(test)] +mod test { + use std::str::FromStr; + + use assert_matches::assert_matches; + use rstest::rstest; + + use super::{SemverVersion, Version, VersionToSemverError}; + + #[rstest] + #[case("1.2.3", "1.2.3")] + #[case("0.0.0", "0.0.0")] + #[case("1.0.0", "1.0.0")] + #[case("18446744073709551615.0.1", "18446744073709551615.0.1")] + #[case("1.2.3-rc.1", "1.2.3.rc.1")] + #[case("1.2.3-alpha1", "1.2.3.alpha1")] + #[case("1.2.3-0.3.7", "1.2.3.0.3.7")] + #[case("1.2.3-x.7.z.92", "1.2.3.x.7.z.92")] + #[case("1.2.3-RC.1", "1.2.3.rc.1")] + #[case("1.2.3-dev.1", "1.2.3.dev.1")] + #[case("1.2.3-alpha-1", "1.2.3.alpha-1")] + #[case("1.2.3+build.5", "1.2.3+build.5")] + #[case("1.2.3-rc.1+build.5", "1.2.3.rc.1+build.5")] + #[case("1.2.3+21AF26D3----117B344092BD", "1.2.3+21af26d3-117b344092bd")] + // Numbers that do not fit in a `u64` are kept as text. + #[case("1.2.3-99999999999999999999", "1.2.3.99999999999999999999")] + fn test_from_semver(#[case] input: &str, #[case] expected: &str) { + let semver = SemverVersion::parse(input).unwrap(); + let version = Version::from(&semver); + assert_eq!(version.to_string(), expected); + + // Building the version directly should give the same result as parsing + // the semver string, at least when that string is a valid conda version. + if let Ok(parsed) = Version::from_str(input) { + assert_eq!(version, parsed); + } + } + + #[rstest] + #[case("1.2.3", "1.2.3")] + #[case("1.2", "1.2.0")] + #[case("1", "1.0.0")] + #[case("0!1.2.3", "1.2.3")] + #[case("1.2.3.0", "1.2.3")] + #[case("1.2.3rc1", "1.2.3-rc.1")] + #[case("1.2.3.rc1", "1.2.3-rc.1")] + #[case("1.2.3.0rc1", "1.2.3-rc.1")] + #[case("1.2.3.rc.1", "1.2.3-rc.1")] + #[case("1.rc1", "1.0.0-rc.1")] + #[case("1.2.3.dev1", "1.2.3-dev.1")] + #[case("1.2.3+build.5", "1.2.3+build.5")] + #[case("1.2.3.rc1+build.5", "1.2.3-rc.1+build.5")] + #[case("1.0.1-", "1.0.1--")] + fn test_to_semver(#[case] input: &str, #[case] expected: &str) { + let version = Version::from_str(input).unwrap(); + let semver = SemverVersion::try_from(&version).unwrap(); + assert_eq!(semver.to_string(), expected); + } + + #[rstest] + #[case("1!2.3.4", VersionToSemverError::EpochNotSupported(1))] + #[case("1.2.3.4", VersionToSemverError::TooManyReleaseSegments)] + #[case("1.2.3.4rc1", VersionToSemverError::TooManyReleaseSegments)] + #[case("1.2.3.post1", VersionToSemverError::PostReleaseNotSupported)] + #[case("1.2.3_", VersionToSemverError::UnderscoreNotSupported)] + fn test_to_semver_error(#[case] input: &str, #[case] expected: VersionToSemverError) { + let version = Version::from_str(input).unwrap(); + let error = SemverVersion::try_from(&version).unwrap_err(); + assert_eq!( + std::mem::discriminant(&error), + std::mem::discriminant(&expected), + "expected `{expected}` but got `{error}`" + ); + } + + /// Versions that survive a round trip through a conda version unchanged. + #[rstest] + #[case("1.2.3")] + #[case("1.2.3-rc.1")] + #[case("1.2.3-alpha.1.beta")] + #[case("1.2.3+build.5")] + #[case("1.2.3-rc.1+build.5")] + fn test_round_trip(#[case] input: &str) { + let semver = SemverVersion::parse(input).unwrap(); + let version = Version::from(&semver); + assert_eq!(SemverVersion::try_from(&version).unwrap(), semver); + } + + /// The conversion keeps the ordering of semver versions. + #[test] + fn test_ordering_is_preserved() { + let ordered = [ + "1.0.0-alpha", + "1.0.0-alpha.1", + "1.0.0-beta", + "1.0.0-beta.2", + "1.0.0-beta.11", + "1.0.0-rc.1", + "1.0.0", + "1.0.1", + "1.1.0", + "2.0.0", + ]; + + for pair in ordered.windows(2) { + let left = Version::from(SemverVersion::parse(pair[0]).unwrap()); + let right = Version::from(SemverVersion::parse(pair[1]).unwrap()); + assert!(left < right, "expected {left} < {right}"); + } + } + + /// Where conda and semver ordering disagree. There is no way around these. + #[test] + fn test_ordering_divergence() { + // semver orders numeric identifiers below alphanumeric ones, conda + // orders numbers above strings. + let numeric = SemverVersion::parse("1.0.0-alpha.1").unwrap(); + let alphanumeric = SemverVersion::parse("1.0.0-alpha.beta").unwrap(); + assert!(numeric < alphanumeric); + assert!(Version::from(&numeric) > Version::from(&alphanumeric)); + + // `post` is special in conda, so it sorts above the release instead of + // below it. + let post = SemverVersion::parse("1.0.0-post").unwrap(); + let release = SemverVersion::parse("1.0.0").unwrap(); + assert!(post < release); + assert!(Version::from(&post) > Version::from(&release)); + } + + /// A version with more identifiers than can be encoded must not panic. + #[test] + fn test_pathological_prerelease() { + let pre = ["a"; 200].join("."); + let semver = SemverVersion::parse(&format!("1.2.3-{pre}+build")).unwrap(); + let version = Version::from(&semver); + + // No room left for the build metadata, so it is dropped. + assert!(!version.has_local()); + assert_eq!(version.to_string(), format!("1.2.3.{pre}")); + } + + #[test] + fn test_owned_conversions() { + let semver = SemverVersion::parse("1.2.3-rc.1").unwrap(); + assert_eq!(Version::from(semver.clone()), Version::from(&semver)); + + let version = Version::from_str("1.2.3.rc1").unwrap(); + assert_matches!(SemverVersion::try_from(version), Ok(_)); + } +} diff --git a/pixi.toml b/pixi.toml index 14732c68df..f2dd6881ec 100644 --- a/pixi.toml +++ b/pixi.toml @@ -31,7 +31,7 @@ build = "cargo build" check = "cargo check" # libsolv compilation cannot find pixi's clang for some reason # so we skip that test for now -test = "cargo nextest run --workspace --no-default-features --features=indicatif,tokio,serde,reqwest,sparse,gateway,resolvo,libsolv_c,s3,edit,rattler_config,cli-tools -E 'not test(libsolv_bindings_up_to_date)' --no-fail-fast" +test = "cargo nextest run --workspace --no-default-features --features=indicatif,tokio,serde,reqwest,sparse,gateway,resolvo,libsolv_c,s3,edit,rattler_config,cli-tools,rattler_conda_types/semver -E 'not test(libsolv_bindings_up_to_date)' --no-fail-fast" rattler = "cargo run --bin rattler --release --" doc = "RUSTDOCFLAGS='-Dwarnings -Wunreachable-pub' cargo doc --no-deps --all --all-features" From 4edf98a9a0eab3c93aa639a8f3c3e954efb1acd9 Mon Sep 17 00:00:00 2001 From: Wolf Vollprecht Date: Thu, 6 Aug 2026 21:24:32 +0200 Subject: [PATCH 19/21] feat(rattler_cache): add package layer filtering (#2652) --- .../src/package_cache/cache_key.rs | 40 ++- crates/rattler_cache/src/package_cache/mod.rs | 324 ++++++++++++++++-- 2 files changed, 326 insertions(+), 38 deletions(-) diff --git a/crates/rattler_cache/src/package_cache/cache_key.rs b/crates/rattler_cache/src/package_cache/cache_key.rs index 6414579d6f..4531fc75a0 100644 --- a/crates/rattler_cache/src/package_cache/cache_key.rs +++ b/crates/rattler_cache/src/package_cache/cache_key.rs @@ -1,6 +1,8 @@ -use rattler_conda_types::PackageRecord; -use rattler_conda_types::package::CondaArchiveIdentifier; use rattler_conda_types::utils::{InvalidPathComponentError, ensure_safe_path_component}; +use rattler_conda_types::{ + MatchSpec, PackageName, PackageRecord, VersionWithSource, match_spec::Matches, + package::CondaArchiveIdentifier, +}; use rattler_digest::{Md5Hash, Sha256, Sha256Hash, compute_bytes_digest, compute_url_digest}; use std::path::Path; @@ -83,6 +85,40 @@ impl CacheKey { pub fn md5(&self) -> Option { self.md5 } + + /// Matches the parts of a [`MatchSpec`] represented by a cache key. + /// + /// A cache key contains name, version, build string and optional hashes. + /// Specifications constraining any other package metadata cannot be + /// evaluated reliably and therefore do not match. + pub(crate) fn matches_spec(&self, spec: &MatchSpec) -> bool { + if spec.build_number.is_some() + || spec.file_name.is_some() + || spec.extras.is_some() + || spec.flags.is_some() + || spec.channel.is_some() + || spec.subdir.is_some() + || spec.namespace.is_some() + || spec.url.is_some() + || spec.license.is_some() + || spec.license_family.is_some() + || spec.condition.is_some() + || spec.track_features.is_some() + { + return false; + } + + let Ok(name) = self.name.parse::() else { + return false; + }; + let Ok(version) = self.version.parse::() else { + return false; + }; + let mut record = PackageRecord::new(name, version, self.build_string.clone()); + record.sha256 = self.sha256; + record.md5 = self.md5; + spec.matches(&record) + } } impl From for CacheKey { diff --git a/crates/rattler_cache/src/package_cache/mod.rs b/crates/rattler_cache/src/package_cache/mod.rs index 60e613f7c3..b19baff515 100644 --- a/crates/rattler_cache/src/package_cache/mod.rs +++ b/crates/rattler_cache/src/package_cache/mod.rs @@ -20,7 +20,9 @@ use fs_err::tokio as tokio_fs; use futures::TryFutureExt; use itertools::Itertools; use parking_lot::Mutex; -use rattler_conda_types::{PackageRecord, RepoDataRecord, package::CondaArchiveIdentifier}; +use rattler_conda_types::{ + MatchSpec, PackageRecord, RepoDataRecord, package::CondaArchiveIdentifier, +}; use rattler_digest::Sha256Hash; use rattler_networking::{ LazyClient, @@ -52,15 +54,45 @@ pub struct PackageCache { cache_origin: bool, } -#[derive(Default)] +#[derive(Clone, Default)] struct PackageCacheInner { layers: Vec, } +#[derive(Clone)] pub struct PackageCacheLayer { path: PathBuf, - packages: DashMap>>, + packages: Arc>>>, validation_mode: ValidationMode, + filter: PackageCacheLayerFilter, +} + +/// Controls which packages may be read from or written to a package-cache layer. +/// +/// Include and exclude specifications are matched using the package name, +/// version, build string, and hashes available in a [`CacheKey`]. A package is +/// accepted when it matches at least one include specification (or there are no +/// includes) and does not match any exclude specification. `MatchSpec` fields +/// that are not represented by a cache key never match. +#[derive(Clone, Debug, Default)] +pub struct PackageCacheLayerFilter { + includes: Vec, + excludes: Vec, +} + +impl PackageCacheLayerFilter { + fn accepts(&self, cache_key: &CacheKey) -> bool { + let included = self.includes.is_empty() + || self + .includes + .iter() + .any(|spec| cache_key.matches_spec(spec)); + included + && !self + .excludes + .iter() + .any(|spec| cache_key.matches_spec(spec)) + } } /// A snapshot of the packages present in a [`PackageCache`] at the moment it @@ -77,14 +109,21 @@ pub struct PackageCacheLayer { #[derive(Debug, Clone)] pub struct CacheIndex { /// Directory names as produced by [`CacheKey::to_path_segment`], mapped to - /// the sha256 each layer records for that entry (absent when the entry - /// predates hash recording, or its metadata could not be read). Every - /// layer holding the name contributes, in layer order: lookup walks past - /// a layer whose hash does not match, so the index must too. - entries: HashMap>>, + /// the sha256 and filter of each layer holding that entry. A sha256 is + /// absent when the entry predates hash recording or its metadata could not + /// be read. Every layer holding the name contributes, in layer order: + /// lookup walks past a filtered or hash-mismatching layer, so the index + /// must too. + entries: HashMap>, cache_origin: bool, } +#[derive(Debug, Clone)] +struct CacheIndexEntry { + sha256: Option, + filter: PackageCacheLayerFilter, +} + impl CacheIndex { /// Returns whether the package a [`RepoDataRecord`] describes is present /// in the cache. @@ -147,8 +186,9 @@ impl CacheIndex { // still needs the network. Apply the very same rule here, to every // layer: lookup continues past a mismatching layer, so a match // anywhere is a match. - cached_sha256s.iter().any(|cached_sha256| { - !cache_lock::sha256_mismatch(cache_key.sha256().as_ref(), cached_sha256.as_ref()) + cached_sha256s.iter().any(|entry| { + entry.filter.accepts(cache_key) + && !cache_lock::sha256_mismatch(cache_key.sha256().as_ref(), entry.sha256.as_ref()) }) } @@ -201,8 +241,8 @@ pub enum PackageCacheError { #[error("failed to interact with the package cache layer.")] LayerError(#[source] Box), // Wraps layer-specific errors - /// There are no writable layers to cache package to - #[error("no writable layers to cache package to")] + /// There are no eligible writable layers to cache the package to. + #[error("no eligible writable layers to cache package to")] NoWritableLayers, /// The cache key contains metadata that could lead to path traversal. @@ -278,6 +318,49 @@ fn is_mounted_readonly(_path: &Path) -> bool { } impl PackageCacheLayer { + /// Creates an unfiltered package-cache layer with default validation. + pub fn new(path: impl Into) -> Self { + Self { + path: path.into(), + packages: Arc::new(DashMap::default()), + validation_mode: ValidationMode::default(), + filter: PackageCacheLayerFilter::default(), + } + } + + /// Sets the validation mode used by this layer. + pub fn with_validation_mode(mut self, validation_mode: ValidationMode) -> Self { + self.validation_mode = validation_mode; + self + } + + /// Adds an inclusion [`MatchSpec`] to this layer. + /// + /// Multiple inclusion specifications are combined with OR. If no + /// inclusion specification is configured, all packages are included. + pub fn with_filter(mut self, spec: MatchSpec) -> Self { + self.filter.includes.push(spec); + self + } + + /// Excludes packages matching `spec` from this layer. + /// + /// Exclusions take precedence over inclusion specifications. + pub fn excluding(mut self, spec: MatchSpec) -> Self { + self.filter.excludes.push(spec); + self + } + + /// Returns the root directory of this layer. + pub fn path(&self) -> &Path { + &self.path + } + + /// Returns whether this layer accepts `cache_key`. + pub fn accepts(&self, cache_key: &CacheKey) -> bool { + self.filter.accepts(cache_key) + } + /// Determine if the layer is read-only in the filesystem pub fn is_readonly(&self) -> bool { self.path @@ -387,6 +470,28 @@ impl PackageCache { } } + /// Prepends a configured layer to this cache. + /// + /// Existing layers, filters, cached-origin behavior, and in-memory entry + /// coordination are preserved. This is useful for adding a temporary + /// overlay to an already configured cache. + pub fn with_prepended_layer(mut self, layer: PackageCacheLayer) -> Self { + Arc::make_mut(&mut self.inner).layers.insert(0, layer); + self + } + + /// Excludes packages matching `spec` from every existing layer. + /// + /// Combined with [`Self::with_prepended_layer`], this allows a package to + /// be routed exclusively to a new layer without reconstructing the + /// existing cache or losing custom cache directories. + pub fn excluding_from_all_layers(mut self, spec: MatchSpec) -> Self { + for layer in &mut Arc::make_mut(&mut self.inner).layers { + layer.filter.excludes.push(spec.clone()); + } + self + } + /// Acquires a global lock on the package cache. /// /// This lock can be used to coordinate multiple package operations, @@ -430,17 +535,27 @@ impl PackageCache { I: IntoIterator, I::Item: Into, { - let layers = paths + let layers: Vec<_> = paths .into_iter() - .map(|path| PackageCacheLayer { - path: path.into(), - packages: DashMap::default(), - validation_mode, - }) + .map(|path| PackageCacheLayer::new(path).with_validation_mode(validation_mode)) .collect(); + Self::from_layers(layers, cache_origin) + } + + /// Constructs a cache from configured layers. + /// + /// This constructor enables per-layer routing through + /// [`PackageCacheLayer::with_filter`] and [`PackageCacheLayer::excluding`]. + /// Unfiltered layers retain the behavior of [`PackageCache::new_layered`]. + pub fn from_layers(layers: I, cache_origin: bool) -> Self + where + I: IntoIterator, + { Self { - inner: Arc::new(PackageCacheInner { layers }), + inner: Arc::new(PackageCacheInner { + layers: layers.into_iter().collect(), + }), cache_origin, } } @@ -455,17 +570,17 @@ impl PackageCache { /// thousands of filesystem calls. It runs on a blocking thread to keep /// them off the async runtime. pub async fn index(&self) -> std::io::Result { - let layer_paths: Vec = self + let layers: Vec<(PathBuf, PackageCacheLayerFilter)> = self .inner .layers .iter() - .map(|layer| layer.path.clone()) + .map(|layer| (layer.path.clone(), layer.filter.clone())) .collect(); let cache_origin = self.cache_origin; let scan = tokio::task::spawn_blocking(move || { - let mut entries: HashMap>> = HashMap::new(); - for layer_path in layer_paths { + let mut entries: HashMap> = HashMap::new(); + for (layer_path, filter) in layers { let dir = match fs_err::read_dir(&layer_path) { Ok(dir) => dir, Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue, @@ -480,7 +595,13 @@ impl PackageCache { let sha256 = cache_lock::peek_sha256(&entry.path()); // Every layer holding the name contributes: lookup // walks past a layer whose hash does not match. - entries.entry(name.to_owned()).or_default().push(sha256); + entries + .entry(name.to_owned()) + .or_default() + .push(CacheIndexEntry { + sha256, + filter: filter.clone(), + }); } } } @@ -524,10 +645,10 @@ impl PackageCache { /// /// ## Layer Priority /// - /// Layers are checked in the order they were provided to [`PackageCache::new_layered`]. - /// If a valid package is found in any layer, it is returned immediately. If no valid - /// package is found in any layer, the package is fetched and written to the first - /// writable layer. + /// Eligible layers are checked in configured order. If a valid package is + /// found in any eligible layer, it is returned immediately. Otherwise the + /// package is fetched and written to the first eligible writable layer. + /// Unfiltered layers are eligible for every package. /// /// If the package is already being fetched by another task/thread the /// request is coalesced. No duplicate fetch is performed. @@ -547,9 +668,12 @@ impl PackageCache { // Reject keys that could escape the cache root (GHSA-h672-p7h7-97v9). let cache_segment = cache_key.to_path_segment()?; - let (_, writable_layers) = self.split_layers(); - - for layer in self.inner.layers.iter() { + for layer in self + .inner + .layers + .iter() + .filter(|layer| layer.accepts(&cache_key)) + { let cache_path = layer.path.join(&cache_segment); if cache_path.exists() { @@ -576,9 +700,16 @@ impl PackageCache { } } - // No matches in all layers, let's write to the first writable layer - tracing::debug!("no matches in all layers. writing to first writable layer"); - if let Some(layer) = writable_layers.first() { + // No matches in eligible layers: write to the first eligible, + // writable layer. + tracing::debug!("no matches in eligible layers; writing to first eligible writable layer"); + if let Some(layer) = self + .inner + .layers + .iter() + .filter(|layer| layer.accepts(&cache_key)) + .find(|layer| !layer.is_readonly()) + { return match layer.validate_or_fetch(fetch, &cache_key, reporter).await { Ok(cache_metadata) => Ok(cache_metadata), Err(e) => Err(e.into()), @@ -1154,7 +1285,9 @@ mod test { use bytes::Bytes; use futures::stream; use rattler_conda_types::package::{CondaArchiveIdentifier, PackageFile, PathsJson}; - use rattler_conda_types::{PackageName, PackageRecord, RepoDataRecord, VersionWithSource}; + use rattler_conda_types::{ + MatchSpec, PackageName, PackageRecord, RepoDataRecord, VersionWithSource, + }; use rattler_digest::{ Sha256, compute_bytes_digest, compute_file_digest, parse_digest_from_hex, }; @@ -1167,7 +1300,7 @@ mod test { use tokio_stream::StreamExt; use url::Url; - use super::{PackageCache, cache_lock, rename_with_retry}; + use super::{PackageCache, PackageCacheLayer, cache_lock, rename_with_retry}; use crate::{ package_cache::{CacheKey, PackageCacheError}, validation::{ValidationMode, validate_package_directory}, @@ -1718,6 +1851,125 @@ mod test { test_flaky_package_cache(conda, Middleware::FailWithBrokenPipe(50)).await; } + fn clobber_python_spec() -> MatchSpec { + "clobber-python ==0.1.0 cpython".parse().unwrap() + } + + #[tokio::test] + async fn test_layer_filters_route_packages_to_different_layers() { + let temp = tempdir().unwrap(); + let global = tempdir().unwrap(); + let spec = clobber_python_spec(); + let cache = PackageCache::new(global.path()) + .excluding_from_all_layers(spec.clone()) + .with_prepended_layer(PackageCacheLayer::new(temp.path()).with_filter(spec)); + + let python = get_test_data_dir().join("clobber/clobber-python-0.1.0-cpython.conda"); + let other = get_test_data_dir().join("clobber/clobber-pypy-0.1.0-h4616a5c_0.conda"); + + let python_metadata = cache + .get_or_fetch_from_path(&python, None, None) + .await + .unwrap(); + let other_metadata = cache + .get_or_fetch_from_path(&other, None, None) + .await + .unwrap(); + + assert!(python_metadata.path().starts_with(temp.path())); + assert!( + !global + .path() + .join(python_metadata.path().file_name().unwrap()) + .exists() + ); + assert!(other_metadata.path().starts_with(global.path())); + assert!( + !temp + .path() + .join(other_metadata.path().file_name().unwrap()) + .exists() + ); + } + + #[tokio::test] + async fn test_layer_filters_apply_to_lookup_and_index() { + let temp = tempdir().unwrap(); + let global = tempdir().unwrap(); + let package = get_test_data_dir().join("clobber/clobber-python-0.1.0-cpython.conda"); + let identifier = CondaArchiveIdentifier::try_from_path(&package).unwrap(); + + // Seed an entry that the subsequently routed cache must not use. + PackageCache::new(global.path()) + .get_or_fetch_from_path(&package, None, None) + .await + .unwrap(); + + let spec = clobber_python_spec(); + let cache = PackageCache::from_layers( + [ + PackageCacheLayer::new(temp.path()).with_filter(spec.clone()), + PackageCacheLayer::new(global.path()).excluding(spec), + ], + false, + ); + + assert!( + !cache + .index() + .await + .unwrap() + .contains_path(identifier, &package) + ); + let metadata = cache + .get_or_fetch_from_path(&package, None, None) + .await + .unwrap(); + assert!(metadata.path().starts_with(temp.path())); + } + + #[tokio::test] + async fn test_first_eligible_layer_wins() { + let first = tempdir().unwrap(); + let second = tempdir().unwrap(); + let package = get_test_data_dir().join("clobber/clobber-python-0.1.0-cpython.conda"); + + for path in [first.path(), second.path()] { + PackageCache::new(path) + .get_or_fetch_from_path(&package, None, None) + .await + .unwrap(); + } + + let cache = PackageCache::from_layers( + [ + PackageCacheLayer::new(first.path()), + PackageCacheLayer::new(second.path()), + ], + false, + ); + let metadata = cache + .get_or_fetch_from_path(&package, None, None) + .await + .unwrap(); + assert!(metadata.path().starts_with(first.path())); + } + + #[tokio::test] + async fn test_no_eligible_writable_layer() { + let layer = tempdir().unwrap(); + let cache = PackageCache::from_layers( + [PackageCacheLayer::new(layer.path()).with_filter(clobber_python_spec())], + false, + ); + let package = get_test_data_dir().join("clobber/clobber-pypy-0.1.0-h4616a5c_0.conda"); + + assert_matches!( + cache.get_or_fetch_from_path(&package, None, None).await, + Err(PackageCacheError::NoWritableLayers) + ); + } + /// An index over a cache directory that does not exist yet is empty rather /// than an error: a cache is allowed to be cold. #[tokio::test] From bb636c546a72ce3c9fbe6ea9260fa3e29fd49574 Mon Sep 17 00:00:00 2001 From: Hofer-Julian <30049909+Hofer-Julian@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:26:52 +0200 Subject: [PATCH 20/21] feat: add a shared config layer read by all rattler-based tools (#2645) --- crates/rattler_config/src/config.rs | 111 ++- crates/rattler_config/src/lib.rs | 84 +- crates/rattler_config/src/locations.rs | 212 ++++- crates/rattler_config/tests/shared_layer.rs | 867 ++++++++++++++++++++ 4 files changed, 1202 insertions(+), 72 deletions(-) create mode 100644 crates/rattler_config/tests/shared_layer.rs diff --git a/crates/rattler_config/src/config.rs b/crates/rattler_config/src/config.rs index d514a3ae6a..df3720f4c5 100644 --- a/crates/rattler_config/src/config.rs +++ b/crates/rattler_config/src/config.rs @@ -25,6 +25,7 @@ use crate::config::{ build::BuildConfig, concurrency::ConcurrencyConfig, index::IndexConfig, proxy::ProxyConfig, repodata_config::RepodataConfig, run_post_link_scripts::RunPostLinkScripts, }; +use crate::locations::{ConfigLayer, ConfigLocation}; pub mod build; pub mod channel_config; @@ -493,10 +494,64 @@ where )) } + /// Parse a *shared* configuration file from a TOML string. + /// + /// Shared files (see [`crate::locations::ConfigLayer::Shared`]) may only + /// contain the keys shared by all rattler-based tools: the document is + /// deserialized into [`CommonConfig`] alone and the extension is left at + /// its default. Returns the parsed configuration together with the set + /// of keys [`CommonConfig`] did not recognize, including extension keys + /// the tool itself would understand, so that a shared file means the + /// same thing to every tool reading it. + pub fn from_toml_str_shared(input: &str) -> Result<(Self, BTreeSet), toml::de::Error> { + let mut unknown = BTreeSet::new(); + let common: CommonConfig = serde_ignored::deserialize( + toml::de::Deserializer::parse(input)?, + |path: serde_ignored::Path<'_>| { + unknown.insert(path.to_string()); + }, + )?; + + Ok(( + Self { + common, + extensions: T::default(), + loaded_from: Vec::new(), + }, + unknown, + )) + } + + /// Parse the file at `path` according to its `layer` and merge it into + /// `self`, warning about ignored keys. + fn merge_from_path(self, path: &Path, layer: ConfigLayer) -> Result { + let content = fs_err::read_to_string(path)?; + let (mut other, unused) = match layer { + ConfigLayer::Shared => Self::from_toml_str_shared(&content)?, + ConfigLayer::Tool => Self::from_toml_str(&content)?, + }; + for key in &unused { + match layer { + ConfigLayer::Shared => tracing::warn!( + "Ignoring configuration key `{key}` in {}: not a key shared by all rattler-based tools", + path.display() + ), + ConfigLayer::Tool => tracing::warn!( + "Ignoring unknown configuration key `{key}` in {}", + path.display() + ), + } + } + other.loaded_from.push(path.to_path_buf()); + self.merge_config(&other) + .map_err(|e| LoadError::MergeError(e, path.to_path_buf())) + } + /// Load the configuration by merging all the given files, in order: - /// later files take precedence over earlier ones. Unrecognized keys are - /// reported as `tracing` warnings; the merged configuration is validated - /// before it is returned. + /// later files take precedence over earlier ones. Every file is parsed + /// as a tool file (common keys plus extension keys); unrecognized keys + /// are reported as `tracing` warnings. The merged configuration is + /// validated before it is returned. /// /// Missing files result in an error; callers that search default /// locations should filter for existing files first (see @@ -505,38 +560,44 @@ where where I: IntoIterator, P: AsRef, + { + Self::load_from_locations(paths.into_iter().map(|path| ConfigLocation { + path: path.as_ref().to_path_buf(), + layer: ConfigLayer::Tool, + })) + } + + /// Load the configuration by merging all the given locations, in order: + /// later locations take precedence over earlier ones. Each file is + /// parsed according to its layer: shared files accept only the common + /// keys (see [`ConfigBase::from_toml_str_shared`]), tool files also + /// accept the extension keys. Unrecognized keys are reported as + /// `tracing` warnings; the merged configuration is validated before it + /// is returned. + pub fn load_from_locations(locations: I) -> Result + where + I: IntoIterator, { let mut config = Self::default(); - for path in paths { - let path = path.as_ref(); - let content = fs_err::read_to_string(path)?; - let (mut other, unused) = Self::from_toml_str(&content)?; - for key in &unused { - tracing::warn!( - "Ignoring unknown configuration key `{key}` in {}", - path.display() - ); - } - other.loaded_from.push(path.to_path_buf()); - config = config - .merge_config(&other) - .map_err(|e| LoadError::MergeError(e, path.to_path_buf()))?; + for location in locations { + config = config.merge_from_path(&location.path, location.layer)?; } config.validate()?; Ok(config) } - /// Load the configuration from the default locations of the given tools - /// (e.g. `&["pixi", "rattler-build"]`), skipping files that do not - /// exist. See [`crate::locations::config_search_paths`] for the exact - /// search order. - pub fn load_from_default_locations(tool_dirs: &[&str]) -> Result { - Self::load_from_files( - crate::locations::config_search_paths(tool_dirs) + /// Load the configuration from the default locations of the given tool + /// (e.g. `"rattler-build"`), skipping files that do not exist: the + /// shared `rattler` configuration layered with the tool's own files. + /// See [`crate::locations::config_search_paths`] for the exact search + /// order. + pub fn load_from_default_locations(tool: &str) -> Result { + Self::load_from_locations( + crate::locations::config_search_paths(tool) .into_iter() - .filter(|path| path.is_file()), + .filter(|location| location.path.is_file()), ) } } diff --git a/crates/rattler_config/src/lib.rs b/crates/rattler_config/src/lib.rs index a7feec4d4d..6c3f9de6d1 100644 --- a/crates/rattler_config/src/lib.rs +++ b/crates/rattler_config/src/lib.rs @@ -58,10 +58,11 @@ //! [`config::ConfigBase::load_from_files`] merges a list of files in order //! (later files win) and validates the result. //! [`config::ConfigBase::load_from_default_locations`] does the same for the -//! conventional locations described in [`locations`], which is how tools -//! share one configuration: e.g. rattler-build can load -//! `&["pixi", "rattler-build"]` to layer its own configuration on top of -//! pixi's. +//! conventional locations described in [`locations`]: the shared `rattler` +//! configuration files, which every rattler-based tool reads and which may +//! only contain the [`config::CommonConfig`] keys, layered with the tool's +//! own files. This is how tools share one configuration without reading +//! each other's files. //! //! # Editing //! @@ -76,6 +77,7 @@ pub mod edit; pub mod locations; pub use config::{CommonConfig, Config, ConfigBase, LoadError, MergeError, NoExtension}; +pub use locations::{ConfigLayer, ConfigLocation}; #[cfg(test)] mod tests { @@ -469,6 +471,80 @@ mod tests { assert!(unused.contains("custom_field")); } + #[test] + fn test_from_toml_str_shared_rejects_extension_keys() { + let toml = r#" + default-channels = ["conda-forge"] + tls-no-verify = true + custom_field = "an extension key" + definitely-a-typo = true + "#; + + let (config, unused) = TestConfig::from_toml_str_shared(toml).unwrap(); + + // Common keys are consumed as usual. + assert_eq!(config.default_channels.as_ref().map(Vec::len), Some(1)); + assert_eq!(config.tls_no_verify, Some(true)); + + // A shared file means the same thing to every tool: extension keys + // are reported as unused even though the extension knows them, and + // the extension stays at its default. + assert!(unused.contains("custom_field")); + assert!(unused.contains("definitely-a-typo")); + assert_eq!(config.extensions, TestExtension::default()); + } + + #[test] + fn test_load_from_locations_layers_shared_and_tool_files() { + use crate::locations::{ConfigLayer, ConfigLocation}; + + let temp_dir = TempDir::new().unwrap(); + let shared_path = temp_dir.path().join("shared.toml"); + let tool_path = temp_dir.path().join("tool.toml"); + std::fs::write( + &shared_path, + r#" + default-channels = ["conda-forge"] + tls-no-verify = true + "#, + ) + .unwrap(); + std::fs::write( + &tool_path, + r#" + default-channels = ["bioconda"] + custom_field = "tool files accept extension keys" + "#, + ) + .unwrap(); + + let config = TestConfig::load_from_locations([ + ConfigLocation { + path: shared_path.clone(), + layer: ConfigLayer::Shared, + }, + ConfigLocation { + path: tool_path.clone(), + layer: ConfigLayer::Tool, + }, + ]) + .unwrap(); + + // The tool file wins where both set a key… + assert_eq!( + config.default_channels.as_ref().and_then(|c| c.first()), + Some(&"bioconda".parse().unwrap()) + ); + // …values only in the shared file are kept… + assert_eq!(config.tls_no_verify, Some(true)); + // …and extension keys from the tool file are consumed. + assert_eq!( + config.extensions.custom_field.as_deref(), + Some("tool files accept extension keys") + ); + assert_eq!(config.loaded_from, vec![shared_path, tool_path]); + } + #[test] fn test_validation_recurses_into_extension() { let toml = "numeric_field = 101"; diff --git a/crates/rattler_config/src/locations.rs b/crates/rattler_config/src/locations.rs index 046da6f4f5..21d83f3157 100644 --- a/crates/rattler_config/src/locations.rs +++ b/crates/rattler_config/src/locations.rs @@ -1,6 +1,22 @@ //! Standard configuration file locations shared by rattler-based tools. //! -//! Every tool has three conventional configuration locations, from lowest to +//! Configuration comes from two layers: +//! +//! - the **shared** layer: files every rattler-based tool reads. They may +//! only contain the keys shared by all tools ([`crate::config::CommonConfig`]); +//! tool-specific keys in these files are ignored with a warning. +//! - the **tool** layer: the tool's own files, which accept the shared keys +//! plus the tool-specific extension keys. +//! +//! The shared layer lives in the `rattler` directory: +//! `/etc/rattler/config.toml` (`C:\ProgramData\rattler\config.toml` on +//! Windows) and `$XDG_CONFIG_HOME/rattler/config.toml` (or the platform +//! equivalent reported by [`dirs::config_dir`]). `$RATTLER_HOME/config.toml` +//! is honored when the environment variable is set, but unlike the tool +//! layer there is no `~/.rattler` fallback: the shared layer is pure +//! configuration and does not warrant a home directory. +//! +//! Each tool has three conventional configuration locations, from lowest to //! highest precedence: //! //! 1. a system-wide file: `/etc//config.toml` (Linux/macOS) or @@ -12,17 +28,40 @@ //! environment variable is set (e.g. `PIXI_HOME`), otherwise //! `~/./config.toml`. //! -//! [`config_search_paths`] combines these for a *list* of tools so that a -//! tool can layer its own configuration on top of the configuration of the -//! tools it cooperates with — e.g. `rattler-build` passing -//! `&["pixi", "rattler-build"]` reads pixi's global configuration and -//! overrides it with its own. +//! [`config_search_paths`] combines both layers for a tool, from lowest to +//! highest precedence: system shared, system tool, user shared, user tool. +//! The user always overrides the system, and within each level the +//! tool-specific file overrides the shared one. use std::path::PathBuf; /// The conventional file name of a configuration file. pub const CONFIG_FILE_NAME: &str = "config.toml"; +/// The directory name of the shared configuration layer. +pub const SHARED_CONFIG_DIR: &str = "rattler"; + +/// The configuration layer a file belongs to, which determines the keys the +/// file may contain. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConfigLayer { + /// A file shared by all rattler-based tools; only the common keys are + /// allowed. + Shared, + /// A tool's own file; common keys plus the tool's extension keys are + /// allowed. + Tool, +} + +/// A candidate configuration file together with the layer it belongs to. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConfigLocation { + /// The path of the configuration file. + pub path: PathBuf, + /// The layer the file belongs to. + pub layer: ConfigLayer, +} + /// The name of the environment variable pointing at a tool's home directory, /// e.g. `PIXI_HOME` for `pixi` or `RATTLER_BUILD_HOME` for `rattler-build`. fn home_env_var(tool: &str) -> String { @@ -71,27 +110,86 @@ pub fn tool_home(tool: &str) -> Option { } } -/// All configuration file locations for the given tools, from lowest to -/// highest precedence: first the system-wide files of every tool, then the -/// per-user files of every tool. Within each group, later tools in the list -/// take precedence over earlier ones. +/// The system-wide shared configuration file: +/// `/etc/rattler/config.toml`, or `C:\ProgramData\rattler\config.toml` on +/// Windows. +pub fn shared_system_config_path() -> PathBuf { + system_config_path(SHARED_CONFIG_DIR) +} + +/// The per-user shared configuration files, from lowest to highest +/// precedence. Unlike [`user_config_paths`], `$RATTLER_HOME/config.toml` is +/// only included when the environment variable is set: the shared layer has +/// no `~/.rattler` fallback. +pub fn shared_user_config_paths() -> Vec { + [ + // On macOS, honor an explicitly set XDG_CONFIG_HOME even though it + // is not part of the platform convention used by `dirs`. + #[cfg(target_os = "macos")] + std::env::var("XDG_CONFIG_HOME").ok().map(|d| { + PathBuf::from(d) + .join(SHARED_CONFIG_DIR) + .join(CONFIG_FILE_NAME) + }), + dirs::config_dir().map(|d| d.join(SHARED_CONFIG_DIR).join(CONFIG_FILE_NAME)), + std::env::var_os(home_env_var(SHARED_CONFIG_DIR)) + .map(|home| PathBuf::from(home).join(CONFIG_FILE_NAME)), + ] + .into_iter() + .flatten() + .collect() +} + +/// All configuration file locations for a tool, from lowest to highest +/// precedence: the system-wide shared file, the system-wide tool file, the +/// per-user shared files, and the per-user tool files. The user always +/// overrides the system, and within each level the tool file overrides the +/// shared one. /// /// The returned paths are candidates; they are not checked for existence. -/// Duplicates (e.g. from overlapping tool homes) are removed, keeping the -/// occurrence with the highest precedence. -pub fn config_search_paths(tools: &[&str]) -> Vec { - let mut paths: Vec = tools +/// Duplicates are removed, keeping the occurrence with the highest +/// precedence; a path that appears in both layers (e.g. `RATTLER_HOME` +/// pointing into a tool's directory) is parsed as a tool file, since the +/// tool layer accepts a superset of the shared keys. +pub fn config_search_paths(tool: &str) -> Vec { + let mut locations: Vec = [(shared_system_config_path(), ConfigLayer::Shared)] + .into_iter() + .chain([(system_config_path(tool), ConfigLayer::Tool)]) + .chain( + shared_user_config_paths() + .into_iter() + .map(|path| (path, ConfigLayer::Shared)), + ) + .chain( + user_config_paths(tool) + .into_iter() + .map(|path| (path, ConfigLayer::Tool)), + ) + .map(|(path, layer)| ConfigLocation { path, layer }) + .collect(); + + let tool_paths: std::collections::HashSet = locations .iter() - .map(|tool| system_config_path(tool)) - .chain(tools.iter().flat_map(|tool| user_config_paths(tool))) + .filter(|location| location.layer == ConfigLayer::Tool) + .map(|location| location.path.clone()) .collect(); - // Deduplicate, keeping the *last* occurrence (highest precedence). + // Deduplicate by path, keeping the *last* occurrence (highest + // precedence). A path that also appears in the tool layer keeps the + // `Tool` parse mode regardless of which occurrence survives. let mut seen = std::collections::HashSet::new(); - let mut deduped: Vec = paths + let mut deduped: Vec = locations .drain(..) .rev() - .filter(|path| seen.insert(path.clone())) + .filter(|location| seen.insert(location.path.clone())) + .map(|location| { + let layer = if tool_paths.contains(&location.path) { + ConfigLayer::Tool + } else { + ConfigLayer::Shared + }; + ConfigLocation { layer, ..location } + }) .collect(); deduped.reverse(); deduped @@ -108,38 +206,66 @@ mod tests { } #[test] - fn search_paths_order_system_before_user() { - let paths = config_search_paths(&["pixi", "rattler-build"]); - let system_pixi = system_config_path("pixi"); - let user_pixi = user_config_paths("pixi"); + fn shared_user_paths_have_no_dotdir_fallback() { + // Without RATTLER_HOME set, the shared layer must not fall back to + // `~/.rattler` the way `user_config_paths` falls back to `~/.`. + if std::env::var_os("RATTLER_HOME").is_none() + && let Some(home) = dirs::home_dir() + { + let dotdir = home.join(".rattler").join(CONFIG_FILE_NAME); + assert!( + !shared_user_config_paths().contains(&dotdir), + "shared layer must not use a ~/.rattler dotdir" + ); + } + } - let system_pos = paths.iter().position(|p| p == &system_pixi); - let user_pos = user_pixi - .first() - .and_then(|first| paths.iter().position(|p| p == first)); + #[test] + fn search_paths_interleave_layers_by_level() { + let locations = config_search_paths("pixi"); + let position = |path: &std::path::Path| locations.iter().position(|l| l.path == path); + + let system_shared = position(&shared_system_config_path()); + let system_tool = position(&system_config_path("pixi")); + let user_shared = shared_user_config_paths().first().and_then(|p| position(p)); + let user_tool = user_config_paths("pixi").first().and_then(|p| position(p)); - if let (Some(system_pos), Some(user_pos)) = (system_pos, user_pos) { + if let (Some(system_shared), Some(system_tool)) = (system_shared, system_tool) { + assert!( + system_shared < system_tool, + "system tool config must override system shared config" + ); + } + if let (Some(system_tool), Some(user_shared)) = (system_tool, user_shared) { + assert!( + system_tool < user_shared, + "user shared config must override system tool config" + ); + } + if let (Some(user_shared), Some(user_tool)) = (user_shared, user_tool) { assert!( - system_pos < user_pos, - "system config must have lower precedence than user config" + user_shared < user_tool, + "user tool config must override user shared config" ); } } #[test] - fn search_paths_order_within_user_group_follows_tool_order() { - let paths = config_search_paths(&["pixi", "rattler-build"]); - let pixi_user = user_config_paths("pixi"); - let rb_user = user_config_paths("rattler-build"); - - if let (Some(pixi_first), Some(rb_last)) = (pixi_user.first(), rb_user.last()) { - let pixi_pos = paths.iter().position(|p| p == pixi_first); - let rb_pos = paths.iter().position(|p| p == rb_last); - if let (Some(pixi_pos), Some(rb_pos)) = (pixi_pos, rb_pos) { - assert!( - pixi_pos < rb_pos, - "later tools must take precedence over earlier ones" - ); + fn search_paths_mark_layers() { + let locations = config_search_paths("pixi"); + let tool_paths: Vec = [system_config_path("pixi")] + .into_iter() + .chain(user_config_paths("pixi")) + .collect(); + for location in &locations { + let is_shared_path = location.path == shared_system_config_path() + || shared_user_config_paths().contains(&location.path); + // A path in both layers is parsed as a tool file. + match location.layer { + ConfigLayer::Shared => { + assert!(is_shared_path && !tool_paths.contains(&location.path)); + } + ConfigLayer::Tool => assert!(tool_paths.contains(&location.path)), } } } diff --git a/crates/rattler_config/tests/shared_layer.rs b/crates/rattler_config/tests/shared_layer.rs new file mode 100644 index 0000000000..563cd2521e --- /dev/null +++ b/crates/rattler_config/tests/shared_layer.rs @@ -0,0 +1,867 @@ +//! End-to-end integration tests for the shared configuration layer: +//! `ConfigLayer`/`ConfigLocation`, `load_from_locations`, +//! `from_toml_str_shared`, the layered search paths and the tracing +//! warnings emitted for ignored keys. + +use std::ffi::OsStr; +use std::path::PathBuf; +use std::process::Command; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; + +use rattler_config::config::{Config, ConfigBase, MergeError}; +use rattler_config::locations::{ConfigLayer, ConfigLocation, config_search_paths}; +use serde::{Deserialize, Serialize}; +use tempfile::TempDir; +use tracing::field::{Field, Visit}; +use tracing::{Event, Level, Metadata, Subscriber, span}; +use url::Url; + +/// A tool-specific extension, mirroring what pixi/rattler-build would do. +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct ToolExt { + #[serde(default)] + custom_field: Option, + #[serde(default)] + numeric_field: Option, +} + +impl Config for ToolExt { + fn merge_config(self, other: &Self) -> Result { + Ok(Self { + custom_field: other.custom_field.clone().or(self.custom_field), + numeric_field: other.numeric_field.or(self.numeric_field), + }) + } +} + +type ToolConfig = ConfigBase; + +// --------------------------------------------------------------------------- +// Warning capture: a minimal tracing subscriber recording WARN messages. +// --------------------------------------------------------------------------- + +struct RecordingSubscriber { + warnings: Arc>>, + next_id: AtomicU64, +} + +struct MessageVisitor(Option); + +impl Visit for MessageVisitor { + fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { + if field.name() == "message" { + self.0 = Some(format!("{value:?}")); + } + } +} + +impl Subscriber for RecordingSubscriber { + fn enabled(&self, _metadata: &Metadata<'_>) -> bool { + true + } + fn new_span(&self, _span: &span::Attributes<'_>) -> span::Id { + span::Id::from_u64(self.next_id.fetch_add(1, Ordering::Relaxed) + 1) + } + fn record(&self, _span: &span::Id, _values: &span::Record<'_>) {} + fn record_follows_from(&self, _span: &span::Id, _follows: &span::Id) {} + fn event(&self, event: &Event<'_>) { + if *event.metadata().level() != Level::WARN { + return; + } + let mut visitor = MessageVisitor(None); + event.record(&mut visitor); + if let Some(message) = visitor.0 { + self.warnings.lock().unwrap().push(message); + } + } + fn enter(&self, _span: &span::Id) {} + fn exit(&self, _span: &span::Id) {} +} + +/// Run `f` with a thread-local recording subscriber and return the result +/// together with all WARN-level messages emitted during the call. +fn capture_warnings(f: impl FnOnce() -> R) -> (R, Vec) { + let warnings = Arc::new(Mutex::new(Vec::new())); + let subscriber = RecordingSubscriber { + warnings: Arc::clone(&warnings), + next_id: AtomicU64::new(0), + }; + let result = tracing::subscriber::with_default(subscriber, f); + let warnings = warnings.lock().unwrap().clone(); + (result, warnings) +} + +fn write_file(dir: &TempDir, name: &str, content: &str) -> PathBuf { + let path = dir.path().join(name); + std::fs::write(&path, content).unwrap(); + path +} + +const SHARED_WARNING_MARKER: &str = "not a key shared by all rattler-based tools"; +const TOOL_WARNING_MARKER: &str = "Ignoring unknown configuration key"; + +// --------------------------------------------------------------------------- +// 1. Shared-layer file: only common keys honored, everything else ignored +// with the shared warning. +// --------------------------------------------------------------------------- + +#[test] +fn shared_layer_honors_common_keys_only_and_warns() { + let dir = TempDir::new().unwrap(); + let shared = write_file( + &dir, + "shared.toml", + r#" + default-channels = ["conda-forge"] + tls-no-verify = true + custom_field = "an extension key the tool itself understands" + definitely-a-typo = 1 + "#, + ); + + let (result, warnings) = capture_warnings(|| { + ToolConfig::load_from_locations([ConfigLocation { + path: shared.clone(), + layer: ConfigLayer::Shared, + }]) + }); + let config = result.unwrap(); + + // Common keys are honored. + assert_eq!( + config.default_channels, + Some(vec!["conda-forge".parse().unwrap()]) + ); + assert_eq!(config.tls_no_verify, Some(true)); + + // The extension stays at its default even though the tool knows the key. + assert_eq!(config.extensions, ToolExt::default()); + + // Both ignored keys warn with the shared-layer message. + let shared_warnings: Vec<&String> = warnings + .iter() + .filter(|w| w.contains(SHARED_WARNING_MARKER)) + .collect(); + assert!( + shared_warnings.iter().any(|w| w.contains("`custom_field`")), + "expected a shared-layer warning for custom_field, got: {warnings:?}" + ); + assert!( + shared_warnings + .iter() + .any(|w| w.contains("`definitely-a-typo`")), + "expected a shared-layer warning for definitely-a-typo, got: {warnings:?}" + ); + // The warnings name the offending file. + assert!( + shared_warnings + .iter() + .all(|w| w.contains(shared.display().to_string().as_str())), + "shared warnings must name the file, got: {warnings:?}" + ); + // No tool-layer warning text for a shared file. + assert!( + warnings.iter().all(|w| !w.contains(TOOL_WARNING_MARKER)), + "shared file must not use the tool-layer warning, got: {warnings:?}" + ); + + assert_eq!(config.loaded_from, vec![shared]); +} + +// --------------------------------------------------------------------------- +// 2. Tool-layer file: common + extension keys accepted, unknown keys warn +// with the tool message. +// --------------------------------------------------------------------------- + +#[test] +fn tool_layer_accepts_extension_keys_and_warns_on_unknown() { + let dir = TempDir::new().unwrap(); + let tool = write_file( + &dir, + "tool.toml", + r#" + default-channels = ["bioconda"] + custom_field = "consumed" + numeric_field = 42 + definitely-a-typo = 1 + "#, + ); + + let (result, warnings) = capture_warnings(|| { + ToolConfig::load_from_locations([ConfigLocation { + path: tool.clone(), + layer: ConfigLayer::Tool, + }]) + }); + let config = result.unwrap(); + + assert_eq!( + config.default_channels, + Some(vec!["bioconda".parse().unwrap()]) + ); + assert_eq!(config.extensions.custom_field.as_deref(), Some("consumed")); + assert_eq!(config.extensions.numeric_field, Some(42)); + + // Exactly the typo warns, with the tool-layer message. + assert!( + warnings + .iter() + .any(|w| w.contains(TOOL_WARNING_MARKER) && w.contains("`definitely-a-typo`")), + "expected a tool-layer warning for definitely-a-typo, got: {warnings:?}" + ); + // Extension keys must not be warned about in a tool file. + assert!( + warnings.iter().all(|w| !w.contains("custom_field")), + "tool file must not warn about its own extension keys, got: {warnings:?}" + ); + assert!( + warnings.iter().all(|w| !w.contains(SHARED_WARNING_MARKER)), + "tool file must not use the shared-layer warning, got: {warnings:?}" + ); +} + +// --------------------------------------------------------------------------- +// 6. Same file content: shared parse warns about the extension key, tool +// parse does not. +// --------------------------------------------------------------------------- + +#[test] +fn same_content_warns_as_shared_but_not_as_tool() { + let dir = TempDir::new().unwrap(); + let content = r#" + default-channels = ["conda-forge"] + custom_field = "extension key" + "#; + let path = write_file(&dir, "config.toml", content); + + let (shared_result, shared_warnings) = capture_warnings(|| { + ToolConfig::load_from_locations([ConfigLocation { + path: path.clone(), + layer: ConfigLayer::Shared, + }]) + }); + shared_result.unwrap(); + assert!( + shared_warnings + .iter() + .any(|w| w.contains(SHARED_WARNING_MARKER) && w.contains("`custom_field`")), + "shared parse must warn about custom_field, got: {shared_warnings:?}" + ); + + let (tool_result, tool_warnings) = capture_warnings(|| { + ToolConfig::load_from_locations([ConfigLocation { + path: path.clone(), + layer: ConfigLayer::Tool, + }]) + }); + tool_result.unwrap(); + assert!( + tool_warnings.is_empty(), + "tool parse of the same content must not warn, got: {tool_warnings:?}" + ); +} + +// --------------------------------------------------------------------------- +// 3. Realistic 4-file stack: system shared, system tool, user shared, user +// tool. Later files win per key, earlier-only keys survive, maps merge +// additively, loaded_from records all files in order, and extension keys +// in shared files never leak into the extension. +// --------------------------------------------------------------------------- + +#[test] +fn four_file_stack_merges_with_correct_precedence() { + let dir = TempDir::new().unwrap(); + let mirror_upstream_1 = "https://conda.anaconda.org/one/"; + let mirror_upstream_2 = "https://conda.anaconda.org/two/"; + let mirror_upstream_3 = "https://conda.anaconda.org/three/"; + + let system_shared = write_file( + &dir, + "system_shared.toml", + &format!( + r#" + default-channels = ["from-system-shared"] + tls-no-verify = true + custom_field = "from-system-shared" + + [mirrors] + "{mirror_upstream_1}" = ["https://mirror.example/one-old/"] + "# + ), + ); + let system_tool = write_file( + &dir, + "system_tool.toml", + &format!( + r#" + default-channels = ["from-system-tool"] + authentication-override-file = "/etc/auth.json" + custom_field = "from-system-tool" + + [mirrors] + "{mirror_upstream_2}" = ["https://mirror.example/two/"] + "# + ), + ); + let user_shared = write_file( + &dir, + "user_shared.toml", + &format!( + r#" + default-channels = ["from-user-shared"] + allow-hard-links = false + custom_field = "from-user-shared" + + [mirrors] + "{mirror_upstream_1}" = ["https://mirror.example/one-new/"] + "# + ), + ); + let user_tool = write_file( + &dir, + "user_tool.toml", + &format!( + r#" + default-channels = ["from-user-tool"] + numeric_field = 7 + + [mirrors] + "{mirror_upstream_3}" = ["https://mirror.example/three/"] + "# + ), + ); + + let (result, warnings) = capture_warnings(|| { + ToolConfig::load_from_locations([ + ConfigLocation { + path: system_shared.clone(), + layer: ConfigLayer::Shared, + }, + ConfigLocation { + path: system_tool.clone(), + layer: ConfigLayer::Tool, + }, + ConfigLocation { + path: user_shared.clone(), + layer: ConfigLayer::Shared, + }, + ConfigLocation { + path: user_tool.clone(), + layer: ConfigLayer::Tool, + }, + ]) + }); + let config = result.unwrap(); + + // Later files win per key. + assert_eq!( + config.default_channels, + Some(vec!["from-user-tool".parse().unwrap()]) + ); + // Keys set only in earlier files survive. + assert_eq!(config.tls_no_verify, Some(true), "from system shared"); + assert_eq!( + config.authentication_override_file, + Some(PathBuf::from("/etc/auth.json")), + "from system tool" + ); + assert_eq!(config.allow_hard_links, Some(false), "from user shared"); + + // Mirrors merge additively across all four files; the later shared file + // overrides the earlier one per upstream URL. + let mirrors = &config.mirrors; + assert_eq!( + mirrors.len(), + 3, + "mirrors must merge additively: {mirrors:?}" + ); + assert_eq!( + mirrors[&Url::parse(mirror_upstream_1).unwrap()], + vec![Url::parse("https://mirror.example/one-new/").unwrap()], + "later shared file must override the earlier one per key" + ); + assert_eq!( + mirrors[&Url::parse(mirror_upstream_2).unwrap()], + vec![Url::parse("https://mirror.example/two/").unwrap()] + ); + assert_eq!( + mirrors[&Url::parse(mirror_upstream_3).unwrap()], + vec![Url::parse("https://mirror.example/three/").unwrap()] + ); + + // Extension keys come only from tool files. `custom_field` is set in + // both shared files (later than the system tool file!) but must keep + // the system tool value; `numeric_field` comes from the user tool file. + assert_eq!( + config.extensions.custom_field.as_deref(), + Some("from-system-tool"), + "extension keys in shared files must not leak into the extension" + ); + assert_eq!(config.extensions.numeric_field, Some(7)); + + // loaded_from records all files in load order. + assert_eq!( + config.loaded_from, + vec![system_shared, system_tool, user_shared, user_tool] + ); + + // Both shared files warned about their extension key; the tool files + // did not warn at all. + let shared_warning_count = warnings + .iter() + .filter(|w| w.contains(SHARED_WARNING_MARKER) && w.contains("`custom_field`")) + .count(); + assert_eq!( + shared_warning_count, 2, + "each shared file must warn about custom_field, got: {warnings:?}" + ); + assert!( + warnings.iter().all(|w| !w.contains(TOOL_WARNING_MARKER)), + "no tool-layer warnings expected, got: {warnings:?}" + ); +} + +// --------------------------------------------------------------------------- +// 4. `load_from_files` still parses everything as the tool layer. +// --------------------------------------------------------------------------- + +#[test] +fn load_from_files_parses_all_files_as_tool_layer() { + let dir = TempDir::new().unwrap(); + let first = write_file( + &dir, + "first.toml", + r#" + custom_field = "from-first" + numeric_field = 1 + "#, + ); + let second = write_file( + &dir, + "second.toml", + r#" + custom_field = "from-second" + "#, + ); + + let (result, warnings) = + capture_warnings(|| ToolConfig::load_from_files([first.clone(), second.clone()])); + let config = result.unwrap(); + + assert_eq!( + config.extensions.custom_field.as_deref(), + Some("from-second") + ); + assert_eq!(config.extensions.numeric_field, Some(1)); + assert!( + warnings.is_empty(), + "extension keys must be consumed without warnings, got: {warnings:?}" + ); + assert_eq!(config.loaded_from, vec![first, second]); +} + +// --------------------------------------------------------------------------- +// Shared files with a malformed common key still fail to parse. +// --------------------------------------------------------------------------- + +#[test] +fn shared_layer_still_rejects_malformed_common_values() { + let dir = TempDir::new().unwrap(); + let shared = write_file(&dir, "bad.toml", "tls-no-verify = \"not-a-bool\"\n"); + + let result = ToolConfig::load_from_locations([ConfigLocation { + path: shared, + layer: ConfigLayer::Shared, + }]); + assert!( + result.is_err(), + "malformed common value in a shared file must be an error" + ); +} + +// --------------------------------------------------------------------------- +// 5. `config_search_paths` interleaving, layer tags and RATTLER_HOME +// behavior. Environment-mutating assertions run in a child process (this +// same test binary, filtered to one probe test) so parallel tests in +// this binary are never affected. +// --------------------------------------------------------------------------- + +const PROBE_ENV: &str = "SHARED_LAYER_ENV_PROBE"; + +fn run_probe(probe_name: &str, marker: &str, envs: &[(&str, Option<&OsStr>)]) { + let exe = std::env::current_exe().unwrap(); + let mut command = Command::new(exe); + command.args(["--exact", probe_name, "--nocapture"]); + // Start from a known state for every variable the probes look at. + for var in ["RATTLER_HOME", "XDG_CONFIG_HOME", "HOME", "SOME_TOOL_HOME"] { + command.env_remove(var); + } + for (key, value) in envs { + match value { + Some(value) => command.env(key, value), + None => command.env_remove(key), + }; + } + command.env(PROBE_ENV, marker); + let output = command.output().unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "probe {probe_name} failed\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + // Guard against `--exact` silently matching zero tests (which exits 0). + assert!( + stdout.contains("PROBE-DONE"), + "probe {probe_name} did not run\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); +} + +/// Probe body: with `RATTLER_HOME` set, `$RATTLER_HOME/config.toml` appears as +/// a Shared location and the full interleaving is +/// system-shared, system-tool, user-shared(s), user-tool(s). +#[test] +fn env_probe_search_paths_with_rattler_home() { + if std::env::var(PROBE_ENV).as_deref() != Ok("with-rattler-home") { + return; + } + let rattler_home = PathBuf::from(std::env::var("RATTLER_HOME").unwrap()); + + let locations = config_search_paths("some-tool"); + + #[cfg(target_os = "linux")] + { + let xdg = PathBuf::from(std::env::var("XDG_CONFIG_HOME").unwrap()); + let home = PathBuf::from(std::env::var("HOME").unwrap()); + let expected = vec![ + ConfigLocation { + path: PathBuf::from("/etc/rattler/config.toml"), + layer: ConfigLayer::Shared, + }, + ConfigLocation { + path: PathBuf::from("/etc/some-tool/config.toml"), + layer: ConfigLayer::Tool, + }, + ConfigLocation { + path: xdg.join("rattler").join("config.toml"), + layer: ConfigLayer::Shared, + }, + ConfigLocation { + path: rattler_home.join("config.toml"), + layer: ConfigLayer::Shared, + }, + ConfigLocation { + path: xdg.join("some-tool").join("config.toml"), + layer: ConfigLayer::Tool, + }, + ConfigLocation { + path: home.join(".some-tool").join("config.toml"), + layer: ConfigLayer::Tool, + }, + ]; + assert_eq!(locations, expected); + } + #[cfg(not(target_os = "linux"))] + { + let rattler_home_location = locations + .iter() + .find(|l| l.path == rattler_home.join("config.toml")) + .expect("RATTLER_HOME/config.toml must be a search path"); + assert_eq!(rattler_home_location.layer, ConfigLayer::Shared); + } + println!("PROBE-DONE"); +} + +/// Probe body: without `RATTLER_HOME` there is no `~/.rattler` fallback, and +/// the interleaving is system-shared, system-tool, user-shared, user-tool. +#[test] +fn env_probe_search_paths_without_rattler_home() { + if std::env::var(PROBE_ENV).as_deref() != Ok("without-rattler-home") { + return; + } + let home = PathBuf::from(std::env::var("HOME").unwrap()); + + let locations = config_search_paths("some-tool"); + + // No ~/.rattler path may appear anywhere. + let dot_rattler = home.join(".rattler").join("config.toml"); + assert!( + locations.iter().all(|l| l.path != dot_rattler), + "shared layer must have no ~/.rattler fallback, got: {locations:?}" + ); + assert!( + locations + .iter() + .all(|l| !l.path.to_string_lossy().contains(".rattler")), + "no .rattler dotdir expected, got: {locations:?}" + ); + + #[cfg(target_os = "linux")] + { + let xdg = PathBuf::from(std::env::var("XDG_CONFIG_HOME").unwrap()); + let expected = vec![ + ConfigLocation { + path: PathBuf::from("/etc/rattler/config.toml"), + layer: ConfigLayer::Shared, + }, + ConfigLocation { + path: PathBuf::from("/etc/some-tool/config.toml"), + layer: ConfigLayer::Tool, + }, + ConfigLocation { + path: xdg.join("rattler").join("config.toml"), + layer: ConfigLayer::Shared, + }, + ConfigLocation { + path: xdg.join("some-tool").join("config.toml"), + layer: ConfigLayer::Tool, + }, + ConfigLocation { + path: home.join(".some-tool").join("config.toml"), + layer: ConfigLayer::Tool, + }, + ]; + assert_eq!(locations, expected); + } + println!("PROBE-DONE"); +} + +#[test] +fn search_paths_respect_rattler_home() { + let rattler_home = TempDir::new().unwrap(); + let xdg = TempDir::new().unwrap(); + let home = TempDir::new().unwrap(); + run_probe( + "env_probe_search_paths_with_rattler_home", + "with-rattler-home", + &[ + ("RATTLER_HOME", Some(rattler_home.path().as_os_str())), + ("XDG_CONFIG_HOME", Some(xdg.path().as_os_str())), + ("HOME", Some(home.path().as_os_str())), + ], + ); +} + +#[test] +fn search_paths_without_rattler_home_have_no_dotdir() { + let xdg = TempDir::new().unwrap(); + let home = TempDir::new().unwrap(); + run_probe( + "env_probe_search_paths_without_rattler_home", + "without-rattler-home", + &[ + ("RATTLER_HOME", None), + ("XDG_CONFIG_HOME", Some(xdg.path().as_os_str())), + ("HOME", Some(home.path().as_os_str())), + ], + ); +} + +/// Probe body: when `RATTLER_HOME` and `SOME_TOOL_HOME` point at the same +/// directory, the colliding path is deduplicated to a single entry. A path +/// that appears in both layers is always parsed as a tool file, whichever +/// occurrence survives the dedup. +#[test] +fn env_probe_search_paths_dedup_on_layer_collision() { + if std::env::var(PROBE_ENV).as_deref() != Ok("layer-collision") { + return; + } + let shared_home = PathBuf::from(std::env::var("RATTLER_HOME").unwrap()); + let colliding = shared_home.join("config.toml"); + + let locations = config_search_paths("some-tool"); + + let matches: Vec<&ConfigLocation> = locations.iter().filter(|l| l.path == colliding).collect(); + assert_eq!( + matches.len(), + 1, + "colliding path must be deduplicated to one entry, got: {locations:?}" + ); + assert_eq!( + matches[0].layer, + ConfigLayer::Tool, + "dedup must keep the highest-precedence occurrence (the tool layer)" + ); + println!("PROBE-DONE"); +} + +#[test] +fn search_paths_dedup_collision_between_layers() { + let shared_and_tool_home = TempDir::new().unwrap(); + let xdg = TempDir::new().unwrap(); + let home = TempDir::new().unwrap(); + run_probe( + "env_probe_search_paths_dedup_on_layer_collision", + "layer-collision", + &[ + ( + "RATTLER_HOME", + Some(shared_and_tool_home.path().as_os_str()), + ), + ( + "SOME_TOOL_HOME", + Some(shared_and_tool_home.path().as_os_str()), + ), + ("XDG_CONFIG_HOME", Some(xdg.path().as_os_str())), + ("HOME", Some(home.path().as_os_str())), + ], + ); +} + +/// Probe body: the reverse collision direction. `RATTLER_HOME` points at +/// the tool's *system* directory, so the shared occurrence of the colliding +/// path comes *after* the tool occurrence and survives the dedup. The entry +/// must still be parsed as a tool file: the tool's own system config +/// legitimately contains extension keys, and parsing it as shared would +/// silently drop them. +#[cfg(not(target_os = "windows"))] +#[test] +fn env_probe_search_paths_dedup_reverse_layer_collision() { + if std::env::var(PROBE_ENV).as_deref() != Ok("reverse-layer-collision") { + return; + } + let colliding = PathBuf::from("/etc/some-tool/config.toml"); + + let locations = config_search_paths("some-tool"); + + let matches: Vec<&ConfigLocation> = locations.iter().filter(|l| l.path == colliding).collect(); + assert_eq!( + matches.len(), + 1, + "colliding path must be deduplicated to one entry, got: {locations:?}" + ); + assert_eq!( + matches[0].layer, + ConfigLayer::Tool, + "a path in both layers must be parsed as a tool file" + ); + println!("PROBE-DONE"); +} + +#[cfg(not(target_os = "windows"))] +#[test] +fn search_paths_dedup_reverse_collision_keeps_tool_layer() { + let xdg = TempDir::new().unwrap(); + let home = TempDir::new().unwrap(); + run_probe( + "env_probe_search_paths_dedup_reverse_layer_collision", + "reverse-layer-collision", + &[ + ("RATTLER_HOME", Some(OsStr::new("/etc/some-tool"))), + ("XDG_CONFIG_HOME", Some(xdg.path().as_os_str())), + ("HOME", Some(home.path().as_os_str())), + ], + ); +} + +// --------------------------------------------------------------------------- +// End-to-end through the default locations: a real 4-file stack in +// RATTLER_HOME / tool home / XDG dirs, loaded via +// `load_from_default_locations` in a child process with a controlled +// environment. +// --------------------------------------------------------------------------- + +/// Probe body: build the user-level part of the stack on disk and load it +/// through `load_from_default_locations`. +/// +/// Unix only: on Windows `dirs::config_dir` uses the known-folder API and +/// ignores `XDG_CONFIG_HOME`, so the files written below would never be +/// found. +#[cfg(unix)] +#[test] +fn env_probe_load_from_default_locations() { + if std::env::var(PROBE_ENV).as_deref() != Ok("default-locations") { + return; + } + let xdg = PathBuf::from(std::env::var("XDG_CONFIG_HOME").unwrap()); + let rattler_home = PathBuf::from(std::env::var("RATTLER_HOME").unwrap()); + + // user shared (XDG): common key + extension key that must be ignored. + let xdg_shared_dir = xdg.join("rattler"); + std::fs::create_dir_all(&xdg_shared_dir).unwrap(); + std::fs::write( + xdg_shared_dir.join("config.toml"), + r#" + default-channels = ["from-xdg-shared"] + tls-no-verify = true + custom_field = "leaked-from-xdg-shared" + "#, + ) + .unwrap(); + + // user shared (RATTLER_HOME): higher precedence than the XDG shared file. + std::fs::write( + rattler_home.join("config.toml"), + r#" + default-channels = ["from-rattler-home"] + custom_field = "leaked-from-rattler-home" + "#, + ) + .unwrap(); + + // user tool (XDG): extension key must be consumed. + let xdg_tool_dir = xdg.join("some-tool"); + std::fs::create_dir_all(&xdg_tool_dir).unwrap(); + std::fs::write( + xdg_tool_dir.join("config.toml"), + r#" + default-channels = ["from-xdg-tool"] + custom_field = "from-xdg-tool" + "#, + ) + .unwrap(); + + let config = ToolConfig::load_from_default_locations("some-tool").unwrap(); + + // The tool file has the highest precedence among the files we created. + assert_eq!( + config.default_channels, + Some(vec!["from-xdg-tool".parse().unwrap()]), + "user tool file must win" + ); + // A common key set only in the lowest shared file survives. + assert_eq!(config.tls_no_verify, Some(true)); + // Extension keys in shared files never reach the extension. + assert_eq!( + config.extensions.custom_field.as_deref(), + Some("from-xdg-tool"), + "extension value must come from the tool file only" + ); + // All three files we created were recorded, in precedence order. The + // comparison ignores files outside the controlled environment (a real + // `/etc/rattler/config.toml` or `/etc/some-tool/config.toml` may exist + // on the machine running the tests and loads with lower precedence). + let ours: Vec = config + .loaded_from + .iter() + .filter(|path| !path.starts_with("/etc")) + .cloned() + .collect(); + assert_eq!( + ours, + vec![ + xdg_shared_dir.join("config.toml"), + rattler_home.join("config.toml"), + xdg_tool_dir.join("config.toml"), + ] + ); + println!("PROBE-DONE"); +} + +#[cfg(unix)] +#[test] +fn load_from_default_locations_layers_shared_and_tool() { + let rattler_home = TempDir::new().unwrap(); + let xdg = TempDir::new().unwrap(); + let home = TempDir::new().unwrap(); + run_probe( + "env_probe_load_from_default_locations", + "default-locations", + &[ + ("RATTLER_HOME", Some(rattler_home.path().as_os_str())), + ("XDG_CONFIG_HOME", Some(xdg.path().as_os_str())), + ("HOME", Some(home.path().as_os_str())), + ], + ); +} From 01711c3fbfbcb76d05f6a1b7f67a4c00e2886b7c Mon Sep 17 00:00:00 2001 From: Bas Zalmstra <4995967+baszalmstra@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:29:58 +0200 Subject: [PATCH 21/21] perf: speed up CUDA virtual package detection (#2568) --- Cargo.lock | 3 + crates/rattler-bin/src/commands/create.rs | 1 + crates/rattler-bin/src/commands/exec.rs | 16 +- crates/rattler-bin/src/commands/prefix.rs | 1 + crates/rattler-bin/src/commands/solve.rs | 10 +- .../src/commands/virtual_packages.rs | 36 +- crates/rattler_virtual_packages/Cargo.toml | 7 + crates/rattler_virtual_packages/src/cuda.rs | 1726 ++++++++++++++--- .../src/cuda/cache.rs | 603 ++++++ crates/rattler_virtual_packages/src/lib.rs | 142 +- py-rattler/Cargo.lock | 3 + .../virtual_package/virtual_package.py | 16 +- py-rattler/src/index_json.rs | 2 +- py-rattler/src/record.rs | 2 +- py-rattler/src/utils.rs | 4 +- py-rattler/src/virtual_package.rs | 21 +- 16 files changed, 2307 insertions(+), 286 deletions(-) create mode 100644 crates/rattler_virtual_packages/src/cuda/cache.rs diff --git a/Cargo.lock b/Cargo.lock index d61d5fc2df..4316a77768 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5599,9 +5599,12 @@ dependencies = [ "rattler_conda_types", "regex", "serde", + "serde_json", "temp-env", + "tempfile", "thiserror 2.0.19", "tracing", + "windows-sys 0.61.2", "winver", ] diff --git a/crates/rattler-bin/src/commands/create.rs b/crates/rattler-bin/src/commands/create.rs index 12255c04bb..11e631421e 100644 --- a/crates/rattler-bin/src/commands/create.rs +++ b/crates/rattler-bin/src/commands/create.rs @@ -246,6 +246,7 @@ pub async fn create(opt: Opt, offline: bool) -> miette::Result<()> { VirtualPackages::detect_for_platform( install_platform, &VirtualPackageOverrides::from_env(), + rattler::default_cache_dir().ok().as_deref(), ) .map(|vpkgs| vpkgs.into_generic_virtual_packages().collect::>()) .into_diagnostic() diff --git a/crates/rattler-bin/src/commands/exec.rs b/crates/rattler-bin/src/commands/exec.rs index 7b9b31c1fc..8e1a7f0b61 100644 --- a/crates/rattler-bin/src/commands/exec.rs +++ b/crates/rattler-bin/src/commands/exec.rs @@ -262,13 +262,15 @@ async fn create_exec_prefix(options: CreateExecPrefixOptions<'_>) -> miette::Res tracing::debug!("loaded {} records from repodata", total_records); // Determine virtual packages of the current platform - let virtual_packages: Vec = - VirtualPackage::detect(&VirtualPackageOverrides::from_env()) - .into_diagnostic() - .context("failed to determine virtual packages")? - .into_iter() - .map(GenericVirtualPackage::from) - .collect(); + let virtual_packages: Vec = VirtualPackage::detect( + &VirtualPackageOverrides::from_env(), + rattler::default_cache_dir().ok().as_deref(), + ) + .into_diagnostic() + .context("failed to determine virtual packages")? + .into_iter() + .map(GenericVirtualPackage::from) + .collect(); let solver_task = SolverTask { specs: specs.to_vec(), diff --git a/crates/rattler-bin/src/commands/prefix.rs b/crates/rattler-bin/src/commands/prefix.rs index bf9e4f6b57..e9f6ff1621 100644 --- a/crates/rattler-bin/src/commands/prefix.rs +++ b/crates/rattler-bin/src/commands/prefix.rs @@ -233,6 +233,7 @@ fn validate_virtual_package_dependencies( let virtual_packages = rattler_virtual_packages::VirtualPackages::detect_for_platform( platform, &rattler_virtual_packages::VirtualPackageOverrides::from_env(), + rattler::default_cache_dir().ok().as_deref(), ) .into_diagnostic() .with_context(|| format!("failed to determine virtual packages for {platform}"))? diff --git a/crates/rattler-bin/src/commands/solve.rs b/crates/rattler-bin/src/commands/solve.rs index c0535d8a01..f76f0899d7 100644 --- a/crates/rattler-bin/src/commands/solve.rs +++ b/crates/rattler-bin/src/commands/solve.rs @@ -186,9 +186,13 @@ pub async fn solve(opt: Opt, offline: bool) -> miette::Result<()> { if let Some(virtual_packages) = &opt.virtual_package { parse_virtual_packages(virtual_packages) } else { - VirtualPackages::detect_for_platform(opt.platform, &VirtualPackageOverrides::from_env()) - .map(|vpkgs| vpkgs.into_generic_virtual_packages().collect::>()) - .into_diagnostic() + VirtualPackages::detect_for_platform( + opt.platform, + &VirtualPackageOverrides::from_env(), + rattler::default_cache_dir().ok().as_deref(), + ) + .map(|vpkgs| vpkgs.into_generic_virtual_packages().collect::>()) + .into_diagnostic() } })?; diff --git a/crates/rattler-bin/src/commands/virtual_packages.rs b/crates/rattler-bin/src/commands/virtual_packages.rs index a1b6fe19a3..903b57daf4 100644 --- a/crates/rattler-bin/src/commands/virtual_packages.rs +++ b/crates/rattler-bin/src/commands/virtual_packages.rs @@ -7,11 +7,37 @@ use rattler_virtual_packages::VirtualPackageOverrides; pub struct Opt {} pub fn virtual_packages(_opt: Opt) -> miette::Result<()> { - let virtual_packages = - rattler_virtual_packages::VirtualPackage::detect(&VirtualPackageOverrides::from_env()) - .into_diagnostic()?; - for package in virtual_packages { - println!("{}", GenericVirtualPackage::from(package.clone())); + let cache_dir = rattler::default_cache_dir().ok(); + tracing::debug!( + cache_dir = %cache_dir + .as_ref() + .map_or_else(|| "".to_string(), |path| path.display().to_string()), + "detecting virtual packages" + ); + + let virtual_packages = rattler_virtual_packages::VirtualPackage::detect( + &VirtualPackageOverrides::from_env(), + cache_dir.as_deref(), + ) + .into_diagnostic()?; + + let generic_virtual_packages = virtual_packages + .into_iter() + .map(GenericVirtualPackage::from) + .collect::>(); + let package_strings = generic_virtual_packages + .iter() + .map(ToString::to_string) + .collect::>(); + + tracing::debug!( + count = package_strings.len(), + packages = ?package_strings, + "detected virtual packages" + ); + + for package in generic_virtual_packages { + println!("{package}"); } Ok(()) } diff --git a/crates/rattler_virtual_packages/Cargo.toml b/crates/rattler_virtual_packages/Cargo.toml index d99621a964..ad4e1c3386 100644 --- a/crates/rattler_virtual_packages/Cargo.toml +++ b/crates/rattler_virtual_packages/Cargo.toml @@ -17,6 +17,8 @@ once_cell = { workspace = true } rattler_conda_types = { workspace = true, default-features = false } regex = { workspace = true } serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +tempfile = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } archspec = { workspace = true } @@ -26,6 +28,11 @@ plist = { workspace = true } [target.'cfg(target_os="windows")'.dependencies] winver = { workspace = true } +windows-sys = { workspace = true, features = [ + "Win32_System_SystemInformation", + "Win32_System_Registry", + "Win32_Devices_DeviceAndDriverInstallation", +] } [dev-dependencies] temp-env = { workspace = true } diff --git a/crates/rattler_virtual_packages/src/cuda.rs b/crates/rattler_virtual_packages/src/cuda.rs index 37eb693a6c..4b74329916 100644 --- a/crates/rattler_virtual_packages/src/cuda.rs +++ b/crates/rattler_virtual_packages/src/cuda.rs @@ -7,8 +7,9 @@ //! The CUDA driver version represents the maximum CUDA version supported by the installed //! NVIDIA drivers. This is detected via: //! -//! * CUDA driver library (libcuda): Standard method -//! * nvidia-smi command: Fallback on musl systems where dynamic library loading is not supported +//! * NVIDIA Management Library (NVML): Standard method +//! * CUDA driver library (libcuda) and the nvidia-smi command: Fallbacks for systems without +//! NVML, and for musl systems where dynamic library loading is not supported //! //! ## CUDA Compute Capability (`__cuda_arch`) //! @@ -18,13 +19,92 @@ use libloading::{Library, Symbol}; use once_cell::sync::OnceCell; use rattler_conda_types::Version; +use serde::{Deserialize, Serialize}; use std::process::Command; +use std::sync::atomic::{AtomicBool, Ordering}; use std::{ - mem::MaybeUninit, - os::raw::{c_int, c_uint, c_ulong}, + os::raw::{c_int, c_uint, c_void}, + path::Path, + ptr, str::FromStr, }; +mod cache; + +const NVML_SUCCESS: c_int = 0; +const NVML_ERROR_UNINITIALIZED: c_int = 1; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum NvmlCudaVersionError { + MissingSymbol, + Nvml(c_int), + InvalidVersion, +} + +impl NvmlCudaVersionError { + fn should_retry_after_init(self) -> bool { + matches!(self, Self::Nvml(NVML_ERROR_UNINITIALIZED)) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(super) enum CudaDetectionMethod { + NvmlNoInit, + NvmlInitialized, + Libcuda, + NvidiaSmi, +} + +impl CudaDetectionMethod { + fn as_str(self) -> &'static str { + match self { + Self::NvmlNoInit => "nvml_no_init", + Self::NvmlInitialized => "nvml_initialized", + Self::Libcuda => "libcuda", + Self::NvidiaSmi => "nvidia_smi", + } + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(super) struct CudaInfoSources { + pub version: Option, + pub arch: Option, +} + +impl CudaInfoSources { + fn version_str(self) -> &'static str { + self.version + .map_or("", CudaDetectionMethod::as_str) + } + + fn arch_str(self) -> &'static str { + self.arch.map_or("", CudaDetectionMethod::as_str) + } +} + +struct DetectedCudaInfo { + info: CudaInfo, + sources: CudaInfoSources, +} + +/// Converts a CUDA driver version integer (as reported by NVML/libcuda) into a [`Version`]. +/// +/// The integer is encoded as `major * 1000 + minor * 10` (e.g. `12040` for CUDA 12.4). Because the +/// FFI out-parameters are zero-initialized, an implausible value (such as `0` from an out-param the +/// driver never wrote, a negative value, or a nonsensically large one) can slip through even on a +/// `SUCCESS` return. Only values whose CUDA major version lies in `1..=99` are accepted; anything +/// else is rejected so that garbage never propagates (or gets cached). +fn parse_cuda_driver_version(version: c_int) -> Option { + // CUDA major version 1..=99, i.e. the encoded integer must be within [1000, 99990]. + if !(1000..=99_990).contains(&version) { + tracing::trace!(version, "rejecting implausible CUDA driver version integer"); + return None; + } + Version::from_str(&format!("{}.{}", version / 1000, (version % 1000) / 10)).ok() +} + /// Validates that a string is in the format "major.minor" where both parts are digits. /// /// Returns `true` if the format is valid for CUDA compute capability. @@ -79,6 +159,17 @@ pub struct CudaInfo { pub arch_info: Option, } +fn display_cuda_version(version: Option<&Version>) -> String { + version.map_or_else(|| "".to_string(), ToString::to_string) +} + +fn display_cuda_arch(arch_info: Option<&CudaArchInfo>) -> String { + arch_info.map_or_else( + || "".to_string(), + |arch| format!("{}.{}", arch.major, arch.minor), + ) +} + /// Returns comprehensive CUDA information from the current platform. /// /// This function returns both the CUDA driver version and compute capability information @@ -87,17 +178,104 @@ pub struct CudaInfo { /// /// This is more efficient than calling [`cuda_version`] and [`cuda_arch`] separately /// because the CUDA library is loaded only once. -pub fn cuda_info() -> &'static CudaInfo { - static DETECTED_CUDA_INFO: OnceCell = OnceCell::new(); - DETECTED_CUDA_INFO.get_or_init(detect_cuda_info) +/// +/// Detection runs at most once per process; the in-memory result is reused afterwards. The on-disk +/// cache, however, is synced lazily: the first call that is given a `cache_dir` reads from and/or +/// writes to it. A call that passes `None` (e.g. `EnvOverride::detect_from_host`) does not disable +/// the disk cache for the rest of the process — a later call passing `Some(cache_dir)` still +/// persists the already-detected result. Pass `None` from every call to fully disable the disk +/// cache. +pub fn cuda_info(cache_dir: Option<&Path>) -> &'static CudaInfo { + static DETECTED_CUDA_INFO: OnceCell = OnceCell::new(); + // Whether the in-memory result has been synced with the on-disk cache (read from it or written + // to it). This lets a later call with a `cache_dir` persist a result first detected without one. + static PERSISTED: AtomicBool = AtomicBool::new(false); + cuda_info_impl( + &cache::CacheEnv::current(), + &DETECTED_CUDA_INFO, + &PERSISTED, + cache_dir, + ) +} + +/// Core of [`cuda_info`], generic over the state so it can be unit-tested with local state instead +/// of the process-global statics. +fn cuda_info_impl<'a>( + env: &cache::CacheEnv, + state: &'a OnceCell, + persisted: &AtomicBool, + cache_dir: Option<&Path>, +) -> &'a CudaInfo { + if let Some(detected) = state.get() { + tracing::trace!(info = ?detected.info, "using process-cached CUDA info"); + maybe_persist(env, detected, persisted, cache_dir); + return &detected.info; + } + + let detected = state.get_or_init(|| { + // Initializing the driver to detect the GPU can be slow, so the result is cached on disk + // and reused until the cache is invalidated (reboot, driver change, GPU change, TTL). + if let Some(cache_dir) = cache_dir { + tracing::trace!(cache_dir = %cache_dir.display(), "checking CUDA info cache"); + if let Some(cached) = cache::read_with_env(env, cache_dir) { + tracing::debug!( + version = %display_cuda_version(cached.info.version.as_ref()), + arch = %display_cuda_arch(cached.info.arch_info.as_ref()), + version_source = cached.sources.version_str(), + arch_source = cached.sources.arch_str(), + "using disk-cached CUDA info" + ); + // We are now in sync with disk; no need to write it back. + persisted.store(true, Ordering::Relaxed); + return cached; + } + } else { + tracing::trace!("CUDA info disk cache disabled"); + } + + tracing::trace!("detecting CUDA info from host"); + let detected = detect_cuda_info(); + tracing::debug!( + version = %display_cuda_version(detected.info.version.as_ref()), + arch = %display_cuda_arch(detected.info.arch_info.as_ref()), + version_source = detected.sources.version_str(), + arch_source = detected.sources.arch_str(), + "detected CUDA info from host" + ); + detected + }); + + // Persist a freshly detected result if this (or a later) call supplied a cache directory. + maybe_persist(env, detected, persisted, cache_dir); + &detected.info +} + +/// Writes the detected result to disk once, if a cache directory is available and it has not been +/// synced with disk yet. A benign race may write twice, which is safe because the write replaces +/// the file atomically. +fn maybe_persist( + env: &cache::CacheEnv, + detected: &DetectedCudaInfo, + persisted: &AtomicBool, + cache_dir: Option<&Path>, +) { + let Some(cache_dir) = cache_dir else { + return; + }; + if persisted.load(Ordering::Relaxed) { + return; + } + cache::write_with_env(env, cache_dir, &detected.info, detected.sources); + // The flag means "we have synced with disk"; set it regardless of write's best-effort outcome. + persisted.store(true, Ordering::Relaxed); } /// Returns the maximum CUDA version available on the current platform. /// /// This corresponds to the `__cuda` virtual package. The result is cached, -/// so subsequent calls are very fast. -pub fn cuda_version() -> Option { - cuda_info().version.clone() +/// so subsequent calls are very fast. See [`cuda_info`] for the `cache_dir` semantics. +pub fn cuda_version(cache_dir: Option<&Path>) -> Option { + cuda_info(cache_dir).version.clone() } /// Returns CUDA compute capability information from the current platform. @@ -109,206 +287,393 @@ pub fn cuda_version() -> Option { /// * No CUDA drivers are installed /// * No CUDA devices are detected /// * Device enumeration fails -/// * The system is using musl libc (dynamic library loading not supported) /// -/// The result is cached, so subsequent calls are very fast. -pub fn cuda_arch() -> Option { - cuda_info().arch_info.clone() +/// The result is cached, so subsequent calls are very fast. See [`cuda_info`] for the `cache_dir` +/// semantics. +pub fn cuda_arch(cache_dir: Option<&Path>) -> Option { + cuda_info(cache_dir).arch_info.clone() } /// Detects comprehensive CUDA information from the current system. /// /// This function performs unified detection of both CUDA driver version and compute -/// capability by loading the CUDA library once and querying all necessary information. +/// capability by loading NVML once and querying all necessary information. /// /// The detection process: -/// 1. Attempts to load the CUDA driver library (`libcuda`) -/// 2. Initializes the CUDA driver API -/// 3. Queries the driver version (for `__cuda` virtual package) -/// 4. Enumerates all CUDA devices and queries their compute capabilities -/// 5. Returns the minimum compute capability across all devices (for `__cuda_arch` virtual package) +/// 1. Attempts to load NVML (`libnvidia-ml`/`nvml.dll`) +/// 2. Queries the driver version (for `__cuda` virtual package); this does not require init +/// 3. Initializes NVML and enumerates all CUDA devices to query their compute capabilities +/// 4. Returns the minimum compute capability across all devices (for `__cuda_arch` virtual package) /// -/// On musl systems, only the version is detected via `nvidia-smi` since dynamic library +/// On musl systems, both are detected via the `nvidia-smi` command since dynamic library /// loading is not supported. -fn detect_cuda_info() -> CudaInfo { - if cfg!(target_env = "musl") { +fn detect_cuda_info() -> DetectedCudaInfo { + let mut detected = if cfg!(target_env = "musl") { + tracing::trace!("detecting CUDA info via nvidia-smi because musl cannot load NVML"); // Dynamically loading a library is not supported on musl so we have to fall-back to using - // the nvidia-smi command. Architecture detection requires library loading, so it's - // unavailable on musl. - CudaInfo { - version: detect_cuda_version_via_nvidia_smi(), - arch_info: None, + // the nvidia-smi command. + let version = detect_cuda_version_via_nvidia_smi(); + // Only query the compute capability when the driver version was found. On a GPU-less musl + // system the version query already failed, so running the arch query too would just spawn + // another doomed process and could produce an inconsistent `{version: None, arch: Some}`. + let arch_info = version + .as_ref() + .and_then(|_| detect_cuda_arch_via_nvidia_smi()); + DetectedCudaInfo { + sources: CudaInfoSources { + version: version.is_some().then_some(CudaDetectionMethod::NvidiaSmi), + arch: arch_info + .is_some() + .then_some(CudaDetectionMethod::NvidiaSmi), + }, + info: CudaInfo { version, arch_info }, } } else { - // Try to detect via libcuda which allows us to get both version and architecture info - detect_cuda_info_via_libcuda() + tracing::trace!("detecting CUDA info via NVML"); + // Prefer NVML because it is not affected by `CUDA_VISIBLE_DEVICES`, but fall back to the + // older probes so systems that expose libcuda (or nvidia-smi) without NVML still report + // `__cuda`. + let mut detected = detect_cuda_info_via_nvml(); + + if detected.info.version.is_none() { + tracing::debug!( + "NVML did not detect a CUDA driver version; trying libcuda/nvidia-smi fallbacks" + ); + if let Some((version, source)) = detect_cuda_version_fallbacks() { + detected.info.version = Some(version); + detected.sources.version = Some(source); + } + } + + if detected.info.version.is_some() && detected.info.arch_info.is_none() { + tracing::debug!( + "NVML did not detect CUDA compute capability; trying nvidia-smi fallback" + ); + detected.info.arch_info = detect_cuda_arch_via_nvidia_smi(); + if detected.info.arch_info.is_some() { + detected.sources.arch = Some(CudaDetectionMethod::NvidiaSmi); + } + } + + // Last resort for platforms that ship libcuda but neither NVML nor nvidia-smi (e.g. + // Jetson/Tegra). libcuda comes last because its device enumeration is affected by + // `CUDA_VISIBLE_DEVICES`. + if detected.info.version.is_some() && detected.info.arch_info.is_none() { + tracing::debug!( + "nvidia-smi did not detect CUDA compute capability; trying libcuda fallback" + ); + detected.info.arch_info = detect_cuda_arch_via_libcuda(); + if detected.info.arch_info.is_some() { + detected.sources.arch = Some(CudaDetectionMethod::Libcuda); + } + } + + detected + }; + + // Normalization for all paths: `__cuda_arch` is meaningless without `__cuda`. If no driver + // version was detected, drop any compute capability so callers can never observe + // arch-without-version. + if detected.info.version.is_none() + && (detected.info.arch_info.is_some() || detected.sources.arch.is_some()) + { + tracing::debug!( + "dropping CUDA compute capability because no CUDA driver version was detected" + ); + detected.info.arch_info = None; + detected.sources.arch = None; } + + detected } -/// Detects CUDA version and architecture information via the CUDA driver library. +/// Detects CUDA version and architecture information via the NVIDIA Management Library. /// -/// This function loads `libcuda` and uses the CUDA Driver API to query both the driver -/// version and device compute capabilities. This is more efficient than separate detection -/// because the library is loaded only once. +/// The library is loaded once and used to query both the driver version and device compute +/// capabilities. NVML is preferred over libcuda because it is not affected by +/// `CUDA_VISIBLE_DEVICES`. /// /// Returns a `CudaInfo` struct where: /// * `version` is `None` if the driver version cannot be determined /// * `arch_info` is `None` if no devices are present or device queries fail -fn detect_cuda_info_via_libcuda() -> CudaInfo { - // Try to open the CUDA library - let cuda_library = match cuda_library_paths() - .iter() - .find_map(|path| unsafe { Library::new(*path).ok() }) - { - Some(lib) => lib, - None => { - return CudaInfo { +fn detect_cuda_info_via_nvml() -> DetectedCudaInfo { + let mut library = None; + for path in nvml_library_paths() { + match unsafe { Library::new(*path) } { + Ok(loaded) => { + tracing::trace!(library_path = *path, "loaded NVML library"); + library = Some(loaded); + break; + } + Err(err) => { + tracing::trace!(library_path = *path, error = %err, "failed to load NVML library"); + } + } + } + + let Some(library) = library else { + tracing::debug!("could not load NVML library from any known path"); + return DetectedCudaInfo { + info: CudaInfo { version: None, arch_info: None, - }; - } + }, + sources: CudaInfoSources::default(), + }; }; - // Get entry points from the library - let cu_init: Symbol<'_, unsafe extern "C" fn(c_uint) -> c_ulong> = - match unsafe { cuda_library.get(b"cuInit\0") } { - Ok(init) => init, - Err(_) => { - return CudaInfo { - version: None, - arch_info: None, - }; - } - }; + // Attempt the cheap no-init query. Some drivers answer `nvmlSystemGetCudaDriverVersion` before + // `nvmlInit`, but it is not officially supported, so this result is only used as a fall back for + // when `nvmlInit` itself fails below. + let no_init_version = match cuda_version_from_nvml_library(&library) { + Ok(version) => { + tracing::trace!(%version, "detected CUDA driver version via NVML without init"); + Some(version) + } + Err(err) => { + tracing::trace!( + ?err, + "CUDA driver version query via NVML without init failed" + ); + None + } + }; - // Initialize the CUDA library - if unsafe { cu_init(0) } != 0 { - return CudaInfo { - version: None, - arch_info: None, - }; - } + // Compute capability requires enumerating devices, which needs NVML to be initialized. Since + // NVML is being initialized anyway, query the driver version while initialized too and prefer + // that officially supported result over the no-init query. + let (initialized_version, arch_info) = + detect_cuda_initialized_info_via_nvml(&library, true, true); - // Detect the driver version (can succeed even without devices) - let version = detect_cuda_version_from_library(&cuda_library); + // Prefer the version obtained from initialized NVML whenever it is available; only fall back to + // the no-init value when `nvmlInit` (and thus the initialized query) did not produce one. + let (version, version_source) = if let Some(version) = initialized_version { + (Some(version), Some(CudaDetectionMethod::NvmlInitialized)) + } else if let Some(version) = no_init_version { + (Some(version), Some(CudaDetectionMethod::NvmlNoInit)) + } else { + (None, None) + }; - // Detect architecture info (requires devices to be present) - let arch_info = detect_cuda_arch_from_library(&cuda_library); + let arch_source = arch_info + .as_ref() + .map(|_| CudaDetectionMethod::NvmlInitialized); - CudaInfo { version, arch_info } + DetectedCudaInfo { + info: CudaInfo { version, arch_info }, + sources: CudaInfoSources { + version: version_source, + arch: arch_source, + }, + } } -/// Detects CUDA driver version from an already-loaded CUDA library. +/// Queries the CUDA driver version from an already-loaded NVML library. /// -/// This function queries the CUDA driver version using `cuDriverGetVersion`. -/// The version can be detected even if no GPU devices are present on the system. -fn detect_cuda_version_from_library(cuda_library: &Library) -> Option { - let cu_driver_get_version: Symbol<'_, unsafe extern "C" fn(*mut c_int) -> c_ulong> = - unsafe { cuda_library.get(b"cuDriverGetVersion\0") }.ok()?; +/// Some drivers allow `nvmlSystemGetCudaDriverVersion` before `nvmlInit`, but others return +/// `NVML_ERROR_UNINITIALIZED`; callers may retry after initializing NVML for that error. +fn cuda_version_from_nvml_library(library: &Library) -> Result { + // Find the `nvmlSystemGetCudaDriverVersion_v2` function. If that function cannot be found, fall + // back to the `nvmlSystemGetCudaDriverVersion` function instead. + let nvml_system_get_cuda_driver_version: Symbol<'_, unsafe extern "C" fn(*mut c_int) -> c_int> = + unsafe { + library + .get(b"nvmlSystemGetCudaDriverVersion_v2\0") + .or_else(|_| library.get(b"nvmlSystemGetCudaDriverVersion\0")) + } + .map_err(|_err| NvmlCudaVersionError::MissingSymbol)?; - // Get the version from the library - let mut version_int = MaybeUninit::uninit(); - if unsafe { cu_driver_get_version(version_int.as_mut_ptr()) != 0 } { - return None; + // Zero-initialize the out-parameter so that a driver returning `NVML_SUCCESS` without actually + // writing it yields a deterministic `0`, which `parse_cuda_driver_version` rejects, rather than + // undefined behavior from reading uninitialized memory. + let mut cuda_driver_version: c_int = 0; + let result = unsafe { nvml_system_get_cuda_driver_version(&mut cuda_driver_version) }; + if result != NVML_SUCCESS { + return Err(NvmlCudaVersionError::Nvml(result)); } - let version = unsafe { version_int.assume_init() }; - // Convert the version integer to a version string - Version::from_str(&format!("{}.{}", version / 1000, (version % 1000) / 10)).ok() + parse_cuda_driver_version(cuda_driver_version).ok_or(NvmlCudaVersionError::InvalidVersion) } -/// Detects CUDA compute capability from an already-loaded CUDA library. +/// Queries information that requires initialized NVML. /// -/// This function enumerates all CUDA devices and queries their compute capabilities, -/// returning the **minimum** compute capability found across all devices along with -/// the name of the device that has this minimum capability. -/// -/// Returns `None` if: -/// * No CUDA devices are detected (`cuDeviceGetCount` returns 0) -/// * Device enumeration fails -/// * Any of the required CUDA Driver API functions cannot be loaded -fn detect_cuda_arch_from_library(cuda_library: &Library) -> Option { - // CUDA device attribute constants for querying compute capability - const CU_DEVICE_ATTRIBUTE_COMPUTE_CAPABILITY_MAJOR: c_int = 75; - const CU_DEVICE_ATTRIBUTE_COMPUTE_CAPABILITY_MINOR: c_int = 76; - - // Get required function pointers from the library - let cu_device_get_count: Symbol<'_, unsafe extern "C" fn(*mut c_int) -> c_ulong> = - unsafe { cuda_library.get(b"cuDeviceGetCount\0") }.ok()?; +/// Returns `(version, arch_info)`. Each field is only queried when the corresponding `query_*` +/// argument is true. The initialized version query is a compatibility fallback for drivers that +/// reject `nvmlSystemGetCudaDriverVersion` before `nvmlInit`. +fn detect_cuda_initialized_info_via_nvml( + library: &Library, + query_version: bool, + query_arch: bool, +) -> (Option, Option) { + // NVML device handle (`nvmlDevice_t`) is an opaque pointer. + type NvmlDevice = *mut c_void; - let cu_device_get: Symbol<'_, unsafe extern "C" fn(*mut c_int, c_int) -> c_ulong> = - unsafe { cuda_library.get(b"cuDeviceGet\0") }.ok()?; + let Some(nvml_init): Option c_int>> = (unsafe { + library + .get(b"nvmlInit_v2\0") + .or_else(|_| library.get(b"nvmlInit\0")) + .ok() + }) else { + tracing::debug!("missing nvmlInit symbol"); + return (None, None); + }; - let cu_device_get_attribute: Symbol< - '_, - unsafe extern "C" fn(*mut c_int, c_int, c_int) -> c_ulong, - > = unsafe { cuda_library.get(b"cuDeviceGetAttribute\0") }.ok()?; + let Some(nvml_shutdown): Option c_int>> = + (unsafe { library.get(b"nvmlShutdown\0").ok() }) + else { + tracing::debug!("missing nvmlShutdown symbol"); + return (None, None); + }; - // Get the number of CUDA devices - let mut device_count = MaybeUninit::uninit(); - if unsafe { cu_device_get_count(device_count.as_mut_ptr()) } != 0 { - return None; + tracing::trace!(query_version, query_arch, "initializing NVML"); + let init_result = unsafe { nvml_init() }; + if init_result != NVML_SUCCESS { + tracing::debug!(return_code = init_result, "nvmlInit failed"); + return (None, None); } - let device_count = unsafe { device_count.assume_init() }; - // No devices found - if device_count == 0 { - return None; - } + let version = if query_version { + match cuda_version_from_nvml_library(library) { + Ok(version) => { + tracing::debug!(%version, "detected CUDA driver version via initialized NVML"); + Some(version) + } + Err(err) => { + tracing::debug!( + ?err, + "CUDA driver version query via initialized NVML failed" + ); + None + } + } + } else { + None + }; - // Iterate through all devices to find the minimum compute capability - let mut min_arch: Option = None; + // Enumerate devices to find the minimum compute capability. Wrapped in a closure so we always + // reach the `nvmlShutdown` call below regardless of the outcome. + let arch_info = query_arch + .then(|| { + tracing::trace!("querying CUDA compute capability via NVML"); + let nvml_device_get_count: Symbol<'_, unsafe extern "C" fn(*mut c_uint) -> c_int> = + match unsafe { + library + .get(b"nvmlDeviceGetCount_v2\0") + .or_else(|_| library.get(b"nvmlDeviceGetCount\0")) + } { + Ok(symbol) => symbol, + Err(err) => { + tracing::trace!(error = %err, "missing NVML device count symbol"); + return None; + } + }; - for device_idx in 0..device_count { - // Get device handle - let mut device = MaybeUninit::uninit(); - if unsafe { cu_device_get(device.as_mut_ptr(), device_idx) } != 0 { - continue; - } - let device = unsafe { device.assume_init() }; + let nvml_device_get_handle_by_index: Symbol< + '_, + unsafe extern "C" fn(c_uint, *mut NvmlDevice) -> c_int, + > = match unsafe { + library + .get(b"nvmlDeviceGetHandleByIndex_v2\0") + .or_else(|_| library.get(b"nvmlDeviceGetHandleByIndex\0")) + } { + Ok(symbol) => symbol, + Err(err) => { + tracing::trace!(error = %err, "missing NVML device handle symbol"); + return None; + } + }; - // Get compute capability major version - let mut cc_major = MaybeUninit::uninit(); - if unsafe { - cu_device_get_attribute( - cc_major.as_mut_ptr(), - CU_DEVICE_ATTRIBUTE_COMPUTE_CAPABILITY_MAJOR, - device, - ) - } != 0 - { - continue; - } - let cc_major = unsafe { cc_major.assume_init() } as u32; + let nvml_device_get_cuda_compute_capability: Symbol< + '_, + unsafe extern "C" fn(NvmlDevice, *mut c_int, *mut c_int) -> c_int, + > = match unsafe { library.get(b"nvmlDeviceGetCudaComputeCapability\0") } { + Ok(symbol) => symbol, + Err(err) => { + tracing::trace!( + error = %err, + "missing NVML CUDA compute capability symbol" + ); + return None; + } + }; - // Get compute capability minor version - let mut cc_minor = MaybeUninit::uninit(); - if unsafe { - cu_device_get_attribute( - cc_minor.as_mut_ptr(), - CU_DEVICE_ATTRIBUTE_COMPUTE_CAPABILITY_MINOR, - device, - ) - } != 0 - { - continue; - } - let cc_minor = unsafe { cc_minor.assume_init() } as u32; + let mut device_count: c_uint = 0; + let device_count_result = unsafe { nvml_device_get_count(&mut device_count) }; + if device_count_result != NVML_SUCCESS { + tracing::trace!( + return_code = device_count_result, + "nvmlDeviceGetCount failed" + ); + return None; + } + tracing::trace!(device_count, "enumerating CUDA devices via NVML"); - // Check if this is the minimum compute capability so far - let is_new_minimum = min_arch.as_ref().is_none_or(|min| { - cc_major < min.major || (cc_major == min.major && cc_minor < min.minor) - }); + let mut min_arch: Option = None; + for device_idx in 0..device_count { + let mut device: NvmlDevice = ptr::null_mut(); + let handle_result = + unsafe { nvml_device_get_handle_by_index(device_idx, &mut device) }; + if handle_result != NVML_SUCCESS { + tracing::trace!( + device_idx, + return_code = handle_result, + "failed to get NVML device handle" + ); + continue; + } - if is_new_minimum { - min_arch = Some(CudaArchInfo { - major: cc_major, - minor: cc_minor, - }); - } + let mut cc_major: c_int = 0; + let mut cc_minor: c_int = 0; + let compute_capability_result = unsafe { + nvml_device_get_cuda_compute_capability(device, &mut cc_major, &mut cc_minor) + }; + if compute_capability_result != NVML_SUCCESS { + tracing::trace!( + device_idx, + return_code = compute_capability_result, + "failed to get CUDA compute capability via NVML" + ); + continue; + } + let cc_major = cc_major as u32; + let cc_minor = cc_minor as u32; + tracing::trace!( + device_idx, + major = cc_major, + minor = cc_minor, + "detected CUDA compute capability via NVML" + ); + + let is_new_minimum = min_arch.as_ref().is_none_or(|min| { + cc_major < min.major || (cc_major == min.major && cc_minor < min.minor) + }); + if is_new_minimum { + min_arch = Some(CudaArchInfo { + major: cc_major, + minor: cc_minor, + }); + } + } + if let Some(arch) = min_arch.as_ref() { + tracing::debug!( + major = arch.major, + minor = arch.minor, + "selected minimum CUDA compute capability" + ); + } else { + tracing::debug!("no CUDA compute capability detected via NVML"); + } + min_arch + }) + .flatten(); + + // Whatever happens, after initializing NVML we have to call `nvmlShutdown`. + let shutdown_result = unsafe { nvml_shutdown() }; + if shutdown_result != NVML_SUCCESS { + tracing::debug!(return_code = shutdown_result, "nvmlShutdown failed"); } - min_arch + (version, arch_info) } /// Attempts to detect the version of CUDA present in the current operating system by employing the @@ -319,8 +684,28 @@ pub fn detect_cuda_version() -> Option { // the nvidia-smi command. detect_cuda_version_via_nvidia_smi() } else { - detect_cuda_version_via_nvml() + detect_cuda_version_via_nvml().or_else(|| { + tracing::debug!( + "NVML did not detect a CUDA driver version; trying libcuda/nvidia-smi fallbacks" + ); + detect_cuda_version_fallbacks().map(|(version, _source)| version) + }) + } +} + +fn detect_cuda_version_fallbacks() -> Option<(Version, CudaDetectionMethod)> { + if cfg!(target_env = "musl") { + return detect_cuda_version_via_nvidia_smi() + .map(|version| (version, CudaDetectionMethod::NvidiaSmi)); + } + + let version = detect_cuda_version_via_libcuda(); + if let Some(version) = version { + return Some((version, CudaDetectionMethod::Libcuda)); } + + tracing::debug!("libcuda did not detect a CUDA driver version; trying nvidia-smi fallback"); + detect_cuda_version_via_nvidia_smi().map(|version| (version, CudaDetectionMethod::NvidiaSmi)) } /// Attempts to detect the version of CUDA present in the current operating system by loading the @@ -331,58 +716,41 @@ pub fn detect_cuda_version() -> Option { /// Although the required methods in the runtime are not implemented on much older machines it is /// considered old enough to be usable for our use case. Since Conda doesn't provide old versions of /// the CUDA SDK anyway this is considered a non-issue. +/// +/// Some drivers can answer `nvmlSystemGetCudaDriverVersion` without `nvmlInit`, avoiding the +/// expensive driver handshake / GPU attach that makes `nvmlInit` slow on Windows. If that no-init +/// query fails, this falls back to querying while NVML is initialized. pub fn detect_cuda_version_via_nvml() -> Option { // Try to open the library - let library = nvml_library_paths() - .iter() - .find_map(|path| unsafe { libloading::Library::new(*path).ok() })?; - - // Get the initialization function. We first try to get `nvmlInit_v2` but if we can't find that - // we use the `nvmlInit` function. - let nvml_init: Symbol<'_, unsafe extern "C" fn() -> c_int> = unsafe { - library - .get(b"nvmlInit_v2\0") - .or_else(|_| library.get(b"nvmlInit\0")) - } - .ok()?; - - // Find the shutdown function - let nvml_shutdown: Symbol<'_, unsafe extern "C" fn() -> c_int> = - unsafe { library.get(b"nvmlShutdown\0") }.ok()?; - - // Find the `nvmlSystemGetCudaDriverVersion_v2` function. If that function cannot be found, fall - // back to the `nvmlSystemGetCudaDriverVersion` function instead. - let nvml_system_get_cuda_driver_version: Symbol<'_, unsafe extern "C" fn(*mut c_int) -> c_int> = - unsafe { - library - .get(b"nvmlSystemGetCudaDriverVersion_v2\0") - .or_else(|_| library.get(b"nvmlSystemGetCudaDriverVersion\0")) + let mut library = None; + for path in nvml_library_paths() { + match unsafe { libloading::Library::new(*path) } { + Ok(loaded) => { + tracing::trace!(library_path = *path, "loaded NVML library"); + library = Some(loaded); + break; + } + Err(err) => { + tracing::trace!(library_path = *path, error = %err, "failed to load NVML library"); + } } - .ok()?; - - // Call the initialization function - if unsafe { nvml_init() } != 0 { - return None; } + let library = library?; - // Get the version - let mut cuda_driver_version = MaybeUninit::uninit(); - let result = unsafe { nvml_system_get_cuda_driver_version(cuda_driver_version.as_mut_ptr()) }; - - // Call the shutdown function (don't care about the result of the function). Whatever happens, - // after calling `nvmlInit` we have to call `nvmlShutdown`. - let _ = unsafe { nvml_shutdown() }; - - // If the call failed we dont have a version - if result != 0 { - return None; + match cuda_version_from_nvml_library(&library) { + Ok(version) => { + tracing::trace!(%version, "detected CUDA driver version via NVML without init"); + Some(version) + } + Err(err) if err.should_retry_after_init() => { + tracing::debug!(?err, "retrying CUDA driver version query after nvmlInit"); + detect_cuda_initialized_info_via_nvml(&library, true, false).0 + } + Err(err) => { + tracing::debug!(?err, "CUDA driver version query via NVML failed"); + None + } } - - // We can assume the value is initialized by the `nvmlSystemGetCudaDriverVersion` function. - let version = unsafe { cuda_driver_version.assume_init() }; - - // Convert the version integer to a version string - Version::from_str(&format!("{}.{}", version / 1000, (version % 1000) / 10)).ok() } /// Returns platform specific set of search paths for the CUDA library. @@ -430,30 +798,225 @@ fn nvml_library_paths() -> &'static [&'static str] { /// have this limitation. pub fn detect_cuda_version_via_libcuda() -> Option { // Try to open the library - let cuda_library = cuda_library_paths() - .iter() - .find_map(|path| unsafe { libloading::Library::new(*path).ok() })?; + let mut cuda_library = None; + for path in cuda_library_paths() { + match unsafe { libloading::Library::new(*path) } { + Ok(loaded) => { + tracing::trace!(library_path = *path, "loaded CUDA driver library"); + cuda_library = Some(loaded); + break; + } + Err(err) => { + tracing::trace!( + library_path = *path, + error = %err, + "failed to load CUDA driver library" + ); + } + } + } + let cuda_library = cuda_library?; - // Get entry points from the library - let cu_init: Symbol<'_, unsafe extern "C" fn(c_uint) -> c_ulong> = - unsafe { cuda_library.get(b"cuInit\0") }.ok()?; - let cu_driver_get_version: Symbol<'_, unsafe extern "C" fn(*mut c_int) -> c_ulong> = - unsafe { cuda_library.get(b"cuDriverGetVersion\0") }.ok()?; + // Get entry points from the library. `CUresult` is a 32-bit enum, so these are declared to + // return `c_int` (matching the NVML declarations); on some ABIs the upper bits of a wider + // return register are unspecified. + let cu_init: Symbol<'_, unsafe extern "C" fn(c_uint) -> c_int> = + match unsafe { cuda_library.get(b"cuInit\0") } { + Ok(symbol) => symbol, + Err(err) => { + tracing::debug!(error = %err, "missing cuInit symbol"); + return None; + } + }; + let cu_driver_get_version: Symbol<'_, unsafe extern "C" fn(*mut c_int) -> c_int> = + match unsafe { cuda_library.get(b"cuDriverGetVersion\0") } { + Ok(symbol) => symbol, + Err(err) => { + tracing::debug!(error = %err, "missing cuDriverGetVersion symbol"); + return None; + } + }; // Initialize the CUDA library - if unsafe { cu_init(0) } != 0 { + let init_result = unsafe { cu_init(0) }; + if init_result != 0 { + tracing::debug!(return_code = init_result, "cuInit failed"); return None; } - // Get the version from the library - let mut version_int = MaybeUninit::uninit(); - if unsafe { cu_driver_get_version(version_int.as_mut_ptr()) != 0 } { + // Get the version from the library. The out-parameter is zero-initialized so that a driver + // returning success without writing it yields a deterministic `0`, which is rejected below. + let mut version_int: c_int = 0; + let version_result = unsafe { cu_driver_get_version(&mut version_int) }; + if version_result != 0 { + tracing::debug!(return_code = version_result, "cuDriverGetVersion failed"); return None; } - let version = unsafe { version_int.assume_init() }; // Convert the version integer to a version string - Version::from_str(&format!("{}.{}", version / 1000, (version % 1000) / 10)).ok() + let version = parse_cuda_driver_version(version_int); + if let Some(version) = &version { + tracing::trace!(%version, "detected CUDA driver version via libcuda"); + } else { + tracing::trace!("failed to parse CUDA driver version reported by libcuda"); + } + version +} + +/// Attempts to detect the CUDA compute capability by loading the CUDA driver library and +/// enumerating all devices, returning the **minimum** compute capability across all devices. +/// +/// This is the fallback for platforms that ship libcuda but neither NVML nor nvidia-smi (e.g. +/// Jetson/Tegra). Device enumeration through libcuda is affected by `CUDA_VISIBLE_DEVICES`, so +/// the NVML and nvidia-smi probes are preferred. +fn detect_cuda_arch_via_libcuda() -> Option { + // CUDA device attribute constants for querying compute capability + const CU_DEVICE_ATTRIBUTE_COMPUTE_CAPABILITY_MAJOR: c_int = 75; + const CU_DEVICE_ATTRIBUTE_COMPUTE_CAPABILITY_MINOR: c_int = 76; + + // Try to open the library + let mut cuda_library = None; + for path in cuda_library_paths() { + match unsafe { libloading::Library::new(*path) } { + Ok(loaded) => { + tracing::trace!(library_path = *path, "loaded CUDA driver library"); + cuda_library = Some(loaded); + break; + } + Err(err) => { + tracing::trace!( + library_path = *path, + error = %err, + "failed to load CUDA driver library" + ); + } + } + } + let cuda_library = cuda_library?; + + // Get entry points from the library. `CUresult` is a 32-bit enum, so these are declared to + // return `c_int`. + let cu_init: Symbol<'_, unsafe extern "C" fn(c_uint) -> c_int> = + match unsafe { cuda_library.get(b"cuInit\0") } { + Ok(symbol) => symbol, + Err(err) => { + tracing::debug!(error = %err, "missing cuInit symbol"); + return None; + } + }; + let cu_device_get_count: Symbol<'_, unsafe extern "C" fn(*mut c_int) -> c_int> = + match unsafe { cuda_library.get(b"cuDeviceGetCount\0") } { + Ok(symbol) => symbol, + Err(err) => { + tracing::debug!(error = %err, "missing cuDeviceGetCount symbol"); + return None; + } + }; + let cu_device_get: Symbol<'_, unsafe extern "C" fn(*mut c_int, c_int) -> c_int> = + match unsafe { cuda_library.get(b"cuDeviceGet\0") } { + Ok(symbol) => symbol, + Err(err) => { + tracing::debug!(error = %err, "missing cuDeviceGet symbol"); + return None; + } + }; + let cu_device_get_attribute: Symbol< + '_, + unsafe extern "C" fn(*mut c_int, c_int, c_int) -> c_int, + > = match unsafe { cuda_library.get(b"cuDeviceGetAttribute\0") } { + Ok(symbol) => symbol, + Err(err) => { + tracing::debug!(error = %err, "missing cuDeviceGetAttribute symbol"); + return None; + } + }; + + // Initialize the CUDA library + let init_result = unsafe { cu_init(0) }; + if init_result != 0 { + tracing::debug!(return_code = init_result, "cuInit failed"); + return None; + } + + // Get the number of CUDA devices + let mut device_count: c_int = 0; + let device_count_result = unsafe { cu_device_get_count(&mut device_count) }; + if device_count_result != 0 { + tracing::trace!(return_code = device_count_result, "cuDeviceGetCount failed"); + return None; + } + tracing::trace!(device_count, "enumerating CUDA devices via libcuda"); + + // Iterate through all devices to find the minimum compute capability + let mut min_arch: Option = None; + for device_idx in 0..device_count { + let mut device: c_int = 0; + let device_result = unsafe { cu_device_get(&mut device, device_idx) }; + if device_result != 0 { + tracing::trace!( + device_idx, + return_code = device_result, + "failed to get CUDA device handle" + ); + continue; + } + + let mut cc_major: c_int = 0; + let mut cc_minor: c_int = 0; + let major_result = unsafe { + cu_device_get_attribute( + &mut cc_major, + CU_DEVICE_ATTRIBUTE_COMPUTE_CAPABILITY_MAJOR, + device, + ) + }; + let minor_result = unsafe { + cu_device_get_attribute( + &mut cc_minor, + CU_DEVICE_ATTRIBUTE_COMPUTE_CAPABILITY_MINOR, + device, + ) + }; + if major_result != 0 || minor_result != 0 { + tracing::trace!( + device_idx, + major_return_code = major_result, + minor_return_code = minor_result, + "failed to get CUDA compute capability via libcuda" + ); + continue; + } + let cc_major = cc_major as u32; + let cc_minor = cc_minor as u32; + tracing::trace!( + device_idx, + major = cc_major, + minor = cc_minor, + "detected CUDA compute capability via libcuda" + ); + + let is_new_minimum = min_arch.as_ref().is_none_or(|min| { + cc_major < min.major || (cc_major == min.major && cc_minor < min.minor) + }); + if is_new_minimum { + min_arch = Some(CudaArchInfo { + major: cc_major, + minor: cc_minor, + }); + } + } + + if let Some(arch) = min_arch.as_ref() { + tracing::debug!( + major = arch.major, + minor = arch.minor, + "selected minimum CUDA compute capability from libcuda" + ); + } else { + tracing::debug!("no CUDA compute capability detected via libcuda"); + } + + min_arch } /// Returns platform specific set of search paths for the CUDA library. @@ -502,14 +1065,10 @@ fn cuda_library_paths() -> &'static [&'static str] { /// dynamically load a library which might not be supported on all systems. The downside is that /// executing a subprocess is generally slower and more prone to errors. fn detect_cuda_version_via_nvidia_smi() -> Option { - static CUDA_VERSION_RE: once_cell::sync::Lazy = - once_cell::sync::Lazy::new(|| { - regex::Regex::new("(.*)<\\/cuda_version>").unwrap() - }); - + tracing::trace!("detecting CUDA driver version via nvidia-smi"); // Invoke the "nvidia-smi" command to query the driver version that is usually installed when // Cuda drivers are installed. - let nvidia_smi_output = Command::new("nvidia-smi") + let nvidia_smi_output = match Command::new("nvidia-smi") // Display GPU or unit info .arg("--query") // Show unit, rather than GPU, attributes @@ -524,25 +1083,371 @@ fn detect_cuda_version_via_nvidia_smi() -> Option { // environment. .env_remove("CUDA_VISIBLE_DEVICES") .output() - .ok()?; + { + Ok(output) => output, + Err(err) => { + tracing::debug!(error = %err, "failed to run nvidia-smi for CUDA driver version"); + return None; + } + }; + + // nvidia-smi can exit non-zero in degraded-but-parseable states (e.g. one GPU lost while others + // are healthy) where the XML still contains a usable ``. Log the failure but still + // attempt to parse stdout instead of bailing out on the exit status. + if !nvidia_smi_output.status.success() { + tracing::debug!( + status = %nvidia_smi_output.status, + stderr = %String::from_utf8_lossy(&nvidia_smi_output.stderr), + "nvidia-smi CUDA driver version query exited non-zero; attempting to parse output anyway" + ); + } // Convert the output to Utf8. The conversion is lossy so it might contain some illegal // characters. If that is the case we simply assume the version in the file also wont make sense // during parsing. let output = String::from_utf8_lossy(&nvidia_smi_output.stdout); + parse_nvidia_smi_cuda_version(&output) +} + +/// Extracts the CUDA driver version from the XML output produced by `nvidia-smi --query -u -x`. +/// +/// Returns `None` if the `` element is missing or cannot be parsed as a [`Version`]. +fn parse_nvidia_smi_cuda_version(output: &str) -> Option { + static CUDA_VERSION_RE: once_cell::sync::Lazy = + once_cell::sync::Lazy::new(|| { + regex::Regex::new("(.*)<\\/cuda_version>").unwrap() + }); // Extract the version from the XML - let version_match = CUDA_VERSION_RE.captures(&output)?; - let version_str = version_match.get(1)?.as_str(); + let Some(version_match) = CUDA_VERSION_RE.captures(output) else { + tracing::trace!("nvidia-smi output did not contain a CUDA driver version"); + return None; + }; + let Some(version_match) = version_match.get(1) else { + tracing::trace!("nvidia-smi CUDA driver version match was empty"); + return None; + }; + let version_str = version_match.as_str(); // Parse and return - Version::from_str(version_str).ok() + match Version::from_str(version_str) { + Ok(version) => { + tracing::trace!(%version, "detected CUDA driver version via nvidia-smi"); + Some(version) + } + Err(err) => { + tracing::trace!(version = version_str, error = %err, "failed to parse nvidia-smi CUDA driver version"); + None + } + } +} + +/// Attempts to detect the CUDA compute capability by executing the "nvidia-smi" command and +/// querying the `compute_cap` field of every GPU, returning the **minimum** across all devices. +/// +/// Like [`detect_cuda_version_via_nvidia_smi`] this does not dynamically load a library and thus +/// also works on musl systems. The `compute_cap` query field requires a reasonably modern driver +/// (roughly R510+); on older drivers the command fails and `None` is returned. +fn detect_cuda_arch_via_nvidia_smi() -> Option { + tracing::trace!("detecting CUDA compute capability via nvidia-smi"); + let nvidia_smi_output = match Command::new("nvidia-smi") + // Query the compute capability of every GPU as plain CSV, one line per GPU. + .arg("--query-gpu=compute_cap") + .arg("--format=csv,noheader") + // See `detect_cuda_version_via_nvidia_smi` for why this variable is removed. + .env_remove("CUDA_VISIBLE_DEVICES") + .output() + { + Ok(output) => output, + Err(err) => { + tracing::debug!(error = %err, "failed to run nvidia-smi for CUDA compute capability"); + return None; + } + }; + + // On drivers that do not support the `compute_cap` field the command exits with an error, but it + // can also exit non-zero while still reporting some healthy GPUs on stdout. Log the failure but + // still attempt to parse whatever was produced instead of bailing out on the exit status. + if !nvidia_smi_output.status.success() { + tracing::debug!( + status = %nvidia_smi_output.status, + stderr = %String::from_utf8_lossy(&nvidia_smi_output.stderr), + "nvidia-smi CUDA compute capability query exited non-zero; attempting to parse output anyway" + ); + } + + let output = String::from_utf8_lossy(&nvidia_smi_output.stdout); + parse_nvidia_smi_compute_capabilities(&output) +} + +/// Parses the CSV output of `nvidia-smi --query-gpu=compute_cap --format=csv,noheader` and returns +/// the **minimum** compute capability across all parseable GPU lines. +/// +/// Each line is expected to be a `major.minor` value. Lines that are not in that format (such as +/// `[N/A]` reported for a GPU whose capability is unknown, or other junk) are skipped while still +/// using the valid ones. Returns `None` if no line could be parsed. +fn parse_nvidia_smi_compute_capabilities(output: &str) -> Option { + // Find the minimum compute capability across all devices + let mut min_arch: Option = None; + for (device_idx, line) in output.lines().enumerate() { + let line = line.trim(); + let Some((major, minor)) = line.split_once('.') else { + tracing::trace!( + device_idx, + line, + "ignoring invalid nvidia-smi compute capability line" + ); + continue; + }; + let (Ok(major), Ok(minor)) = (major.parse::(), minor.parse::()) else { + tracing::trace!( + device_idx, + line, + "ignoring unparsable nvidia-smi compute capability line" + ); + continue; + }; + tracing::trace!( + device_idx, + major, + minor, + "detected CUDA compute capability via nvidia-smi" + ); + + let is_new_minimum = min_arch + .as_ref() + .is_none_or(|min| major < min.major || (major == min.major && minor < min.minor)); + if is_new_minimum { + min_arch = Some(CudaArchInfo { major, minor }); + } + } + + if let Some(arch) = min_arch.as_ref() { + tracing::debug!( + major = arch.major, + minor = arch.minor, + "selected minimum CUDA compute capability from nvidia-smi" + ); + } else { + tracing::debug!("no CUDA compute capability detected via nvidia-smi"); + } + + min_arch } #[cfg(test)] mod test { use super::*; + /// Times loading the NVML library only, as the first NVML use in this process. + /// + /// Compared against [`bench_cold_version`] this separates the cost of mapping the library from + /// the cost of the version query itself (which loads the CUDA driver library internally). + /// + /// Run on its own, see [`bench_cold_version`]. + #[test] + #[ignore = "benchmark, run manually and in isolation"] + fn bench_cold_library_load() { + let start = std::time::Instant::now(); + let loaded = nvml_library_paths() + .iter() + .find_map(|path| unsafe { Library::new(*path).ok() }) + .is_some(); + println!( + "cold NVML library load: {:?} -> loaded={loaded}", + start.elapsed() + ); + } + + /// Times a single cold `__cuda` detection, as the first NVML call in this process. + /// + /// The dominant factor is how long ago anything last touched the GPU, not the code path: the + /// driver powers down when idle, and the first query after that re-initializes it. Measured on + /// Windows with an NVIDIA GPU this is around 1.5s for an idle driver against roughly 460ms for + /// one that was used seconds earlier, so compare runs in the same driver state. + /// + /// Run on its own so nothing else has warmed up the driver: + /// + /// ```text + /// cargo test -p rattler_virtual_packages --release -- --ignored --nocapture --exact \ + /// cuda::test::bench_cold_version + /// ``` + #[test] + #[ignore = "benchmark, run manually and in isolation"] + fn bench_cold_version() { + let start = std::time::Instant::now(); + let version = detect_cuda_version_via_nvml(); + println!( + "cold __cuda (no init): {:?} -> {version:?}", + start.elapsed() + ); + } + + /// Times a single cold `__cuda_arch` detection, as the first NVML call in this process. + /// + /// Run on its own, see [`bench_cold_version`]. + #[test] + #[ignore = "benchmark, run manually and in isolation"] + fn bench_cold_arch() { + let start = std::time::Instant::now(); + let info = detect_cuda_info(); + println!( + "cold __cuda + __cuda_arch: {:?} -> {:?}", + start.elapsed(), + info.info.arch_info + ); + } + + /// Times a single cold detection through the on-disk cache, as the first NVML call in this + /// process. Run twice: the first run populates the cache, the second measures a cache hit. + /// + /// Run on its own, see [`bench_cold_version`]. + #[test] + #[ignore = "benchmark, run manually and in isolation"] + fn bench_cold_cached() { + let cache_dir = std::env::temp_dir().join("rattler-cuda-bench-cache"); + std::fs::create_dir_all(&cache_dir).unwrap(); + let start = std::time::Instant::now(); + let info = cuda_info(Some(&cache_dir)); + println!( + "cold detection via cache: {:?} -> {:?} / {:?}", + start.elapsed(), + info.version, + info.arch_info + ); + println!( + "(run again to measure a cache hit; cache dir: {})", + cache_dir.display() + ); + } + + /// Times each CUDA detection path so the cost of the on-disk cache can be + /// compared against actually talking to the driver. + /// + /// Ignored by default because it is a benchmark and because the interesting + /// numbers only show up on a machine with an NVIDIA GPU. Run it with: + /// + /// ```text + /// cargo test -p rattler_virtual_packages --release -- --ignored --nocapture bench_cuda_detection + /// ``` + #[test] + #[ignore = "benchmark, run manually on a machine with an NVIDIA GPU"] + fn bench_cuda_detection() { + /// Reproduction of the detection this crate used before the driver library was queried + /// without initializing NVML, so the two can be compared side by side. + fn legacy_detect_cuda_version_via_nvml() -> Option { + let library = nvml_library_paths() + .iter() + .find_map(|path| unsafe { Library::new(*path).ok() })?; + let nvml_init: Symbol<'_, unsafe extern "C" fn() -> c_int> = unsafe { + library + .get(b"nvmlInit_v2\0") + .or_else(|_| library.get(b"nvmlInit\0")) + } + .ok()?; + let nvml_shutdown: Symbol<'_, unsafe extern "C" fn() -> c_int> = + unsafe { library.get(b"nvmlShutdown\0") }.ok()?; + let get_version: Symbol<'_, unsafe extern "C" fn(*mut c_int) -> c_int> = unsafe { + library + .get(b"nvmlSystemGetCudaDriverVersion_v2\0") + .or_else(|_| library.get(b"nvmlSystemGetCudaDriverVersion\0")) + } + .ok()?; + + if unsafe { nvml_init() } != 0 { + return None; + } + let mut raw = std::mem::MaybeUninit::uninit(); + let result = unsafe { get_version(raw.as_mut_ptr()) }; + let _ = unsafe { nvml_shutdown() }; + if result != 0 { + return None; + } + parse_cuda_driver_version(unsafe { raw.assume_init() }) + } + + // The first call is the one that matters: every process pays it once, and the on-disk + // cache exists to avoid exactly that. The warm best-of-5 is reported next to it to show + // how much of the cost is one-time (library loading, driver wake-up) rather than + // intrinsic to the query. + fn time(label: &str, mut f: impl FnMut() -> T) -> T { + let start = std::time::Instant::now(); + let mut result = f(); + let first = start.elapsed(); + + let mut best = std::time::Duration::MAX; + for _ in 0..5 { + let start = std::time::Instant::now(); + result = f(); + best = best.min(start.elapsed()); + } + println!("{label:<48} {first:>12.3?} {best:>12.3?}"); + result + } + + println!("\n{:<48} {:>12} {:>12}", "", "first (cold)", "best of 5"); + println!("--- detection paths ---"); + let legacy = time("version via NVML (legacy, with init)", || { + legacy_detect_cuda_version_via_nvml() + }); + let version = time("version via NVML (no init)", detect_cuda_version_via_nvml); + let info = time("version + arch via NVML (one init)", detect_cuda_info); + let smi_version = time("version via nvidia-smi", detect_cuda_version_via_nvidia_smi); + let smi_arch = time("arch via nvidia-smi", detect_cuda_arch_via_nvidia_smi); + println!("legacy version: {legacy:?}"); + + println!("\n--- breakdown ---"); + if let Some(path) = nvml_library_paths() + .iter() + .copied() + .find(|path| unsafe { Library::new(*path) }.is_ok()) + { + time("load NVML library only", || { + drop(unsafe { Library::new(path) }); + }); + let library = unsafe { Library::new(path) }.expect("just loaded successfully"); + time("version query (library already loaded)", || { + cuda_version_from_nvml_library(&library).ok() + }); + time("init + arch (library already loaded)", || { + detect_cuda_initialized_info_via_nvml(&library, false, true) + }); + } + if let Some(path) = cuda_library_paths() + .iter() + .copied() + .find(|path| unsafe { Library::new(*path) }.is_ok()) + { + time("load CUDA driver library only", || { + drop(unsafe { Library::new(path) }); + }); + } + + println!("\n--- cache paths ---"); + let dir = tempfile::tempdir().unwrap(); + let env = time("cache env (boot + driver + device keys)", || { + cache::CacheEnv::current() + }); + time("cache write", || { + cache::write_with_env(&env, dir.path(), &info.info, info.sources); + }); + let cache_read = time("cache read (hit)", || { + cache::read_with_env(&env, dir.path()) + }); + + println!("\n--- results ---"); + println!("version: {version:?}"); + println!("arch: {:?}", info.info.arch_info); + println!("nvidia-smi: {smi_version:?} / {smi_arch:?}"); + println!("cache read: {:?}", cache_read.map(|c| c.info)); + if version.is_none() { + println!( + "\nNOTE: no CUDA driver found, so the driver paths short-circuit and their\n\ + timings are meaningless. Run this on a machine with an NVIDIA GPU." + ); + } + } + #[test] pub fn doesnt_crash() { let version = detect_cuda_version_via_nvml(); @@ -555,9 +1460,15 @@ mod test { println!("Cuda {version:?}"); } + #[test] + pub fn doesnt_crash_nvidia_smi_arch() { + let arch = detect_cuda_arch_via_nvidia_smi(); + println!("Cuda arch {arch:?}"); + } + #[test] pub fn test_cuda_info() { - let info = cuda_info(); + let info = cuda_info(None); println!("CUDA Info: {info:?}"); if let Some(ref arch) = info.arch_info { println!(" Compute capability: {}.{}", arch.major, arch.minor); @@ -566,10 +1477,280 @@ mod test { #[test] pub fn test_cuda_arch() { - let arch = cuda_arch(); + let arch = cuda_arch(None); println!("CUDA Arch: {arch:?}"); } + /// Builds a fully specified, deterministic cache environment for the tests. The `driver` + /// string becomes a kernel-module fingerprint and the `device` string a single GPU bus id. + fn fake_env( + boot: &str, + driver: Option<&str>, + device: Option<&str>, + now: u64, + ) -> cache::CacheEnv { + cache::CacheEnv { + boot_id: Some(cache::BootId::Uuid(boot.to_owned())), + driver_fingerprint: driver.map(|version| cache::DriverFingerprint::Module { + version: version.to_owned(), + }), + device_fingerprint: device.map(|gpu| cache::DeviceFingerprint { + gpus: vec![gpu.to_owned()], + device_nodes: Vec::new(), + }), + now, + } + } + + fn full_info() -> (CudaInfo, CudaInfoSources) { + ( + CudaInfo { + version: Some(Version::from_str("12.4").unwrap()), + arch_info: Some(CudaArchInfo { major: 8, minor: 6 }), + }, + CudaInfoSources { + version: Some(CudaDetectionMethod::NvmlInitialized), + arch: Some(CudaDetectionMethod::NvidiaSmi), + }, + ) + } + + #[test] + fn test_cache_roundtrip() { + let dir = tempfile::tempdir().unwrap(); + let env = fake_env("boot-1", Some("driver-1"), Some("dev-1"), 1_000); + + // Nothing cached yet. + assert!(cache::read_with_env(&env, dir.path()).is_none()); + + // Negative results are not cached. + cache::write_with_env( + &env, + dir.path(), + &CudaInfo { + version: None, + arch_info: None, + }, + CudaInfoSources::default(), + ); + assert!(cache::read_with_env(&env, dir.path()).is_none()); + + let (info, sources) = full_info(); + cache::write_with_env(&env, dir.path(), &info, sources); + let cached = cache::read_with_env(&env, dir.path()).unwrap(); + assert_eq!(cached.info.version, info.version); + assert_eq!(cached.info.arch_info, info.arch_info); + assert_eq!(cached.sources, sources); + + // Updating the cache should atomically replace the previous file. + let updated_info = CudaInfo { + version: Some(Version::from_str("12.5").unwrap()), + arch_info: None, + }; + let updated_sources = CudaInfoSources { + version: Some(CudaDetectionMethod::Libcuda), + arch: None, + }; + cache::write_with_env(&env, dir.path(), &updated_info, updated_sources); + let cached = cache::read_with_env(&env, dir.path()).unwrap(); + assert_eq!(cached.info.version, updated_info.version); + assert_eq!(cached.info.arch_info, updated_info.arch_info); + assert_eq!(cached.sources, updated_sources); + } + + #[test] + fn test_cache_ttl_version_only_vs_full() { + let dir = tempfile::tempdir().unwrap(); + let write_env = fake_env("boot-1", Some("driver-1"), Some("dev-1"), 1_000); + + // A version-only entry (transient arch failure) is readable immediately but expires after + // ten minutes. + let version_only = CudaInfo { + version: Some(Version::from_str("12.4").unwrap()), + arch_info: None, + }; + cache::write_with_env( + &write_env, + dir.path(), + &version_only, + CudaInfoSources::default(), + ); + assert!(cache::read_with_env(&write_env, dir.path()).is_some()); + let just_before = fake_env("boot-1", Some("driver-1"), Some("dev-1"), 1_000 + 600); + assert!(cache::read_with_env(&just_before, dir.path()).is_some()); + let after_10m = fake_env("boot-1", Some("driver-1"), Some("dev-1"), 1_000 + 601); + assert!(cache::read_with_env(&after_10m, dir.path()).is_none()); + + // A full entry is still readable at that same age (it uses the 24h TTL) but expires past a + // day. + let (info, sources) = full_info(); + cache::write_with_env(&write_env, dir.path(), &info, sources); + assert!(cache::read_with_env(&after_10m, dir.path()).is_some()); + let after_24h = fake_env( + "boot-1", + Some("driver-1"), + Some("dev-1"), + 1_000 + 24 * 3600 + 1, + ); + assert!(cache::read_with_env(&after_24h, dir.path()).is_none()); + } + + #[test] + fn test_cache_rejects_future_write_time() { + let dir = tempfile::tempdir().unwrap(); + let write_env = fake_env("boot-1", Some("driver-1"), Some("dev-1"), 10_000); + let (info, sources) = full_info(); + cache::write_with_env(&write_env, dir.path(), &info, sources); + + // Written more than five minutes in the future (clock stepped backwards): rejected. + let past = fake_env("boot-1", Some("driver-1"), Some("dev-1"), 10_000 - 301); + assert!(cache::read_with_env(&past, dir.path()).is_none()); + // Within the tolerated skew: still accepted. + let slight_past = fake_env("boot-1", Some("driver-1"), Some("dev-1"), 10_000 - 299); + assert!(cache::read_with_env(&slight_past, dir.path()).is_some()); + } + + #[test] + fn test_cache_invalidated_on_device_change() { + let dir = tempfile::tempdir().unwrap(); + let env = fake_env("boot-1", Some("driver-1"), Some("dev-a"), 1_000); + let (info, sources) = full_info(); + cache::write_with_env(&env, dir.path(), &info, sources); + + // A different device fingerprint (e.g. another container / hot-plugged GPU) invalidates. + let other_device = fake_env("boot-1", Some("driver-1"), Some("dev-b"), 1_000); + assert!(cache::read_with_env(&other_device, dir.path()).is_none()); + // `Some` cached versus `None` current also invalidates. + let no_device = fake_env("boot-1", Some("driver-1"), None, 1_000); + assert!(cache::read_with_env(&no_device, dir.path()).is_none()); + // The matching fingerprint still reads. + assert!(cache::read_with_env(&env, dir.path()).is_some()); + } + + #[test] + fn test_cache_requires_driver_fingerprint() { + let dir = tempfile::tempdir().unwrap(); + let (info, sources) = full_info(); + + // Without a current driver fingerprint nothing is written. + let no_driver = fake_env("boot-1", None, Some("dev-1"), 1_000); + cache::write_with_env(&no_driver, dir.path(), &info, sources); + assert!(!dir.path().join("cuda-info-v1.json").exists()); + + // A hand-written cache file is rejected when the current fingerprint is unavailable. + std::fs::write( + dir.path().join("cuda-info-v1.json"), + r#"{"boot_id":{"uuid":"boot-1"},"driver_fingerprint":{"module":{"version":"driver-1"}},"device_fingerprint":{"gpus":["dev-1"],"device_nodes":[]},"written_at":1000,"version":"12.4","arch":[8,6]}"#, + ) + .unwrap(); + assert!(cache::read_with_env(&no_driver, dir.path()).is_none()); + // With a driver fingerprint available it reads. + let with_driver = fake_env("boot-1", Some("driver-1"), Some("dev-1"), 1_000); + assert!(cache::read_with_env(&with_driver, dir.path()).is_some()); + } + + #[test] + fn test_cache_invalidated_after_driver_change() { + let dir = tempfile::tempdir().unwrap(); + // A cache file written with a different driver installed is ignored. + std::fs::write( + dir.path().join("cuda-info-v1.json"), + r#"{"boot_id":{"uuid":"boot-1"},"driver_fingerprint":{"module":{"version":"535.0"}},"device_fingerprint":null,"written_at":1000,"version":"12.4","arch":[8,6]}"#, + ) + .unwrap(); + let stale = fake_env("boot-1", Some("550.0"), None, 1_000); + assert!(cache::read_with_env(&stale, dir.path()).is_none()); + // The original driver still reads. + let current = fake_env("boot-1", Some("535.0"), None, 1_000); + assert!(cache::read_with_env(¤t, dir.path()).is_some()); + } + + #[test] + fn test_cache_invalidated_after_reboot() { + let dir = tempfile::tempdir().unwrap(); + // A cache file from a different boot session is ignored. + std::fs::write( + dir.path().join("cuda-info-v1.json"), + r#"{"boot_id":{"uuid":"boot-A"},"driver_fingerprint":{"module":{"version":"driver-1"}},"device_fingerprint":null,"written_at":1000,"version":"12.4","arch":[8,6]}"#, + ) + .unwrap(); + let other_boot = fake_env("boot-B", Some("driver-1"), None, 1_000); + assert!(cache::read_with_env(&other_boot, dir.path()).is_none()); + // The same boot session still reads. + let same_boot = fake_env("boot-A", Some("driver-1"), None, 1_000); + assert!(cache::read_with_env(&same_boot, dir.path()).is_some()); + } + + #[test] + fn test_boot_time_tolerance() { + // The extracted numeric comparison, testable on every platform. + assert!(cache::boot_times_within_tolerance(1_000, 1_000)); + assert!(cache::boot_times_within_tolerance(1_000, 1_120)); + assert!(cache::boot_times_within_tolerance(1_120, 1_000)); + assert!(!cache::boot_times_within_tolerance(1_000, 1_121)); + + // Two derived boot times match within tolerance. + let a = cache::BootId::BootTime(1000); + let b = cache::BootId::BootTime(1050); + assert!(a.matches(&b)); + let c = cache::BootId::BootTime(2000); + assert!(!a.matches(&c)); + + // Boot counters must match exactly, and a boot counter never matches a boot time. + let count = cache::BootId::BootCount(5); + assert!(count.matches(&cache::BootId::BootCount(5))); + assert!(!count.matches(&cache::BootId::BootCount(6))); + assert!(!count.matches(&a)); + } + + #[test] + fn test_late_persistence() { + let dir = tempfile::tempdir().unwrap(); + let env = fake_env("boot-1", Some("driver-1"), Some("dev-1"), 1_000); + + // Pre-seed the state with a known detection result, as if detection had already run. + let state: OnceCell = OnceCell::new(); + let (info, sources) = full_info(); + let _ = state.set(DetectedCudaInfo { info, sources }); + let persisted = AtomicBool::new(false); + + // A call with no cache directory does not persist to disk. + cuda_info_impl(&env, &state, &persisted, None); + assert!(!persisted.load(Ordering::Relaxed)); + assert!(cache::read_with_env(&env, dir.path()).is_none()); + + // A later call with a cache directory persists the already-detected result. + cuda_info_impl(&env, &state, &persisted, Some(dir.path())); + assert!(persisted.load(Ordering::Relaxed)); + let cached = cache::read_with_env(&env, dir.path()).unwrap(); + assert_eq!( + cached.info.version, + Some(Version::from_str("12.4").unwrap()) + ); + assert_eq!( + cached.info.arch_info, + Some(CudaArchInfo { major: 8, minor: 6 }) + ); + } + + #[test] + fn test_is_nvidia_pci_device_id() { + // Real NVIDIA device ids as Windows reports them + assert!(cache::is_nvidia_pci_device_id( + "PCI\\VEN_10DE&DEV_2484&SUBSYS_147D10DE&REV_A1\\4&2D2E5D1F&0&0008" + )); + assert!(cache::is_nvidia_pci_device_id("PCI\\VEN_10DE&DEV_1EB1")); + // Match case insensitively + assert!(cache::is_nvidia_pci_device_id("pci\\ven_10de&dev_2484")); + + // Other vendors: Intel, AMD + assert!(!cache::is_nvidia_pci_device_id( + "PCI\\VEN_8086&DEV_9A49&SUBSYS_00011025&REV_01\\3&11583659&0&10" + )); + assert!(!cache::is_nvidia_pci_device_id("PCI\\VEN_1002&DEV_73FF")); + assert!(!cache::is_nvidia_pci_device_id("")); + } + #[test] fn test_is_valid_cuda_version_format() { // Valid formats @@ -596,4 +1777,85 @@ mod test { assert!(!is_valid_cuda_version_format("8-6")); assert!(!is_valid_cuda_version_format("8_6")); } + + #[test] + fn test_parse_cuda_driver_version() { + // Valid values are decoded as `major.minor`. + assert_eq!( + parse_cuda_driver_version(12_040), + Some(Version::from_str("12.4").unwrap()) + ); + assert_eq!( + parse_cuda_driver_version(11_080), + Some(Version::from_str("11.8").unwrap()) + ); + // Smallest plausible value (CUDA major 1). + assert_eq!( + parse_cuda_driver_version(1_000), + Some(Version::from_str("1.0").unwrap()) + ); + // Largest plausible value (CUDA major 99). + assert_eq!( + parse_cuda_driver_version(99_990), + Some(Version::from_str("99.99").unwrap()) + ); + + // Implausible values are rejected rather than propagating garbage. + assert_eq!(parse_cuda_driver_version(0), None); + assert_eq!(parse_cuda_driver_version(-1), None); + assert_eq!(parse_cuda_driver_version(999), None); + assert_eq!(parse_cuda_driver_version(100_000), None); + assert_eq!(parse_cuda_driver_version(c_int::MAX), None); + assert_eq!(parse_cuda_driver_version(c_int::MIN), None); + } + + #[test] + fn test_parse_nvidia_smi_cuda_version() { + // A representative fragment of the `nvidia-smi --query -u -x` XML output. + let xml = "\n 12.4\n"; + assert_eq!( + parse_nvidia_smi_cuda_version(xml), + Some(Version::from_str("12.4").unwrap()) + ); + + // Missing the tag entirely. + assert_eq!( + parse_nvidia_smi_cuda_version(""), + None + ); + + // Empty input. + assert_eq!(parse_nvidia_smi_cuda_version(""), None); + } + + #[test] + fn test_parse_nvidia_smi_compute_capabilities() { + // Multiple GPUs: the minimum capability is selected. + assert_eq!( + parse_nvidia_smi_compute_capabilities("8.6\n7.5\n9.0"), + Some(CudaArchInfo { major: 7, minor: 5 }) + ); + + // Junk and `[N/A]`-style lines are skipped while the valid ones are still used. Here the + // valid minimum line (7.5) is retained even though another GPU reports `[N/A]`. + assert_eq!( + parse_nvidia_smi_compute_capabilities("8.6\n[N/A]\n7.5\ngarbage"), + Some(CudaArchInfo { major: 7, minor: 5 }) + ); + + // A line that is unparsable as numbers (`x.y`) is skipped as well. + assert_eq!( + parse_nvidia_smi_compute_capabilities("x.y\n8.0"), + Some(CudaArchInfo { major: 8, minor: 0 }) + ); + + // All lines are junk: nothing is detected. + assert_eq!( + parse_nvidia_smi_compute_capabilities("[N/A]\ngarbage\n"), + None + ); + + // Empty input. + assert_eq!(parse_nvidia_smi_compute_capabilities(""), None); + } } diff --git a/crates/rattler_virtual_packages/src/cuda/cache.rs b/crates/rattler_virtual_packages/src/cuda/cache.rs new file mode 100644 index 0000000000..e78216f7fd --- /dev/null +++ b/crates/rattler_virtual_packages/src/cuda/cache.rs @@ -0,0 +1,603 @@ +//! On-disk cache for detected CUDA information, valid for the current boot session. +//! +//! Detecting CUDA can be slow (initializing NVML attaches to every GPU), so the result is cached +//! on disk between processes. The cache is keyed on everything that can invalidate a previous +//! detection without the code noticing: +//! +//! * the current **boot session**, because a reboot re-enumerates hardware and drivers; +//! * a **driver fingerprint**, because drivers can be updated (and NVML reloaded) without a reboot; +//! * a **device fingerprint** of the host-visible GPUs, because two containers sharing a cache +//! volume see different GPU subsets and topology can change within a session (eGPU hot-plug, +//! PCI hot-plug into VMs, suspend/resume); +//! * a **TTL** as a staleness backstop for anything the fingerprints cannot catch (and to let +//! transient arch-detection failures self-heal quickly). +//! +//! Reads and writes are best-effort: any failure simply results in a fresh detection. Delete the +//! file or use the `CONDA_OVERRIDE_CUDA*` variables to bypass it. + +use super::{CudaArchInfo, CudaDetectionMethod, CudaInfo, CudaInfoSources, DetectedCudaInfo}; +use rattler_conda_types::Version; +use serde::{Deserialize, Serialize}; +use std::{ + io::Write, + path::{Path, PathBuf}, + str::FromStr, +}; + +const CACHE_FILE_NAME: &str = "cuda-info-v1.json"; + +/// Full detections (with compute capability) are trusted for a day; the fingerprints catch most +/// changes sooner, so this is only a staleness backstop. +const FULL_TTL_SECS: u64 = 24 * 60 * 60; +/// Entries without compute capability represent a transient arch-detection failure, so they expire +/// quickly and self-heal instead of lingering for the whole boot session. +const ARCH_MISSING_TTL_SECS: u64 = 10 * 60; +/// Reject entries whose write time is further than this into the future, which means the clock +/// stepped backwards and the recorded `written_at` can no longer be trusted for the TTL. +const MAX_CLOCK_SKEW_SECS: u64 = 5 * 60; + +/// Identifies a single boot session of the machine. +/// +/// All variants are compiled on every platform so the comparison logic can be unit-tested +/// anywhere; `current` only ever produces the variants that exist on the host platform. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(super) enum BootId { + /// The kernel's per-boot UUID from `/proc/sys/kernel/random/boot_id` (Linux). + Uuid(String), + /// The prefetcher boot counter from the registry, incremented once per boot (Windows). + BootCount(u32), + /// A boot time in unix seconds derived from the uptime (Windows fallback). The derivation + /// drifts a little between processes, which `matches` absorbs with a tolerance. + BootTime(u64), +} + +impl BootId { + /// Returns the identifier of the current boot session, or `None` if it cannot be determined + /// (in which case no caching takes place). + pub(super) fn current() -> Option { + #[cfg(target_os = "linux")] + { + // The kernel generates a fresh UUID on every boot. + let id = std::fs::read_to_string("/proc/sys/kernel/random/boot_id").ok()?; + Some(Self::Uuid(id.trim().to_owned())) + } + #[cfg(target_os = "windows")] + { + // Prefer the prefetcher boot counter: it increments exactly once per boot and involves + // no clock arithmetic, so it cannot be confused by reboots or clock steps. + if let Some(count) = windows_boot_count() { + return Some(Self::BootCount(count)); + } + // Fall back to deriving the boot time from the uptime. + let uptime_secs = + unsafe { windows_sys::Win32::System::SystemInformation::GetTickCount64() } / 1000; + let now_secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .ok()? + .as_secs(); + Some(Self::BootTime(now_secs.checked_sub(uptime_secs)?)) + } + #[cfg(not(any(target_os = "linux", target_os = "windows")))] + { + None + } + } + + /// Returns true if both identifiers refer to the same boot session. + pub(super) fn matches(&self, other: &Self) -> bool { + match (self, other) { + // Two derived boot times can drift a few seconds between processes; treat them as the + // same session when they are within tolerance. + (Self::BootTime(a), Self::BootTime(b)) => boot_times_within_tolerance(*a, *b), + // Everything else (boot UUIDs, boot counters, or mixed kinds) must match exactly; two + // different kinds never refer to the same session. + _ => self == other, + } + } +} + +/// Returns true if two `boottime:` second values are close enough to be the same boot session. +/// +/// Extracted as a plain function (not `cfg(windows)`-gated) so the tolerance logic is compiled and +/// unit-tested on every platform. A real reboot shifts the derived boot time by at least the +/// previous uptime, which is far larger than this tolerance. +pub(super) fn boot_times_within_tolerance(a: u64, b: u64) -> bool { + /// The derived boot time drifts a little between processes. + const BOOT_TIME_TOLERANCE_SECS: u64 = 120; + a.abs_diff(b) <= BOOT_TIME_TOLERANCE_SECS +} + +/// Reads the prefetcher boot counter from the registry, incremented once per boot. +#[cfg(target_os = "windows")] +fn windows_boot_count() -> Option { + use windows_sys::Win32::System::Registry::{ + HKEY_LOCAL_MACHINE, RRF_RT_REG_DWORD, RegGetValueW, + }; + + fn wide(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() + } + + let subkey = wide( + "SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Memory Management\\PrefetchParameters", + ); + let value = wide("BootId"); + let mut data: u32 = 0; + let mut data_size: u32 = std::mem::size_of::() as u32; + let status = unsafe { + RegGetValueW( + HKEY_LOCAL_MACHINE, + subkey.as_ptr(), + value.as_ptr(), + RRF_RT_REG_DWORD, + std::ptr::null_mut(), + std::ptr::addr_of_mut!(data).cast::(), + &mut data_size, + ) + }; + // ERROR_SUCCESS + if status == 0 { Some(data) } else { None } +} + +/// Identifies the installed NVIDIA driver. +/// +/// Drivers can be updated without a reboot, so the boot session alone is not enough to key the +/// cache on. All variants are compiled on every platform so they can be unit-tested anywhere. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(super) enum DriverFingerprint { + /// The version of the loaded `nvidia` kernel module (Linux), which changes when the driver is + /// updated and the module is reloaded. + Module { version: String }, + /// The identity of the NVML library file on disk (Windows, WSL2), which driver updates + /// replace. + File { + path: PathBuf, + mtime_secs: u64, + len: u64, + }, +} + +/// Identifies the set of host-visible GPUs. +/// +/// This distinguishes containers that share a cache volume but see different GPU subsets, and it +/// changes when GPUs are hot-plugged or unplugged within a boot session. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub(super) struct DeviceFingerprint { + /// Sorted identifiers of the GPUs the host exposes: PCI bus ids from + /// `/proc/driver/nvidia/gpus` on Linux, Plug and Play device ids on Windows. + pub(super) gpus: Vec, + /// Sorted `/dev/nvidiaN` device nodes that are present. Always empty on Windows, which has no + /// device nodes. + pub(super) device_nodes: Vec, +} + +#[derive(Serialize, Deserialize)] +struct CacheFile { + /// The boot session during which detection ran. + boot_id: BootId, + /// Fingerprint of the installed driver when detection ran. Required: entries without a driver + /// fingerprint cannot be trusted, so they are neither written nor read. + driver_fingerprint: DriverFingerprint, + /// Fingerprint of the host-visible GPUs when detection ran, or `None` if it could not be + /// determined on this platform. + device_fingerprint: Option, + /// Unix seconds at which the entry was written, used for the TTL. + written_at: u64, + version: String, + arch: Option<(u32, u32)>, + #[serde(default)] + version_source: Option, + #[serde(default)] + arch_source: Option, +} + +/// The environment against which a cache entry is validated: everything that can invalidate a +/// previous detection, plus the current time for the TTL. +/// +/// [`read_with_env`] and [`write_with_env`] operate against an explicit `CacheEnv` so they can be +/// unit-tested deterministically on any machine; [`CacheEnv::current`] gathers the real values for +/// the call sites in `cuda.rs`. +pub(super) struct CacheEnv { + pub(super) boot_id: Option, + pub(super) driver_fingerprint: Option, + pub(super) device_fingerprint: Option, + pub(super) now: u64, +} + +impl CacheEnv { + /// Gathers the real cache environment from the current host. + pub(super) fn current() -> Self { + Self { + boot_id: BootId::current(), + driver_fingerprint: driver_fingerprint(), + device_fingerprint: device_fingerprint(), + now: now_unix_secs(), + } + } +} + +/// Returns the current time in unix seconds, or `0` if the clock is before the epoch. +fn now_unix_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_secs()) +} + +/// Absolute libnvidia-ml paths probed by the detector, used as a driver-fingerprint fallback where +/// `/sys/module/nvidia` does not exist (notably WSL2). +/// +/// Keep in sync with the absolute entries of `nvml_library_paths()` in `cuda.rs`. +#[cfg(target_os = "linux")] +const LIBNVIDIA_ML_ABSOLUTE_PATHS: &[&str] = &[ + "/usr/lib64/nvidia/libnvidia-ml.so.1", // RHEL/Centos/Fedora + "/usr/lib64/nvidia/libnvidia-ml.so", + "/usr/lib/x86_64-linux-gnu/libnvidia-ml.so.1", // Ubuntu + "/usr/lib/x86_64-linux-gnu/libnvidia-ml.so", + "/usr/lib/wsl/lib/libnvidia-ml.so.1", // WSL + "/usr/lib/wsl/lib/libnvidia-ml.so", +]; + +/// Fingerprints a file by its path, modification time and length, or `None` if it does not exist. +#[cfg(any(target_os = "linux", target_os = "windows"))] +fn file_fingerprint(path: &Path) -> Option { + let metadata = std::fs::metadata(path).ok()?; + let mtime = metadata + .modified() + .ok()? + .duration_since(std::time::UNIX_EPOCH) + .ok()?; + Some(DriverFingerprint::File { + path: path.to_path_buf(), + mtime_secs: mtime.as_secs(), + len: metadata.len(), + }) +} + +/// Returns a fingerprint of the installed NVIDIA driver, or `None` if it cannot be determined. +fn driver_fingerprint() -> Option { + #[cfg(target_os = "linux")] + { + // The version of the loaded kernel module, which changes when the driver is updated and + // the module is reloaded. + if let Ok(version) = std::fs::read_to_string("/sys/module/nvidia/version") { + return Some(DriverFingerprint::Module { + version: version.trim().to_owned(), + }); + } + // WSL2 (and similar setups) has no `/sys/module/nvidia`, so fall back to fingerprinting the + // libnvidia-ml file the detector would load. + for path in LIBNVIDIA_ML_ABSOLUTE_PATHS { + if let Some(fingerprint) = file_fingerprint(Path::new(path)) { + return Some(fingerprint); + } + } + None + } + #[cfg(target_os = "windows")] + { + // Driver updates on Windows usually complete without a reboot but replace nvml.dll. The DLL + // does not always load from System32, so also consider the NVSMI install location. + let mut candidates = Vec::new(); + if let Some(windir) = std::env::var_os("WINDIR") { + candidates.push(Path::new(&windir).join("System32").join("nvml.dll")); + } + if let Some(program_files) = std::env::var_os("ProgramFiles") { + candidates.push( + Path::new(&program_files) + .join("NVIDIA Corporation") + .join("NVSMI") + .join("nvml.dll"), + ); + } + for path in candidates { + if let Some(fingerprint) = file_fingerprint(&path) { + return Some(fingerprint); + } + } + None + } + #[cfg(not(any(target_os = "linux", target_os = "windows")))] + { + None + } +} + +/// Returns true if a `/dev` entry name is an `nvidiaN` device node (all-digits suffix), which +/// excludes control nodes like `nvidiactl` and `nvidia-uvm`. +#[cfg(target_os = "linux")] +fn is_nvidia_device_node(name: &str) -> bool { + name.strip_prefix("nvidia") + .is_some_and(|rest| !rest.is_empty() && rest.bytes().all(|b| b.is_ascii_digit())) +} + +/// Returns a fingerprint of the host-visible GPUs, or `None` if it cannot be determined. +fn device_fingerprint() -> Option { + #[cfg(target_os = "linux")] + { + // PCI bus ids of the GPUs the driver exposes to us. + let gpus_dir = std::fs::read_dir("/proc/driver/nvidia/gpus").ok(); + let has_gpus_dir = gpus_dir.is_some(); + let mut gpus: Vec = gpus_dir + .map(|entries| { + entries + .filter_map(Result::ok) + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .collect() + }) + .unwrap_or_default(); + gpus.sort(); + + // The `/dev/nvidiaN` device nodes that are actually present. + let mut devices: Vec = std::fs::read_dir("/dev") + .map(|entries| { + entries + .filter_map(Result::ok) + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .filter(|name| is_nvidia_device_node(name)) + .collect() + }) + .unwrap_or_default(); + devices.sort(); + + if !has_gpus_dir && devices.is_empty() { + return None; + } + Some(DeviceFingerprint { + gpus, + device_nodes: devices, + }) + } + #[cfg(target_os = "windows")] + { + // Enumerate the NVIDIA PCI devices Windows knows about. This only reads Plug and Play metadata, so + // it does not initialize the driver or wake a powered-down GPU. + let mut gpus = windows_nvidia_device_ids()?; + gpus.sort(); + Some(DeviceFingerprint { + gpus, + // Device nodes are a Linux concept. + device_nodes: Vec::new(), + }) + } + #[cfg(not(any(target_os = "linux", target_os = "windows")))] + { + // No cheap per-container GPU enumeration is available; the TTL is the backstop here. + None + } +} + +/// Returns the Plug and Play device ids of all NVIDIA PCI devices, or `None` if they cannot be +/// enumerated. +/// +/// Uses the configuration manager rather than the driver so that hot-plugged GPUs (for instance an +/// external GPU over Thunderbolt) are noticed without paying for driver initialization. +#[cfg(target_os = "windows")] +fn windows_nvidia_device_ids() -> Option> { + use windows_sys::Win32::Devices::DeviceAndDriverInstallation::{ + CM_GETIDLIST_FILTER_ENUMERATOR, CM_GETIDLIST_FILTER_PRESENT, CM_Get_Device_ID_List_SizeW, + CM_Get_Device_ID_ListW, CR_SUCCESS, + }; + + // Only enumerate devices that are currently present on the PCI bus. + let filter: Vec = "PCI".encode_utf16().chain(std::iter::once(0)).collect(); + let flags = CM_GETIDLIST_FILTER_ENUMERATOR | CM_GETIDLIST_FILTER_PRESENT; + + let mut len: u32 = 0; + if unsafe { CM_Get_Device_ID_List_SizeW(&mut len, filter.as_ptr(), flags) } != CR_SUCCESS { + return None; + } + + let mut buffer = vec![0u16; len as usize]; + if unsafe { CM_Get_Device_ID_ListW(filter.as_ptr(), buffer.as_mut_ptr(), len, flags) } + != CR_SUCCESS + { + return None; + } + + // The buffer is a sequence of null terminated strings, terminated by an empty string. + Some( + buffer + .split(|&c| c == 0) + .filter(|segment| !segment.is_empty()) + .map(String::from_utf16_lossy) + .filter(|id| is_nvidia_pci_device_id(id)) + .collect(), + ) +} + +/// Returns true if `id` is the Plug and Play device id of an NVIDIA PCI device. +/// +/// NVIDIA's PCI vendor id is `10DE`. Windows reports device ids uppercased, but match +/// case-insensitively to be safe. +#[cfg(any(target_os = "windows", test))] +pub(super) fn is_nvidia_pci_device_id(id: &str) -> bool { + id.to_ascii_uppercase().contains("VEN_10DE") +} + +/// Reads a cached detection result, validating it against the given host environment. +pub(super) fn read_with_env(env: &CacheEnv, cache_dir: &Path) -> Option { + let path = cache_dir.join(CACHE_FILE_NAME); + let Ok(content) = std::fs::read_to_string(&path) else { + tracing::trace!("no CUDA info cache found at {}", path.display()); + return None; + }; + let Ok(cached) = serde_json::from_str::(&content) else { + tracing::debug!("ignoring invalid CUDA info cache at {}", path.display()); + return None; + }; + let Some(current_boot_id) = env.boot_id.as_ref() else { + tracing::debug!( + "ignoring CUDA info cache because the current boot id could not be determined" + ); + return None; + }; + if !cached.boot_id.matches(current_boot_id) { + tracing::info!( + cache_path = %path.display(), + cached_boot_id = ?cached.boot_id, + current_boot_id = ?current_boot_id, + "invalidating CUDA info cache from a previous boot session" + ); + return None; + } + let Some(current_driver_fingerprint) = env.driver_fingerprint.as_ref() else { + tracing::debug!( + "ignoring CUDA info cache because the current driver fingerprint could not be determined" + ); + return None; + }; + if &cached.driver_fingerprint != current_driver_fingerprint { + tracing::info!( + cache_path = %path.display(), + cached_driver_fingerprint = ?cached.driver_fingerprint, + current_driver_fingerprint = ?current_driver_fingerprint, + "invalidating CUDA info cache because the driver changed" + ); + return None; + } + if cached.device_fingerprint != env.device_fingerprint { + tracing::info!( + cache_path = %path.display(), + cached_device_fingerprint = ?cached.device_fingerprint, + current_device_fingerprint = ?env.device_fingerprint, + "invalidating CUDA info cache because the visible GPUs changed" + ); + return None; + } + // Reject entries written in the future: the clock stepped backwards and the TTL below can no + // longer be trusted. + if cached.written_at.saturating_sub(env.now) > MAX_CLOCK_SKEW_SECS { + tracing::debug!( + cache_path = %path.display(), + written_at = cached.written_at, + now = env.now, + "ignoring CUDA info cache written in the future" + ); + return None; + } + // Full detections are trusted for a day; transient arch failures self-heal within minutes. + let ttl = if cached.arch.is_some() { + FULL_TTL_SECS + } else { + ARCH_MISSING_TTL_SECS + }; + if env.now.saturating_sub(cached.written_at) > ttl { + tracing::info!( + cache_path = %path.display(), + written_at = cached.written_at, + now = env.now, + ttl, + "invalidating expired CUDA info cache" + ); + return None; + } + let version = match Version::from_str(&cached.version) { + Ok(version) => version, + Err(err) => { + tracing::debug!( + version = cached.version, + error = %err, + "ignoring CUDA info cache with invalid version" + ); + return None; + } + }; + tracing::trace!("using CUDA info cached at {}", path.display()); + Some(DetectedCudaInfo { + info: CudaInfo { + version: Some(version), + arch_info: cached + .arch + .map(|(major, minor)| CudaArchInfo { major, minor }), + }, + sources: CudaInfoSources { + version: cached.version_source, + arch: cached.arch_source, + }, + }) +} + +/// Writes a detection result to the cache, keyed on the given host environment. +pub(super) fn write_with_env( + env: &CacheEnv, + cache_dir: &Path, + info: &CudaInfo, + sources: CudaInfoSources, +) { + // Only cache when a driver was found: detection without a driver is fast anyway, and not + // caching the negative result means a freshly installed driver is picked up immediately. + let Some(version) = &info.version else { + tracing::trace!("not caching CUDA info because no CUDA driver version was detected"); + return; + }; + let Some(boot_id) = env.boot_id.clone() else { + tracing::debug!( + "not caching CUDA info because the current boot id could not be determined" + ); + return; + }; + // The driver fingerprint is required: without it the cache is keyed on the boot session alone + // (a host driver update would serve stale data on e.g. WSL2), so we simply do not cache. + let Some(driver_fingerprint) = env.driver_fingerprint.clone() else { + tracing::debug!( + "not caching CUDA info because the current driver fingerprint could not be determined" + ); + return; + }; + if let Err(err) = std::fs::create_dir_all(cache_dir) { + tracing::debug!( + cache_dir = %cache_dir.display(), + error = %err, + "failed to create CUDA info cache directory" + ); + return; + } + let cached = CacheFile { + boot_id, + driver_fingerprint, + device_fingerprint: env.device_fingerprint.clone(), + written_at: env.now, + version: version.to_string(), + arch: info.arch_info.as_ref().map(|arch| (arch.major, arch.minor)), + version_source: sources.version, + arch_source: sources.arch, + }; + let Ok(content) = serde_json::to_string(&cached) else { + tracing::debug!("failed to serialize CUDA info cache entry"); + return; + }; + // Write to a temporary file in the cache directory and persist it into place so concurrent + // readers never see a partial cache file. + let path = cache_dir.join(CACHE_FILE_NAME); + let mut tmp = match tempfile::NamedTempFile::new_in(cache_dir) { + Ok(tmp) => tmp, + Err(err) => { + tracing::debug!( + cache_dir = %cache_dir.display(), + error = %err, + "failed to create temporary CUDA info cache file" + ); + return; + } + }; + if let Err(err) = tmp.write_all(content.as_bytes()) { + tracing::debug!( + cache_path = %path.display(), + error = %err, + "failed to write temporary CUDA info cache file" + ); + return; + } + match tmp.persist(&path) { + Ok(_) => tracing::trace!("cached CUDA info at {}", path.display()), + Err(err) => { + tracing::debug!( + cache_path = %path.display(), + error = %err.error, + "failed to persist CUDA info cache" + ); + } + } +} diff --git a/crates/rattler_virtual_packages/src/lib.rs b/crates/rattler_virtual_packages/src/lib.rs index 915b28b7d7..db6844a8fe 100644 --- a/crates/rattler_virtual_packages/src/lib.rs +++ b/crates/rattler_virtual_packages/src/lib.rs @@ -44,6 +44,7 @@ use std::{ env, fmt, fmt::Display, hash::{Hash, Hasher}, + path::Path, str::FromStr, sync::Arc, }; @@ -159,6 +160,33 @@ pub trait EnvOverride: Sized { Self::detect_with_fallback(ov, Self::detect_from_host) }) } + + /// Detect the virtual package for the current system, using `cache_dir` for any on-disk + /// detection cache. + /// + /// The default implementation ignores `cache_dir` and defers to + /// [`EnvOverride::detect_from_host`]. Virtual packages with an expensive detection step (such + /// as CUDA) override this to thread the cache directory through. + fn detect_from_host_with_cache_dir( + cache_dir: Option<&Path>, + ) -> Result, DetectVirtualPackageError> { + let _ = cache_dir; + Self::detect_from_host() + } + + /// Like [`EnvOverride::detect`] but funnels the host detection through + /// [`EnvOverride::detect_from_host_with_cache_dir`] so an on-disk detection cache can be used. + fn detect_cached( + ov: Option<&Override>, + cache_dir: Option<&Path>, + ) -> Result, DetectVirtualPackageError> { + ov.map_or_else( + || Self::detect_from_host_with_cache_dir(cache_dir), + |ov| { + Self::detect_with_fallback(ov, || Self::detect_from_host_with_cache_dir(cache_dir)) + }, + ) + } } /// An enum that represents all virtual package types provided by this library. @@ -274,12 +302,32 @@ impl VirtualPackages { /// Detect the virtual packages of the current system with the given /// overrides. - pub fn detect(overrides: &VirtualPackageOverrides) -> Result { - let cuda = Cuda::detect(overrides.cuda.as_ref())?; - let mut cuda_arch = CudaArch::detect(overrides.cuda_arch.as_ref())?; + /// + /// `cache_dir` is used to cache expensive detection results (currently CUDA) on disk across + /// processes; pass `None` to disable the on-disk cache. + pub fn detect( + overrides: &VirtualPackageOverrides, + cache_dir: Option<&Path>, + ) -> Result { + tracing::trace!( + cache_dir = %cache_dir.map_or_else( + || "".to_string(), + |path| path.display().to_string() + ), + "detecting virtual packages" + ); + + let cuda = Cuda::detect_with_cache_dir(overrides.cuda.as_ref(), cache_dir)?; + tracing::trace!(?cuda, "detected CUDA virtual package"); + let mut cuda_arch = + CudaArch::detect_with_cache_dir(overrides.cuda_arch.as_ref(), cache_dir)?; + tracing::trace!(?cuda_arch, "detected CUDA architecture virtual package"); // Enforce CEP requirement: __cuda_arch must be absent when __cuda is absent if cuda.is_none() { + if cuda_arch.is_some() { + tracing::debug!("dropping __cuda_arch because __cuda was not detected"); + } cuda_arch = None; } @@ -322,8 +370,9 @@ impl VirtualPackages { pub fn detect_for_platform( platform: Platform, overrides: &VirtualPackageOverrides, + cache_dir: Option<&Path>, ) -> Result { - let virtual_packages = Self::detect(overrides)?; + let virtual_packages = Self::detect(overrides, cache_dir)?; if platform == Platform::current() { // If we're targeting the current platform, just return the detected packages Ok(virtual_packages) @@ -483,18 +532,21 @@ impl VirtualPackage { /// the versions could not be properly detected. #[deprecated( since = "1.1.0", - note = "Use `VirtualPackage::detect(&VirtualPackageOverrides::default())` instead." + note = "Use `VirtualPackage::detect(&VirtualPackageOverrides::default(), None)` instead." )] pub fn current() -> Result, DetectVirtualPackageError> { - Self::detect(&VirtualPackageOverrides::default()) + Self::detect(&VirtualPackageOverrides::default(), None) } /// Detect the virtual packages of the current system with the given /// overrides. + /// + /// See [`VirtualPackages::detect`] for the `cache_dir` semantics. pub fn detect( overrides: &VirtualPackageOverrides, + cache_dir: Option<&Path>, ) -> Result, DetectVirtualPackageError> { - Ok(VirtualPackages::detect(overrides)? + Ok(VirtualPackages::detect(overrides, cache_dir)? .into_virtual_packages() .collect()) } @@ -706,8 +758,19 @@ pub struct Cuda { impl Cuda { /// Returns the maximum Cuda version available on the current platform. - pub fn current() -> Option { - cuda::cuda_version().map(|version| Self { version }) + /// + /// See [`cuda::cuda_info`] for the `cache_dir` semantics. + pub fn current(cache_dir: Option<&Path>) -> Option { + cuda::cuda_version(cache_dir).map(|version| Self { version }) + } + + /// Detect the Cuda virtual package with the given override, using `cache_dir` for the on-disk + /// detection cache. + pub fn detect_with_cache_dir( + ov: Option<&Override>, + cache_dir: Option<&Path>, + ) -> Result, DetectVirtualPackageError> { + ::detect_cached(ov, cache_dir) } } @@ -724,7 +787,12 @@ impl EnvOverride for Cuda { }) } fn detect_from_host() -> Result, DetectVirtualPackageError> { - Ok(Self::current()) + Ok(Self::current(None)) + } + fn detect_from_host_with_cache_dir( + cache_dir: Option<&Path>, + ) -> Result, DetectVirtualPackageError> { + Ok(Self::current(cache_dir)) } const DEFAULT_ENV_NAME: &'static str = "CONDA_OVERRIDE_CUDA"; } @@ -776,13 +844,23 @@ impl CudaArch { /// * No CUDA drivers are installed /// * No CUDA devices are detected /// * Device enumeration fails - /// * The system is using musl libc (dynamic library loading not supported) - pub fn current() -> Option { - cuda::cuda_arch().map(|arch_info| Self { + /// + /// See [`cuda::cuda_info`] for the `cache_dir` semantics. + pub fn current(cache_dir: Option<&Path>) -> Option { + cuda::cuda_arch(cache_dir).map(|arch_info| Self { version: Version::from_str(&format!("{}.{}", arch_info.major, arch_info.minor)) .unwrap_or_else(|_| Version::major(u64::from(arch_info.major))), }) } + + /// Detect the CUDA compute capability virtual package with the given override, using + /// `cache_dir` for the on-disk detection cache. + pub fn detect_with_cache_dir( + ov: Option<&Override>, + cache_dir: Option<&Path>, + ) -> Result, DetectVirtualPackageError> { + ::detect_cached(ov, cache_dir) + } } impl EnvOverride for CudaArch { @@ -799,7 +877,13 @@ impl EnvOverride for CudaArch { } fn detect_from_host() -> Result, DetectVirtualPackageError> { - Ok(Self::current()) + Ok(Self::current(None)) + } + + fn detect_from_host_with_cache_dir( + cache_dir: Option<&Path>, + ) -> Result, DetectVirtualPackageError> { + Ok(Self::current(cache_dir)) } const DEFAULT_ENV_NAME: &'static str = "CONDA_OVERRIDE_CUDA_ARCH"; @@ -1265,7 +1349,7 @@ mod test { #[test] fn doesnt_crash() { let virtual_packages = - VirtualPackages::detect(&VirtualPackageOverrides::default()).unwrap(); + VirtualPackages::detect(&VirtualPackageOverrides::default(), None).unwrap(); println!("{virtual_packages:#?}"); } @@ -1437,7 +1521,7 @@ mod test { // Test Linux 64-bit let linux_packages = - VirtualPackages::detect_for_platform(Platform::Linux64, &overrides).unwrap(); + VirtualPackages::detect_for_platform(Platform::Linux64, &overrides, None).unwrap(); let linux_names: Vec = linux_packages .into_generic_virtual_packages() .map(|pkg| pkg.name.as_normalized().to_string()) @@ -1449,7 +1533,7 @@ mod test { // Test macOS ARM64 let osx_packages = - VirtualPackages::detect_for_platform(Platform::OsxArm64, &overrides).unwrap(); + VirtualPackages::detect_for_platform(Platform::OsxArm64, &overrides, None).unwrap(); let osx_names: Vec = osx_packages .into_generic_virtual_packages() .map(|pkg| pkg.name.as_normalized().to_string()) @@ -1460,7 +1544,7 @@ mod test { // Test Windows 64-bit let win_packages = - VirtualPackages::detect_for_platform(Platform::Win64, &overrides).unwrap(); + VirtualPackages::detect_for_platform(Platform::Win64, &overrides, None).unwrap(); let win_names: Vec = win_packages .into_generic_virtual_packages() .map(|pkg| pkg.name.as_normalized().to_string()) @@ -1481,7 +1565,7 @@ mod test { if !current.is_linux() { let packages = - VirtualPackages::detect_for_platform(Platform::Linux64, &overrides).unwrap(); + VirtualPackages::detect_for_platform(Platform::Linux64, &overrides, None).unwrap(); assert_eq!( packages.linux.expect("__linux should be present").version, defaults::default_linux_version() @@ -1493,7 +1577,7 @@ mod test { if !current.is_osx() { let packages = - VirtualPackages::detect_for_platform(Platform::OsxArm64, &overrides).unwrap(); + VirtualPackages::detect_for_platform(Platform::OsxArm64, &overrides, None).unwrap(); assert_eq!( packages.osx.expect("__osx should be present").version, defaults::default_mac_os_version(Platform::OsxArm64).unwrap() @@ -1502,7 +1586,7 @@ mod test { if !current.is_windows() { let packages = - VirtualPackages::detect_for_platform(Platform::Win64, &overrides).unwrap(); + VirtualPackages::detect_for_platform(Platform::Win64, &overrides, None).unwrap(); assert_eq!( packages.win.expect("__win should be present").version, Some(defaults::default_windows_version()) @@ -1517,6 +1601,7 @@ mod test { let ios_packages = VirtualPackages::detect_for_platform( Platform::IosArm64, &VirtualPackageOverrides::default(), + None, ) .unwrap(); let ios_names: Vec = ios_packages @@ -1533,7 +1618,8 @@ mod test { ..Default::default() }; let ios_packages = - VirtualPackages::detect_for_platform(Platform::IosSimulatorArm64, &overrides).unwrap(); + VirtualPackages::detect_for_platform(Platform::IosSimulatorArm64, &overrides, None) + .unwrap(); let ios = ios_packages .into_generic_virtual_packages() .find(|pkg| pkg.name.as_normalized() == "__ios") @@ -1545,6 +1631,7 @@ mod test { let android_packages = VirtualPackages::detect_for_platform( Platform::AndroidAarch64, &VirtualPackageOverrides::default(), + None, ) .unwrap(); let android_names: Vec = android_packages @@ -1561,7 +1648,8 @@ mod test { ..Default::default() }; let android_packages = - VirtualPackages::detect_for_platform(Platform::AndroidArmV7a, &overrides).unwrap(); + VirtualPackages::detect_for_platform(Platform::AndroidArmV7a, &overrides, None) + .unwrap(); let android = android_packages .into_generic_virtual_packages() .find(|pkg| pkg.name.as_normalized() == "__android") @@ -1630,7 +1718,7 @@ mod test { // Case 1: Both not present - cuda_arch should be None let overrides = VirtualPackageOverrides::default(); - let packages = VirtualPackages::detect(&overrides).unwrap(); + let packages = VirtualPackages::detect(&overrides, None).unwrap(); // If cuda is None, cuda_arch must also be None if packages.cuda.is_none() { assert!( @@ -1646,7 +1734,7 @@ mod test { cuda_arch: Some(cuda_arch_override), ..Default::default() }; - let packages = VirtualPackages::detect(&overrides).unwrap(); + let packages = VirtualPackages::detect(&overrides, None).unwrap(); if packages.cuda.is_none() { assert!( packages.cuda_arch.is_none(), @@ -1662,7 +1750,7 @@ mod test { cuda_arch: Some(cuda_arch_override), ..Default::default() }; - let packages = VirtualPackages::detect(&overrides).unwrap(); + let packages = VirtualPackages::detect(&overrides, None).unwrap(); assert!( packages.cuda.is_some(), "cuda should be present with override" @@ -1682,7 +1770,7 @@ mod test { cuda_arch: Some(cuda_arch_override), ..Default::default() }; - let packages = VirtualPackages::detect(&overrides).unwrap(); + let packages = VirtualPackages::detect(&overrides, None).unwrap(); assert!( packages.cuda.is_none(), "cuda should be None with empty string override" diff --git a/py-rattler/Cargo.lock b/py-rattler/Cargo.lock index e75284df14..d8e29e68f9 100644 --- a/py-rattler/Cargo.lock +++ b/py-rattler/Cargo.lock @@ -4446,8 +4446,11 @@ dependencies = [ "rattler_conda_types", "regex", "serde", + "serde_json", + "tempfile", "thiserror 2.0.18", "tracing", + "windows-sys 0.61.2", "winver", ] diff --git a/py-rattler/rattler/virtual_package/virtual_package.py b/py-rattler/rattler/virtual_package/virtual_package.py index 1abbdc14b5..ccaafe497b 100644 --- a/py-rattler/rattler/virtual_package/virtual_package.py +++ b/py-rattler/rattler/virtual_package/virtual_package.py @@ -1,5 +1,6 @@ from __future__ import annotations -from typing import List +import os +from typing import List, Optional, Union import warnings from rattler.rattler import PyVirtualPackage, PyOverride, PyVirtualPackageOverrides @@ -231,11 +232,20 @@ def current() -> List[VirtualPackage]: return VirtualPackage.detect() @staticmethod - def detect(overrides: VirtualPackageOverrides = VirtualPackageOverrides()) -> List[VirtualPackage]: + def detect( + overrides: VirtualPackageOverrides = VirtualPackageOverrides(), + cache_dir: Optional[Union[str, os.PathLike[str]]] = None, + ) -> List[VirtualPackage]: """ Returns virtual packages detected for the current system with the given overrides. + + If `cache_dir` is given, expensive detection results (currently CUDA) are cached in that + directory across processes until the next reboot. """ - return [VirtualPackage._from_py_virtual_package(vp) for vp in PyVirtualPackage.detect(overrides._overrides)] + return [ + VirtualPackage._from_py_virtual_package(vp) + for vp in PyVirtualPackage.detect(overrides._overrides, cache_dir) + ] def into_generic(self) -> GenericVirtualPackage: """ diff --git a/py-rattler/src/index_json.rs b/py-rattler/src/index_json.rs index 040eafe8a9..183403b40b 100644 --- a/py-rattler/src/index_json.rs +++ b/py-rattler/src/index_json.rs @@ -261,7 +261,7 @@ impl PyIndexJson { if let Some(ts) = timestamp { self.inner.timestamp = Some(TimestampMs::from_timestamp_millis( jiff::Timestamp::from_millisecond(ts) - .map_err(|_| PyValueError::new_err("Invalid timestamp"))?, + .map_err(|err| PyValueError::new_err(format!("Invalid timestamp: {err}")))?, )); } else { self.inner.timestamp = None; diff --git a/py-rattler/src/record.rs b/py-rattler/src/record.rs index d87363149f..b148019ce1 100644 --- a/py-rattler/src/record.rs +++ b/py-rattler/src/record.rs @@ -610,7 +610,7 @@ impl PyRecord { if let Some(ts) = timestamp { self.as_package_record_mut().timestamp = Some(TimestampMs::from_timestamp_millis( jiff::Timestamp::from_millisecond(ts) - .map_err(|_| PyValueError::new_err("Invalid timestamp"))?, + .map_err(|err| PyValueError::new_err(format!("Invalid timestamp: {err}")))?, )); } else { self.as_package_record_mut().timestamp = None; diff --git a/py-rattler/src/utils.rs b/py-rattler/src/utils.rs index 4558b0c495..bcce2dd85d 100644 --- a/py-rattler/src/utils.rs +++ b/py-rattler/src/utils.rs @@ -4,10 +4,10 @@ use rattler_digest::{Md5Hash, Sha256Hash}; pub fn sha256_from_pybytes(bytes: Bound<'_, PyBytes>) -> Result { Sha256Hash::try_from(bytes.as_bytes()) - .map_err(|_| PyValueError::new_err("Expected a 32 byte SHA256 digest")) + .map_err(|_err| PyValueError::new_err("Expected a 32 byte SHA256 digest")) } pub fn md5_from_pybytes(bytes: Bound<'_, PyBytes>) -> Result { Md5Hash::try_from(bytes.as_bytes()) - .map_err(|_| PyValueError::new_err("Expected a 16 byte MD5 digest")) + .map_err(|_err| PyValueError::new_err("Expected a 16 byte MD5 digest")) } diff --git a/py-rattler/src/virtual_package.rs b/py-rattler/src/virtual_package.rs index e75f860d37..b624c8ac39 100644 --- a/py-rattler/src/virtual_package.rs +++ b/py-rattler/src/virtual_package.rs @@ -1,3 +1,5 @@ +use std::path::PathBuf; + use pyo3::{PyResult, pyclass, pymethods}; use rattler_virtual_packages::{Override, VirtualPackage, VirtualPackageOverrides}; @@ -177,14 +179,23 @@ impl PyVirtualPackage { // we just warn directly from python. #[staticmethod] pub fn current() -> PyResult> { - Self::detect(&PyVirtualPackageOverrides::none()) + Self::detect(&PyVirtualPackageOverrides::none(), None) } + /// Returns virtual packages detected for the current system with the given overrides. If + /// `cache_dir` is given, expensive detection results (currently CUDA) are cached there across + /// processes until the next reboot. #[staticmethod] - pub fn detect(overrides: &PyVirtualPackageOverrides) -> PyResult> { - Ok(VirtualPackage::detect(&overrides.clone().into()) - .map(|vp| vp.iter().map(|v| v.clone().into()).collect::>()) - .map_err(PyRattlerError::from)?) + #[pyo3(signature = (overrides, cache_dir=None))] + pub fn detect( + overrides: &PyVirtualPackageOverrides, + cache_dir: Option, + ) -> PyResult> { + Ok( + VirtualPackage::detect(&overrides.clone().into(), cache_dir.as_deref()) + .map(|vp| vp.iter().map(|v| v.clone().into()).collect::>()) + .map_err(PyRattlerError::from)?, + ) } pub fn as_generic(&self) -> PyGenericVirtualPackage {