diff --git a/crates/uv-distribution-filename/src/build_tag.rs b/crates/uv-distribution-filename/src/build_tag.rs index 30c048f5b83..690142c2321 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, underscores, and periods")] + 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,45 @@ impl std::fmt::Display for BuildTag { } } } + +fn is_build_tag_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.') +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + 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, underscores, and periods"); + + let err = BuildTag::from_str(r"1..\..\target").unwrap_err(); + 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, underscores, and periods"); + + let err = BuildTag::from_str("1-target").unwrap_err(); + 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, underscores, and periods"); + + let err = BuildTag::from_str("1target\u{e9}").unwrap_err(); + 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 f3be4a75370..3c5afb2ed56 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, underscores, and periods"#); + } + + #[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/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 34bb7f83af2..42070211803 100644 --- a/crates/uv-platform-tags/src/platform_tag.rs +++ b/crates/uv-platform-tags/src/platform_tag.rs @@ -7,6 +7,43 @@ 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(|byte| byte.is_ascii_alphanumeric() || byte == b'_') + { + 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 +97,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,9 +725,10 @@ impl FromStr for PlatformTag { tag: s.to_string(), }); } - return Ok(Self::FreeBsd { - release_arch: SmallString::from(rest), + release_arch: rest + .parse::() + .map_err(|_| ParsePlatformTagError::InvalidCharacters { tag: s.to_string() })?, }); } @@ -702,9 +740,10 @@ impl FromStr for PlatformTag { tag: s.to_string(), }); } - return Ok(Self::NetBsd { - release_arch: SmallString::from(rest), + release_arch: rest + .parse::() + .map_err(|_| ParsePlatformTagError::InvalidCharacters { tag: s.to_string() })?, }); } @@ -716,9 +755,10 @@ impl FromStr for PlatformTag { tag: s.to_string(), }); } - return Ok(Self::OpenBsd { - release_arch: SmallString::from(rest), + release_arch: rest + .parse::() + .map_err(|_| ParsePlatformTagError::InvalidCharacters { tag: s.to_string() })?, }); } @@ -730,9 +770,10 @@ impl FromStr for PlatformTag { tag: s.to_string(), }); } - return Ok(Self::Dragonfly { - release_arch: SmallString::from(rest), + release_arch: rest + .parse::() + .map_err(|_| ParsePlatformTagError::InvalidCharacters { tag: s.to_string() })?, }); } @@ -744,9 +785,10 @@ impl FromStr for PlatformTag { tag: s.to_string(), }); } - return Ok(Self::Haiku { - release_arch: SmallString::from(rest), + release_arch: rest + .parse::() + .map_err(|_| ParsePlatformTagError::InvalidCharacters { tag: s.to_string() })?, }); } @@ -758,9 +800,10 @@ impl FromStr for PlatformTag { tag: s.to_string(), }); } - return Ok(Self::Illumos { - release_arch: SmallString::from(rest), + release_arch: rest + .parse::() + .map_err(|_| ParsePlatformTagError::InvalidCharacters { tag: s.to_string() })?, }); } @@ -776,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: SmallString::from(release_arch), + release_arch: release_arch.parse::().map_err(|_| { + ParsePlatformTagError::InvalidCharacters { tag: s.to_string() } + })?, }); } } @@ -872,6 +917,10 @@ impl FromStr for PlatformTag { } } +#[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}")] @@ -886,13 +935,17 @@ 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)] 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}; @@ -1110,40 +1163,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"), @@ -1154,6 +1213,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-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 = 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<()> {