Skip to content

Commit 7d63afb

Browse files
committed
Enforce lockfile schema versions
1 parent 1b9b9d5 commit 7d63afb

File tree

6 files changed

+172
-18
lines changed

6 files changed

+172
-18
lines changed

crates/uv-resolver/src/lib.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ pub use exclude_newer::ExcludeNewer;
44
pub use exclusions::Exclusions;
55
pub use flat_index::{FlatDistributions, FlatIndex};
66
pub use lock::{
7-
Lock, LockError, RequirementsTxtExport, ResolverManifest, SatisfiesResult, TreeDisplay,
7+
Lock, LockError, LockVersion, RequirementsTxtExport, ResolverManifest, SatisfiesResult,
8+
TreeDisplay, VERSION,
89
};
910
pub use manifest::Manifest;
1011
pub use options::{Flexibility, Options, OptionsBuilder};

crates/uv-resolver/src/lock/mod.rs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ mod requirements_txt;
5050
mod tree;
5151

5252
/// The current version of the lockfile format.
53-
const VERSION: u32 = 1;
53+
pub const VERSION: u32 = 1;
5454

5555
static LINUX_MARKERS: LazyLock<MarkerTree> = LazyLock::new(|| {
5656
MarkerTree::from_str(
@@ -494,6 +494,11 @@ impl Lock {
494494
self
495495
}
496496

497+
/// Returns the lockfile version.
498+
pub fn version(&self) -> u32 {
499+
self.version
500+
}
501+
497502
/// Returns the number of packages in the lockfile.
498503
pub fn len(&self) -> usize {
499504
self.packages.len()
@@ -1509,6 +1514,21 @@ impl TryFrom<LockWire> for Lock {
15091514
}
15101515
}
15111516

1517+
/// Like [`Lock`], but limited to the version field. Used for error reporting: by limiting parsing
1518+
/// to the version field, we can verify compatibility for lockfiles that may otherwise be
1519+
/// unparseable.
1520+
#[derive(Clone, Debug, serde::Deserialize)]
1521+
pub struct LockVersion {
1522+
version: u32,
1523+
}
1524+
1525+
impl LockVersion {
1526+
/// Returns the lockfile version.
1527+
pub fn version(&self) -> u32 {
1528+
self.version
1529+
}
1530+
}
1531+
15121532
#[derive(Clone, Debug, PartialEq, Eq)]
15131533
pub struct Package {
15141534
pub(crate) id: PackageId,

crates/uv/src/commands/project/add.rs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -556,8 +556,8 @@ pub(crate) async fn add(
556556

557557
// Update the `pypackage.toml` in-memory.
558558
let project = project
559-
.with_pyproject_toml(toml::from_str(&content).map_err(ProjectError::TomlParse)?)
560-
.ok_or(ProjectError::TomlUpdate)?;
559+
.with_pyproject_toml(toml::from_str(&content).map_err(ProjectError::PyprojectTomlParse)?)
560+
.ok_or(ProjectError::PyprojectTomlUpdate)?;
561561

562562
// Set the Ctrl-C handler to revert changes on exit.
563563
let _ = ctrlc::set_handler({
@@ -758,8 +758,10 @@ async fn lock_and_sync(
758758

759759
// Update the `pypackage.toml` in-memory.
760760
project = project
761-
.with_pyproject_toml(toml::from_str(&content).map_err(ProjectError::TomlParse)?)
762-
.ok_or(ProjectError::TomlUpdate)?;
761+
.with_pyproject_toml(
762+
toml::from_str(&content).map_err(ProjectError::PyprojectTomlParse)?,
763+
)
764+
.ok_or(ProjectError::PyprojectTomlUpdate)?;
763765

764766
// Invalidate the project metadata.
765767
if let VirtualProject::Project(ref project) = project {

crates/uv/src/commands/project/lock.rs

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ use std::collections::BTreeSet;
44
use std::fmt::Write;
55
use std::path::Path;
66

7-
use anstream::eprint;
87
use owo_colors::OwoColorize;
98
use rustc_hash::{FxBuildHasher, FxHashMap};
109
use tracing::debug;
@@ -28,8 +27,8 @@ use uv_python::{Interpreter, PythonDownloads, PythonEnvironment, PythonPreferenc
2827
use uv_requirements::upgrade::{read_lock_requirements, LockedRequirements};
2928
use uv_requirements::ExtrasResolver;
3029
use uv_resolver::{
31-
FlatIndex, InMemoryIndex, Lock, Options, OptionsBuilder, PythonRequirement, RequiresPython,
32-
ResolverManifest, ResolverMarkers, SatisfiesResult,
30+
FlatIndex, InMemoryIndex, Lock, LockVersion, Options, OptionsBuilder, PythonRequirement,
31+
RequiresPython, ResolverManifest, ResolverMarkers, SatisfiesResult, VERSION,
3332
};
3433
use uv_types::{BuildContext, BuildIsolation, EmptyInstalledPackages, HashStrategy};
3534
use uv_warnings::{warn_user, warn_user_once};
@@ -204,7 +203,15 @@ pub(super) async fn do_safe_lock(
204203
Ok(result)
205204
} else {
206205
// Read the existing lockfile.
207-
let existing = read(workspace).await?;
206+
let existing = match read(workspace).await {
207+
Ok(Some(existing)) => Some(existing),
208+
Ok(None) => None,
209+
Err(ProjectError::Lock(err)) => {
210+
warn_user!("Failed to read existing lockfile; ignoring locked requirements: {err}");
211+
None
212+
}
213+
Err(err) => return Err(err),
214+
};
208215

209216
// Perform the lock operation.
210217
let result = do_lock(
@@ -903,13 +910,34 @@ async fn commit(lock: &Lock, workspace: &Workspace) -> Result<(), ProjectError>
903910
/// Returns `Ok(None)` if the lockfile does not exist.
904911
pub(crate) async fn read(workspace: &Workspace) -> Result<Option<Lock>, ProjectError> {
905912
match fs_err::tokio::read_to_string(&workspace.install_path().join("uv.lock")).await {
906-
Ok(encoded) => match toml::from_str(&encoded) {
907-
Ok(lock) => Ok(Some(lock)),
908-
Err(err) => {
909-
eprint!("Failed to parse lockfile; ignoring locked requirements: {err}");
910-
Ok(None)
913+
Ok(encoded) => {
914+
match toml::from_str::<Lock>(&encoded) {
915+
Ok(lock) => {
916+
// If the lockfile uses an unsupported version, raise an error.
917+
if lock.version() != VERSION {
918+
return Err(ProjectError::UnsupportedLockVersion(
919+
VERSION,
920+
lock.version(),
921+
));
922+
}
923+
Ok(Some(lock))
924+
}
925+
Err(err) => {
926+
// If we failed to parse the lockfile, determine whether it's a supported
927+
// version.
928+
if let Ok(lock) = toml::from_str::<LockVersion>(&encoded) {
929+
if lock.version() != VERSION {
930+
return Err(ProjectError::UnparsableLockVersion(
931+
VERSION,
932+
lock.version(),
933+
err,
934+
));
935+
}
936+
}
937+
Err(ProjectError::UvLockParse(err))
938+
}
911939
}
912-
},
940+
}
913941
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
914942
Err(err) => Err(err.into()),
915943
}

crates/uv/src/commands/project/mod.rs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,12 @@ pub(crate) enum ProjectError {
6464
)]
6565
MissingLockfile,
6666

67+
#[error("The lockfile at `uv.lock` uses an unsupported schema version (v{1}, but only v{0} is supported). Downgrade to a compatible uv version, or remove the `uv.lock` prior to running `uv lock` or `uv sync`.")]
68+
UnsupportedLockVersion(u32, u32),
69+
70+
#[error("Failed to parse `uv.lock`, which uses an unsupported schema version (v{1}, but only v{0} is supported). Downgrade to a compatible uv version, or remove the `uv.lock` prior to running `uv lock` or `uv sync`.")]
71+
UnparsableLockVersion(u32, u32, #[source] toml::de::Error),
72+
6773
#[error("The current Python version ({0}) is not compatible with the locked Python requirement: `{1}`")]
6874
LockedPythonIncompatibility(Version, RequiresPython),
6975

@@ -128,11 +134,14 @@ pub(crate) enum ProjectError {
128134
#[error("Project virtual environment directory `{0}` cannot be used because {1}")]
129135
InvalidProjectEnvironmentDir(PathBuf, String),
130136

137+
#[error("Failed to parse `uv.lock`")]
138+
UvLockParse(#[source] toml::de::Error),
139+
131140
#[error("Failed to parse `pyproject.toml`")]
132-
TomlParse(#[source] toml::de::Error),
141+
PyprojectTomlParse(#[source] toml::de::Error),
133142

134143
#[error("Failed to update `pyproject.toml`")]
135-
TomlUpdate,
144+
PyprojectTomlUpdate,
136145

137146
#[error(transparent)]
138147
Python(#[from] uv_python::Error),

crates/uv/tests/it/lock.rs

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14913,6 +14913,100 @@ fn lock_invalid_project_table() -> Result<()> {
1491314913
Ok(())
1491414914
}
1491514915

14916+
#[test]
14917+
fn lock_unsupported_version() -> Result<()> {
14918+
let context = TestContext::new("3.12");
14919+
14920+
let pyproject_toml = context.temp_dir.child("pyproject.toml");
14921+
pyproject_toml.write_str(
14922+
r#"
14923+
[project]
14924+
name = "project"
14925+
version = "0.1.0"
14926+
requires-python = ">=3.12"
14927+
dependencies = ["iniconfig==2.0.0"]
14928+
14929+
[build-system]
14930+
requires = ["setuptools>=42"]
14931+
build-backend = "setuptools.build_meta"
14932+
"#,
14933+
)?;
14934+
14935+
// Validate schema, invalid version.
14936+
context.temp_dir.child("uv.lock").write_str(
14937+
r#"
14938+
version = 2
14939+
requires-python = ">=3.12"
14940+
14941+
[options]
14942+
exclude-newer = "2024-03-25T00:00:00Z"
14943+
14944+
[[package]]
14945+
name = "iniconfig"
14946+
version = "2.0.0"
14947+
source = { registry = "https://pypi.org/simple" }
14948+
sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 }
14949+
wheels = [
14950+
{ url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 },
14951+
]
14952+
14953+
[[package]]
14954+
name = "project"
14955+
version = "0.1.0"
14956+
source = { editable = "." }
14957+
dependencies = [
14958+
{ name = "iniconfig" },
14959+
]
14960+
14961+
[package.metadata]
14962+
requires-dist = [{ name = "iniconfig", specifier = "==2.0.0" }]
14963+
"#,
14964+
)?;
14965+
14966+
uv_snapshot!(context.filters(), context.lock().arg("--frozen"), @r###"
14967+
success: false
14968+
exit_code: 2
14969+
----- stdout -----
14970+
14971+
----- stderr -----
14972+
error: The lockfile at `uv.lock` uses an unsupported schema version (v2, but only v1 is supported). Downgrade to a compatible uv version, or remove the `uv.lock` prior to running `uv lock` or `uv sync`.
14973+
"###);
14974+
14975+
// Invalid schema (`iniconfig` is referenced, but missing), invalid version.
14976+
context.temp_dir.child("uv.lock").write_str(
14977+
r#"
14978+
version = 2
14979+
requires-python = ">=3.12"
14980+
14981+
[options]
14982+
exclude-newer = "2024-03-25T00:00:00Z"
14983+
14984+
[[package]]
14985+
name = "project"
14986+
version = "0.1.0"
14987+
source = { editable = "." }
14988+
dependencies = [
14989+
{ name = "iniconfig" },
14990+
]
14991+
14992+
[package.metadata]
14993+
requires-dist = [{ name = "iniconfig", specifier = "==2.0.0" }]
14994+
"#,
14995+
)?;
14996+
14997+
uv_snapshot!(context.filters(), context.lock().arg("--frozen"), @r###"
14998+
success: false
14999+
exit_code: 2
15000+
----- stdout -----
15001+
15002+
----- stderr -----
15003+
error: Failed to parse `uv.lock`, which uses an unsupported schema version (v2, but only v1 is supported). Downgrade to a compatible uv version, or remove the `uv.lock` prior to running `uv lock` or `uv sync`.
15004+
Caused by: Dependency `iniconfig` has missing `version` field but has more than one matching package
15005+
"###);
15006+
15007+
Ok(())
15008+
}
15009+
1491615010
/// See: <https://github.com/astral-sh/uv/issues/7618>
1491715011
#[test]
1491815012
fn lock_change_requires_python() -> Result<()> {

0 commit comments

Comments
 (0)