Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enforce hashes in lockfile install #5170

Merged
merged 1 commit into from
Jul 17, 2024
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
10 changes: 9 additions & 1 deletion crates/distribution-types/src/resolution.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use std::collections::BTreeMap;

use pypi_types::{Requirement, RequirementSource};
use pypi_types::{HashDigest, Requirement, RequirementSource};
use uv_normalize::{ExtraName, GroupName, PackageName};

use crate::{BuiltDist, Diagnostic, Dist, Name, ResolvedDist, SourceDist};
Expand All @@ -9,17 +9,20 @@ use crate::{BuiltDist, Diagnostic, Dist, Name, ResolvedDist, SourceDist};
#[derive(Debug, Default, Clone)]
pub struct Resolution {
packages: BTreeMap<PackageName, ResolvedDist>,
hashes: BTreeMap<PackageName, Vec<HashDigest>>,
diagnostics: Vec<ResolutionDiagnostic>,
}

impl Resolution {
/// Create a new resolution from the given pinned packages.
pub fn new(
packages: BTreeMap<PackageName, ResolvedDist>,
hashes: BTreeMap<PackageName, Vec<HashDigest>>,
diagnostics: Vec<ResolutionDiagnostic>,
) -> Self {
Self {
packages,
hashes,
diagnostics,
}
}
Expand All @@ -32,6 +35,11 @@ impl Resolution {
}
}

/// Return the hashes for the given package name, if they exist.
pub fn get_hashes(&self, package_name: &PackageName) -> &[HashDigest] {
self.hashes.get(package_name).map_or(&[], Vec::as_slice)
}

/// Iterate over the [`PackageName`] entities in this resolution.
pub fn packages(&self) -> impl Iterator<Item = &PackageName> {
self.packages.keys()
Expand Down
12 changes: 7 additions & 5 deletions crates/uv-resolver/src/lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,7 @@ impl Lock {
}

let mut map = BTreeMap::default();
let mut hashes = BTreeMap::default();
while let Some((dist, extra)) = queue.pop_front() {
let deps =
if let Some(extra) = extra {
Expand All @@ -406,13 +407,14 @@ impl Lock {
}
}
}
let name = dist.id.name.clone();
let resolved_dist =
ResolvedDist::Installable(dist.to_dist(project.workspace().install_path(), tags)?);
map.insert(name, resolved_dist);
map.insert(
dist.id.name.clone(),
ResolvedDist::Installable(dist.to_dist(project.workspace().install_path(), tags)?),
);
hashes.insert(dist.id.name.clone(), dist.hashes());
}
let diagnostics = vec![];
Ok(Resolution::new(map, diagnostics))
Ok(Resolution::new(map, hashes, diagnostics))
}

/// Returns the TOML representation of this lock file.
Expand Down
4 changes: 4 additions & 0 deletions crates/uv-resolver/src/resolution/graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,10 @@ impl From<ResolutionGraph> for distribution_types::Resolution {
.dists()
.map(|node| (node.name().clone(), node.dist.clone()))
.collect(),
graph
.dists()
.map(|node| (node.name().clone(), node.hashes.clone()))
.collect(),
graph.diagnostics,
)
}
Expand Down
37 changes: 33 additions & 4 deletions crates/uv-types/src/hash.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ use std::str::FromStr;
use std::sync::Arc;
use url::Url;

use distribution_types::{DistributionMetadata, HashPolicy, UnresolvedRequirement, VersionId};
use distribution_types::{
DistributionMetadata, HashPolicy, Name, Resolution, UnresolvedRequirement, VersionId,
};
use pep440_rs::Version;
use pep508_rs::MarkerEnvironment;
use pypi_types::{HashDigest, HashError, Requirement, RequirementSource};
Expand Down Expand Up @@ -168,10 +170,37 @@ impl HashStrategy {
hashes.insert(id, digests);
}

let hashes = Arc::new(hashes);
match mode {
HashCheckingMode::Verify => Ok(Self::Verify(hashes)),
HashCheckingMode::Require => Ok(Self::Require(hashes)),
HashCheckingMode::Verify => Ok(Self::Verify(Arc::new(hashes))),
HashCheckingMode::Require => Ok(Self::Require(Arc::new(hashes))),
}
}

