1use futures::executor::block_on;
7use futures::io::AllowStdIo;
8use std::io;
9use std::path::Path;
10use thiserror::Error;
11use tokio::io::AsyncReadExt;
12use tokio_util::compat::FuturesAsyncReadCompatExt;
13use uv_distribution_filename::WheelFilename;
14use uv_normalize::InvalidNameError;
15use uv_pypi_types::ResolutionMetadata;
16
17pub use dist_info_stem::DistInfoStem;
18
19mod dist_info_stem;
20
21#[derive(Debug, Error)]
23pub enum Error {
24 #[error("Failed to read `dist-info` metadata from built wheel")]
25 DistInfo,
26 #[error("No .dist-info directory found")]
27 MissingDistInfo,
28 #[error("Multiple .dist-info directories found: {0}")]
29 MultipleDistInfo(String),
30 #[error(
31 "The .dist-info directory does not consist of the normalized package name and version: `{0}`"
32 )]
33 MissingDistInfoSegments(String),
34 #[error("The .dist-info directory {0} does not start with the normalized package name: {1}")]
35 MissingDistInfoPackageName(String, String),
36 #[error("The .dist-info directory name contains invalid characters")]
37 InvalidName(#[from] InvalidNameError),
38 #[error("The metadata at {0} is invalid")]
39 InvalidMetadata(String, Box<uv_pypi_types::MetadataError>),
40 #[error("Bad CRC (got {computed:08x}, expected {expected:08x}) for file: {path}")]
41 BadCrc32 {
42 path: String,
43 computed: u32,
44 expected: u32,
45 },
46 #[error("Failed to read from zip file")]
47 AsyncZip(#[source] async_zip::error::ZipError),
48 #[error(
49 "Archive contains a file with an unsupported compression method; files must be compressed with 'stored', 'DEFLATE', or 'zstd'"
50 )]
51 UnsupportedCompression,
52 #[error(transparent)]
54 Io(io::Error),
55}
56
57impl From<async_zip::error::ZipError> for Error {
58 fn from(err: async_zip::error::ZipError) -> Self {
59 match err {
60 async_zip::error::ZipError::CompressionNotSupported(_) => Self::UnsupportedCompression,
61 o => Self::AsyncZip(o),
62 }
63 }
64}
65
66pub fn find_archive_dist_info<'a, T: Copy>(
72 filename: &WheelFilename,
73 files: impl Iterator<Item = (T, &'a str)>,
74) -> Result<(T, DistInfoStem<'a>), Error> {
75 let metadatas: Vec<_> = files
76 .filter_map(|(payload, path)| {
77 let (dist_info_dir, file) = path.split_once('/')?;
78 if file != "METADATA" {
79 return None;
80 }
81 let dist_info_stem = dist_info_dir.strip_suffix(".dist-info")?;
82 Some((payload, dist_info_stem))
83 })
84 .collect();
85
86 let (payload, dist_info_stem) = match metadatas[..] {
88 [] => {
89 return Err(Error::MissingDistInfo);
90 }
91 [(payload, path)] => (payload, path),
92 _ => {
93 return Err(Error::MultipleDistInfo(
94 metadatas
95 .into_iter()
96 .map(|(_, dist_info_dir)| dist_info_dir.to_string())
97 .collect::<Vec<_>>()
98 .join(", "),
99 ));
100 }
101 };
102
103 Ok((payload, DistInfoStem::new(dist_info_stem, &filename.name)?))
104}
105
106fn metadata_entry<'a>(
108 path: &'a str,
109 filename: &WheelFilename,
110) -> Result<Option<DistInfoStem<'a>>, Error> {
111 let Some((dist_info_dir, file)) = path.split_once('/') else {
112 return Ok(None);
113 };
114 if file != "METADATA" {
115 return Ok(None);
116 }
117 let Some(dist_info_stem) = dist_info_dir.strip_suffix(".dist-info") else {
118 return Ok(None);
119 };
120
121 DistInfoStem::new(dist_info_stem, &filename.name).map(Some)
122}
123
124pub fn read_archive_metadata(
126 filename: &WheelFilename,
127 reader: impl std::io::BufRead + std::io::Seek + Unpin,
128) -> Result<Vec<u8>, Error> {
129 block_on(async {
130 let mut zip_reader =
131 async_zip::base::read::seek::ZipFileReader::new(AllowStdIo::new(reader)).await?;
132
133 let (metadata_index, _dist_info_stem) = find_archive_dist_info(
134 filename,
135 zip_reader
136 .file()
137 .entries()
138 .iter()
139 .enumerate()
140 .filter_map(|(index, entry)| Some((index, entry.filename().as_str().ok()?))),
141 )?;
142
143 let mut buffer = Vec::new();
144 zip_reader
145 .reader_with_entry(metadata_index)
146 .await?
147 .read_to_end_checked(&mut buffer)
148 .await?;
149
150 Ok(buffer)
151 })
152}
153
154fn find_flat_dist_info(
158 filename: &WheelFilename,
159 path: impl AsRef<Path>,
160) -> Result<DistInfoStem<'static>, Error> {
161 let Some(dist_info_stem) = fs_err::read_dir(path.as_ref())
163 .map_err(Error::Io)?
164 .find_map(|entry| {
165 let entry = entry.ok()?;
166 let file_type = entry.file_type().ok()?;
167 if file_type.is_dir() {
168 let path = entry.path();
169
170 let extension = path.extension()?;
171 if extension != "dist-info" {
172 return None;
173 }
174
175 let dist_info_stem = path.file_stem()?.to_str()?;
176 Some(dist_info_stem.to_string())
177 } else {
178 None
179 }
180 })
181 else {
182 return Err(Error::MissingDistInfo);
183 };
184
185 DistInfoStem::new(dist_info_stem, &filename.name)
186}
187
188fn read_dist_info_metadata(
190 dist_info_stem: &DistInfoStem<'_>,
191 wheel: impl AsRef<Path>,
192) -> Result<Vec<u8>, Error> {
193 let metadata_file = wheel
194 .as_ref()
195 .join(format!("{dist_info_stem}.dist-info/METADATA"));
196 fs_err::read(metadata_file).map_err(Error::Io)
197}
198
199pub async fn read_metadata_async_stream<R: futures::AsyncRead + Unpin>(
201 filename: &WheelFilename,
202 debug_path: &str,
203 reader: R,
204) -> Result<ResolutionMetadata, Error> {
205 let reader = futures::io::BufReader::with_capacity(128 * 1024, reader);
206 let mut zip = async_zip::base::read::stream::ZipFileReader::new(reader);
207
208 while let Some(mut entry) = zip.next_with_entry().await? {
209 let path = entry.reader().entry().filename().as_str()?.to_owned();
211
212 if metadata_entry(&path, filename)?.is_some() {
213 let mut reader = entry.reader_mut().compat();
214 let mut contents = Vec::new();
215 reader.read_to_end(&mut contents).await.map_err(Error::Io)?;
216
217 let reader = reader.into_inner();
220 let computed = reader.compute_hash();
221 let expected = reader.entry().crc32();
222 if computed != expected {
223 let error = Error::BadCrc32 {
224 path,
225 computed,
226 expected,
227 };
228 if expected == 0 {
234 tracing::warn!("presumed missing CRC: {error}");
235 } else {
236 return Err(error);
237 }
238 }
239
240 let metadata = ResolutionMetadata::parse_metadata(&contents)
241 .map_err(|err| Error::InvalidMetadata(debug_path.to_string(), Box::new(err)))?;
242 return Ok(metadata);
243 }
244
245 (.., zip) = entry.skip().await?;
248 }
249
250 Err(Error::MissingDistInfo)
251}
252
253pub fn read_flat_wheel_metadata(
255 filename: &WheelFilename,
256 wheel: impl AsRef<Path>,
257) -> Result<ResolutionMetadata, Error> {
258 let dist_info_stem = find_flat_dist_info(filename, &wheel)?;
259 let metadata = read_dist_info_metadata(&dist_info_stem, &wheel)?;
260 ResolutionMetadata::parse_metadata(&metadata).map_err(|err| {
261 Error::InvalidMetadata(
262 format!("{dist_info_stem}.dist-info/METADATA"),
263 Box::new(err),
264 )
265 })
266}
267
268#[cfg(test)]
269mod test {
270 use super::{DistInfoStem, find_archive_dist_info, metadata_entry};
271 use std::str::FromStr;
272 use uv_distribution_filename::WheelFilename;
273
274 #[test]
275 fn test_dot_in_name() {
276 let files = [
277 "mastodon/Mastodon.py",
278 "mastodon/__init__.py",
279 "mastodon/streaming.py",
280 "Mastodon.py-1.5.1.dist-info/DESCRIPTION.rst",
281 "Mastodon.py-1.5.1.dist-info/metadata.json",
282 "Mastodon.py-1.5.1.dist-info/top_level.txt",
283 "Mastodon.py-1.5.1.dist-info/WHEEL",
284 "Mastodon.py-1.5.1.dist-info/METADATA",
285 "Mastodon.py-1.5.1.dist-info/RECORD",
286 ];
287 let filename = WheelFilename::from_str("Mastodon.py-1.5.1-py2.py3-none-any.whl").unwrap();
288 let (_, dist_info_stem) =
289 find_archive_dist_info(&filename, files.into_iter().map(|file| (file, file))).unwrap();
290 assert_eq!(dist_info_stem.as_str(), "Mastodon.py-1.5.1");
291 }
292
293 #[test]
294 fn test_dist_info_stem_compatibility() {
295 let filename = WheelFilename::from_str("friendly_bard-1.0-py3-none-any.whl")
296 .expect("valid wheel filename");
297 for name in [
298 "friendly-bard-1.0",
299 "FrIeNdLy-._.-bArD-1.0",
300 "friendly_bard-1.0+local",
301 "friendly_bard",
302 "friendly_bard_extra-2.0",
303 ] {
304 let path = format!("{name}.dist-info/METADATA");
305 let ((), archive_name) =
306 find_archive_dist_info(&filename, [((), path.as_str())].into_iter())
307 .expect("accepted archive directory name");
308 let stream_name = metadata_entry(&path, &filename)
309 .expect("accepted streaming directory name")
310 .expect("metadata entry");
311 let owned_name = DistInfoStem::new(name.to_owned(), &filename.name)
312 .expect("accepted owned directory name");
313
314 assert_eq!(archive_name.as_str(), name);
315 assert_eq!(stream_name.as_str(), name);
316 assert_eq!(owned_name.as_str(), name);
317 }
318 }
319
320 #[test]
321 fn test_dist_info_stem_mismatch() {
322 let filename = WheelFilename::from_str("friendly_bard-1.0-py3-none-any.whl")
323 .expect("valid wheel filename");
324 let name = "other_package-1.0";
325 let path = format!("{name}.dist-info/METADATA");
326 let expected = "The .dist-info directory other_package-1.0 does not start with the normalized package name: friendly-bard";
327
328 assert_eq!(
329 find_archive_dist_info(&filename, [((), path.as_str())].into_iter())
330 .expect_err("mismatched archive directory name")
331 .to_string(),
332 expected
333 );
334 assert_eq!(
335 metadata_entry(&path, &filename)
336 .expect_err("mismatched streaming directory name")
337 .to_string(),
338 expected
339 );
340 assert_eq!(
341 DistInfoStem::new(name.to_owned(), &filename.name)
342 .expect_err("mismatched owned directory name")
343 .to_string(),
344 expected
345 );
346
347 assert!(
348 metadata_entry("other_package-1.0.dist-info/WHEEL", &filename)
349 .expect("non-metadata entry")
350 .is_none()
351 );
352 assert_eq!(
353 find_archive_dist_info(
354 &filename,
355 [
356 ((), path.as_str()),
357 ((), "friendly_bard-1.0.dist-info/METADATA"),
358 ]
359 .into_iter(),
360 )
361 .expect_err("multiple metadata directories")
362 .to_string(),
363 "Multiple .dist-info directories found: other_package-1.0, friendly_bard-1.0"
364 );
365 }
366}