Skip to main content

uv_metadata/
lib.rs

1//! Read metadata from wheels and source distributions.
2//!
3//! This module reads all fields exhaustively. The fields are defined in the [Core metadata
4//! specification](https://packaging.python.org/en/latest/specifications/core-metadata/).
5
6use 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/// The caller is responsible for attaching the path or url we failed to read.
22#[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    // No `#[from]` to enforce manual review of `io::Error` sources.
53    #[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
66/// Find the `.dist-info` directory in a zipped wheel.
67///
68/// Returns the validated [`DistInfoStem`].
69///
70/// Reference implementation: <https://github.com/pypa/pip/blob/36823099a9cdd83261fdbc8c1d2a24fa2eea72ca/src/pip/_internal/utils/wheel.py#L38>
71pub 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    // Like `pip`, assert that there is exactly one `.dist-info` directory.
87    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
106/// Return the validated [`DistInfoStem`] if the path is the requested entry.
107fn 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
125/// Given an archive, read the `METADATA` from the `.dist-info` directory.
126pub 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
155/// Find the `.dist-info` directory in an unzipped wheel.
156///
157/// See: <https://github.com/PyO3/python-pkginfo-rs>
158fn find_flat_dist_info(
159    filename: &WheelFilename,
160    path: impl AsRef<Path>,
161) -> Result<DistInfoStem<'static>, Error> {
162    // Iterate over `path` to find the `.dist-info` directory. It should be at the top-level.
163    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
189/// Read the wheel `METADATA` metadata from a `.dist-info` directory.
190fn 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
200/// Read and parse a wheel's `METADATA` file from a zip stream without seeking.
201pub 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
211/// Read a named `.dist-info` file from a wheel without seeking.
212async 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        // Find the requested `.dist-info` entry.
222        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            // Validate the CRC of any file we unpack
230            // (It would be nice if async_zip made it harder to Not do this...)
231            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                // There are some cases where we fail to get a proper CRC.
241                // This is probably connected to out-of-line data descriptors
242                // which are problematic to access in a streaming context.
243                // In those cases the CRC seems to reliably be stubbed inline as 0,
244                // so we downgrade this to a (hidden-by-default) warning.
245                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        // Close current file to get access to the next one. See docs:
256        // https://docs.rs/async_zip/0.0.16/async_zip/base/read/stream/
257        (.., zip) = entry.skip().await?;
258    }
259
260    Err(Error::MissingDistInfo)
261}
262
263/// Read the [`ResolutionMetadata`] from an unzipped wheel.
264pub 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}