From 31a05c87cc4b442e00070c4b30a9e4d1aa03497c Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Mon, 11 May 2026 12:34:44 -0400 Subject: [PATCH 1/4] Apply stricter validation to all wheel filename segments --- .../uv-distribution-filename/src/build_tag.rs | 49 +++++++++++- crates/uv-distribution-filename/src/wheel.rs | 77 +++++++++++++++---- crates/uv-platform-tags/src/platform_tag.rs | 64 +++++++++++++++ crates/uv/tests/it/pip_sync.rs | 48 ++++++++++++ 4 files changed, 224 insertions(+), 14 deletions(-) diff --git a/crates/uv-distribution-filename/src/build_tag.rs b/crates/uv-distribution-filename/src/build_tag.rs index 30c048f5b83..93676299dd4 100644 --- a/crates/uv-distribution-filename/src/build_tag.rs +++ b/crates/uv-distribution-filename/src/build_tag.rs @@ -9,6 +9,8 @@ pub enum BuildTagError { Empty, #[error("must start with a digit")] NoLeadingDigit, + #[error("must contain only ASCII letters, digits, and underscores")] + InvalidCharacters, #[error(transparent)] ParseInt(#[from] ParseIntError), } @@ -45,8 +47,18 @@ impl FromStr for BuildTag { return Err(BuildTagError::Empty); } + let mut prefix_end = None; + for (index, byte) in s.bytes().enumerate() { + if !is_build_tag_byte(byte) { + return Err(BuildTagError::InvalidCharacters); + } + if prefix_end.is_none() && !byte.is_ascii_digit() { + prefix_end = Some(index); + } + } + // A build tag must start with a digit. - let (prefix, suffix) = match s.find(|c: char| !c.is_ascii_digit()) { + let (prefix, suffix) = match prefix_end { // Ex) `abc` Some(0) => return Err(BuildTagError::NoLeadingDigit), // Ex) `123abc` @@ -70,3 +82,38 @@ impl std::fmt::Display for BuildTag { } } } + +fn is_build_tag_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || byte == b'_' +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use super::BuildTag; + + #[test] + fn err_invalid_characters() { + let err = BuildTag::from_str("1/../../target").unwrap_err(); + insta::assert_snapshot!(err, @"must contain only ASCII letters, digits, and underscores"); + + let err = BuildTag::from_str(r"1..\..\target").unwrap_err(); + insta::assert_snapshot!(err, @"must contain only ASCII letters, digits, and underscores"); + + let err = BuildTag::from_str("1target:stream").unwrap_err(); + insta::assert_snapshot!(err, @"must contain only ASCII letters, digits, and underscores"); + + let err = BuildTag::from_str("1-target").unwrap_err(); + insta::assert_snapshot!(err, @"must contain only ASCII letters, digits, and underscores"); + + let err = BuildTag::from_str("1.target").unwrap_err(); + insta::assert_snapshot!(err, @"must contain only ASCII letters, digits, and underscores"); + + let err = BuildTag::from_str("1 target").unwrap_err(); + insta::assert_snapshot!(err, @"must contain only ASCII letters, digits, and underscores"); + + let err = BuildTag::from_str("1target\u{e9}").unwrap_err(); + insta::assert_snapshot!(err, @"must contain only ASCII letters, digits, and underscores"); + } +} diff --git a/crates/uv-distribution-filename/src/wheel.rs b/crates/uv-distribution-filename/src/wheel.rs index f3be4a75370..5095745a3db 100644 --- a/crates/uv-distribution-filename/src/wheel.rs +++ b/crates/uv-distribution-filename/src/wheel.rs @@ -15,7 +15,7 @@ use uv_platform_tags::{ }; use crate::splitter::MemchrSplitter; -use crate::wheel_tag::{WheelTag, WheelTagLarge, WheelTagSmall}; +use crate::wheel_tag::{TagSet, WheelTag, WheelTagLarge, WheelTagSmall}; use crate::{BuildTag, BuildTagError}; #[derive( @@ -262,18 +262,9 @@ impl WheelFilename { WheelTag::Large { large: Box::new(WheelTagLarge { build_tag, - python_tag: MemchrSplitter::split(python_tag, b'.') - .map(LanguageTag::from_str) - .filter_map(Result::ok) - .collect(), - abi_tag: MemchrSplitter::split(abi_tag, b'.') - .map(AbiTag::from_str) - .filter_map(Result::ok) - .collect(), - platform_tag: MemchrSplitter::split(platform_tag, b'.') - .map(PlatformTag::from_str) - .filter_map(Result::ok) - .collect(), + python_tag: parse_large_tag_component::(python_tag, filename)?, + abi_tag: parse_large_tag_component::(abi_tag, filename)?, + platform_tag: parse_large_tag_component::(platform_tag, filename)?, repr: repr.into(), }), } @@ -287,6 +278,39 @@ impl WheelFilename { } } +fn parse_large_tag_component( + component: &str, + filename: &str, +) -> Result, WheelFilenameError> { + if component.is_empty() { + return Err(invalid_tag_component(filename)); + } + + let mut tags = TagSet::new(); + for tag in MemchrSplitter::split(component, b'.') { + if tag.is_empty() || !tag.bytes().all(is_tag_atom_byte) { + return Err(invalid_tag_component(filename)); + } + if let Ok(tag) = T::from_str(tag) { + tags.push(tag); + } + } + + Ok(tags) +} + +fn invalid_tag_component(filename: &str) -> WheelFilenameError { + WheelFilenameError::InvalidWheelFileName( + filename.to_string(), + "Tag components must contain only ASCII letters, digits, underscores, and periods" + .to_string(), + ) +} + +fn is_tag_atom_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || byte == b'_' +} + impl<'de> Deserialize<'de> for WheelFilename { fn deserialize(deserializer: D) -> Result where @@ -408,6 +432,33 @@ mod tests { fn err_invalid_build_tag() { let err = WheelFilename::from_str("foo-1.2.3-tag-py3-none-any.whl").unwrap_err(); insta::assert_snapshot!(err, @r#"The wheel filename "foo-1.2.3-tag-py3-none-any.whl" has an invalid build tag: must start with a digit"#); + + let err = WheelFilename::from_str("foo-1.2.3-1/../../target-py3-none-any.whl").unwrap_err(); + insta::assert_snapshot!(err, @r#"The wheel filename "foo-1.2.3-1/../../target-py3-none-any.whl" has an invalid build tag: must contain only ASCII letters, digits, and underscores"#); + } + + #[test] + fn err_invalid_tag_component() { + let err = WheelFilename::from_str("foo-1.2.3-py3-none-../target.whl").unwrap_err(); + insta::assert_snapshot!(err, @r#"The wheel filename "foo-1.2.3-py3-none-../target.whl" is invalid: Tag components must contain only ASCII letters, digits, underscores, and periods"#); + + let err = WheelFilename::from_str(r"foo-1.2.3-py3-none-..\target.whl").unwrap_err(); + insta::assert_snapshot!(err, @r#"The wheel filename "foo-1.2.3-py3-none-..\target.whl" is invalid: Tag components must contain only ASCII letters, digits, underscores, and periods"#); + + let err = WheelFilename::from_str("foo-1.2.3-py3-none-target:stream.whl").unwrap_err(); + insta::assert_snapshot!(err, @r#"The wheel filename "foo-1.2.3-py3-none-target:stream.whl" is invalid: Tag components must contain only ASCII letters, digits, underscores, and periods"#); + + let err = WheelFilename::from_str("foo-1.2.3-py3-none-freebsd_13_x86/64.whl").unwrap_err(); + insta::assert_snapshot!(err, @r#"The wheel filename "foo-1.2.3-py3-none-freebsd_13_x86/64.whl" is invalid: Tag components must contain only ASCII letters, digits, underscores, and periods"#); + + let err = WheelFilename::from_str("foo-1.2.3-py3-none-unknown tag.whl").unwrap_err(); + insta::assert_snapshot!(err, @r#"The wheel filename "foo-1.2.3-py3-none-unknown tag.whl" is invalid: Tag components must contain only ASCII letters, digits, underscores, and periods"#); + + let err = WheelFilename::from_str("foo-1.2.3-py3-none-unknown\u{e9}.whl").unwrap_err(); + insta::assert_snapshot!(err, @"The wheel filename \"foo-1.2.3-py3-none-unknown\u{e9}.whl\" is invalid: Tag components must contain only ASCII letters, digits, underscores, and periods"); + + let err = WheelFilename::from_str("foo-1.2.3-py3-none-unknown..tag.whl").unwrap_err(); + insta::assert_snapshot!(err, @r#"The wheel filename "foo-1.2.3-py3-none-unknown..tag.whl" is invalid: Tag components must contain only ASCII letters, digits, underscores, and periods"#); } #[test] diff --git a/crates/uv-platform-tags/src/platform_tag.rs b/crates/uv-platform-tags/src/platform_tag.rs index 34bb7f83af2..384afd3d89d 100644 --- a/crates/uv-platform-tags/src/platform_tag.rs +++ b/crates/uv-platform-tags/src/platform_tag.rs @@ -688,6 +688,7 @@ impl FromStr for PlatformTag { tag: s.to_string(), }); } + validate_release_arch(rest, s)?; return Ok(Self::FreeBsd { release_arch: SmallString::from(rest), @@ -702,6 +703,7 @@ impl FromStr for PlatformTag { tag: s.to_string(), }); } + validate_release_arch(rest, s)?; return Ok(Self::NetBsd { release_arch: SmallString::from(rest), @@ -716,6 +718,7 @@ impl FromStr for PlatformTag { tag: s.to_string(), }); } + validate_release_arch(rest, s)?; return Ok(Self::OpenBsd { release_arch: SmallString::from(rest), @@ -730,6 +733,7 @@ impl FromStr for PlatformTag { tag: s.to_string(), }); } + validate_release_arch(rest, s)?; return Ok(Self::Dragonfly { release_arch: SmallString::from(rest), @@ -744,6 +748,7 @@ impl FromStr for PlatformTag { tag: s.to_string(), }); } + validate_release_arch(rest, s)?; return Ok(Self::Haiku { release_arch: SmallString::from(rest), @@ -758,6 +763,7 @@ impl FromStr for PlatformTag { tag: s.to_string(), }); } + validate_release_arch(rest, s)?; return Ok(Self::Illumos { release_arch: SmallString::from(rest), @@ -775,6 +781,8 @@ impl FromStr for PlatformTag { if let Some(release_arch) = rest.strip_suffix("_64bit") { if !release_arch.is_empty() { + validate_release_arch(release_arch, s)?; + return Ok(Self::Solaris { release_arch: SmallString::from(release_arch), }); @@ -872,6 +880,20 @@ impl FromStr for PlatformTag { } } +fn validate_release_arch(release_arch: &str, tag: &str) -> Result<(), ParsePlatformTagError> { + if !release_arch.bytes().all(is_platform_tag_byte) { + return Err(ParsePlatformTagError::InvalidCharacters { + tag: tag.to_string(), + }); + } + + Ok(()) +} + +fn is_platform_tag_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || byte == b'_' +} + #[derive(Debug, thiserror::Error, PartialEq, Eq)] pub enum ParsePlatformTagError { #[error("Unknown platform tag format: {0}")] @@ -886,6 +908,8 @@ pub enum ParsePlatformTagError { InvalidArch { platform: &'static str, tag: String }, #[error("Invalid API level in {platform} platform tag: {tag}")] InvalidApiLevel { platform: &'static str, tag: String }, + #[error("Platform tag must contain only ASCII letters, digits, and underscores: {tag}")] + InvalidCharacters { tag: String }, } #[cfg(test)] @@ -1154,6 +1178,46 @@ mod tests { ); } + #[test] + fn invalid_characters_platform() { + assert_eq!( + PlatformTag::from_str("freebsd_13_x86/64"), + Err(ParsePlatformTagError::InvalidCharacters { + tag: "freebsd_13_x86/64".to_string() + }) + ); + assert_eq!( + PlatformTag::from_str(r"netbsd_9_x86\64"), + Err(ParsePlatformTagError::InvalidCharacters { + tag: r"netbsd_9_x86\64".to_string() + }) + ); + assert_eq!( + PlatformTag::from_str("solaris_11_4_x86:64_64bit"), + Err(ParsePlatformTagError::InvalidCharacters { + tag: "solaris_11_4_x86:64_64bit".to_string() + }) + ); + assert_eq!( + PlatformTag::from_str("freebsd_13.14_x86_64"), + Err(ParsePlatformTagError::InvalidCharacters { + tag: "freebsd_13.14_x86_64".to_string() + }) + ); + assert_eq!( + PlatformTag::from_str("freebsd_13 x86_64"), + Err(ParsePlatformTagError::InvalidCharacters { + tag: "freebsd_13 x86_64".to_string() + }) + ); + assert_eq!( + PlatformTag::from_str("freebsd_13_x86\u{e9}"), + Err(ParsePlatformTagError::InvalidCharacters { + tag: "freebsd_13_x86\u{e9}".to_string() + }) + ); + } + #[test] fn pyodide_platform() { let tag = PlatformTag::Pyodide { diff --git a/crates/uv/tests/it/pip_sync.rs b/crates/uv/tests/it/pip_sync.rs index a5bb0b832fa..e1c39120bd4 100644 --- a/crates/uv/tests/it/pip_sync.rs +++ b/crates/uv/tests/it/pip_sync.rs @@ -1308,6 +1308,54 @@ fn install_local_wheel() -> Result<()> { Ok(()) } +/// Reject decoded path separators in an unnamed wheel URL before using the filename in cache paths. +#[test] +fn install_unnamed_wheel_url_rejects_path_traversal() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + let requirements_txt = context.temp_dir.child("requirements.txt"); + requirements_txt + .write_str("https://example.com/packages/pkg-1.0-py3-none-..%2F..%2F..%2Ftarget.whl")?; + + uv_snapshot!(context.filters(), context.pip_sync() + .arg("requirements.txt") + .arg("--strict"), @" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: The wheel filename \"pkg-1.0-py3-none-../../../target.whl\" is invalid: Tag components must contain only ASCII letters, digits, underscores, and periods + " + ); + + Ok(()) +} + +/// Reject decoded stream separators in an unnamed wheel URL before using the filename in cache paths. +#[test] +fn install_unnamed_wheel_url_rejects_stream_separator() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + let requirements_txt = context.temp_dir.child("requirements.txt"); + requirements_txt + .write_str("https://example.com/packages/pkg-1.0-py3-none-target%3Astream.whl")?; + + uv_snapshot!(context.filters(), context.pip_sync() + .arg("requirements.txt") + .arg("--strict"), @" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: The wheel filename \"pkg-1.0-py3-none-target:stream.whl\" is invalid: Tag components must contain only ASCII letters, digits, underscores, and periods + " + ); + + Ok(()) +} + /// Install a wheel whose actual version doesn't match the version encoded in the filename. #[test] fn mismatched_version() -> Result<()> { From e57d519e8a9c0b65852897e8a5658d43f5073376 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Mon, 11 May 2026 18:44:14 -0400 Subject: [PATCH 2/4] Use a new type --- crates/uv-platform-tags/src/lib.rs | 2 +- crates/uv-platform-tags/src/platform.rs | 8 ++ crates/uv-platform-tags/src/platform_tag.rs | 145 ++++++++++++-------- crates/uv-platform-tags/src/tags.rs | 71 ++++++++-- 4 files changed, 158 insertions(+), 68 deletions(-) diff --git a/crates/uv-platform-tags/src/lib.rs b/crates/uv-platform-tags/src/lib.rs index 5350310b9b7..a93a34983de 100644 --- a/crates/uv-platform-tags/src/lib.rs +++ b/crates/uv-platform-tags/src/lib.rs @@ -1,7 +1,7 @@ pub use abi_tag::{AbiTag, CPythonAbiVariants, ParseAbiTagError}; pub use language_tag::{LanguageTag, ParseLanguageTagError}; pub use platform::{Arch, Os, Platform, PlatformError}; -pub use platform_tag::{ParsePlatformTagError, PlatformTag}; +pub use platform_tag::{ParsePlatformTagError, ParseReleaseArchError, PlatformTag, ReleaseArch}; pub use tags::{ BinaryFormat, IncompatibleTag, TagCompatibility, TagPriority, Tags, TagsError, TagsOptions, }; diff --git a/crates/uv-platform-tags/src/platform.rs b/crates/uv-platform-tags/src/platform.rs index d6eeb4c796a..bfea8c86046 100644 --- a/crates/uv-platform-tags/src/platform.rs +++ b/crates/uv-platform-tags/src/platform.rs @@ -5,12 +5,20 @@ use std::{fmt, io}; use thiserror::Error; +use crate::ParseReleaseArchError; + #[derive(Error, Debug)] pub enum PlatformError { #[error(transparent)] IOError(#[from] io::Error), #[error("Failed to detect the operating system version: {0}")] OsVersionDetectionError(String), + #[error("Invalid platform release and architecture `{release_arch}`: {error}")] + InvalidReleaseArch { + release_arch: String, + #[source] + error: ParseReleaseArchError, + }, #[error("Invalid Android architecture: {0}")] InvalidAndroidArch(Arch), #[error("Invalid iOS simulator architecture: {0}")] diff --git a/crates/uv-platform-tags/src/platform_tag.rs b/crates/uv-platform-tags/src/platform_tag.rs index 384afd3d89d..74e5540868f 100644 --- a/crates/uv-platform-tags/src/platform_tag.rs +++ b/crates/uv-platform-tags/src/platform_tag.rs @@ -7,6 +7,41 @@ use crate::tags::AndroidAbi; use crate::tags::IosMultiarch; use crate::{Arch, BinaryFormat}; +/// Opaque release-and-architecture suffix used by [`PlatformTag`] variants that preserve a native +/// platform-specific suffix. +#[derive( + Debug, + Clone, + Eq, + PartialEq, + Ord, + PartialOrd, + Hash, + rkyv::Archive, + rkyv::Deserialize, + rkyv::Serialize, +)] +#[rkyv(derive(Debug))] +pub struct ReleaseArch(SmallString); + +impl FromStr for ReleaseArch { + type Err = ParseReleaseArchError; + + fn from_str(s: &str) -> Result { + if s.bytes().all(is_platform_tag_byte) { + Ok(Self(SmallString::from(s))) + } else { + Err(ParseReleaseArchError) + } + } +} + +impl std::fmt::Display for ReleaseArch { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + /// A tag to represent the platform compatibility of a Python distribution. /// /// This is the third segment in the wheel filename, following the language and ABI tags. For @@ -60,19 +95,19 @@ pub enum PlatformTag { /// Ex) `android_21_x86_64` Android { api_level: u16, abi: AndroidAbi }, /// Ex) `freebsd_12_x86_64` - FreeBsd { release_arch: SmallString }, + FreeBsd { release_arch: ReleaseArch }, /// Ex) `netbsd_9_x86_64` - NetBsd { release_arch: SmallString }, + NetBsd { release_arch: ReleaseArch }, /// Ex) `openbsd_6_x86_64` - OpenBsd { release_arch: SmallString }, + OpenBsd { release_arch: ReleaseArch }, /// Ex) `dragonfly_6_x86_64` - Dragonfly { release_arch: SmallString }, + Dragonfly { release_arch: ReleaseArch }, /// Ex) `haiku_1_x86_64` - Haiku { release_arch: SmallString }, + Haiku { release_arch: ReleaseArch }, /// Ex) `illumos_5_11_x86_64` - Illumos { release_arch: SmallString }, + Illumos { release_arch: ReleaseArch }, /// Ex) `solaris_11_4_x86_64` - Solaris { release_arch: SmallString }, + Solaris { release_arch: ReleaseArch }, /// Ex) `pyodide_2024_0_wasm32` Pyodide { major: u16, minor: u16 }, /// Ex) `ios_13_0_arm64_iphoneos` / `ios_13_0_arm64_iphonesimulator` @@ -688,10 +723,8 @@ impl FromStr for PlatformTag { tag: s.to_string(), }); } - validate_release_arch(rest, s)?; - return Ok(Self::FreeBsd { - release_arch: SmallString::from(rest), + release_arch: parse_release_arch(rest, s)?, }); } @@ -703,10 +736,8 @@ impl FromStr for PlatformTag { tag: s.to_string(), }); } - validate_release_arch(rest, s)?; - return Ok(Self::NetBsd { - release_arch: SmallString::from(rest), + release_arch: parse_release_arch(rest, s)?, }); } @@ -718,10 +749,8 @@ impl FromStr for PlatformTag { tag: s.to_string(), }); } - validate_release_arch(rest, s)?; - return Ok(Self::OpenBsd { - release_arch: SmallString::from(rest), + release_arch: parse_release_arch(rest, s)?, }); } @@ -733,10 +762,8 @@ impl FromStr for PlatformTag { tag: s.to_string(), }); } - validate_release_arch(rest, s)?; - return Ok(Self::Dragonfly { - release_arch: SmallString::from(rest), + release_arch: parse_release_arch(rest, s)?, }); } @@ -748,10 +775,8 @@ impl FromStr for PlatformTag { tag: s.to_string(), }); } - validate_release_arch(rest, s)?; - return Ok(Self::Haiku { - release_arch: SmallString::from(rest), + release_arch: parse_release_arch(rest, s)?, }); } @@ -763,10 +788,8 @@ impl FromStr for PlatformTag { tag: s.to_string(), }); } - validate_release_arch(rest, s)?; - return Ok(Self::Illumos { - release_arch: SmallString::from(rest), + release_arch: parse_release_arch(rest, s)?, }); } @@ -781,10 +804,8 @@ impl FromStr for PlatformTag { if let Some(release_arch) = rest.strip_suffix("_64bit") { if !release_arch.is_empty() { - validate_release_arch(release_arch, s)?; - return Ok(Self::Solaris { - release_arch: SmallString::from(release_arch), + release_arch: parse_release_arch(release_arch, s)?, }); } } @@ -880,20 +901,22 @@ impl FromStr for PlatformTag { } } -fn validate_release_arch(release_arch: &str, tag: &str) -> Result<(), ParsePlatformTagError> { - if !release_arch.bytes().all(is_platform_tag_byte) { - return Err(ParsePlatformTagError::InvalidCharacters { - tag: tag.to_string(), - }); - } - - Ok(()) -} - fn is_platform_tag_byte(byte: u8) -> bool { byte.is_ascii_alphanumeric() || byte == b'_' } +fn parse_release_arch(release_arch: &str, tag: &str) -> Result { + release_arch + .parse() + .map_err(|_| ParsePlatformTagError::InvalidCharacters { + tag: tag.to_string(), + }) +} + +#[derive(Debug, Clone, Copy, thiserror::Error, PartialEq, Eq)] +#[error("release and architecture must contain only ASCII letters, digits, and underscores")] +pub struct ParseReleaseArchError; + #[derive(Debug, thiserror::Error, PartialEq, Eq)] pub enum ParsePlatformTagError { #[error("Unknown platform tag format: {0}")] @@ -916,7 +939,9 @@ pub enum ParsePlatformTagError { mod tests { use std::str::FromStr; - use crate::platform_tag::{ParsePlatformTagError, PlatformTag}; + use crate::platform_tag::{ + ParsePlatformTagError, ParseReleaseArchError, PlatformTag, ReleaseArch, + }; use crate::tags::AndroidAbi; use crate::tags::IosMultiarch; use crate::{Arch, BinaryFormat}; @@ -1134,40 +1159,46 @@ mod tests { assert_eq!(PlatformTag::WinArm64.to_string(), "win_arm64"); } + #[test] + fn release_arch() { + assert_eq!( + ReleaseArch::from_str("13_14_x86_64").map(|release_arch| release_arch.to_string()), + Ok("13_14_x86_64".to_string()) + ); + assert_eq!( + ReleaseArch::from_str("13_x86/64"), + Err(ParseReleaseArchError) + ); + } + #[test] fn freebsd_platform() { - let tag = PlatformTag::FreeBsd { - release_arch: "13_14_x86_64".into(), - }; assert_eq!( - PlatformTag::from_str("freebsd_13_14_x86_64").as_ref(), - Ok(&tag) + PlatformTag::from_str("freebsd_13_14_x86_64") + .as_ref() + .map(ToString::to_string), + Ok("freebsd_13_14_x86_64".to_string()) ); - assert_eq!(tag.to_string(), "freebsd_13_14_x86_64"); } #[test] fn illumos_platform() { - let tag = PlatformTag::Illumos { - release_arch: "5_11_x86_64".into(), - }; assert_eq!( - PlatformTag::from_str("illumos_5_11_x86_64").as_ref(), - Ok(&tag) + PlatformTag::from_str("illumos_5_11_x86_64") + .as_ref() + .map(ToString::to_string), + Ok("illumos_5_11_x86_64".to_string()) ); - assert_eq!(tag.to_string(), "illumos_5_11_x86_64"); } #[test] fn solaris_platform() { - let tag = PlatformTag::Solaris { - release_arch: "11_4_x86_64".into(), - }; assert_eq!( - PlatformTag::from_str("solaris_11_4_x86_64_64bit").as_ref(), - Ok(&tag) + PlatformTag::from_str("solaris_11_4_x86_64_64bit") + .as_ref() + .map(ToString::to_string), + Ok("solaris_11_4_x86_64_64bit".to_string()) ); - assert_eq!(tag.to_string(), "solaris_11_4_x86_64_64bit"); assert_eq!( PlatformTag::from_str("solaris_11_4_x86_64"), diff --git a/crates/uv-platform-tags/src/tags.rs b/crates/uv-platform-tags/src/tags.rs index 532d5aaedbc..38e7f130d65 100644 --- a/crates/uv-platform-tags/src/tags.rs +++ b/crates/uv-platform-tags/src/tags.rs @@ -6,10 +6,8 @@ use std::{cmp, num::NonZeroU32}; use rustc_hash::FxHashMap; -use uv_small_str::SmallString; - use crate::abi_tag::CPythonAbiVariants; -use crate::{AbiTag, Arch, LanguageTag, Os, Platform, PlatformError, PlatformTag}; +use crate::{AbiTag, Arch, LanguageTag, Os, Platform, PlatformError, PlatformTag, ReleaseArch}; #[derive(Debug, thiserror::Error)] pub enum TagsError { @@ -680,7 +678,12 @@ fn compatible_tags(platform: &Platform) -> Result, PlatformErro let arch_tag = arch.machine(); let release_arch = format!("{release_tag}_{arch_tag}"); vec![PlatformTag::FreeBsd { - release_arch: SmallString::from(release_arch), + release_arch: release_arch.parse::().map_err(|error| { + PlatformError::InvalidReleaseArch { + release_arch, + error, + } + })?, }] } (Os::NetBsd { release }, arch) => { @@ -688,7 +691,12 @@ fn compatible_tags(platform: &Platform) -> Result, PlatformErro let arch_tag = arch.machine(); let release_arch = format!("{release_tag}_{arch_tag}"); vec![PlatformTag::NetBsd { - release_arch: SmallString::from(release_arch), + release_arch: release_arch.parse::().map_err(|error| { + PlatformError::InvalidReleaseArch { + release_arch, + error, + } + })?, }] } (Os::OpenBsd { release }, arch) => { @@ -696,21 +704,36 @@ fn compatible_tags(platform: &Platform) -> Result, PlatformErro let arch_tag = arch.machine(); let release_arch = format!("{release_tag}_{arch_tag}"); vec![PlatformTag::OpenBsd { - release_arch: SmallString::from(release_arch), + release_arch: release_arch.parse::().map_err(|error| { + PlatformError::InvalidReleaseArch { + release_arch, + error, + } + })?, }] } (Os::Dragonfly { release }, arch) => { let release = release.replace(['.', '-'], "_"); let release_arch = format!("{release}_{arch}"); vec![PlatformTag::Dragonfly { - release_arch: SmallString::from(release_arch), + release_arch: release_arch.parse::().map_err(|error| { + PlatformError::InvalidReleaseArch { + release_arch, + error, + } + })?, }] } (Os::Haiku { release }, arch) => { let release = release.replace(['.', '-'], "_"); let release_arch = format!("{release}_{arch}"); vec![PlatformTag::Haiku { - release_arch: SmallString::from(release_arch), + release_arch: release_arch.parse::().map_err(|error| { + PlatformError::InvalidReleaseArch { + release_arch, + error, + } + })?, }] } (Os::Illumos { release, arch }, _) => { @@ -727,14 +750,24 @@ fn compatible_tags(platform: &Platform) -> Result, PlatformErro let arch = format!("{arch}_64bit"); let release_arch = format!("{release}_{arch}"); return Ok(vec![PlatformTag::Solaris { - release_arch: SmallString::from(release_arch), + release_arch: release_arch.parse::().map_err(|error| { + PlatformError::InvalidReleaseArch { + release_arch, + error, + } + })?, }]); } } let release_arch = format!("{release}_{arch}"); vec![PlatformTag::Illumos { - release_arch: SmallString::from(release_arch), + release_arch: release_arch.parse::().map_err(|error| { + PlatformError::InvalidReleaseArch { + release_arch, + error, + } + })?, }] } (Os::Android { api_level }, arch) => { @@ -1454,6 +1487,24 @@ mod tests { ); } + #[test] + fn test_platform_tags_invalid_release_arch() { + let error = compatible_tags(&Platform::new( + Os::FreeBsd { + release: "13/14".to_string(), + }, + Arch::X86_64, + )) + .unwrap_err(); + + assert_debug_snapshot!(error, @r#" + InvalidReleaseArch { + release_arch: "13/14_amd64", + error: ParseReleaseArchError, + } + "#); + } + #[test] fn test_platform_tags_android() { let tags = From af53eff703cfcbaedf3d31e813ee7428021526e6 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Mon, 11 May 2026 18:48:19 -0400 Subject: [PATCH 3/4] Allow periods in wheel build tags Keep editable wheel names like 0.editable valid while preserving the stricter rejection of unsafe build-tag characters. Co-authored-by: Codex --- .../uv-distribution-filename/src/build_tag.rs | 29 ++++++++++++------- crates/uv-distribution-filename/src/wheel.rs | 2 +- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/crates/uv-distribution-filename/src/build_tag.rs b/crates/uv-distribution-filename/src/build_tag.rs index 93676299dd4..690142c2321 100644 --- a/crates/uv-distribution-filename/src/build_tag.rs +++ b/crates/uv-distribution-filename/src/build_tag.rs @@ -9,7 +9,7 @@ pub enum BuildTagError { Empty, #[error("must start with a digit")] NoLeadingDigit, - #[error("must contain only ASCII letters, digits, and underscores")] + #[error("must contain only ASCII letters, digits, underscores, and periods")] InvalidCharacters, #[error(transparent)] ParseInt(#[from] ParseIntError), @@ -84,7 +84,7 @@ impl std::fmt::Display for BuildTag { } fn is_build_tag_byte(byte: u8) -> bool { - byte.is_ascii_alphanumeric() || byte == b'_' + byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.') } #[cfg(test)] @@ -93,27 +93,34 @@ mod tests { use super::BuildTag; + #[test] + fn parse_periods() { + assert_eq!( + BuildTag::from_str("0.editable") + .map(|build_tag| build_tag.to_string()) + .map_err(|err| err.to_string()), + Ok("0.editable".to_string()) + ); + } + #[test] fn err_invalid_characters() { let err = BuildTag::from_str("1/../../target").unwrap_err(); - insta::assert_snapshot!(err, @"must contain only ASCII letters, digits, and underscores"); + insta::assert_snapshot!(err, @"must contain only ASCII letters, digits, underscores, and periods"); let err = BuildTag::from_str(r"1..\..\target").unwrap_err(); - insta::assert_snapshot!(err, @"must contain only ASCII letters, digits, and underscores"); + insta::assert_snapshot!(err, @"must contain only ASCII letters, digits, underscores, and periods"); let err = BuildTag::from_str("1target:stream").unwrap_err(); - insta::assert_snapshot!(err, @"must contain only ASCII letters, digits, and underscores"); + insta::assert_snapshot!(err, @"must contain only ASCII letters, digits, underscores, and periods"); let err = BuildTag::from_str("1-target").unwrap_err(); - insta::assert_snapshot!(err, @"must contain only ASCII letters, digits, and underscores"); - - let err = BuildTag::from_str("1.target").unwrap_err(); - insta::assert_snapshot!(err, @"must contain only ASCII letters, digits, and underscores"); + insta::assert_snapshot!(err, @"must contain only ASCII letters, digits, underscores, and periods"); let err = BuildTag::from_str("1 target").unwrap_err(); - insta::assert_snapshot!(err, @"must contain only ASCII letters, digits, and underscores"); + insta::assert_snapshot!(err, @"must contain only ASCII letters, digits, underscores, and periods"); let err = BuildTag::from_str("1target\u{e9}").unwrap_err(); - insta::assert_snapshot!(err, @"must contain only ASCII letters, digits, and underscores"); + insta::assert_snapshot!(err, @"must contain only ASCII letters, digits, underscores, and periods"); } } diff --git a/crates/uv-distribution-filename/src/wheel.rs b/crates/uv-distribution-filename/src/wheel.rs index 5095745a3db..3c5afb2ed56 100644 --- a/crates/uv-distribution-filename/src/wheel.rs +++ b/crates/uv-distribution-filename/src/wheel.rs @@ -434,7 +434,7 @@ mod tests { insta::assert_snapshot!(err, @r#"The wheel filename "foo-1.2.3-tag-py3-none-any.whl" has an invalid build tag: must start with a digit"#); let err = WheelFilename::from_str("foo-1.2.3-1/../../target-py3-none-any.whl").unwrap_err(); - insta::assert_snapshot!(err, @r#"The wheel filename "foo-1.2.3-1/../../target-py3-none-any.whl" has an invalid build tag: must contain only ASCII letters, digits, and underscores"#); + insta::assert_snapshot!(err, @r#"The wheel filename "foo-1.2.3-1/../../target-py3-none-any.whl" has an invalid build tag: must contain only ASCII letters, digits, underscores, and periods"#); } #[test] From 0243fea28d79b833b1485eed2aaac9fd3ec740a2 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Tue, 12 May 2026 14:11:33 -0400 Subject: [PATCH 4/4] Simplify release architecture parsing --- crates/uv-platform-tags/src/platform_tag.rs | 44 +++++++++++---------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/crates/uv-platform-tags/src/platform_tag.rs b/crates/uv-platform-tags/src/platform_tag.rs index 74e5540868f..42070211803 100644 --- a/crates/uv-platform-tags/src/platform_tag.rs +++ b/crates/uv-platform-tags/src/platform_tag.rs @@ -28,7 +28,9 @@ impl FromStr for ReleaseArch { type Err = ParseReleaseArchError; fn from_str(s: &str) -> Result { - if s.bytes().all(is_platform_tag_byte) { + if s.bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') + { Ok(Self(SmallString::from(s))) } else { Err(ParseReleaseArchError) @@ -724,7 +726,9 @@ impl FromStr for PlatformTag { }); } return Ok(Self::FreeBsd { - release_arch: parse_release_arch(rest, s)?, + release_arch: rest + .parse::() + .map_err(|_| ParsePlatformTagError::InvalidCharacters { tag: s.to_string() })?, }); } @@ -737,7 +741,9 @@ impl FromStr for PlatformTag { }); } return Ok(Self::NetBsd { - release_arch: parse_release_arch(rest, s)?, + release_arch: rest + .parse::() + .map_err(|_| ParsePlatformTagError::InvalidCharacters { tag: s.to_string() })?, }); } @@ -750,7 +756,9 @@ impl FromStr for PlatformTag { }); } return Ok(Self::OpenBsd { - release_arch: parse_release_arch(rest, s)?, + release_arch: rest + .parse::() + .map_err(|_| ParsePlatformTagError::InvalidCharacters { tag: s.to_string() })?, }); } @@ -763,7 +771,9 @@ impl FromStr for PlatformTag { }); } return Ok(Self::Dragonfly { - release_arch: parse_release_arch(rest, s)?, + release_arch: rest + .parse::() + .map_err(|_| ParsePlatformTagError::InvalidCharacters { tag: s.to_string() })?, }); } @@ -776,7 +786,9 @@ impl FromStr for PlatformTag { }); } return Ok(Self::Haiku { - release_arch: parse_release_arch(rest, s)?, + release_arch: rest + .parse::() + .map_err(|_| ParsePlatformTagError::InvalidCharacters { tag: s.to_string() })?, }); } @@ -789,7 +801,9 @@ impl FromStr for PlatformTag { }); } return Ok(Self::Illumos { - release_arch: parse_release_arch(rest, s)?, + release_arch: rest + .parse::() + .map_err(|_| ParsePlatformTagError::InvalidCharacters { tag: s.to_string() })?, }); } @@ -805,7 +819,9 @@ impl FromStr for PlatformTag { if let Some(release_arch) = rest.strip_suffix("_64bit") { if !release_arch.is_empty() { return Ok(Self::Solaris { - release_arch: parse_release_arch(release_arch, s)?, + release_arch: release_arch.parse::().map_err(|_| { + ParsePlatformTagError::InvalidCharacters { tag: s.to_string() } + })?, }); } } @@ -901,18 +917,6 @@ impl FromStr for PlatformTag { } } -fn is_platform_tag_byte(byte: u8) -> bool { - byte.is_ascii_alphanumeric() || byte == b'_' -} - -fn parse_release_arch(release_arch: &str, tag: &str) -> Result { - release_arch - .parse() - .map_err(|_| ParsePlatformTagError::InvalidCharacters { - tag: tag.to_string(), - }) -} - #[derive(Debug, Clone, Copy, thiserror::Error, PartialEq, Eq)] #[error("release and architecture must contain only ASCII letters, digits, and underscores")] pub struct ParseReleaseArchError;