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 dist_info_entry<'a>(
108 path: &'a str,
109 filename: &WheelFilename,
110 entry_name: &str,
111) -> Result<Option<DistInfoStem<'a>>, Error> {
112 let Some((dist_info_dir, file)) = path.split_once('/') else {
113 return Ok(None);
114 };
115 if file != entry_name {
116 return Ok(None);
117 }
118 let Some(dist_info_stem) = dist_info_dir.strip_suffix(".dist-info") else {
119 return Ok(None);
120 };
121
122 DistInfoStem::new(dist_info_stem, &filename.name).map(Some)
123}
124
125pub fn read_archive_metadata(
127 filename: &WheelFilename,
128 reader: impl std::io::BufRead + std::io::Seek + Unpin,
129) -> Result<Vec<u8>, Error> {
130 block_on(async {
131 let mut zip_reader =
132 async_zip::base::read::seek::ZipFileReader::new(AllowStdIo::new(reader)).await?;
133
134 let (metadata_index, _dist_info_stem) = find_archive_dist_info(
135 filename,
136 zip_reader
137 .file()
138 .entries()
139 .iter()
140 .enumerate()
141 .filter_map(|(index, entry)| Some((index, entry.filename().as_str().ok()?))),
142 )?;
143
144 let mut buffer = Vec::new();
145 zip_reader
146 .reader_with_entry(metadata_index)
147 .await?
148 .read_to_end_checked(&mut buffer)
149 .await?;
150
151 Ok(buffer)
152 })
153}
154
155fn find_flat_dist_info(
159 filename: &WheelFilename,
160 path: impl AsRef<Path>,
161) -> Result<DistInfoStem<'static>, Error> {
162 let Some(dist_info_stem) = fs_err::read_dir(path.as_ref())
164 .map_err(Error::Io)?
165 .find_map(|entry| {
166 let entry = entry.ok()?;
167 let file_type = entry.file_type().ok()?;
168 if file_type.is_dir() {
169 let path = entry.path();
170
171 let extension = path.extension()?;
172 if extension != "dist-info" {
173 return None;
174 }
175
176 let dist_info_stem = path.file_stem()?.to_str()?;
177 Some(dist_info_stem.to_string())
178 } else {
179 None
180 }
181 })
182 else {
183 return Err(Error::MissingDistInfo);
184 };
185
186 DistInfoStem::new(dist_info_stem, &filename.name)
187}
188
189fn read_dist_info_metadata(
191 dist_info_stem: &DistInfoStem<'_>,
192 wheel: impl AsRef<Path>,
193) -> Result<Vec<u8>, Error> {
194 let metadata_file = wheel
195 .as_ref()
196 .join(format!("{dist_info_stem}.dist-info/METADATA"));
197 fs_err::read(metadata_file).map_err(Error::Io)
198}
199
200pub async fn read_metadata_async_stream<R: futures::AsyncRead + Unpin>(
202 filename: &WheelFilename,
203 debug_path: &str,
204 reader: R,
205) -> Result<ResolutionMetadata, Error> {
206 let contents = read_dist_info_file_async_stream(filename, "METADATA", reader).await?;
207 ResolutionMetadata::parse_metadata(&contents)
208 .map_err(|err| Error::InvalidMetadata(debug_path.to_string(), Box::new(err)))
209}
210
211async fn read_dist_info_file_async_stream<R: futures::AsyncRead + Unpin>(
213 filename: &WheelFilename,
214 entry_name: &str,
215 reader: R,
216) -> Result<Vec<u8>, Error> {
217 let reader = futures::io::BufReader::with_capacity(128 * 1024, reader);
218 let mut zip = async_zip::base::read::stream::ZipFileReader::new(reader);
219
220 while let Some(mut entry) = zip.next_with_entry().await? {
221 let path = entry.reader().entry().filename().as_str()?.to_owned();
223
224 if dist_info_entry(&path, filename, entry_name)?.is_some() {
225 let mut reader = entry.reader_mut().compat();
226 let mut contents = Vec::new();
227 reader.read_to_end(&mut contents).await.map_err(Error::Io)?;
228
229 let reader = reader.into_inner();
232 let computed = reader.compute_hash();
233 let expected = reader.entry().crc32();
234 if computed != expected {
235 let error = Error::BadCrc32 {
236 path,
237 computed,
238 expected,
239 };
240 if expected == 0 {
246 tracing::warn!("presumed missing CRC: {error}");
247 } else {
248 return Err(error);
249 }
250 }
251
252 return Ok(contents);
253 }
254
255 (.., zip) = entry.skip().await?;
258 }
259
260 Err(Error::MissingDistInfo)
261}
262
263pub fn read_flat_wheel_metadata(
265 filename: &WheelFilename,
266 wheel: impl AsRef<Path>,
267) -> Result<ResolutionMetadata, Error> {
268 let dist_info_stem = find_flat_dist_info(filename, &wheel)?;
269 let metadata = read_dist_info_metadata(&dist_info_stem, &wheel)?;
270 ResolutionMetadata::parse_metadata(&metadata).map_err(|err| {
271 Error::InvalidMetadata(
272 format!("{dist_info_stem}.dist-info/METADATA"),
273 Box::new(err),
274 )
275 })
276}
277
278#[cfg(test)]
279mod test {
280 use super::{DistInfoStem, dist_info_entry, find_archive_dist_info};
281 use std::str::FromStr;
282 use uv_distribution_filename::WheelFilename;
283
284 #[test]
285 fn test_dot_in_name() {
286 let files = [
287 "mastodon/Mastodon.py",
288 "mastodon/__init__.py",
289 "mastodon/streaming.py",
290 "Mastodon.py-1.5.1.dist-info/DESCRIPTION.rst",
291 "Mastodon.py-1.5.1.dist-info/metadata.json",
292 "Mastodon.py-1.5.1.dist-info/top_level.txt",
293 "Mastodon.py-1.5.1.dist-info/WHEEL",
294 "Mastodon.py-1.5.1.dist-info/METADATA",
295 "Mastodon.py-1.5.1.dist-info/RECORD",
296 ];
297 let filename = WheelFilename::from_str("Mastodon.py-1.5.1-py2.py3-none-any.whl").unwrap();
298 let (_, dist_info_stem) =
299 find_archive_dist_info(&filename, files.into_iter().map(|file| (file, file))).unwrap();
300 assert_eq!(dist_info_stem.as_str(), "Mastodon.py-1.5.1");
301 }
302
303 #[test]
304 fn test_dist_info_stem_compatibility() {
305 let filename = WheelFilename::from_str("friendly_bard-1.0-py3-none-any.whl")
306 .expect("valid wheel filename");
307 for name in [
308 "friendly-bard-1.0",
309 "FrIeNdLy-._.-bArD-1.0",
310 "friendly_bard-1.0+local",
311 "friendly_bard",
312 "friendly_bard_extra-2.0",
313 ] {
314 let path = format!("{name}.dist-info/METADATA");
315 let ((), archive_name) =
316 find_archive_dist_info(&filename, [((), path.as_str())].into_iter())
317 .expect("accepted archive directory name");
318 let stream_name = dist_info_entry(&path, &filename, "METADATA")
319 .expect("accepted streaming directory name")
320 .expect("metadata entry");
321 let owned_name = DistInfoStem::new(name.to_owned(), &filename.name)
322 .expect("accepted owned directory name");
323
324 assert_eq!(archive_name.as_str(), name);
325 assert_eq!(stream_name.as_str(), name);
326 assert_eq!(owned_name.as_str(), name);
327 }
328 }
329
330 #[test]
331 fn test_dist_info_stem_mismatch() {
332 let filename = WheelFilename::from_str("friendly_bard-1.0-py3-none-any.whl")
333 .expect("valid wheel filename");
334 let name = "other_package-1.0";
335 let path = format!("{name}.dist-info/METADATA");
336 let expected = "The .dist-info directory other_package-1.0 does not start with the normalized package name: friendly-bard";
337
338 assert_eq!(
339 find_archive_dist_info(&filename, [((), path.as_str())].into_iter())
340 .expect_err("mismatched archive directory name")
341 .to_string(),
342 expected
343 );
344 assert_eq!(
345 dist_info_entry(&path, &filename, "METADATA")
346 .expect_err("mismatched streaming directory name")
347 .to_string(),
348 expected
349 );
350 assert_eq!(
351 DistInfoStem::new(name.to_owned(), &filename.name)
352 .expect_err("mismatched owned directory name")
353 .to_string(),
354 expected
355 );
356
357 assert!(
358 dist_info_entry("other_package-1.0.dist-info/WHEEL", &filename, "METADATA")
359 .expect("non-metadata entry")
360 .is_none()
361 );
362 assert_eq!(
363 find_archive_dist_info(
364 &filename,
365 [
366 ((), path.as_str()),
367 ((), "friendly_bard-1.0.dist-info/METADATA"),
368 ]
369 .into_iter(),
370 )
371 .expect_err("multiple metadata directories")
372 .to_string(),
373 "Multiple .dist-info directories found: other_package-1.0, friendly_bard-1.0"
374 );
375 }
376}