1use std::io;
7use std::path::Path;
8
9use async_zip::base::read::seek::ZipFileReader;
10use futures::executor::block_on;
11use futures::io::{AllowStdIo, AsyncBufRead, AsyncSeek};
12use thiserror::Error;
13use tokio::io::AsyncReadExt;
14use tokio_util::compat::FuturesAsyncReadCompatExt;
15use uv_distribution_filename::{VariantLabel, WheelFilename};
16use uv_distribution_types::{VariantsJsonContent, VariantsJsonParseError};
17use uv_normalize::InvalidNameError;
18use uv_pypi_types::ResolutionMetadata;
19
20pub use dist_info_stem::DistInfoStem;
21
22mod dist_info_stem;
23
24#[derive(Debug, Error)]
26pub enum Error {
27 #[error("Failed to read `dist-info` metadata from built wheel")]
28 DistInfo,
29 #[error("No .dist-info directory found")]
30 MissingDistInfo,
31 #[error("Multiple .dist-info directories found: {0}")]
32 MultipleDistInfo(String),
33 #[error(
34 "The .dist-info directory does not consist of the normalized package name and version: {0}"
35 )]
36 MissingDistInfoSegments(String),
37 #[error("The .dist-info directory `{0}` does not start with the normalized package name: {1}")]
38 MissingDistInfoPackageName(String, String),
39 #[error("The .dist-info directory name contains invalid characters")]
40 InvalidName(#[from] InvalidNameError),
41 #[error("The metadata at `{0}` is invalid")]
42 InvalidMetadata(String, Box<uv_pypi_types::MetadataError>),
43 #[error("Missing `variant.json` in the wheel's .dist-info directory")]
44 MissingVariantMetadata,
45 #[error(transparent)]
46 InvalidVariantMetadata(#[from] VariantsJsonParseError),
47 #[error("Bad CRC (got {computed:08x}, expected {expected:08x}) for file: {path}")]
48 BadCrc32 {
49 path: String,
50 computed: u32,
51 expected: u32,
52 },
53 #[error("Failed to read from zip file")]
54 AsyncZip(#[source] async_zip::error::ZipError),
55 #[error(
56 "Archive contains a file with an unsupported compression method; files must be compressed with 'stored', 'DEFLATE', or 'zstd'"
57 )]
58 UnsupportedCompression,
59 #[error(transparent)]
61 Io(io::Error),
62}
63
64impl From<async_zip::error::ZipError> for Error {
65 fn from(err: async_zip::error::ZipError) -> Self {
66 match err {
67 async_zip::error::ZipError::CompressionNotSupported(_) => Self::UnsupportedCompression,
68 o => Self::AsyncZip(o),
69 }
70 }
71}
72
73pub fn find_archive_dist_info<'a, T: Copy>(
79 filename: &WheelFilename,
80 files: impl Iterator<Item = (T, &'a str)>,
81) -> Result<(T, DistInfoStem<'a>), Error> {
82 let metadatas: Vec<_> = files
83 .filter_map(|(payload, path)| {
84 let (dist_info_dir, file) = path.split_once('/')?;
85 if file != "METADATA" {
86 return None;
87 }
88 let dist_info_stem = dist_info_dir.strip_suffix(".dist-info")?;
89 Some((payload, dist_info_stem))
90 })
91 .collect();
92
93 let (payload, dist_info_stem) = match metadatas[..] {
95 [] => {
96 return Err(Error::MissingDistInfo);
97 }
98 [(payload, path)] => (payload, path),
99 _ => {
100 return Err(Error::MultipleDistInfo(
101 metadatas
102 .into_iter()
103 .map(|(_, dist_info_dir)| dist_info_dir.to_string())
104 .collect::<Vec<_>>()
105 .join(", "),
106 ));
107 }
108 };
109
110 Ok((payload, DistInfoStem::new(dist_info_stem, &filename.name)?))
111}
112
113fn dist_info_entry<'a>(
115 path: &'a str,
116 filename: &WheelFilename,
117 entry_name: &str,
118) -> Result<Option<DistInfoStem<'a>>, Error> {
119 let Some((dist_info_dir, file)) = path.split_once('/') else {
120 return Ok(None);
121 };
122 if file != entry_name {
123 return Ok(None);
124 }
125 let Some(dist_info_stem) = dist_info_dir.strip_suffix(".dist-info") else {
126 return Ok(None);
127 };
128
129 DistInfoStem::new(dist_info_stem, &filename.name).map(Some)
130}
131
132pub fn read_archive_metadata(
134 filename: &WheelFilename,
135 reader: impl std::io::BufRead + std::io::Seek + Unpin,
136) -> Result<Vec<u8>, Error> {
137 block_on(async {
138 let mut zip_reader = ZipFileReader::new(AllowStdIo::new(reader)).await?;
139
140 let (metadata_index, dist_info_stem) = find_archive_dist_info(
141 filename,
142 zip_reader
143 .file()
144 .entries()
145 .iter()
146 .enumerate()
147 .filter_map(|(index, entry)| Some((index, entry.filename().as_str().ok()?))),
148 )?;
149 if let Some(label) = filename.variant() {
150 let path = format!("{dist_info_stem}.dist-info/variant.json");
151 validate_archive_variant(&mut zip_reader, &path, label).await?;
152 }
153
154 let mut buffer = Vec::new();
155 zip_reader
156 .reader_with_entry(metadata_index)
157 .await?
158 .read_to_end_checked(&mut buffer)
159 .await?;
160
161 Ok(buffer)
162 })
163}
164
165pub async fn validate_archive_variant<R: AsyncBufRead + AsyncSeek + Unpin>(
167 reader: &mut ZipFileReader<R>,
168 path: &str,
169 label: &VariantLabel,
170) -> Result<(), Error> {
171 let index = reader
172 .file()
173 .entries()
174 .iter()
175 .position(|entry| entry.filename().as_str().ok() == Some(path))
176 .ok_or(Error::MissingVariantMetadata)?;
177 let mut contents = Vec::new();
178 reader
179 .reader_with_entry(index)
180 .await?
181 .read_to_end_checked(&mut contents)
182 .await?;
183 VariantsJsonContent::from_wheel(&contents, label)?;
184 Ok(())
185}
186
187pub fn find_flat_dist_info(
191 filename: &WheelFilename,
192 path: impl AsRef<Path>,
193) -> Result<DistInfoStem<'static>, Error> {
194 let Some(dist_info_stem) = fs_err::read_dir(path.as_ref())
196 .map_err(Error::Io)?
197 .find_map(|entry| {
198 let entry = entry.ok()?;
199 let file_type = entry.file_type().ok()?;
200 if file_type.is_dir() {
201 let path = entry.path();
202
203 let extension = path.extension()?;
204 if extension != "dist-info" {
205 return None;
206 }
207
208 let dist_info_stem = path.file_stem()?.to_str()?;
209 Some(dist_info_stem.to_string())
210 } else {
211 None
212 }
213 })
214 else {
215 return Err(Error::MissingDistInfo);
216 };
217
218 DistInfoStem::new(dist_info_stem, &filename.name)
219}
220
221fn read_dist_info_metadata(
223 dist_info_stem: &DistInfoStem<'_>,
224 wheel: impl AsRef<Path>,
225) -> Result<Vec<u8>, Error> {
226 let metadata_file = wheel
227 .as_ref()
228 .join(format!("{dist_info_stem}.dist-info/METADATA"));
229 fs_err::read(metadata_file).map_err(Error::Io)
230}
231
232pub async fn read_metadata_async_stream<R: futures::AsyncRead + Unpin>(
234 filename: &WheelFilename,
235 debug_path: &str,
236 reader: R,
237) -> Result<ResolutionMetadata, Error> {
238 let reader = futures::io::BufReader::with_capacity(128 * 1024, reader);
239 let mut zip = async_zip::base::read::stream::ZipFileReader::new(reader);
240 let mut dist_info = None;
241 let mut metadata = None;
242 let mut pending_variant = filename.variant();
243
244 while let Some(mut entry) = zip.next_with_entry().await? {
245 let path = entry.reader().entry().filename().as_str()?.to_owned();
246 let file = if let Some(stem) = dist_info_entry(&path, filename, "METADATA")? {
247 Some((stem, None))
248 } else if let Some(label) = pending_variant
249 && let Some(stem) = dist_info_entry(&path, filename, "variant.json")?
250 {
251 Some((stem, Some(label)))
252 } else {
253 None
254 };
255
256 if let Some((stem, variant_label)) = file {
257 let dir = dist_info.get_or_insert_with(|| stem.to_string());
259 if dir != stem.as_str() {
260 return Err(Error::MultipleDistInfo(format!("{dir}, {stem}")));
261 }
262
263 let mut reader = entry.reader_mut().compat();
264 let mut contents = Vec::new();
265 reader.read_to_end(&mut contents).await.map_err(Error::Io)?;
266
267 let reader = reader.into_inner();
270 let computed = reader.compute_hash();
271 let expected = reader.entry().crc32();
272 if computed != expected {
273 let error = Error::BadCrc32 {
274 path,
275 computed,
276 expected,
277 };
278 if expected == 0 {
284 tracing::warn!("presumed missing CRC: {error}");
285 } else {
286 return Err(error);
287 }
288 }
289
290 if let Some(label) = variant_label {
291 VariantsJsonContent::from_wheel(&contents, label)?;
292 pending_variant = None;
293 } else {
294 metadata = Some(contents);
295 }
296 if pending_variant.is_none()
297 && let Some(metadata) = metadata.take()
298 {
299 return ResolutionMetadata::parse_metadata(&metadata)
300 .map_err(|err| Error::InvalidMetadata(debug_path.to_string(), Box::new(err)));
301 }
302 }
303
304 (.., zip) = entry.skip().await?;
307 }
308
309 if metadata.is_some() {
310 Err(Error::MissingVariantMetadata)
311 } else {
312 Err(Error::MissingDistInfo)
313 }
314}
315
316pub fn read_flat_wheel_metadata(
318 filename: &WheelFilename,
319 wheel: impl AsRef<Path>,
320) -> Result<ResolutionMetadata, Error> {
321 let wheel = wheel.as_ref();
322 let dist_info_stem = find_flat_dist_info(filename, wheel)?;
323 if let Some(label) = filename.variant() {
324 let path = wheel.join(format!("{dist_info_stem}.dist-info/variant.json"));
325 read_wheel_variant(&path, label)?;
326 }
327 let metadata = read_dist_info_metadata(&dist_info_stem, wheel)?;
328 ResolutionMetadata::parse_metadata(&metadata).map_err(|err| {
329 Error::InvalidMetadata(
330 format!("{dist_info_stem}.dist-info/METADATA"),
331 Box::new(err),
332 )
333 })
334}
335
336pub fn read_wheel_variant(path: &Path, label: &VariantLabel) -> Result<VariantsJsonContent, Error> {
338 let contents = fs_err::read(path).map_err(Error::Io)?;
339 Ok(VariantsJsonContent::from_wheel(&contents, label)?)
340}
341
342#[cfg(test)]
343mod test {
344 use super::{DistInfoStem, dist_info_entry, find_archive_dist_info};
345 use std::str::FromStr;
346 use uv_distribution_filename::WheelFilename;
347
348 #[test]
349 fn test_dot_in_name() {
350 let files = [
351 "mastodon/Mastodon.py",
352 "mastodon/__init__.py",
353 "mastodon/streaming.py",
354 "Mastodon.py-1.5.1.dist-info/DESCRIPTION.rst",
355 "Mastodon.py-1.5.1.dist-info/metadata.json",
356 "Mastodon.py-1.5.1.dist-info/top_level.txt",
357 "Mastodon.py-1.5.1.dist-info/WHEEL",
358 "Mastodon.py-1.5.1.dist-info/METADATA",
359 "Mastodon.py-1.5.1.dist-info/RECORD",
360 ];
361 let filename = WheelFilename::from_str("Mastodon.py-1.5.1-py2.py3-none-any.whl").unwrap();
362 let (_, dist_info_stem) =
363 find_archive_dist_info(&filename, files.into_iter().map(|file| (file, file))).unwrap();
364 assert_eq!(dist_info_stem.as_str(), "Mastodon.py-1.5.1");
365 }
366
367 #[test]
368 fn test_dist_info_stem_compatibility() {
369 let filename = WheelFilename::from_str("friendly_bard-1.0-py3-none-any.whl")
370 .expect("valid wheel filename");
371 for name in [
372 "friendly-bard-1.0",
373 "FrIeNdLy-._.-bArD-1.0",
374 "friendly_bard-1.0+local",
375 "friendly_bard",
376 "friendly_bard_extra-2.0",
377 ] {
378 let path = format!("{name}.dist-info/METADATA");
379 let ((), archive_name) =
380 find_archive_dist_info(&filename, [((), path.as_str())].into_iter())
381 .expect("accepted archive directory name");
382 let stream_name = dist_info_entry(&path, &filename, "METADATA")
383 .expect("accepted streaming directory name")
384 .expect("metadata entry");
385 let owned_name = DistInfoStem::new(name.to_owned(), &filename.name)
386 .expect("accepted owned directory name");
387
388 assert_eq!(archive_name.as_str(), name);
389 assert_eq!(stream_name.as_str(), name);
390 assert_eq!(owned_name.as_str(), name);
391 }
392 }
393
394 #[test]
395 fn test_dist_info_stem_mismatch() {
396 let filename = WheelFilename::from_str("friendly_bard-1.0-py3-none-any.whl")
397 .expect("valid wheel filename");
398 let name = "other_package-1.0";
399 let path = format!("{name}.dist-info/METADATA");
400 let expected = "The .dist-info directory `other_package-1.0` does not start with the normalized package name: friendly-bard";
401
402 assert_eq!(
403 find_archive_dist_info(&filename, [((), path.as_str())].into_iter())
404 .expect_err("mismatched archive directory name")
405 .to_string(),
406 expected
407 );
408 assert_eq!(
409 dist_info_entry(&path, &filename, "METADATA")
410 .expect_err("mismatched streaming directory name")
411 .to_string(),
412 expected
413 );
414 assert_eq!(
415 DistInfoStem::new(name.to_owned(), &filename.name)
416 .expect_err("mismatched owned directory name")
417 .to_string(),
418 expected
419 );
420
421 assert!(
422 dist_info_entry("other_package-1.0.dist-info/WHEEL", &filename, "METADATA")
423 .expect("non-metadata entry")
424 .is_none()
425 );
426 assert_eq!(
427 find_archive_dist_info(
428 &filename,
429 [
430 ((), path.as_str()),
431 ((), "friendly_bard-1.0.dist-info/METADATA"),
432 ]
433 .into_iter(),
434 )
435 .expect_err("multiple metadata directories")
436 .to_string(),
437 "Multiple .dist-info directories found: other_package-1.0, friendly_bard-1.0"
438 );
439 }
440}