Skip to content
1 change: 1 addition & 0 deletions crates/rattler-bin/src/commands/create.rs
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,7 @@ pub async fn create(opt: Opt) -> miette::Result<()> {
timeout: opt.timeout.map(Duration::from_millis),
strategy: opt.strategy.map_or_else(Default::default, Into::into),
exclude_newer: opt.exclude_newer.map(Into::into),
channel_package_names: RepoData::collect_channel_package_names(&repo_data),
..SolverTask::from_iter(&repo_data)
};

Expand Down
12 changes: 11 additions & 1 deletion crates/rattler_repodata_gateway/src/gateway/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -417,8 +417,18 @@ impl QueryExecutor {
result.records.extend(records);
}
SourceSpecs::Input(specs) => {
// Only a subset matches — filter and clone matching Arcs.
for record in &records {
// Track all package names present in this channel,
// regardless of spec filtering. This enables strict
// channel priority enforcement in the solver even when no
// versions from a higher-priority channel match the spec.
result
.channel_package_names
.entry(record.channel.clone())
.or_default()
.insert(record.package_record.name.clone());

// Only include records that match at least one input spec.
if specs.iter().any(|s| s.matches(record.as_ref())) {
result.records.push(record.clone());
}
Expand Down
38 changes: 36 additions & 2 deletions crates/rattler_repodata_gateway/src/gateway/repo_data.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
use std::sync::Arc;
use std::{
collections::{HashMap, HashSet},
sync::Arc,
};

use rattler_conda_types::RepoDataRecord;
use rattler_conda_types::{PackageName, RepoDataRecord};

/// A container for [`RepoDataRecord`]s that are returned from the [`super::Gateway`].
///
Expand All @@ -12,6 +15,9 @@ use rattler_conda_types::RepoDataRecord;
#[derive(Debug, Default, Clone)]
pub struct RepoData {
pub(crate) records: Vec<Arc<RepoDataRecord>>,
/// All package names present in this source, keyed by channel URL.
/// Includes names whose records were filtered out by version constraints.
pub(crate) channel_package_names: HashMap<Option<String>, HashSet<PackageName>>,
}

impl RepoData {
Expand All @@ -32,6 +38,34 @@ impl RepoData {
self.records.is_empty()
}

/// Returns the package names present per channel in this source,
/// including names whose records were filtered out by version constraints.
pub fn channel_package_names(&self) -> &HashMap<Option<String>, HashSet<PackageName>> {
&self.channel_package_names
}

/// Build a list of `(channel, package_names)` entries from a sequence of
/// [`RepoData`] results, preserving priority order (first entry = highest
/// priority). Channels that appear in multiple entries are merged.
pub fn collect_channel_package_names(
all: &[RepoData],
) -> Vec<(Option<String>, HashSet<PackageName>)> {
let mut result: Vec<(Option<String>, HashSet<PackageName>)> = Vec::new();
let mut channel_index: HashMap<Option<String>, usize> = HashMap::new();
for rd in all {
for (channel, names) in &rd.channel_package_names {
if let Some(&idx) = channel_index.get(channel) {
result[idx].1.extend(names.iter().cloned());
} else {
let idx = result.len();
channel_index.insert(channel.clone(), idx);
result.push((channel.clone(), names.clone()));
}
}
}
result
}

/// Returns an iterator over the Arc-wrapped records.
///
/// This is useful when you want to clone records cheaply (Arc clone
Expand Down
1 change: 1 addition & 0 deletions crates/rattler_solve/benches/sorting_bench.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ fn bench_sort(c: &mut Criterion, sparse_repo_data: &SparseRepoData, spec: &str)
None,
rattler_solve::SolveStrategy::Highest,
Vec::new(),
&[],
)
.expect("failed to create dependency provider");

Expand Down
9 changes: 9 additions & 0 deletions crates/rattler_solve/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,14 @@ pub struct SolverTask<TAvailablePackagesIterator> {

/// Dependency overrides that replace dependencies of matching packages.
pub dependency_overrides: Vec<(MatchSpec, MatchSpec)>,

/// Package names known to exist per channel, in priority order (highest
/// priority first). Enables strict channel priority enforcement even
/// when the gateway's version filtering removed all records from a
/// higher-priority channel.
///
/// Each entry is `(channel_url, package_names_in_that_channel)`.
pub channel_package_names: Vec<(Option<String>, HashSet<PackageName>)>,
}

impl<'r, I: IntoIterator<Item = &'r RepoDataRecord>> FromIterator<I>
Expand All @@ -301,6 +309,7 @@ impl<'r, I: IntoIterator<Item = &'r RepoDataRecord>> FromIterator<I>
min_age: None,
strategy: SolveStrategy::default(),
dependency_overrides: Vec::new(),
channel_package_names: Vec::new(),
}
}
}
Expand Down
21 changes: 18 additions & 3 deletions crates/rattler_solve/src/resolvo/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,7 @@ impl<'a> CondaDependencyProvider<'a> {
min_age: Option<&MinimumAgeConfig>,
strategy: SolveStrategy,
dependency_overrides: Vec<DependencyOverride>,
channel_package_names: &[(Option<String>, HashSet<PackageName>)],
) -> Result<Self, SolveError> {
let pool = Pool::default();
let mut records: HashMap<NameId, Candidates> = HashMap::default();
Expand Down Expand Up @@ -333,7 +334,20 @@ impl<'a> CondaDependencyProvider<'a> {
.collect::<Vec<_>>();

// Hashmap that maps the package name to the channel it was first found in.
let mut package_name_found_in_channel = HashMap::<String, &Option<String>>::new();
let mut package_name_found_in_channel = HashMap::<String, Option<String>>::new();

// Pre-populate channel ownership from explicit package name data.
// This enables strict channel priority even when no records from a
// higher-priority channel match the requested spec.
if channel_priority == ChannelPriority::Strict {
for (channel, names) in channel_package_names {
for name in names {
package_name_found_in_channel
.entry(name.as_normalized().to_string())
.or_insert_with(|| channel.clone());
}
}
}

// Add additional records
for repo_data in repodata {
Expand Down Expand Up @@ -504,7 +518,7 @@ impl<'a> CondaDependencyProvider<'a> {
channel_priority,
) {
// Add the record to the excluded list when it is from a different channel.
if first_channel != &&record.channel {
if first_channel != &record.channel {
if let Some(channel) = &record.channel {
tracing::debug!(
"Ignoring '{}' from '{}' because of strict channel priority.",
Expand All @@ -531,7 +545,7 @@ impl<'a> CondaDependencyProvider<'a> {
} else {
package_name_found_in_channel.insert(
record.package_record.name.as_normalized().to_string(),
&record.channel,
record.channel.clone(),
);
}
}
Expand Down Expand Up @@ -973,6 +987,7 @@ impl super::SolverImpl for Solver {
task.min_age.as_ref(),
task.strategy,
dependency_overrides,
&task.channel_package_names,
)?;

// Construct the requirements that the solver needs to satisfy.
Expand Down
67 changes: 64 additions & 3 deletions crates/rattler_solve/tests/backends/main.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
use std::{collections::BTreeMap, str::FromStr, time::Instant};
use std::{collections::BTreeMap, collections::HashSet, str::FromStr, time::Instant};

use chrono::{DateTime, Utc};
use once_cell::sync::Lazy;
use rattler_conda_types::{
package::{ArchiveIdentifier, CondaArchiveType, DistArchiveIdentifier, DistArchiveType},
Channel, ChannelConfig, GenericVirtualPackage, MatchSpec, NoArchType, PackageRecord,
ParseMatchSpecOptions, ParseStrictness, RepoData, RepoDataRecord, SolverResult, Version,
Channel, ChannelConfig, GenericVirtualPackage, MatchSpec, NoArchType, PackageName,
PackageRecord, ParseMatchSpecOptions, ParseStrictness, RepoData, RepoDataRecord, SolverResult,
Version,
};
use rattler_repodata_gateway::sparse::{PackageFormatSelection, SparseRepoData};
use rattler_solve::{
Expand Down Expand Up @@ -710,6 +711,7 @@ mod libsolv_c {
min_age: None,
strategy: SolveStrategy::default(),
dependency_overrides: Vec::new(),
channel_package_names: Vec::new(),
})
.unwrap()
.records;
Expand Down Expand Up @@ -1388,3 +1390,62 @@ fn channel_priority_disabled_libsolv_c() {
ChannelPriority::Disabled,
);
}

/// Regression test for issue #1928: strict channel priority must be enforced
/// even when a higher-priority channel has no version matching the spec.
///
/// Scenario:
/// - Channel A (higher priority) has `my-pkg` 1.0 only.
/// - Channel B (lower priority) has `my-pkg` 2.0 only.
/// - Spec: `my-pkg >=2.0`
///
/// After gateway filtering only channel B's record survives, but
/// `channel_package_names` tells the solver that channel A also owns the
/// package. Under strict channel priority the solver must NOT select
/// channel B and the solve should fail.
#[test]
fn channel_priority_strict_direct_dep_higher_channel_no_match() {
let channel_a = "https://conda.anaconda.org/channel-a/";
let channel_b = "https://conda.anaconda.org/channel-b/";

// Channel B: lower priority, has 2.0 (the only record passing the
// gateway's version filter for spec ">=2.0").
let records_b = [PackageBuilder::new("my-pkg")
.channel(channel_b)
.subdir("linux-64")
.version("2.0")
.build_string("h0_0")
.build_number(0)
.build()];

let spec = MatchSpec::from_str("my-pkg>=2.0", ParseStrictness::Lenient).unwrap();
let pkg_name = PackageName::from_str("my-pkg").unwrap();

// Channel A (higher priority) has my-pkg but no version matching >=2.0.
// The gateway strips those records but reports the package name via
// channel_package_names so the solver can enforce strict priority.
let channel_package_names = vec![
(
Some(channel_a.to_string()),
HashSet::from([pkg_name.clone()]),
),
(Some(channel_b.to_string()), HashSet::from([pkg_name])),
];

// Only channel B's records reach the solver.
let solver_task = SolverTask {
specs: vec![spec],
channel_priority: ChannelPriority::Strict,
channel_package_names,
..SolverTask::from_iter([records_b.iter()])
};

let result = rattler_solve::resolvo::Solver.solve(solver_task);

// The solve must fail: channel A owns "my-pkg" but has no >=2.0, and
// channel B's 2.0 is excluded by strict channel priority.
assert!(
result.is_err(),
"expected unsolvable due to strict channel priority, but solve succeeded: {result:?}"
);
}
1 change: 1 addition & 0 deletions crates/rattler_solve/tests/sorting.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ fn create_sorting_snapshot(package_name: &str, strategy: SolveStrategy) -> Strin
None, // min_age
strategy,
Vec::new(), // dependency_overrides
&[], // channel_package_names
)
.expect("failed to create dependency provider");

Expand Down
2 changes: 2 additions & 0 deletions py-rattler/src/solver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ pub fn py_solve<'a>(
min_age,
strategy: strategy.map_or_else(Default::default, |v| v.0),
dependency_overrides: Vec::new(),
channel_package_names: Vec::new(),
};

Ok::<_, PyErr>(
Expand Down Expand Up @@ -272,6 +273,7 @@ pub fn py_solve_with_sparse_repodata<'py>(
min_age,
strategy: strategy.map_or_else(Default::default, |v| v.0),
dependency_overrides: Vec::new(),
channel_package_names: Vec::new(),
};

Ok::<_, PyErr>(
Expand Down
1 change: 1 addition & 0 deletions tools/create-resolvo-snapshot/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ async fn main() {
None,
SolveStrategy::default(),
Vec::new(),
&[],
)
.unwrap();

Expand Down
Loading