/// Generate the required hashes from a [`Resolution`].
pub fn from_resolution(
resolution: &Resolution,
mode: HashCheckingMode,
) -> Result<Self, HashStrategyError> {
let mut hashes = FxHashMap::<VersionId, Vec<HashDigest>>::default();

for dist in resolution.distributions() {
let digests = resolution.get_hashes(dist.name());
if digests.is_empty() {
// Under `--require-hashes`, every requirement must include a hash.
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))),
}
}

Expand Down
3 changes: 3 additions & 0 deletions crates/uv/src/commands/project/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ pub(crate) enum ProjectError {
#[error(transparent)]
Virtualenv(#[from] uv_virtualenv::Error),

#[error(transparent)]
HashStrategy(#[from] uv_types::HashStrategyError),

#[error(transparent)]
Tags(#[from] platform_tags::TagsError),

Expand Down
8 changes: 6 additions & 2 deletions crates/uv/src/commands/project/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ use anyhow::Result;

use uv_cache::Cache;
use uv_client::{Connectivity, FlatIndexClient, RegistryClientBuilder};
use uv_configuration::{Concurrency, ExtrasSpecification, PreviewMode, SetupPyStrategy};
use uv_configuration::{
Concurrency, ExtrasSpecification, HashCheckingMode, PreviewMode, SetupPyStrategy,
};
use uv_dispatch::BuildDispatch;
use uv_distribution::{VirtualProject, DEV_DEPENDENCIES};
use uv_installer::SitePackages;
Expand Down Expand Up @@ -230,9 +232,11 @@ pub(super) async fn do_sync(
// optional on the downstream APIs.
let build_isolation = BuildIsolation::default();
let dry_run = false;
let hasher = HashStrategy::default();
let setup_py = SetupPyStrategy::default();

// Extract the hashes from the lockfile.
let hasher = HashStrategy::from_resolution(&resolution, HashCheckingMode::Verify)?;

// Resolve the flat indexes from `--find-links`.
let flat_index = {
let client = FlatIndexClient::new(&client, cache);
Expand Down
86 changes: 86 additions & 0 deletions crates/uv/tests/lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3920,3 +3920,89 @@ fn lock_new_extras() -> Result<()> {

Ok(())
}

/// Ensure that the installer rejects invalid hashes from the lockfile.
///
/// In this case, the hashes for `idna` have all been incremented by one in the left-most digit.
#[test]
fn lock_invalid_hash() -> Result<()> {
let context = TestContext::new("3.12");

let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(
r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["anyio==3.7.0"]
"#,
)?;

let lock = context.temp_dir.child("uv.lock");
lock.write_str(r#"
version = 1
requires-python = ">=3.12"

[[distribution]]
name = "anyio"
version = "3.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "sniffio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c6/b3/fefbf7e78ab3b805dec67d698dc18dd505af7a18a8dd08868c9b4fa736b5/anyio-3.7.0.tar.gz", hash = "sha256:275d9973793619a5374e1c89a4f4ad3f4b0a5510a2b5b939444bee8f4c4d37ce", size = 142737 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/68/fe/7ce1926952c8a403b35029e194555558514b365ad77d75125f521a2bec62/anyio-3.7.0-py3-none-any.whl", hash = "sha256:eddca883c4175f14df8aedce21054bfca3adb70ffe76a9f607aef9d7fa2ea7f0", size = 80873 },
]

[[distribution]]
name = "idna"
version = "3.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bf/3f/ea4b9117521a1e9c50344b909be7886dd00a519552724809bb1f486986c2/idna-3.6.tar.gz", hash = "sha256:aecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca", size = 175426 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/e7/a82b05cf63a603df6e68d59ae6a68bf5064484a0718ea5033660af4b54a9/idna-3.6-py3-none-any.whl", hash = "sha256:d05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f", size = 61567 },
]

[[distribution]]
name = "project"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "anyio" },
]

[[distribution]]
name = "sniffio"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 },
]
"#)?;

// Install from the lockfile.
uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r###"
success: false
exit_code: 2
----- stdout -----

----- stderr -----
warning: `uv sync` is experimental and may change without warning.
error: Failed to prepare distributions
Caused by: Failed to fetch wheel: idna==3.6
Caused by: Hash mismatch for `idna==3.6`

Expected:
sha256:aecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca
sha256:d05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f

Computed:
sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f
"###);

Ok(())
}
Loading