From d261212ca6fd1e59937e4892d34e2a56a70ef3b3 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Mon, 15 Jun 2026 18:08:04 -0400 Subject: [PATCH] Validate dependency group includes (#19866) ## Summary Prior to this change, a dependency-group object containing `include-group` alongside additional keys was accepted as a group include. We silently discarded the additional fields even though the current specification defines an include object as containing exactly one key. This recognizes `include-group` only when it is the object's sole key. Mixed tables are preserved as dependency object specifiers, allowing future syntax to deserialize without being misinterpreted, while the existing semantic validation rejects them when their group is processed. The focused coverage verifies both that mixed tables retain all of their fields during deserialization and that `uv lock` rejects the currently unsupported object when resolving its group. --- crates/uv-pypi-types/src/dependency_groups.rs | 13 ++++---- crates/uv-workspace/src/workspace.rs | 13 ++++++++ crates/uv/tests/lock/lock.rs | 32 +++++++++++++++++++ 3 files changed, 52 insertions(+), 6 deletions(-) diff --git a/crates/uv-pypi-types/src/dependency_groups.rs b/crates/uv-pypi-types/src/dependency_groups.rs index 4365c2cf2e..e933386258 100644 --- a/crates/uv-pypi-types/src/dependency_groups.rs +++ b/crates/uv-pypi-types/src/dependency_groups.rs @@ -129,12 +129,13 @@ impl<'de> Deserialize<'de> for DependencyGroupSpecifier { return Err(serde::de::Error::custom("missing field `include-group`")); } - if let Some(include_group) = map_data - .get("include-group") - .map(String::as_str) - .map(GroupName::from_str) - .transpose() - .map_err(serde::de::Error::custom)? + if map_data.len() == 1 + && let Some(include_group) = map_data + .get("include-group") + .map(String::as_str) + .map(GroupName::from_str) + .transpose() + .map_err(serde::de::Error::custom)? { Ok(DependencyGroupSpecifier::IncludeGroup { include_group }) } else { diff --git a/crates/uv-workspace/src/workspace.rs b/crates/uv-workspace/src/workspace.rs index f9a0cf6d64..7957fe825a 100644 --- a/crates/uv-workspace/src/workspace.rs +++ b/crates/uv-workspace/src/workspace.rs @@ -2178,6 +2178,7 @@ impl VirtualProject { #[cfg(test)] #[cfg(unix)] // Avoid path escaping for the unit tests mod tests { + use std::collections::BTreeMap; use std::env; use std::path::Path; use std::str::FromStr; @@ -3244,6 +3245,7 @@ mod tests { [dependency-groups] foo = ["a", {include-group = "bar"}] bar = ["b"] +future = [{include-group = "bar", unknown = "value"}] "#; let result = PyProjectToml::from_string(toml.to_string(), "pyproject.toml") @@ -3272,6 +3274,17 @@ bar = ["b"] bar, &[DependencyGroupSpecifier::Requirement("b".to_string())] ); + + let future = groups + .get(&GroupName::from_str("future").unwrap()) + .expect("Group `future` should be present"); + assert_eq!( + future, + &[DependencyGroupSpecifier::Object(BTreeMap::from([ + ("include-group".to_string(), "bar".to_string()), + ("unknown".to_string(), "value".to_string()), + ]))] + ); } #[tokio::test] diff --git a/crates/uv/tests/lock/lock.rs b/crates/uv/tests/lock/lock.rs index 3cbbf92df9..025133a473 100644 --- a/crates/uv/tests/lock/lock.rs +++ b/crates/uv/tests/lock/lock.rs @@ -26231,6 +26231,38 @@ fn lock_group_invalid_entry_table() -> Result<()> { Ok(()) } +#[test] +fn lock_group_include_with_extra_key() -> Result<()> { + let context = uv_test::test_context!("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 = [] + + [dependency-groups] + foo = [{include-group = "bar", unknown = "value"}] + bar = [] + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @r#" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Project `project` has malformed dependency groups + Caused by: Group `foo` contains an unknown dependency object specifier: {"include-group": "bar", "unknown": "value"} + "#); + + Ok(()) +} + #[test] fn lock_group_invalid_entry_type() -> Result<()> { let context = uv_test::test_context!("3.12");