diff --git a/crates/uv/src/commands/build.rs b/crates/uv/src/commands/build.rs index 8558c77f1bfb4..bf080531b697e 100644 --- a/crates/uv/src/commands/build.rs +++ b/crates/uv/src/commands/build.rs @@ -13,7 +13,7 @@ use std::path::{Path, PathBuf}; use uv_auth::store_credentials_from_url; use uv_cache::Cache; use uv_client::{BaseClientBuilder, Connectivity, FlatIndexClient, RegistryClientBuilder}; -use uv_configuration::{BuildKind, BuildOutput, Concurrency, Constraints}; +use uv_configuration::{BuildKind, BuildOutput, Concurrency, Constraints, HashCheckingMode}; use uv_dispatch::BuildDispatch; use uv_fs::{Simplified, CWD}; use uv_normalize::PackageName; @@ -234,6 +234,23 @@ async fn build_impl( let build_constraints = operations::read_constraints(build_constraints, &client_builder).await?; + // Collect the set of required hashes. + // Enforce (but never require) the build constraints, if `--require-hashes` or `--verify-hashes` + // is provided. _Requiring_ hashes would be too strict, and would break with pip. + let build_hasher = HashStrategy::from_requirements( + std::iter::empty(), + build_constraints + .iter() + .map(|entry| (&entry.requirement, entry.hashes.as_slice())), + Some(&interpreter.resolver_markers()), + HashCheckingMode::Verify, + )?; + let build_constraints = Constraints::from_requirements( + build_constraints + .iter() + .map(|constraint| constraint.requirement.clone()), + ); + // Initialize the registry client. let client = RegistryClientBuilder::new(cache.clone()) .native_tls(native_tls) @@ -258,15 +275,11 @@ async fn build_impl( BuildIsolation::SharedPackage(&environment, no_build_isolation_package) }; - // TODO(charlie): These are all default values. We should consider whether we want to make them - // optional on the downstream APIs. - let hasher = HashStrategy::None; - // Resolve the flat indexes from `--find-links`. let flat_index = { let client = FlatIndexClient::new(&client, cache); let entries = client.fetch(index_locations.flat_index()).await?; - FlatIndex::from_entries(entries, None, &hasher, build_options) + FlatIndex::from_entries(entries, None, &build_hasher, build_options) }; // Initialize any shared state. diff --git a/crates/uv/tests/build.rs b/crates/uv/tests/build.rs index 5bba20ba2aceb..1eaa5d7a3d909 100644 --- a/crates/uv/tests/build.rs +++ b/crates/uv/tests/build.rs @@ -1206,3 +1206,165 @@ fn build_constraints() -> Result<()> { Ok(()) } + +#[test] +fn sha() -> Result<()> { + let context = TestContext::new("3.8"); + let filters = context + .filters() + .into_iter() + .chain([ + (r"exit code: 1", "exit status: 1"), + (r"bdist\.[^/\\\s]+-[^/\\\s]+", "bdist.linux-x86_64"), + (r"\\\.", ""), + ]) + .collect::>(); + + let project = context.temp_dir.child("project"); + + let pyproject_toml = project.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.8" + dependencies = ["anyio==3.7.0"] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + project.child("src").child("__init__.py").touch()?; + project.child("README").touch()?; + + // Reject an incorrect hash. + let constraints = project.child("constraints.txt"); + constraints.write_str("setuptools==68.2.2 --hash=sha256:a248cb506794bececcddeddb1678bc722f9cfcacf02f98f7c0af6b9ed893caf2")?; + + uv_snapshot!(&filters, context.build().arg("--build-constraint").arg("constraints.txt").current_dir(&project), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + Building source distribution... + error: Failed to install requirements from `build-system.requires` (install) + Caused by: Failed to prepare distributions + Caused by: Failed to fetch wheel: setuptools==68.2.2 + Caused by: Hash mismatch for `setuptools==68.2.2` + + Expected: + sha256:a248cb506794bececcddeddb1678bc722f9cfcacf02f98f7c0af6b9ed893caf2 + + Computed: + sha256:b454a35605876da60632df1a60f736524eb73cc47bbc9f3f1ef1b644de74fd2a + "###); + + project + .child("dist") + .child("project-0.1.0.tar.gz") + .assert(predicate::path::missing()); + project + .child("dist") + .child("project-0.1.0-py3-none-any.whl") + .assert(predicate::path::missing()); + + // Accept a correct hash. + let constraints = project.child("constraints.txt"); + constraints.write_str("setuptools==68.2.2 --hash=sha256:b454a35605876da60632df1a60f736524eb73cc47bbc9f3f1ef1b644de74fd2a")?; + + uv_snapshot!(&filters, context.build().arg("--build-constraint").arg("constraints.txt").current_dir(&project), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Building source distribution... + running egg_info + creating src/project.egg-info + writing src/project.egg-info/PKG-INFO + writing dependency_links to src/project.egg-info/dependency_links.txt + writing requirements to src/project.egg-info/requires.txt + writing top-level names to src/project.egg-info/top_level.txt + writing manifest file 'src/project.egg-info/SOURCES.txt' + reading manifest file 'src/project.egg-info/SOURCES.txt' + writing manifest file 'src/project.egg-info/SOURCES.txt' + running sdist + running egg_info + writing src/project.egg-info/PKG-INFO + writing dependency_links to src/project.egg-info/dependency_links.txt + writing requirements to src/project.egg-info/requires.txt + writing top-level names to src/project.egg-info/top_level.txt + reading manifest file 'src/project.egg-info/SOURCES.txt' + writing manifest file 'src/project.egg-info/SOURCES.txt' + running check + creating project-0.1.0 + creating project-0.1.0/src + creating project-0.1.0/src/project.egg-info + copying files to project-0.1.0... + copying README -> project-0.1.0 + copying pyproject.toml -> project-0.1.0 + copying src/__init__.py -> project-0.1.0/src + copying src/project.egg-info/PKG-INFO -> project-0.1.0/src/project.egg-info + copying src/project.egg-info/SOURCES.txt -> project-0.1.0/src/project.egg-info + copying src/project.egg-info/dependency_links.txt -> project-0.1.0/src/project.egg-info + copying src/project.egg-info/requires.txt -> project-0.1.0/src/project.egg-info + copying src/project.egg-info/top_level.txt -> project-0.1.0/src/project.egg-info + Writing project-0.1.0/setup.cfg + Creating tar archive + removing 'project-0.1.0' (and everything under it) + Building wheel from source distribution... + running egg_info + writing src/project.egg-info/PKG-INFO + writing dependency_links to src/project.egg-info/dependency_links.txt + writing requirements to src/project.egg-info/requires.txt + writing top-level names to src/project.egg-info/top_level.txt + reading manifest file 'src/project.egg-info/SOURCES.txt' + writing manifest file 'src/project.egg-info/SOURCES.txt' + running bdist_wheel + running build + running build_py + creating build + creating build/lib + copying src/__init__.py -> build/lib + running egg_info + writing src/project.egg-info/PKG-INFO + writing dependency_links to src/project.egg-info/dependency_links.txt + writing requirements to src/project.egg-info/requires.txt + writing top-level names to src/project.egg-info/top_level.txt + reading manifest file 'src/project.egg-info/SOURCES.txt' + writing manifest file 'src/project.egg-info/SOURCES.txt' + installing to build/bdist.linux-x86_64/wheel + running install + running install_lib + creating build/bdist.linux-x86_64 + creating build/bdist.linux-x86_64/wheel + copying build/lib/__init__.py -> build/bdist.linux-x86_64/wheel + running install_egg_info + Copying src/project.egg-info to build/bdist.linux-x86_64/wheel/project-0.1.0-py3.8.egg-info + running install_scripts + creating build/bdist.linux-x86_64/wheel/project-0.1.0.dist-info/WHEEL + creating '[TEMP_DIR]/project/dist/[TMP]/wheel' to it + adding '__init__.py' + adding 'project-0.1.0.dist-info/METADATA' + adding 'project-0.1.0.dist-info/WHEEL' + adding 'project-0.1.0.dist-info/top_level.txt' + adding 'project-0.1.0.dist-info/RECORD' + removing build/bdist.linux-x86_64/wheel + Successfully built dist/project-0.1.0.tar.gz and dist/project-0.1.0-py3-none-any.whl + "###); + + project + .child("dist") + .child("project-0.1.0.tar.gz") + .assert(predicate::path::is_file()); + project + .child("dist") + .child("project-0.1.0-py3-none-any.whl") + .assert(predicate::path::is_file()); + + Ok(()) +} diff --git a/docs/concepts/projects.md b/docs/concepts/projects.md index c20c1eb209845..d84a363121eab 100644 --- a/docs/concepts/projects.md +++ b/docs/concepts/projects.md @@ -585,6 +585,23 @@ You can limit `uv build` to building a source distribution with `uv build --sour distribution with `uv build --binary`, or build both distributions from source with `uv build --source --binary`. +`uv build` accepts `--build-constraints`, which can be used to constrain the versions of any build +requirements during the build process. When coupled with `--require-hashes`, uv will enforce that +the requirement used to build the project match specific, known hashes, for reproducibility. + +For example, given the following `constraints.txt`: + +```text +setuptools==68.2.2 --hash=sha256:b454a35605876da60632df1a60f736524eb73cc47bbc9f3f1ef1b644de74fd2a +``` + +Running the following would build the project with the specified version of `setuptools`, and verify +that the downloaded `setuptools` distribution matches the specified hash: + +```console +$ uv build --build-constraints constraints.txt --require-hashes +``` + ## Build isolation By default, uv builds all packages in isolated virtual environments, as per