diff --git a/Cargo.lock b/Cargo.lock index 3573b01b00458..9fdd9f7ec6c10 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -276,15 +276,15 @@ dependencies = [ [[package]] name = "astral-tokio-tar" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb50a7aae84a03bf55b067832bc376f4961b790c97e64d3eacee97d389b90277" +checksum = "08648fef353ab39a9d26f909ad53fc4f071be4c91853b78523f5cc3d9e5ebffd" dependencies = [ - "filetime", "futures-core", "libc", "portable-atomic", "rustc-hash", + "rustix 0.38.44", "tokio", "tokio-stream", "xattr", @@ -1464,7 +1464,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2334,7 +2334,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2576,6 +2576,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -3444,7 +3450,7 @@ dependencies = [ "bitflags", "flate2", "procfs-core", - "rustix", + "rustix 1.1.4", ] [[package]] @@ -3736,7 +3742,7 @@ checksum = "13362233b147e57674c37b802d216b7c5e3dcccbed8967c84f0d8d223868ae27" dependencies = [ "cfg-if", "libc", - "rustix", + "rustix 1.1.4", "windows", ] @@ -4128,6 +4134,19 @@ dependencies = [ "nom", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.52.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -4137,8 +4156,8 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", - "windows-sys 0.52.0", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", ] [[package]] @@ -4195,7 +4214,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4400,7 +4419,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4916,10 +4935,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.3", + "getrandom 0.4.1", "once_cell", - "rustix", - "windows-sys 0.52.0", + "rustix 1.1.4", + "windows-sys 0.61.2", ] [[package]] @@ -4928,7 +4947,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -6518,7 +6537,7 @@ dependencies = [ "percent-encoding", "reflink-copy", "rustc-hash", - "rustix", + "rustix 1.1.4", "same-file", "schemars", "self-replace", @@ -6823,7 +6842,7 @@ dependencies = [ "insta", "procfs", "regex", - "rustix", + "rustix 1.1.4", "target-lexicon", "tempfile", "thiserror", @@ -7754,7 +7773,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8127,7 +8146,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix", + "rustix 1.1.4", ] [[package]] @@ -8206,7 +8225,7 @@ dependencies = [ "hex", "libc", "ordered-stream", - "rustix", + "rustix 1.1.4", "serde", "serde_repr", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 98ce7e6a9d21d..9a6514f5511ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -99,7 +99,7 @@ anstream = { version = "1.0.0" } anyhow = { version = "1.0.89" } arcstr = { version = "1.2.0" } arrayvec = { version = "0.7.6" } -astral-tokio-tar = { version = "0.6.2" } +astral-tokio-tar = { version = "0.6.3" } async-channel = { version = "2.3.1" } async-compression = { version = "0.4.12", features = [ "bzip2", diff --git a/crates/uv-client/src/registry_client.rs b/crates/uv-client/src/registry_client.rs index ff039acaa3bc4..467bbcfa59278 100644 --- a/crates/uv-client/src/registry_client.rs +++ b/crates/uv-client/src/registry_client.rs @@ -1491,11 +1491,17 @@ impl SimpleDetailMetadata { // Group the distributions by version and kind for file in files { - let Some(filename) = DistFilename::try_from_filename(&file.filename, package_name) - else { - debug!("Skipping file for {package_name}: {}", file.filename); - continue; - }; + let filename = + match DistFilename::try_from_filename_with_reason(&file.filename, package_name) { + Ok(filename) => filename, + Err(err) => { + debug!( + "Skipping file for {package_name}: {:?} ({err})", + file.filename + ); + continue; + } + }; let file = match File::try_from_pypi(file, &base) { Ok(file) => file, Err(err) => { @@ -1551,11 +1557,17 @@ impl SimpleDetailMetadata { continue; } }; - let Some(filename) = DistFilename::try_from_filename(&file.filename, package_name) - else { - debug!("Skipping file for {package_name}: {}", file.filename); - continue; - }; + let filename = + match DistFilename::try_from_filename_with_reason(&file.filename, package_name) { + Ok(filename) => filename, + Err(err) => { + debug!( + "Skipping file for {package_name}: {:?} ({err})", + file.filename + ); + continue; + } + }; match version_map.entry(filename.version().clone()) { std::collections::btree_map::Entry::Occupied(mut entry) => { entry.get_mut().push(filename, file); diff --git a/crates/uv-distribution-filename/src/lib.rs b/crates/uv-distribution-filename/src/lib.rs index 41220d34c4fc8..9bfe955de8939 100644 --- a/crates/uv-distribution-filename/src/lib.rs +++ b/crates/uv-distribution-filename/src/lib.rs @@ -1,6 +1,7 @@ use std::fmt::{Display, Formatter}; use std::str::FromStr; +use thiserror::Error; use uv_normalize::PackageName; use uv_pep440::Version; @@ -29,20 +30,32 @@ pub enum DistFilename { impl DistFilename { /// Parse a filename as wheel or source dist name. pub fn try_from_filename(filename: &str, package_name: &PackageName) -> Option { + Self::try_from_filename_with_reason(filename, package_name).ok() + } + + /// Parse a filename as wheel or source dist name, returning the reason the filename was + /// rejected when parsing fails. + /// + /// This is useful for surfacing actionable diagnostics when a registry returns entries that + /// do not look like distribution files (for example, devpi index entries like `+searchhelp`, + /// bare version directory links, or files with unrecognized extensions). + pub fn try_from_filename_with_reason( + filename: &str, + package_name: &PackageName, + ) -> Result { match DistExtension::from_path(filename) { - Ok(DistExtension::Wheel) => { - if let Ok(filename) = WheelFilename::from_str(filename) { - return Some(Self::WheelFilename(filename)); - } - } + Ok(DistExtension::Wheel) => match WheelFilename::from_str(filename) { + Ok(filename) => Ok(Self::WheelFilename(filename)), + Err(err) => Err(DistFilenameError::InvalidWheel(err)), + }, Ok(DistExtension::Source(extension)) => { - if let Ok(filename) = SourceDistFilename::parse(filename, extension, package_name) { - return Some(Self::SourceDistFilename(filename)); + match SourceDistFilename::parse(filename, extension, package_name) { + Ok(filename) => Ok(Self::SourceDistFilename(filename)), + Err(err) => Err(DistFilenameError::InvalidSourceDist(err)), } } - Err(_) => {} + Err(err) => Err(DistFilenameError::NoRecognizedExtension(err)), } - None } /// Like [`DistFilename::try_from_normalized_filename`], but without knowing the package name. @@ -98,12 +111,83 @@ impl Display for DistFilename { } } +/// The reason a registry entry could not be parsed as a wheel or source distribution filename. +#[derive(Error, Debug)] +pub enum DistFilenameError { + /// The filename does not have a recognized wheel or source distribution extension. + /// + /// This typically indicates the registry returned a non-distribution entry, such as a + /// directory listing link (e.g., a bare version like `0.1.0`) or an index management endpoint + /// (e.g., devpi's `+searchhelp`, `+status`). + #[error("not a wheel or source distribution archive (expected one of {0})")] + NoRecognizedExtension(#[source] ExtensionError), + /// The filename has a `.whl` extension but is otherwise an invalid wheel filename. + #[error(transparent)] + InvalidWheel(#[from] WheelFilenameError), + /// The filename has a source distribution extension but is otherwise invalid. + #[error(transparent)] + InvalidSourceDist(#[from] SourceDistFilenameError), +} + #[cfg(test)] mod tests { - use crate::WheelFilename; + use std::str::FromStr; + + use uv_normalize::PackageName; + + use crate::{DistFilename, DistFilenameError, WheelFilename}; #[test] fn wheel_filename_size() { assert_eq!(size_of::(), 48); } + + #[test] + fn try_from_filename_with_reason_no_extension() { + // A bare version string (the kind of entry devpi serves for its directory listings) + // is rejected because it has no recognized distribution extension. + let name = PackageName::from_str("my-package").unwrap(); + let err = DistFilename::try_from_filename_with_reason("0.1.0", &name).unwrap_err(); + assert!( + matches!(err, DistFilenameError::NoRecognizedExtension(_)), + "unexpected error variant: {err:?}" + ); + let rendered = err.to_string(); + assert!( + rendered.contains("not a wheel or source distribution archive"), + "unexpected error message: {rendered}" + ); + } + + #[test] + fn try_from_filename_with_reason_empty_filename() { + // An empty filename (which is what devpi reports for its top-level index entries) is + // similarly rejected with the no-extension reason rather than silently swallowing it. + let name = PackageName::from_str("my-package").unwrap(); + let err = DistFilename::try_from_filename_with_reason("", &name).unwrap_err(); + assert!( + matches!(err, DistFilenameError::NoRecognizedExtension(_)), + "unexpected error variant: {err:?}" + ); + } + + #[test] + fn try_from_filename_with_reason_invalid_wheel() { + // A file that looks like a wheel by extension but isn't a valid wheel name should bubble + // up the wheel parsing error rather than a generic extension error. + let name = PackageName::from_str("my-package").unwrap(); + let err = + DistFilename::try_from_filename_with_reason("not-a-wheel.whl", &name).unwrap_err(); + assert!( + matches!(err, DistFilenameError::InvalidWheel(_)), + "unexpected error variant: {err:?}" + ); + } + + #[test] + fn try_from_filename_accepts_valid_wheel() { + let name = PackageName::from_str("my-package").unwrap(); + let parsed = DistFilename::try_from_filename("my_package-0.1.0-py3-none-any.whl", &name); + assert!(parsed.is_some(), "expected wheel to parse successfully"); + } }