From eb6dcc0bd2db76d45470f7b7bf556673dd2fbfc3 Mon Sep 17 00:00:00 2001 From: konstin Date: Mon, 17 Mar 2025 11:07:18 +0100 Subject: [PATCH] Reject lockfiles with incoherent versions Reject lockfiles where the package version and the wheel versions are incoherent. This implicitly checks that all wheel files have the same version. It does not check for the source dist version, since a source dist may not contain a version in the filename and attempting to deserialize source dist filenames we may not need is a performance overhead for something that's already slow in `uv run`. Fixes #12164 --- crates/uv-resolver/src/lock/mod.rs | 20 +++++++ crates/uv/tests/it/sync.rs | 94 ++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+) diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index d66920fef8ab..eea08f668220 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -2871,6 +2871,19 @@ impl PackageWire { requires_python: &RequiresPython, unambiguous_package_ids: &FxHashMap, ) -> Result { + // Consistency check + if let Some(version) = &self.id.version { + for wheel in &self.wheels { + if version != &wheel.filename.version { + return Err(LockError::from(LockErrorKind::InconsistentVersions { + name: self.id.name, + })); + } + } + // We can't check the source dist version since it does not need to contain the version + // in the filename. + } + let unwire_deps = |deps: Vec| -> Result, LockError> { deps.into_iter() .map(|dep| dep.unwire(requires_python, unambiguous_package_ids)) @@ -5156,6 +5169,13 @@ enum LockErrorKind { #[source] err: uv_distribution::Error, }, + /// A package has inconsistent versions in a single entry + // Using name instead of id since the version in the id is part of the conflict. + #[error("Locked package and file versions are inconsistent for `{name}`", name = name.cyan())] + InconsistentVersions { + /// The name of the package with the inconsistent entry. + name: PackageName, + }, #[error( "Found conflicting extras `{package1}[{extra1}]` \ and `{package2}[{extra2}]` enabled simultaneously" diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index d94e9d2b5981..8934b5b4e846 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -8473,3 +8473,97 @@ fn prune_cache_url_subdirectory() -> Result<()> { Ok(()) } + +/// Test that incoherence in the versions in a package entry of the lockfile versions is caught. +/// +/// See +#[test] +fn locked_version_coherence() -> 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 = ["iniconfig"] + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + "); + + let lock = context.read("uv.lock"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r#" + version = 1 + revision = 1 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "iniconfig" + version = "2.0.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, + ] + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "iniconfig" }, + ] + + [package.metadata] + requires-dist = [{ name = "iniconfig" }] + "#); + }); + + // Write an inconsistent iniconfig entry + context + .temp_dir + .child("uv.lock") + .write_str(&lock.replace(r#"version = "2.0.0""#, r#"version = "1.0.0""#))?; + + // An inconsistent lockfile should fail with `--locked` + uv_snapshot!(context.filters(), context.sync().arg("--locked"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Failed to parse `uv.lock` + Caused by: Locked package and file versions are inconsistent for `iniconfig` + "); + + // Without `--locked`, we could fail or recreate the lockfile, currently, we fail. + uv_snapshot!(context.filters(), context.lock(), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Failed to parse `uv.lock` + Caused by: Locked package and file versions are inconsistent for `iniconfig` + "); + + Ok(()) +}