Skip to main content

uv_extract/
error.rs

1use std::{ffi::OsString, path::PathBuf};
2
3use uv_macros::DebugNoInline;
4
5use crate::validate_archive_member_name;
6
7#[derive(DebugNoInline, thiserror::Error)]
8pub enum Error {
9    #[error("I/O operation failed during extraction")]
10    Io(#[source] std::io::Error),
11    #[error("Invalid zip file structure")]
12    AsyncZip(#[source] async_zip::error::ZipError),
13    #[error("Invalid tar file")]
14    Tar(
15        #[source]
16        #[from]
17        tokio_tar::TarError,
18    ),
19    #[error("Invalid tar file")]
20    TarCodec(
21        #[source]
22        #[from]
23        tar_codec::ExtractError<tar_codec::DecodeError>,
24    ),
25    #[error(transparent)]
26    DirectoryHash(#[from] crate::dirhash::DirhashError),
27    #[error(
28        "The top-level of the archive must only contain a list directory, but it contains: {0:?}"
29    )]
30    NonSingularArchive(Vec<OsString>),
31    #[error("The top-level of the archive must only contain a list directory, but it's empty")]
32    EmptyArchive,
33    #[error("ZIP local header filename at offset {offset} does not use UTF-8 encoding")]
34    LocalHeaderNotUtf8 { offset: u64 },
35    #[error("ZIP central directory entry filename at index {index} does not use UTF-8 encoding")]
36    CentralDirectoryEntryNotUtf8 { index: u64 },
37    #[error("Bad CRC (got {computed:08x}, expected {expected:08x}) for file: {}", path.display())]
38    BadCrc32 {
39        path: PathBuf,
40        computed: u32,
41        expected: u32,
42    },
43    #[error("Bad uncompressed size (got {computed:08x}, expected {expected:08x}) for file: {}", path.display())]
44    BadUncompressedSize {
45        path: PathBuf,
46        computed: u64,
47        expected: u64,
48    },
49    #[error("Bad compressed size (got {computed:08x}, expected {expected:08x}) for file: {}", path.display())]
50    BadCompressedSize {
51        path: PathBuf,
52        computed: u64,
53        expected: u64,
54    },
55    #[error("ZIP file contains multiple entries with different contents for: {}", path.display())]
56    DuplicateLocalFileHeader { path: PathBuf },
57    #[error("ZIP file contains multiple entries for the same output path: {}", path.display())]
58    DuplicateOutputPath { path: PathBuf },
59    #[error("ZIP file contains a local file header without a corresponding central-directory record entry for: {} ({offset})", path.display())]
60    MissingCentralDirectoryEntry { path: PathBuf, offset: u64 },
61    #[error("ZIP file contains an end-of-central-directory record entry, but no local file header for: {} ({offset}", path.display())]
62    MissingLocalFileHeader { path: PathBuf, offset: u64 },
63    #[error("ZIP file uses conflicting paths for the local file header at {} (got `{}`, expected `{}`)", offset, local_path.display(), central_directory_path.display())]
64    ConflictingPaths {
65        offset: u64,
66        local_path: PathBuf,
67        central_directory_path: PathBuf,
68    },
69    #[error("ZIP file uses conflicting entry types for the local file header and central-directory record for: {} ({offset})", path.display())]
70    ConflictingEntryTypes { path: PathBuf, offset: u64 },
71    #[error("ZIP file uses conflicting checksums for the local file header and central-directory record (got {local_crc32}, expected {central_directory_crc32}) for: {} ({offset})", path.display())]
72    ConflictingChecksums {
73        path: PathBuf,
74        offset: u64,
75        local_crc32: u32,
76        central_directory_crc32: u32,
77    },
78    #[error("ZIP file uses conflicting compressed sizes for the local file header and central-directory record (got {local_compressed_size}, expected {central_directory_compressed_size}) for: {} ({offset})", path.display())]
79    ConflictingCompressedSizes {
80        path: PathBuf,
81        offset: u64,
82        local_compressed_size: u64,
83        central_directory_compressed_size: u64,
84    },
85    #[error("ZIP file uses conflicting uncompressed sizes for the local file header and central-directory record (got {local_uncompressed_size}, expected {central_directory_uncompressed_size}) for: {} ({offset})", path.display())]
86    ConflictingUncompressedSizes {
87        path: PathBuf,
88        offset: u64,
89        local_uncompressed_size: u64,
90        central_directory_uncompressed_size: u64,
91    },
92    #[error("ZIP file contains trailing contents after the end-of-central-directory record")]
93    TrailingContents,
94    #[error(
95        "ZIP file reports a number of entries in the central directory that conflicts with the actual number of entries (got {actual}, expected {expected})"
96    )]
97    ConflictingNumberOfEntries { actual: u64, expected: u64 },
98    #[error("Data descriptor is missing for file: {}", path.display())]
99    MissingDataDescriptor { path: PathBuf },
100    #[error("File contains an unexpected data descriptor: {}", path.display())]
101    UnexpectedDataDescriptor { path: PathBuf },
102    #[error("ZIP file contains a comment that appears to be an embedded ZIP file")]
103    ZipInZip,
104    #[error("ZIP64 end-of-central-directory record contains unsupported extensible data")]
105    ExtensibleData,
106    #[error("ZIP file end-of-central-directory record contains multiple entries with the same path, but conflicting modes: {}", path.display())]
107    DuplicateExecutableFileHeader { path: PathBuf },
108    #[error("Archive contains a file with an empty filename")]
109    EmptyFilename,
110    #[error("Archive contains unacceptable filename: {filename}")]
111    UnacceptableFilename { filename: String },
112    #[error(
113        "Archive contains a file with an unsupported compression method; files must be compressed with 'stored', 'DEFLATE', or 'zstd'"
114    )]
115    UnsupportedCompression,
116}
117
118impl From<async_zip::error::ZipError> for Error {
119    fn from(err: async_zip::error::ZipError) -> Self {
120        match err {
121            async_zip::error::ZipError::CompressionNotSupported(_) => Self::UnsupportedCompression,
122            async_zip::error::ZipError::TrailingContents => Self::TrailingContents,
123            async_zip::error::ZipError::ZipInZip => Self::ZipInZip,
124            async_zip::error::ZipError::FileNameContainsNul { filename } => {
125                let filename = String::from_utf8_lossy(&filename);
126                validate_archive_member_name(&filename)
127                    .expect_err("a filename containing an embedded NUL must be rejected")
128            }
129            error => Self::AsyncZip(error),
130        }
131    }
132}
133
134impl Error {
135    /// When reading from a ZIP archive, the error can either be an I/O error from the underlying
136    /// operating system, or an error with the archive. Both get wrapped into an I/O error through
137    /// e.g., `io::copy`. This method extracts ZIP errors to distinguish them from invalid archives.
138    pub(crate) fn io_or_zip(err: std::io::Error) -> Self {
139        if err.kind() != std::io::ErrorKind::Other {
140            return Self::Io(err);
141        }
142
143        let err = match err.downcast::<async_zip::error::ZipError>() {
144            Ok(zip_err) => return Self::from(zip_err),
145            Err(err) => err,
146        };
147        Self::Io(err)
148    }
149
150    /// When reading a tar archive, the error can either be an I/O error from the underlying
151    /// reader or an error with the archive. Both get wrapped into an I/O error through operations
152    /// such as `io::copy`. This method extracts tar errors to distinguish them from I/O errors.
153    pub(crate) fn io_or_tar(err: std::io::Error) -> Self {
154        if err.kind() != std::io::ErrorKind::Other {
155            return Self::Io(err);
156        }
157
158        match err.downcast::<tokio_tar::TarError>() {
159            Ok(tar_err) => Self::Tar(tar_err),
160            Err(err) => Self::Io(err),
161        }
162    }
163
164    /// Returns `true` if the error is due to the server not supporting HTTP streaming. Most
165    /// commonly, this is due to serving ZIP files with features that are incompatible with
166    /// streaming, like data descriptors.
167    pub fn is_http_streaming_unsupported(&self) -> bool {
168        matches!(
169            self,
170            Self::AsyncZip(async_zip::error::ZipError::FeatureNotSupported(_))
171        )
172    }
173
174    /// Returns `true` if the error is due to HTTP streaming request failed.
175    pub fn is_http_streaming_failed(&self) -> bool {
176        fn contains_reqwest_error(error: &(dyn std::error::Error + 'static)) -> bool {
177            if error.downcast_ref::<reqwest::Error>().is_some() {
178                return true;
179            }
180            // `std::io::Error::source` forwards to the source of its custom error and can skip the
181            // custom error itself, so inspect `get_ref` before following the standard chain.
182            if let Some(error) = error.downcast_ref::<std::io::Error>()
183                && let Some(inner) = error.get_ref()
184                && contains_reqwest_error(inner)
185            {
186                return true;
187            }
188            error.source().is_some_and(contains_reqwest_error)
189        }
190
191        if matches!(
192            self,
193            Self::AsyncZip(async_zip::error::ZipError::UpstreamReadError(_))
194        ) {
195            return true;
196        }
197        contains_reqwest_error(self)
198    }
199}