Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion crates/uv-types/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ license = { workspace = true }

[lib]
doctest = false
test = false

[lints]
workspace = true
Expand Down
215 changes: 175 additions & 40 deletions crates/uv-types/src/hash.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use std::fmt::Display;
use std::str::FromStr;
use std::sync::Arc;

Expand Down Expand Up @@ -151,31 +152,24 @@ impl HashStrategy {
continue;
};

let digests = if digests.is_empty() {
// If there are no hashes, and the distribution is URL-based, attempt to extract
// it from the fragment.
requirement
.hashes()
.map(HashDigests::from)
.map(|hashes| hashes.to_vec())
.unwrap_or_default()
} else {
// Parse the hashes.
digests
.iter()
.map(|digest| HashDigest::from_str(digest))
.collect::<Result<Vec<_>, _>>()?
};
// Parse the hashes provided directly on the requirement, then merge in any hashes from
// the URL fragment.
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;
}

constraint_hashes.insert(id, digests);
merge_hashes(&mut constraint_hashes, id, digests, requirement)?;
}

// For each requirement, map from name to allowed hashes. We use the last entry for each
// package.
// For each requirement, map from hash identity to allowed hashes.
let mut requirement_hashes = FxHashMap::<VersionId, Vec<HashDigest>>::default();
for (requirement, digests) in requirements {
if !requirement
Expand All @@ -201,30 +195,28 @@ impl HashStrategy {
}
UnresolvedRequirement::Unnamed(requirement) => {
// Direct URLs are always allowed.
VersionId::from_url(&requirement.url.verbatim)
VersionId::from_parsed_url(&requirement.url.parsed_url)
}
};

let digests = if digests.is_empty() {
// If there are no hashes, and the distribution is URL-based, attempt to extract
// it from the fragment.
requirement
.hashes()
.map(HashDigests::from)
.map(|hashes| hashes.to_vec())
.unwrap_or_default()
} else {
// Parse the hashes.
digests
.iter()
.map(|digest| HashDigest::from_str(digest))
.collect::<Result<Vec<_>, _>>()?
};
// Parse the hashes provided directly on the requirement, then merge in any hashes from
// the URL fragment.
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() {
// If there are _only_ hashes on the constraints, use them.
constraint
} else if matches!(id, VersionId::ArchiveUrl { .. }) {
let mut merged = digests;
merge_digests(&mut merged, &constraint, requirement)?;
merged
} else {
// If there are constraint and requirement hashes, take the intersection.
let intersection: Vec<_> = digests
Expand Down Expand Up @@ -254,7 +246,7 @@ impl HashStrategy {
continue;
}

requirement_hashes.insert(id, digests);
merge_hashes(&mut requirement_hashes, id, digests, requirement)?;
}

// Merge the hashes, preferring requirements over constraints, since overlapping
Expand Down Expand Up @@ -296,7 +288,7 @@ impl HashStrategy {
}
}

/// Pin a [`Requirement`] to a [`PackageId`], if possible.
/// Pin a [`Requirement`] to a [`VersionId`], if possible.
fn pin(requirement: &Requirement) -> Option<VersionId> {
match &requirement.source {
RequirementSource::Registry { specifier, .. } => {
Expand All @@ -315,18 +307,87 @@ impl HashStrategy {
specifier.version().clone(),
))
}
RequirementSource::Url { url, .. }
| RequirementSource::Git { url, .. }
| RequirementSource::Path { url, .. }
| RequirementSource::Directory { url, .. } => Some(VersionId::from_url(url)),
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))
}
}
}
}

/// Merge repeated hashes for a requirement or constraint into the hash map.
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(())
}

/// Merge `incoming` digests into `existing`.
///
/// Exact duplicates are ignored. Digests for different algorithms are accumulated. If the
/// same algorithm appears with two different values, returns
/// [`HashStrategyError::ConflictingArchiveUrlHashes`].
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}"
)]
Expand All @@ -338,3 +399,77 @@ pub enum HashStrategyError {
)]
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");
};
let HashPolicy::Validate(digests) = hasher.get_url(url) else {
panic!("expected hash validation policy");
};
assert_eq!(digests, expected.as_slice());
}
}
}
Loading
Loading