use std::fmt::Display;
use std::str::FromStr;
use std::sync::Arc;
use rustc_hash::FxHashMap;
use uv_configuration::HashCheckingMode;
use uv_distribution_types::{
DistributionMetadata, HashGeneration, HashPolicy, Name, Requirement, RequirementSource,
Resolution, UnresolvedRequirement, VersionId,
};
use uv_normalize::PackageName;
use uv_pep440::Version;
use uv_pypi_types::{HashDigest, HashDigests, HashError, ResolverMarkerEnvironment};
use uv_redacted::DisplaySafeUrl;
#[derive(Debug, Default, Clone)]
pub enum HashStrategy {
#[default]
None,
Generate(HashGeneration),
Verify(Arc<FxHashMap<VersionId, Vec<HashDigest>>>),
Require(Arc<FxHashMap<VersionId, Vec<HashDigest>>>),
}
impl HashStrategy {
pub fn get<T: DistributionMetadata>(&self, distribution: &T) -> HashPolicy<'_> {
match self {
Self::None => HashPolicy::None,
Self::Generate(mode) => HashPolicy::Generate(*mode),
Self::Verify(hashes) => {
let id = distribution.version_id();
if let Some(hashes) = hashes.get(&id) {
hash_policy(&id, hashes.as_slice())
} else {
HashPolicy::None
}
}
Self::Require(hashes) => {
let id = distribution.version_id();
hash_policy(&id, hashes.get(&id).map(Vec::as_slice).unwrap_or_default())
}
}
}
pub fn get_package(&self, name: &PackageName, version: &Version) -> HashPolicy<'_> {
let id = VersionId::from_registry(name.clone(), version.clone());
match self {
Self::None => HashPolicy::None,
Self::Generate(mode) => HashPolicy::Generate(*mode),
Self::Verify(hashes) => {
if let Some(hashes) = hashes.get(&id) {
HashPolicy::Any(hashes.as_slice())
} else {
HashPolicy::None
}
}
Self::Require(hashes) => {
HashPolicy::Any(hashes.get(&id).map(Vec::as_slice).unwrap_or_default())
}
}
}
pub fn get_url(&self, url: &DisplaySafeUrl) -> HashPolicy<'_> {
let id = VersionId::from_url(url);
match self {
Self::None => HashPolicy::None,
Self::Generate(mode) => HashPolicy::Generate(*mode),
Self::Verify(hashes) => {
if let Some(hashes) = hashes.get(&id) {
HashPolicy::All(hashes.as_slice())
} else {
HashPolicy::None
}
}
Self::Require(hashes) => {
HashPolicy::All(hashes.get(&id).map(Vec::as_slice).unwrap_or_default())
}
}
}
pub fn allows_package(&self, name: &PackageName, version: &Version) -> bool {
match self {
Self::None => true,
Self::Generate(_) => true,
Self::Verify(_) => true,
Self::Require(hashes) => {
hashes.contains_key(&VersionId::from_registry(name.clone(), version.clone()))
}
}
}
pub fn allows_url(&self, url: &DisplaySafeUrl) -> bool {
match self {
Self::None => true,
Self::Generate(_) => true,
Self::Verify(_) => true,
Self::Require(hashes) => hashes.contains_key(&VersionId::from_url(url)),
}
}
pub fn augment_with_requirements<'a>(
self,
requirements: impl Iterator<Item = &'a Requirement>,
) -> Result<Self, HashStrategyError> {
Ok(match self {
Self::None => Self::None,
Self::Generate(mode) => Self::Generate(mode),
Self::Verify(existing) => {
if let Some(hashes) = Self::augment_hashes(existing.as_ref(), requirements)? {
Self::Verify(Arc::new(hashes))
} else {
Self::Verify(existing)
}
}
Self::Require(existing) => {
if let Some(hashes) = Self::augment_hashes(existing.as_ref(), requirements)? {
Self::Require(Arc::new(hashes))
} else {
Self::Require(existing)
}
}
})
}
pub fn from_requirements<'a>(
requirements: impl Iterator<Item = (&'a UnresolvedRequirement, &'a [String])>,
constraints: impl Iterator<Item = (&'a Requirement, &'a [String])>,
marker_env: Option<&ResolverMarkerEnvironment>,
mode: HashCheckingMode,
) -> Result<Self, HashStrategyError> {
let mut constraint_hashes = FxHashMap::<VersionId, Vec<HashDigest>>::default();
for (requirement, digests) in constraints {
if !requirement
.evaluate_markers(marker_env.map(ResolverMarkerEnvironment::markers), &[])
{
continue;
}
let Some(id) = Self::pin(requirement) else {
if mode.is_require() {
return Err(HashStrategyError::UnpinnedRequirement(
requirement.to_string(),
mode,
));
}
continue;
};
let mut digests = digests
.iter()
.map(|digest| HashDigest::from_str(digest))
.collect::<Result<Vec<_>, _>>()?;
if let Some(fragment_hashes) = requirement.hashes().map(HashDigests::from) {
merge_digests(&mut digests, fragment_hashes.iter(), requirement)?;
}
if digests.is_empty() {
continue;
}
merge_hashes(&mut constraint_hashes, id, digests, requirement)?;
}
let mut requirement_hashes = FxHashMap::<VersionId, Vec<HashDigest>>::default();
for (requirement, digests) in requirements {
if !requirement
.evaluate_markers(marker_env.map(ResolverMarkerEnvironment::markers), &[])
{
continue;
}
let id = match &requirement {
UnresolvedRequirement::Named(requirement) => {
if let Some(id) = Self::pin(requirement) {
id
} else {
if mode.is_require() {
return Err(HashStrategyError::UnpinnedRequirement(
requirement.to_string(),
mode,
));
}
continue;
}
}
UnresolvedRequirement::Unnamed(requirement) => {
VersionId::from_parsed_url(&requirement.url.parsed_url)
}
};
let mut digests = digests
.iter()
.map(|digest| HashDigest::from_str(digest))
.collect::<Result<Vec<_>, _>>()?;
if let Some(fragment_hashes) = requirement.hashes().map(HashDigests::from) {
merge_digests(&mut digests, fragment_hashes.iter(), requirement)?;
}
let digests = if let Some(constraint) = constraint_hashes.remove(&id) {
if digests.is_empty() {
constraint
} else if matches!(id, VersionId::ArchiveUrl { .. }) {
let mut merged = digests;
merge_digests(&mut merged, &constraint, requirement)?;
merged
} else {
let intersection: Vec<_> = digests
.into_iter()
.filter(|digest| constraint.contains(digest))
.collect();
if intersection.is_empty() {
return Err(HashStrategyError::NoIntersection(
requirement.to_string(),
mode,
));
}
intersection
}
} else {
digests
};
if digests.is_empty() {
if mode.is_require() {
return Err(HashStrategyError::MissingHashes(
requirement.to_string(),
mode,
));
}
continue;
}
merge_hashes(&mut requirement_hashes, id, digests, requirement)?;
}
let hashes: FxHashMap<VersionId, Vec<HashDigest>> = constraint_hashes
.into_iter()
.chain(requirement_hashes)
.collect();
match mode {
HashCheckingMode::Verify => Ok(Self::Verify(Arc::new(hashes))),
HashCheckingMode::Require => Ok(Self::Require(Arc::new(hashes))),
}
}
pub fn from_resolution(
resolution: &Resolution,
mode: HashCheckingMode,
) -> Result<Self, HashStrategyError> {
let mut hashes = FxHashMap::<VersionId, Vec<HashDigest>>::default();
for (dist, digests) in resolution.hashes() {
if digests.is_empty() {
if mode.is_require() {
return Err(HashStrategyError::MissingHashes(
dist.name().to_string(),
mode,
));
}
continue;
}
hashes.insert(dist.version_id(), digests.to_vec());
}
match mode {
HashCheckingMode::Verify => Ok(Self::Verify(Arc::new(hashes))),
HashCheckingMode::Require => Ok(Self::Require(Arc::new(hashes))),
}
}
fn augment_hashes<'a>(
existing: &FxHashMap<VersionId, Vec<HashDigest>>,
requirements: impl Iterator<Item = &'a Requirement>,
) -> Result<Option<FxHashMap<VersionId, Vec<HashDigest>>>, HashStrategyError> {
let mut hashes = None;
for requirement in requirements {
let Some((id, digests)) = Self::requirement_hashes(requirement) else {
continue;
};
let current = hashes.as_ref().unwrap_or(existing);
let current_digests = current.get(&id);
let mut merged = current_digests.cloned().unwrap_or_default();
merge_digests(&mut merged, &digests, requirement)?;
if current_digests.map(Vec::as_slice) == Some(merged.as_slice()) {
continue;
}
hashes
.get_or_insert_with(|| existing.clone())
.insert(id, merged);
}
Ok(hashes)
}
fn requirement_hashes(requirement: &Requirement) -> Option<(VersionId, Vec<HashDigest>)> {
let mut digests = HashDigests::from(requirement.hashes()?).to_vec();
if digests.is_empty() {
return None;
}
digests.sort_unstable();
let id = Self::pin(requirement)?;
Some((id, digests))
}
fn pin(requirement: &Requirement) -> Option<VersionId> {
match &requirement.source {
RequirementSource::Registry { specifier, .. } => {
let [specifier] = specifier.as_ref() else {
return None;
};
if *specifier.operator() != uv_pep440::Operator::Equal {
return None;
}
Some(VersionId::from_registry(
requirement.name.clone(),
specifier.version().clone(),
))
}
RequirementSource::Url {
location,
subdirectory,
..
} => Some(VersionId::from_archive(location, subdirectory.as_deref())),
RequirementSource::Git {
git, subdirectory, ..
} => Some(VersionId::from_git(git, subdirectory.as_deref())),
RequirementSource::Path { install_path, .. } => {
Some(VersionId::from_path(install_path))
}
RequirementSource::Directory { install_path, .. } => {
Some(VersionId::from_directory(install_path))
}
}
}
}
fn hash_policy<'a>(id: &VersionId, digests: &'a [HashDigest]) -> HashPolicy<'a> {
match id {
VersionId::NameVersion { .. } => HashPolicy::Any(digests),
VersionId::ArchiveUrl { .. }
| VersionId::Git { .. }
| VersionId::Path { .. }
| VersionId::Directory { .. }
| VersionId::Unknown { .. } => HashPolicy::All(digests),
}
}
fn merge_hashes(
hashes: &mut FxHashMap<VersionId, Vec<HashDigest>>,
id: VersionId,
incoming: Vec<HashDigest>,
requirement: impl Display,
) -> Result<(), HashStrategyError> {
if incoming.is_empty() {
return Ok(());
}
if !matches!(&id, VersionId::ArchiveUrl { .. }) {
hashes.insert(id, incoming);
return Ok(());
}
if let Some(existing) = hashes.get_mut(&id) {
return merge_digests(existing, &incoming, requirement);
}
let mut merged = Vec::new();
merge_digests(&mut merged, &incoming, requirement)?;
hashes.insert(id, merged);
Ok(())
}
fn merge_digests<'a>(
existing: &mut Vec<HashDigest>,
incoming: impl IntoIterator<Item = &'a HashDigest>,
requirement: impl Display,
) -> Result<(), HashStrategyError> {
for digest in incoming {
match existing
.iter()
.find(|candidate| candidate.algorithm == digest.algorithm)
{
Some(candidate) if candidate == digest => {}
Some(conflict) => {
return Err(HashStrategyError::ConflictingArchiveUrlHashes(
requirement.to_string(),
conflict.clone(),
digest.clone(),
));
}
None => existing.push(digest.clone()),
}
}
existing.sort_unstable();
Ok(())
}
#[derive(thiserror::Error, Debug)]
pub enum HashStrategyError {
#[error(transparent)]
Hash(#[from] HashError),
#[error("Conflicting archive URL hashes for `{0}`: `{1}` conflicts with `{2}`")]
ConflictingArchiveUrlHashes(String, HashDigest, HashDigest),
#[error(
"In `{1}` mode, all requirements must have their versions pinned with `==`, but found: {0}"
)]
UnpinnedRequirement(String, HashCheckingMode),
#[error("In `{1}` mode, all requirements must have a hash, but none were provided for: {0}")]
MissingHashes(String, HashCheckingMode),
#[error(
"In `{1}` mode, all requirements must have a hash, but there were no overlapping hashes between the requirements and constraints for: {0}"
)]
NoIntersection(String, HashCheckingMode),
}
#[cfg(test)]
mod tests {
use std::str::FromStr;
use uv_configuration::HashCheckingMode;
use uv_distribution_filename::DistExtension;
use uv_distribution_types::{
HashPolicy, Requirement, RequirementSource, UnresolvedRequirement,
};
use uv_pypi_types::HashDigest;
use super::HashStrategy;
fn requirement(url: &str) -> Requirement {
Requirement {
name: "anyio".parse().unwrap(),
extras: Box::default(),
groups: Box::default(),
marker: "python_version >= '3.8'".parse().unwrap(),
source: RequirementSource::Url {
location: "https://files.pythonhosted.org/packages/36/55/ad4de788d84a630656ece71059665e01ca793c04294c463fd84132f40fe6/anyio-4.0.0-py3-none-any.whl"
.parse()
.unwrap(),
subdirectory: None,
ext: DistExtension::Wheel,
url: url.parse().unwrap(),
},
origin: None,
}
}
#[test]
fn from_requirements_merges_direct_url_hashes_across_fragments() {
let first = UnresolvedRequirement::Named(requirement(
"https://files.pythonhosted.org/packages/36/55/ad4de788d84a630656ece71059665e01ca793c04294c463fd84132f40fe6/anyio-4.0.0-py3-none-any.whl#sha256=cfdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f",
));
let second = UnresolvedRequirement::Named(requirement(
"https://files.pythonhosted.org/packages/36/55/ad4de788d84a630656ece71059665e01ca793c04294c463fd84132f40fe6/anyio-4.0.0-py3-none-any.whl#sha512=f30761c1e8725b49c498273b90dba4b05c0fd157811994c806183062cb6647e773364ce45f0e1ff0b10e32fe6d0232ea5ad39476ccf37109d6b49603a09c11c2",
));
let hasher = HashStrategy::from_requirements(
[(&first, &[][..]), (&second, &[][..])].into_iter(),
std::iter::empty(),
None,
HashCheckingMode::Require,
)
.unwrap();
let mut expected = vec![
HashDigest::from_str(
"sha256:cfdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f",
)
.unwrap(),
HashDigest::from_str(
"sha512:f30761c1e8725b49c498273b90dba4b05c0fd157811994c806183062cb6647e773364ce45f0e1ff0b10e32fe6d0232ea5ad39476ccf37109d6b49603a09c11c2",
)
.unwrap(),
];
expected.sort_unstable();
for requirement in [&first, &second] {
let UnresolvedRequirement::Named(requirement) = requirement else {
panic!("expected named requirement");
};
let RequirementSource::Url { url, .. } = &requirement.source else {
panic!("expected direct URL requirement");
};
assert_eq!(hasher.get_url(url), HashPolicy::All(expected.as_slice()));
}
}
}