Skip to main content

uv_publish/
lib.rs

1mod trusted_publishing;
2
3use std::collections::BTreeSet;
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6use std::{fmt, io};
7
8use fs_err::tokio::File;
9use futures::TryStreamExt;
10use glob::{GlobError, PatternError, glob};
11use itertools::Itertools;
12use reqwest::header::{AUTHORIZATION, LOCATION, ToStrError};
13use reqwest::multipart::Part;
14use reqwest::{Body, Response, StatusCode};
15use reqwest_retry::RetryError;
16use reqwest_retry::policies::ExponentialBackoff;
17use rustc_hash::FxHashMap;
18use serde::Deserialize;
19use thiserror::Error;
20use tokio::io::{AsyncReadExt, BufReader};
21use tokio::sync::Semaphore;
22use tokio_util::io::ReaderStream;
23use tracing::{Level, debug, enabled, trace, warn};
24use url::Url;
25
26use uv_auth::{Credentials, PyxTokenStore, Realm};
27use uv_cache::{Cache, Refresh};
28use uv_client::{
29    BaseClient, ClientBuildError, DEFAULT_MAX_REDIRECTS, MetadataFormat, OwnedArchive,
30    RegistryClientBuilder, RequestBuilder, RetryParsingError, RetryState,
31};
32use uv_configuration::{KeyringProviderType, TrustedPublishing};
33use uv_distribution_filename::{DistFilename, SourceDistExtension, SourceDistFilename};
34use uv_distribution_types::{IndexCapabilities, IndexUrl};
35use uv_extract::hash::{HashReader, Hasher};
36use uv_fs::{ProgressReader, Simplified};
37use uv_metadata::read_metadata_async_seek;
38use uv_pypi_types::{HashAlgorithm, HashDigest, Metadata23, MetadataError};
39use uv_redacted::{DisplaySafeUrl, DisplaySafeUrlError};
40use uv_warnings::warn_user;
41
42use crate::trusted_publishing::pypi::PyPIPublishingService;
43use crate::trusted_publishing::pyx::PyxPublishingService;
44use crate::trusted_publishing::{
45    TrustedPublishingError, TrustedPublishingService, TrustedPublishingToken,
46};
47
48#[derive(Error, Debug)]
49pub enum PublishError {
50    #[error("The publish path is not a valid glob pattern: `{0}`")]
51    Pattern(String, #[source] PatternError),
52    /// [`GlobError`] is a wrapped io error.
53    #[error(transparent)]
54    Glob(#[from] GlobError),
55    #[error("Path patterns didn't match any wheels or source distributions")]
56    NoFiles,
57    #[error(transparent)]
58    Fmt(#[from] fmt::Error),
59    #[error("File is neither a wheel nor a source distribution: `{}`", _0.user_display())]
60    InvalidFilename(PathBuf),
61    #[error("Failed to publish: `{}`", _0.user_display())]
62    PublishPrepare(PathBuf, #[source] Box<PublishPrepareError>),
63    #[error("Failed to publish `{}` to {}", _0.user_display(), _1)]
64    PublishSend(
65        PathBuf,
66        Box<DisplaySafeUrl>,
67        #[source] Box<PublishSendError>,
68    ),
69    #[error("Validation failed for `{}` on {}", _0.user_display(), _1)]
70    Validate(
71        PathBuf,
72        Box<DisplaySafeUrl>,
73        #[source] Box<PublishSendError>,
74    ),
75    #[error("Failed to obtain token for trusted publishing")]
76    TrustedPublishing(#[from] Box<TrustedPublishingError>),
77    #[error("{0} are not allowed when using trusted publishing")]
78    MixedCredentials(String),
79    #[error("Failed to query check URL")]
80    CheckUrlIndex(#[source] uv_client::Error),
81    #[error(transparent)]
82    ClientBuild(#[from] ClientBuildError),
83    #[error(
84        "Local file and index file do not match for {filename}. \
85        Local: {hash_algorithm}={local}, Remote: {hash_algorithm}={remote}"
86    )]
87    HashMismatch {
88        filename: Box<DistFilename>,
89        hash_algorithm: HashAlgorithm,
90        local: String,
91        remote: String,
92    },
93    #[error("Hash is missing in index for {0}")]
94    MissingHash(Box<DistFilename>),
95    #[error(transparent)]
96    RetryParsing(#[from] RetryParsingError),
97    #[error("Failed to reserve upload slot for `{}`", _0.user_display())]
98    Reserve(PathBuf, #[source] Box<PublishSendError>),
99    #[error("Failed to upload to S3 for `{}`", _0.user_display())]
100    S3Upload(PathBuf, #[source] Box<PublishSendError>),
101    #[error("Failed to finalize upload for `{}`", _0.user_display())]
102    Finalize(PathBuf, #[source] Box<PublishSendError>),
103}
104
105/// Failure to get the metadata for a specific file.
106#[derive(Error, Debug)]
107pub enum PublishPrepareError {
108    #[error(transparent)]
109    Io(#[from] io::Error),
110    #[error("Failed to read metadata")]
111    Metadata(#[from] uv_metadata::Error),
112    #[error("Failed to read metadata")]
113    Metadata23(#[from] MetadataError),
114    #[error("Only files ending in `.tar.gz` are valid source distributions: `{0}`")]
115    InvalidExtension(SourceDistFilename),
116    #[error("No PKG-INFO file found")]
117    MissingPkgInfo,
118    #[error("Multiple PKG-INFO files found: `{0}`")]
119    MultiplePkgInfo(String),
120    #[error("Failed to read: `{0}`")]
121    Read(String, #[source] io::Error),
122    #[error("Invalid PEP 740 attestation (not JSON): `{0}`")]
123    InvalidAttestation(PathBuf, #[source] serde_json::Error),
124}
125
126/// Failure in or after (HTTP) transport for a specific file.
127#[derive(Error, Debug)]
128pub enum PublishSendError {
129    #[error("Failed to send POST request")]
130    ReqwestMiddleware(#[source] reqwest_middleware::Error),
131    #[error("Server returned status code {0}")]
132    StatusNoBody(StatusCode, #[source] reqwest::Error),
133    #[error("Server returned status code {0}. Server says: {1}")]
134    Status(StatusCode, String),
135    #[error("Server returned status code {0}. {1}")]
136    StatusProblemDetails(StatusCode, String),
137    #[error(
138        "POST requests are not supported by the endpoint, are you using the simple index URL instead of the upload URL?"
139    )]
140    MethodNotAllowedNoBody,
141    #[error(
142        "POST requests are not supported by the endpoint, are you using the simple index URL instead of the upload URL? Server says: {0}"
143    )]
144    MethodNotAllowed(String),
145    /// The registry returned a "403 Forbidden".
146    #[error("Permission denied (status code {0}): {1}")]
147    PermissionDenied(StatusCode, String),
148    #[error("Too many redirects, only {0} redirects are allowed")]
149    TooManyRedirects(u32),
150    #[error("Redirected URL is not in the same realm. Redirected to: {0}")]
151    RedirectRealmMismatch(String),
152    #[error("Request was redirected, but no location header was provided")]
153    RedirectNoLocation,
154    #[error("Request was redirected, but location header is not a UTF-8 string")]
155    RedirectLocationInvalidStr(#[source] ToStrError),
156    #[error("Request was redirected, but location header is not a URL")]
157    RedirectInvalidLocation(#[source] DisplaySafeUrlError),
158}
159
160pub trait Reporter: Send + Sync + 'static {
161    fn on_progress(&self, name: &str, id: usize);
162    fn on_upload_start(&self, name: &str, size: Option<u64>) -> usize;
163    fn on_upload_progress(&self, id: usize, inc: u64);
164    fn on_upload_complete(&self, id: usize);
165    fn on_hash_start(&self, name: &DistFilename, size: Option<u64>) -> usize;
166    fn on_hash_progress(&self, id: usize, inc: u64);
167    fn on_hash_complete(&self, id: usize);
168}
169
170/// Context for using a fresh registry client for check URL requests.
171pub struct CheckUrlClient<'a> {
172    pub index_url: IndexUrl,
173    pub registry_client_builder: RegistryClientBuilder<'a>,
174    pub client: &'a BaseClient,
175    pub index_capabilities: IndexCapabilities,
176    pub cache: &'a Cache,
177}
178
179impl PublishSendError {
180    /// Extract `code` from the PyPI json error response, if any.
181    ///
182    /// The error response from PyPI contains crucial context, such as the difference between
183    /// "Invalid or non-existent authentication information" and "The user 'konstin' isn't allowed
184    /// to upload to project 'dummy'".
185    ///
186    /// Twine uses the HTTP status reason for its error messages. In HTTP 2.0 and onward this field
187    /// is abolished, so reqwest doesn't expose it, see
188    /// <https://docs.rs/reqwest/0.12.7/reqwest/struct.StatusCode.html#method.canonical_reason>.
189    /// PyPI does respect the content type for error responses and can return an error display as
190    /// HTML, JSON and plain. Since HTML and plain text are both overly verbose, we show the JSON
191    /// response. Examples are shown below, line breaks were inserted for readability. Of those,
192    /// the `code` seems to be the most helpful message, so we return it. If the response isn't a
193    /// JSON document with `code` we return the regular body.
194    ///
195    /// ```json
196    /// {"message": "The server could not comply with the request since it is either malformed or
197    /// otherwise incorrect.\n\n\nError: Use 'source' as Python version for an sdist.\n\n",
198    /// "code": "400 Error: Use 'source' as Python version for an sdist.",
199    /// "title": "Bad Request"}
200    /// ```
201    ///
202    /// ```json
203    /// {"message": "Access was denied to this resource.\n\n\nInvalid or non-existent authentication
204    /// information. See https://test.pypi.org/help/#invalid-auth for more information.\n\n",
205    /// "code": "403 Invalid or non-existent authentication information. See
206    /// https://test.pypi.org/help/#invalid-auth for more information.",
207    /// "title": "Forbidden"}
208    /// ```
209    /// ```json
210    /// {"message": "Access was denied to this resource.\n\n\n\n\n",
211    /// "code": "403 Username/Password authentication is no longer supported. Migrate to API
212    /// Tokens or Trusted Publishers instead. See https://test.pypi.org/help/#apitoken and
213    /// https://test.pypi.org/help/#trusted-publishers",
214    /// "title": "Forbidden"}
215    /// ```
216    ///
217    /// For context, for the last case twine shows:
218    /// ```text
219    /// WARNING  Error during upload. Retry with the --verbose option for more details.
220    /// ERROR    HTTPError: 403 Forbidden from https://test.pypi.org/legacy/
221    ///          Username/Password authentication is no longer supported. Migrate to API
222    ///          Tokens or Trusted Publishers instead. See
223    ///          https://test.pypi.org/help/#apitoken and
224    ///          https://test.pypi.org/help/#trusted-publishers
225    /// ```
226    ///
227    /// ```text
228    /// INFO     Response from https://test.pypi.org/legacy/:
229    ///          403 Username/Password authentication is no longer supported. Migrate to
230    ///          API Tokens or Trusted Publishers instead. See
231    ///          https://test.pypi.org/help/#apitoken and
232    ///          https://test.pypi.org/help/#trusted-publishers
233    /// INFO     <html>
234    ///           <head>
235    ///            <title>403 Username/Password authentication is no longer supported.
236    ///          Migrate to API Tokens or Trusted Publishers instead. See
237    ///          https://test.pypi.org/help/#apitoken and
238    ///          https://test.pypi.org/help/#trusted-publishers</title>
239    ///           </head>
240    ///          <body>
241    ///           <h1>403 Username/Password authentication is no longer supported.
242    ///         Migrate to API Tokens or Trusted Publishers instead. See
243    ///          https://test.pypi.org/help/#apitoken and
244    ///          https://test.pypi.org/help/#trusted-publishers</h1>
245    ///            Access was denied to this resource.<br/><br/>
246    /// ```
247    ///
248    /// In comparison, we now show (line-wrapped for readability):
249    ///
250    /// ```text
251    /// error: Failed to publish `dist/astral_test_1-0.1.0-py3-none-any.whl` to `https://test.pypi.org/legacy/`
252    ///   Caused by: Incorrect credentials (status code 403 Forbidden): 403 Username/Password
253    ///     authentication is no longer supported. Migrate to API Tokens or Trusted Publishers
254    ///     instead. See https://test.pypi.org/help/#apitoken and https://test.pypi.org/help/#trusted-publishers
255    /// ```
256    fn extract_error_message(body: String, content_type: Option<&str>) -> String {
257        if content_type == Some("application/json") {
258            #[derive(Deserialize)]
259            struct ErrorBody {
260                code: String,
261            }
262
263            if let Ok(structured) = serde_json::from_str::<ErrorBody>(&body) {
264                structured.code
265            } else {
266                body
267            }
268        } else {
269            body
270        }
271    }
272}
273
274/// Represents a single "to-be-uploaded" distribution, along with zero
275/// or more attestations that will be uploaded alongside it.
276#[derive(Debug)]
277pub struct UploadDistribution {
278    /// The path to the main distribution file to upload.
279    pub file: PathBuf,
280    /// The raw filename of the main distribution file.
281    pub raw_filename: String,
282    /// The parsed filename of the main distribution file.
283    pub filename: DistFilename,
284    /// Zero or more paths to PEP 740 attestations for the distribution.
285    pub attestations: Vec<PathBuf>,
286}
287
288/// Given a list of paths (which may contain globs), unroll them into
289/// a flat, unique list of files. Files are returned in a stable
290/// but unspecified order.
291fn unroll_paths(paths: Vec<String>) -> Result<Vec<PathBuf>, PublishError> {
292    let mut files = BTreeSet::default();
293    for path in paths {
294        for file in glob(&path).map_err(|err| PublishError::Pattern(path.clone(), err))? {
295            let file = file?;
296            if !file.is_file() {
297                continue;
298            }
299
300            files.insert(file);
301        }
302    }
303
304    Ok(files.into_iter().collect())
305}
306
307/// Given a flat list of input files, merge them into a list of [`UploadDistribution`]s.
308fn group_files(files: Vec<PathBuf>, no_attestations: bool) -> Vec<UploadDistribution> {
309    let mut groups = FxHashMap::default();
310    let mut attestations_by_dist = FxHashMap::default();
311    for file in files {
312        let Some(filename) = file
313            .file_name()
314            .and_then(|filename| filename.to_str())
315            .map(ToString::to_string)
316        else {
317            continue;
318        };
319
320        // Attestations are named as `<dist>.<type>.attestation`, e.g.
321        // `foo-1.2.3.tar.gz.publish.attestation`.
322        // We use this to build up a map of `dist -> [attestations]`
323        // for subsequent merging.
324        let mut filename_parts = filename.rsplitn(3, '.');
325        if filename_parts.next() == Some("attestation")
326            && let Some(_) = filename_parts.next()
327            && let Some(dist_name) = filename_parts.next()
328        {
329            debug!(
330                "Found attestation for distribution: `{}` -> `{}`",
331                file.user_display(),
332                dist_name
333            );
334
335            attestations_by_dist
336                .entry(dist_name.to_string())
337                .or_insert_with(Vec::new)
338                .push(file);
339        } else {
340            let Some(dist_filename) = DistFilename::try_from_normalized_filename(&filename) else {
341                debug!("Not a distribution filename: `{filename}`");
342                // I've never seen these in upper case
343                #[expect(clippy::case_sensitive_file_extension_comparisons)]
344                if filename.ends_with(".whl")
345                    || filename.ends_with(".zip")
346                    // Catch all compressed tar variants, e.g., `.tar.gz`
347                    || filename
348                        .split_once(".tar.")
349                        .is_some_and(|(_, ext)| ext.chars().all(char::is_alphanumeric))
350                {
351                    warn_user!(
352                        "Skipping file that looks like a distribution, \
353                        but is not a valid distribution filename: `{}`",
354                        file.user_display()
355                    );
356                }
357                continue;
358            };
359
360            groups.insert(
361                filename.clone(),
362                UploadDistribution {
363                    file,
364                    raw_filename: filename,
365                    filename: dist_filename,
366                    attestations: Vec::new(),
367                },
368            );
369        }
370    }
371
372    if no_attestations {
373        debug!("Not merging attestations with distributions per user request");
374    } else {
375        // Merge attestations into their respective upload groups.
376        for (dist_name, attestations) in attestations_by_dist {
377            if let Some(group) = groups.get_mut(&dist_name) {
378                group.attestations = attestations;
379                group.attestations.sort();
380            }
381        }
382    }
383
384    groups.into_values().collect()
385}
386
387/// Collect the source distributions and wheels for publishing.
388///
389/// Returns an [`UploadGroup`] for each distribution to be published.
390/// This group contains the path, the raw filename and the parsed filename. The raw filename is a fixup for
391/// <https://github.com/astral-sh/uv/issues/8030> caused by
392/// <https://github.com/pypa/setuptools/issues/3777> in combination with
393/// <https://github.com/pypi/warehouse/blob/50a58f3081e693a3772c0283050a275e350004bf/warehouse/forklift/legacy.py#L1133-L1155>
394pub fn group_files_for_publishing(
395    paths: Vec<String>,
396    no_attestations: bool,
397) -> Result<Vec<UploadDistribution>, PublishError> {
398    Ok(group_files(unroll_paths(paths)?, no_attestations))
399}
400
401pub enum TrustedPublishResult {
402    /// We didn't check for trusted publishing.
403    Skipped,
404    /// We checked for trusted publishing and found a token.
405    Configured(TrustedPublishingToken),
406    /// We checked for optional trusted publishing, but it didn't succeed.
407    Ignored(TrustedPublishingError),
408}
409
410/// If applicable, attempt obtaining a token for trusted publishing.
411pub async fn check_trusted_publishing(
412    username: Option<&str>,
413    password: Option<&str>,
414    keyring_provider: KeyringProviderType,
415    token_store: &PyxTokenStore,
416    trusted_publishing: TrustedPublishing,
417    registry: &DisplaySafeUrl,
418    client: &BaseClient,
419) -> Result<TrustedPublishResult, PublishError> {
420    match trusted_publishing {
421        TrustedPublishing::Automatic => {
422            // If the user provided credentials, use those.
423            if username.is_some()
424                || password.is_some()
425                || keyring_provider != KeyringProviderType::Disabled
426            {
427                return Ok(TrustedPublishResult::Skipped);
428            }
429
430            debug!("Attempting to get a token for trusted publishing");
431
432            // Attempt to get a token for trusted publishing.
433            let token = if token_store.is_known_url(registry) {
434                debug!("Using trusted publishing flow for pyx");
435                PyxPublishingService::new(registry, client)
436                    .get_token()
437                    .await
438            } else {
439                debug!("Using trusted publishing flow for PyPI");
440                PyPIPublishingService::new(registry, client)
441                    .get_token()
442                    .await
443            };
444
445            match token {
446                // Success: we have a token for trusted publishing.
447                Ok(Some(token)) => Ok(TrustedPublishResult::Configured(token)),
448                // Failed to discover an ambient OIDC token.
449                Ok(None) => Ok(TrustedPublishResult::Ignored(
450                    TrustedPublishingError::NoToken,
451                )),
452                // Hard failure during OIDC discovery or token exchange.
453                Err(err) => Ok(TrustedPublishResult::Ignored(err)),
454            }
455        }
456        TrustedPublishing::Always => {
457            debug!("Using trusted publishing for GitHub Actions");
458
459            let mut conflicts = Vec::new();
460            if username.is_some() {
461                conflicts.push("a username");
462            }
463            if password.is_some() {
464                conflicts.push("a password");
465            }
466            if keyring_provider != KeyringProviderType::Disabled {
467                conflicts.push("the keyring");
468            }
469            if !conflicts.is_empty() {
470                return Err(PublishError::MixedCredentials(conflicts.join(" and ")));
471            }
472
473            // Attempt to get a token for trusted publishing.
474            let token = if token_store.is_known_url(registry) {
475                debug!("Using trusted publishing flow for pyx");
476                PyxPublishingService::new(registry, client)
477                    .get_token()
478                    .await
479                    .map_err(Box::new)?
480            } else {
481                debug!("Using trusted publishing flow for PyPI");
482                PyPIPublishingService::new(registry, client)
483                    .get_token()
484                    .await
485                    .map_err(Box::new)?
486            };
487
488            let Some(token) = token else {
489                return Err(PublishError::TrustedPublishing(
490                    TrustedPublishingError::NoToken.into(),
491                ));
492            };
493
494            Ok(TrustedPublishResult::Configured(token))
495        }
496        TrustedPublishing::Never => Ok(TrustedPublishResult::Skipped),
497    }
498}
499
500/// Upload a file to a registry.
501///
502/// Returns `true` if the file was newly uploaded and `false` if it already existed.
503///
504/// Implements a custom retry flow since the request isn't cloneable.
505pub async fn upload(
506    group: &UploadDistribution,
507    form_metadata: &FormMetadata,
508    registry: &DisplaySafeUrl,
509    client: &BaseClient,
510    retry_policy: ExponentialBackoff,
511    credentials: &Credentials,
512    check_url_client: Option<&CheckUrlClient<'_>>,
513    download_concurrency: &Semaphore,
514    reporter: Arc<impl Reporter>,
515) -> Result<bool, PublishError> {
516    let mut n_past_redirections = 0;
517    let max_redirects = DEFAULT_MAX_REDIRECTS;
518    let mut current_registry = registry.clone();
519    let mut retry_state = RetryState::start(retry_policy, registry.clone());
520
521    loop {
522        let (request, idx) = build_upload_request(
523            group,
524            &current_registry,
525            client,
526            credentials,
527            form_metadata,
528            reporter.clone(),
529        )
530        .await
531        .map_err(|err| PublishError::PublishPrepare(group.file.clone(), Box::new(err)))?;
532
533        let result = request.send().await;
534        let response = match result {
535            Ok(response) => {
536                // When the user accidentally uses https://test.pypi.org/legacy (no slash) as publish URL, we
537                // get a redirect to https://test.pypi.org/legacy/ (the canonical index URL).
538                // In the above case we get 308, where reqwest or `RedirectClientWithMiddleware` would try
539                // cloning the streaming body, which is not possible.
540                // For https://test.pypi.org/simple (no slash), we get 301, which means we should make a GET request:
541                // https://fetch.spec.whatwg.org/#http-redirect-fetch).
542                // Reqwest doesn't support redirect policies conditional on the HTTP
543                // method (https://github.com/seanmonstar/reqwest/issues/1777#issuecomment-2303386160), so we're
544                // implementing our custom redirection logic.
545                if response.status().is_redirection() {
546                    if n_past_redirections >= max_redirects {
547                        return Err(PublishError::PublishSend(
548                            group.file.clone(),
549                            current_registry.clone().into(),
550                            PublishSendError::TooManyRedirects(n_past_redirections).into(),
551                        ));
552                    }
553                    let location = response
554                        .headers()
555                        .get(LOCATION)
556                        .ok_or_else(|| {
557                            PublishError::PublishSend(
558                                group.file.clone(),
559                                current_registry.clone().into(),
560                                PublishSendError::RedirectNoLocation.into(),
561                            )
562                        })?
563                        .to_str()
564                        .map_err(|err| {
565                            PublishError::PublishSend(
566                                group.file.clone(),
567                                current_registry.clone().into(),
568                                PublishSendError::RedirectLocationInvalidStr(err).into(),
569                            )
570                        })?;
571                    current_registry = DisplaySafeUrl::parse(location).map_err(|err| {
572                        PublishError::PublishSend(
573                            group.file.clone(),
574                            current_registry.clone().into(),
575                            PublishSendError::RedirectInvalidLocation(err).into(),
576                        )
577                    })?;
578                    if Realm::from(&current_registry) != Realm::from(registry) {
579                        return Err(PublishError::PublishSend(
580                            group.file.clone(),
581                            current_registry.clone().into(),
582                            PublishSendError::RedirectRealmMismatch(current_registry.to_string())
583                                .into(),
584                        ));
585                    }
586                    debug!("Redirecting the request to: {}", current_registry);
587                    n_past_redirections += 1;
588                    continue;
589                }
590                reporter.on_upload_complete(idx);
591                response
592            }
593            Err(err) => {
594                let middleware_retries = if let Some(RetryError::WithRetries { retries, .. }) =
595                    (&err as &dyn std::error::Error).downcast_ref::<RetryError>()
596                {
597                    *retries
598                } else {
599                    0
600                };
601                if let Some(backoff) = retry_state.should_retry(&err, middleware_retries) {
602                    retry_state.sleep_backoff(backoff).await;
603                    continue;
604                }
605                return Err(PublishError::PublishSend(
606                    group.file.clone(),
607                    current_registry.clone().into(),
608                    PublishSendError::ReqwestMiddleware(err).into(),
609                ));
610            }
611        };
612
613        return match handle_response(&current_registry, response).await {
614            Ok(()) => {
615                // Upload successful; for PyPI this can also mean a hash match in a raced upload
616                // (but it doesn't tell us), for other registries it should mean a fresh upload.
617                Ok(true)
618            }
619            Err(err) => {
620                if matches!(
621                    err,
622                    PublishSendError::Status(..) | PublishSendError::StatusNoBody(..)
623                ) {
624                    if let Some(check_url_client) = &check_url_client {
625                        if check_url(
626                            check_url_client,
627                            &group.file,
628                            &group.filename,
629                            download_concurrency,
630                            reporter.clone(),
631                        )
632                        .await?
633                        {
634                            // There was a raced upload of the same file, so even though our upload failed,
635                            // the right file now exists in the registry.
636                            return Ok(false);
637                        }
638                    }
639                }
640                Err(PublishError::PublishSend(
641                    group.file.clone(),
642                    current_registry.clone().into(),
643                    err.into(),
644                ))
645            }
646        };
647    }
648}
649
650/// Validate a distribution before uploading.
651///
652/// Returns `true` if the file should be uploaded, `false` if it already exists on the server.
653pub async fn validate(
654    file: &Path,
655    form_metadata: &FormMetadata,
656    raw_filename: &str,
657    registry: &DisplaySafeUrl,
658    store: &PyxTokenStore,
659    client: &BaseClient,
660    credentials: &Credentials,
661) -> Result<bool, PublishError> {
662    if store.is_known_url(registry) {
663        debug!("Performing validation request for {registry}");
664
665        let mut validation_url = registry.clone();
666        validation_url
667            .path_segments_mut()
668            .expect("URL must have path segments")
669            .push("validate");
670
671        let request = build_metadata_request(
672            raw_filename,
673            &validation_url,
674            client,
675            credentials,
676            form_metadata,
677        );
678
679        let response = request.send().await.map_err(|err| {
680            PublishError::Validate(
681                file.to_path_buf(),
682                registry.clone().into(),
683                PublishSendError::ReqwestMiddleware(err).into(),
684            )
685        })?;
686
687        let status_code = response.status();
688        debug!("Response code for {validation_url}: {status_code}");
689
690        if status_code.is_success() {
691            #[derive(Deserialize)]
692            struct ValidateResponse {
693                exists: bool,
694            }
695
696            // Check if the file already exists.
697            match response.text().await {
698                Ok(body) => {
699                    trace!("Response content for {validation_url}: {body}");
700                    if let Ok(response) = serde_json::from_str::<ValidateResponse>(&body) {
701                        if response.exists {
702                            debug!("File already uploaded: {raw_filename}");
703                            return Ok(false);
704                        }
705                    }
706                }
707                Err(err) => {
708                    trace!("Failed to read response content for {validation_url}: {err}");
709                }
710            }
711            return Ok(true);
712        }
713
714        // Handle error response.
715        handle_response(&validation_url, response)
716            .await
717            .map_err(|err| {
718                PublishError::Validate(file.to_path_buf(), registry.clone().into(), err.into())
719            })?;
720
721        Ok(true)
722    } else {
723        debug!("Skipping validation request for unsupported publish URL: {registry}");
724        Ok(true)
725    }
726}
727
728/// Upload a file using the two-phase upload protocol for pyx.
729///
730/// This is a more efficient upload method that:
731/// 1. Reserves an upload slot and gets a pre-signed S3 URL.
732/// 2. Uploads the file directly to S3.
733/// 3. Finalizes the upload with the registry.
734///
735/// Returns `true` if the file was newly uploaded and `false` if it already existed.
736pub async fn upload_two_phase(
737    group: &UploadDistribution,
738    form_metadata: &FormMetadata,
739    registry: &DisplaySafeUrl,
740    client: &BaseClient,
741    s3_client: &BaseClient,
742    retry_policy: ExponentialBackoff,
743    credentials: &Credentials,
744    reporter: Arc<impl Reporter>,
745) -> Result<bool, PublishError> {
746    #[derive(Debug, Deserialize)]
747    struct ReserveResponse {
748        upload_url: Option<String>,
749        upload_headers: Option<FxHashMap<String, String>>,
750    }
751
752    // Step 1: Reserve an upload slot.
753    let mut reserve_url = registry.clone();
754    reserve_url
755        .path_segments_mut()
756        .expect("URL must have path segments")
757        .push("reserve");
758
759    debug!("Reserving upload slot at {reserve_url}");
760
761    let reserve_request = build_metadata_request(
762        &group.raw_filename,
763        &reserve_url,
764        client,
765        credentials,
766        form_metadata,
767    );
768
769    let response = reserve_request.send().await.map_err(|err| {
770        PublishError::Reserve(
771            group.file.clone(),
772            PublishSendError::ReqwestMiddleware(err).into(),
773        )
774    })?;
775
776    let status = response.status();
777
778    let reserve_response: ReserveResponse = match status {
779        StatusCode::OK => {
780            debug!("File already uploaded: {}", group.raw_filename);
781            return Ok(false);
782        }
783        StatusCode::CREATED => {
784            let body = response.text().await.map_err(|err| {
785                PublishError::Reserve(
786                    group.file.clone(),
787                    PublishSendError::StatusNoBody(status, err).into(),
788                )
789            })?;
790            serde_json::from_str(&body).map_err(|_| {
791                PublishError::Reserve(
792                    group.file.clone(),
793                    PublishSendError::Status(status, format!("Invalid JSON response: {body}"))
794                        .into(),
795                )
796            })?
797        }
798        _ => {
799            let body = response.text().await.unwrap_or_default();
800            return Err(PublishError::Reserve(
801                group.file.clone(),
802                PublishSendError::Status(status, body).into(),
803            ));
804        }
805    };
806
807    // Step 2: Upload the file directly to S3 (if needed).
808    // When upload_url is None, the file already exists on S3 with the correct hash.
809    if let Some(upload_url) = reserve_response.upload_url {
810        let s3_url = DisplaySafeUrl::parse(&upload_url).map_err(|_| {
811            PublishError::S3Upload(
812                group.file.clone(),
813                PublishSendError::Status(
814                    StatusCode::BAD_REQUEST,
815                    "Invalid S3 URL in reserve response".to_string(),
816                )
817                .into(),
818            )
819        })?;
820
821        debug!("Got pre-signed URL for upload: {s3_url}");
822
823        // Use a custom retry loop since streaming uploads can't be retried by the middleware.
824        let file_size = fs_err::tokio::metadata(&group.file)
825            .await
826            .map_err(|err| {
827                PublishError::PublishPrepare(
828                    group.file.clone(),
829                    Box::new(PublishPrepareError::Io(err)),
830                )
831            })?
832            .len();
833
834        let mut retry_state = RetryState::start(retry_policy, s3_url.clone());
835        loop {
836            let file = File::open(&group.file).await.map_err(|err| {
837                PublishError::PublishPrepare(
838                    group.file.clone(),
839                    Box::new(PublishPrepareError::Io(err)),
840                )
841            })?;
842
843            let idx = reporter.on_upload_start(&group.filename.to_string(), Some(file_size));
844            let reporter_clone = reporter.clone();
845            let reader = ProgressReader::new(file, move |read| {
846                reporter_clone.on_upload_progress(idx, read as u64);
847            });
848            let file_reader = Body::wrap_stream(ReaderStream::new(reader));
849
850            let mut request = s3_client
851                .for_host(&s3_url)
852                .raw_client()
853                .put(Url::from(s3_url.clone()))
854                .header(reqwest::header::CONTENT_TYPE, "application/octet-stream")
855                .header(reqwest::header::CONTENT_LENGTH, file_size);
856
857            // Add any required headers from the reserve response (e.g., x-amz-tagging).
858            if let Some(headers) = &reserve_response.upload_headers {
859                for (key, value) in headers {
860                    request = request.header(key, value);
861                }
862            }
863
864            let result = request.body(file_reader).send().await;
865
866            let response = match result {
867                Ok(response) => {
868                    reporter.on_upload_complete(idx);
869                    response
870                }
871                Err(err) => {
872                    let middleware_retries =
873                        if let Some(RetryError::WithRetries { retries, .. }) =
874                            (&err as &dyn std::error::Error).downcast_ref::<RetryError>()
875                        {
876                            *retries
877                        } else {
878                            0
879                        };
880                    if let Some(backoff) = retry_state.should_retry(&err, middleware_retries) {
881                        retry_state.sleep_backoff(backoff).await;
882                        continue;
883                    }
884                    return Err(PublishError::S3Upload(
885                        group.file.clone(),
886                        PublishSendError::ReqwestMiddleware(err).into(),
887                    ));
888                }
889            };
890
891            if response.status().is_success() {
892                break;
893            }
894
895            let status = response.status();
896            let body = response.text().await.unwrap_or_default();
897            return Err(PublishError::S3Upload(
898                group.file.clone(),
899                PublishSendError::Status(status, format!("S3 upload failed: {body}")).into(),
900            ));
901        }
902
903        debug!("S3 upload complete for {}", group.raw_filename);
904    } else {
905        debug!(
906            "File already exists on S3, skipping upload: {}",
907            group.raw_filename
908        );
909    }
910
911    // Step 3: Finalize the upload.
912    let mut finalize_url = registry.clone();
913    finalize_url
914        .path_segments_mut()
915        .expect("URL must have path segments")
916        .push("finalize");
917
918    debug!("Finalizing upload at {finalize_url}");
919
920    let finalize_request = build_metadata_request(
921        &group.raw_filename,
922        &finalize_url,
923        client,
924        credentials,
925        form_metadata,
926    );
927
928    let response = finalize_request.send().await.map_err(|err| {
929        PublishError::Finalize(
930            group.file.clone(),
931            PublishSendError::ReqwestMiddleware(err).into(),
932        )
933    })?;
934
935    handle_response(&finalize_url, response)
936        .await
937        .map_err(|err| PublishError::Finalize(group.file.clone(), err.into()))?;
938
939    debug!("Upload finalized for {}", group.raw_filename);
940
941    Ok(true)
942}
943
944/// Check whether we should skip the upload of a file because it already exists on the index.
945pub async fn check_url(
946    check_url_client: &CheckUrlClient<'_>,
947    file: &Path,
948    filename: &DistFilename,
949    download_concurrency: &Semaphore,
950    reporter: Arc<impl Reporter>,
951) -> Result<bool, PublishError> {
952    let CheckUrlClient {
953        index_url,
954        registry_client_builder,
955        client,
956        index_capabilities,
957        cache,
958    } = check_url_client;
959
960    // Avoid using the PyPI 10min default cache.
961    let cache_refresh = (*cache)
962        .clone()
963        .with_refresh(Refresh::from_args(None, vec![filename.name().clone()]));
964    let registry_client = registry_client_builder
965        .clone()
966        .cache(cache_refresh)
967        .wrap_existing(client)?;
968
969    debug!("Checking for {filename} in the registry");
970    let response = match registry_client
971        .simple_detail(
972            filename.name(),
973            Some(index_url.into()),
974            index_capabilities,
975            download_concurrency,
976        )
977        .await
978    {
979        Ok(response) => response,
980        Err(err) => {
981            return match err.kind() {
982                uv_client::ErrorKind::RemotePackageNotFound(_) => {
983                    // The package doesn't exist, so we can't have uploaded it.
984                    warn!(
985                        "Package not found in the registry; skipping upload check for {filename}"
986                    );
987                    Ok(false)
988                }
989                _ => Err(PublishError::CheckUrlIndex(err)),
990            };
991        }
992    };
993    let [(_, MetadataFormat::Simple(simple_metadata))] = response.as_slice() else {
994        unreachable!("We queried a single index, we must get a single response");
995    };
996    let simple_metadata = OwnedArchive::deserialize(simple_metadata);
997    let Some(metadatum) = simple_metadata
998        .iter()
999        .find(|metadatum| &metadatum.version == filename.version())
1000    else {
1001        return Ok(false);
1002    };
1003
1004    let archived_file = match filename {
1005        DistFilename::SourceDistFilename(source_dist) => metadatum
1006            .files
1007            .source_dists
1008            .iter()
1009            .find(|entry| &entry.name == source_dist)
1010            .map(|entry| &entry.file),
1011        DistFilename::WheelFilename(wheel) => metadatum
1012            .files
1013            .wheels
1014            .iter()
1015            .find(|entry| &entry.name == wheel)
1016            .map(|entry| &entry.file),
1017    };
1018    let Some(archived_file) = archived_file else {
1019        return Ok(false);
1020    };
1021
1022    // TODO(konsti): Do we have a preference for a hash here?
1023    if let Some(remote_hash) = archived_file.hashes.first() {
1024        // We accept the risk for TOCTOU errors here, since we already read the file once before the
1025        // streaming upload to compute the hash for the form metadata.
1026        let local_hash = &hash_file(
1027            file,
1028            filename,
1029            vec![Hasher::from(remote_hash.algorithm)],
1030            reporter,
1031        )
1032        .await
1033        .map_err(|err| {
1034            PublishError::PublishPrepare(file.to_path_buf(), Box::new(PublishPrepareError::Io(err)))
1035        })?[0];
1036        if local_hash.digest == remote_hash.digest {
1037            debug!(
1038                "Found {filename} in the registry with matching hash {}",
1039                remote_hash.digest
1040            );
1041            Ok(true)
1042        } else {
1043            Err(PublishError::HashMismatch {
1044                filename: Box::new(filename.clone()),
1045                hash_algorithm: remote_hash.algorithm,
1046                local: local_hash.digest.to_string(),
1047                remote: remote_hash.digest.to_string(),
1048            })
1049        }
1050    } else {
1051        Err(PublishError::MissingHash(Box::new(filename.clone())))
1052    }
1053}
1054
1055/// Calculate the requested hashes of a file.
1056async fn hash_file(
1057    path: impl AsRef<Path>,
1058    filename: &DistFilename,
1059    hashers: Vec<Hasher>,
1060    reporter: Arc<impl Reporter>,
1061) -> Result<Vec<HashDigest>, io::Error> {
1062    let path = path.as_ref();
1063    debug!("Hashing {}", path.user_display());
1064
1065    let file = File::open(path).await?;
1066    let file_size = file.metadata().await?.len();
1067    let idx = reporter.on_hash_start(filename, Some(file_size));
1068
1069    let reader = BufReader::new(file);
1070    let mut hashers = hashers;
1071    let reporter_clone = reporter.clone();
1072    let mut reader = HashReader::new(
1073        ProgressReader::new(reader, move |read| {
1074            reporter_clone.on_hash_progress(idx, read as u64);
1075        }),
1076        &mut hashers,
1077    );
1078
1079    let result = reader.finish().await;
1080    reporter.on_hash_complete(idx);
1081    result?;
1082
1083    Ok(hashers
1084        .into_iter()
1085        .map(HashDigest::from)
1086        .collect::<Vec<_>>())
1087}
1088
1089// Not in `uv-metadata` because we only support tar files here.
1090async fn source_dist_pkg_info(file: &Path) -> Result<Vec<u8>, PublishPrepareError> {
1091    let reader = BufReader::new(File::open(&file).await?);
1092    let decoded = async_compression::tokio::bufread::GzipDecoder::new(reader);
1093    let mut archive = tokio_tar::Archive::new(decoded);
1094    let mut pkg_infos: Vec<(PathBuf, Vec<u8>)> = archive
1095        .entries()?
1096        .map_err(PublishPrepareError::from)
1097        .try_filter_map(async |mut entry| {
1098            let path = entry
1099                .path()
1100                .map_err(PublishPrepareError::from)?
1101                .to_path_buf();
1102            let mut components = path.components();
1103            let Some(_top_level) = components.next() else {
1104                return Ok(None);
1105            };
1106            let Some(pkg_info) = components.next() else {
1107                return Ok(None);
1108            };
1109            if components.next().is_some() || pkg_info.as_os_str() != "PKG-INFO" {
1110                return Ok(None);
1111            }
1112            let mut buffer = Vec::new();
1113            // We have to read while iterating or the entry is empty as we're beyond it in the file.
1114            entry.read_to_end(&mut buffer).await.map_err(|err| {
1115                PublishPrepareError::Read(path.to_string_lossy().to_string(), err)
1116            })?;
1117            Ok(Some((path, buffer)))
1118        })
1119        .try_collect()
1120        .await?;
1121    match pkg_infos.len() {
1122        0 => Err(PublishPrepareError::MissingPkgInfo),
1123        1 => Ok(pkg_infos.remove(0).1),
1124        _ => Err(PublishPrepareError::MultiplePkgInfo(
1125            pkg_infos
1126                .iter()
1127                .map(|(path, _buffer)| path.to_string_lossy())
1128                .join(", "),
1129        )),
1130    }
1131}
1132
1133async fn metadata(file: &Path, filename: &DistFilename) -> Result<Metadata23, PublishPrepareError> {
1134    let contents = match filename {
1135        DistFilename::SourceDistFilename(source_dist) => {
1136            if source_dist.extension != SourceDistExtension::TarGz {
1137                // See PEP 625. While we support installing legacy source distributions, we don't
1138                // support creating and uploading them.
1139                return Err(PublishPrepareError::InvalidExtension(source_dist.clone()));
1140            }
1141            source_dist_pkg_info(file).await?
1142        }
1143        DistFilename::WheelFilename(wheel) => {
1144            let reader = BufReader::new(File::open(&file).await?);
1145            read_metadata_async_seek(wheel, reader).await?
1146        }
1147    };
1148    Ok(Metadata23::parse(&contents)?)
1149}
1150
1151#[derive(Debug, Clone)]
1152pub struct FormMetadata(Vec<(&'static str, String)>);
1153
1154impl FormMetadata {
1155    /// Collect the non-file fields for the multipart request from the package METADATA.
1156    ///
1157    /// Reference implementation: <https://github.com/pypi/warehouse/blob/d2c36d992cf9168e0518201d998b2707a3ef1e72/warehouse/forklift/legacy.py#L1376-L1430>
1158    pub async fn read_from_file(
1159        file: &Path,
1160        filename: &DistFilename,
1161        reporter: Arc<impl Reporter>,
1162    ) -> Result<Self, PublishPrepareError> {
1163        let hashes = hash_file(
1164            file,
1165            filename,
1166            vec![
1167                Hasher::from(HashAlgorithm::Sha256),
1168                Hasher::from(HashAlgorithm::Blake2b),
1169            ],
1170            reporter,
1171        )
1172        .await?;
1173
1174        let sha256_hash = hashes
1175            .iter()
1176            .find(|hash| hash.algorithm == HashAlgorithm::Sha256)
1177            .unwrap();
1178
1179        let blake2b_hash = hashes
1180            .iter()
1181            .find(|hash| hash.algorithm == HashAlgorithm::Blake2b)
1182            .unwrap();
1183
1184        let metadata = metadata(file, filename).await?;
1185
1186        Ok(Self::from_metadata(
1187            metadata,
1188            filename,
1189            sha256_hash,
1190            blake2b_hash,
1191        ))
1192    }
1193
1194    fn from_metadata(
1195        metadata: Metadata23,
1196        filename: &DistFilename,
1197        sha256_hash: &HashDigest,
1198        blake2b_hash: &HashDigest,
1199    ) -> Self {
1200        let Metadata23 {
1201            metadata_version,
1202            name,
1203            version,
1204            platforms,
1205            // Not used by PyPI legacy upload
1206            supported_platforms: _,
1207            summary,
1208            description,
1209            description_content_type,
1210            keywords,
1211            home_page,
1212            download_url,
1213            author,
1214            author_email,
1215            maintainer,
1216            maintainer_email,
1217            license,
1218            license_expression,
1219            license_files,
1220            classifiers,
1221            requires_dist,
1222            provides_dist,
1223            obsoletes_dist,
1224            requires_python,
1225            requires_external,
1226            project_urls,
1227            provides_extra,
1228            import_names,
1229            import_namespaces,
1230            dynamic,
1231        } = metadata;
1232
1233        let mut form_metadata = vec![
1234            (":action", "file_upload".to_string()),
1235            ("sha256_digest", sha256_hash.digest.to_string()),
1236            ("blake2_256_digest", blake2b_hash.digest.to_string()),
1237            ("protocol_version", "1".to_string()),
1238            ("metadata_version", metadata_version.clone()),
1239            // Twine transforms the name with `re.sub("[^A-Za-z0-9.]+", "-", name)`
1240            // * <https://github.com/pypa/twine/issues/743>
1241            // * <https://github.com/pypa/twine/blob/5bf3f38ff3d8b2de47b7baa7b652c697d7a64776/twine/package.py#L57-L65>
1242            // warehouse seems to call `packaging.utils.canonicalize_name` nowadays and has a separate
1243            // `normalized_name`, so we'll start with this and we'll readjust if there are user reports.
1244            ("name", name.clone()),
1245            ("version", version.clone()),
1246            ("filetype", filename.filetype().to_string()),
1247        ];
1248
1249        if let DistFilename::WheelFilename(wheel) = filename {
1250            form_metadata.push(("pyversion", wheel.python_tags().iter().join(".")));
1251        } else {
1252            form_metadata.push(("pyversion", "source".to_string()));
1253        }
1254
1255        let mut add_option = |name, value: Option<String>| {
1256            if let Some(some) = value.clone() {
1257                form_metadata.push((name, some));
1258            }
1259        };
1260
1261        add_option("author", author);
1262        add_option("author_email", author_email);
1263        add_option("description", description);
1264        add_option("description_content_type", description_content_type);
1265        add_option("download_url", download_url);
1266        add_option("home_page", home_page);
1267        add_option("keywords", keywords.map(|keywords| keywords.as_metadata()));
1268        add_option("license", license);
1269        add_option("license_expression", license_expression);
1270        add_option("maintainer", maintainer);
1271        add_option("maintainer_email", maintainer_email);
1272        add_option("summary", summary);
1273
1274        // The GitLab PyPI repository API implementation requires this metadata field and twine always
1275        // includes it in the request, even when it's empty.
1276        form_metadata.push(("requires_python", requires_python.unwrap_or(String::new())));
1277
1278        let mut add_vec = |name, values: Vec<String>| {
1279            for i in values {
1280                form_metadata.push((name, i.clone()));
1281            }
1282        };
1283
1284        add_vec("classifiers", classifiers);
1285        add_vec("dynamic", dynamic);
1286        add_vec("license_file", license_files);
1287        add_vec("obsoletes_dist", obsoletes_dist);
1288        add_vec("platform", platforms);
1289        add_vec("project_urls", project_urls.to_vec_str());
1290        add_vec("provides_dist", provides_dist);
1291        add_vec("provides_extra", provides_extra);
1292        add_vec("import_names", import_names);
1293        add_vec("import_namespaces", import_namespaces);
1294        add_vec("requires_dist", requires_dist);
1295        add_vec("requires_external", requires_external);
1296
1297        Self(form_metadata)
1298    }
1299
1300    /// Returns an iterator over the metadata fields.
1301    fn iter(&self) -> std::slice::Iter<'_, (&'static str, String)> {
1302        self.0.iter()
1303    }
1304}
1305
1306impl<'a> IntoIterator for &'a FormMetadata {
1307    type Item = &'a (&'a str, String);
1308    type IntoIter = std::slice::Iter<'a, (&'a str, String)>;
1309    fn into_iter(self) -> Self::IntoIter {
1310        self.iter()
1311    }
1312}
1313
1314/// Build the upload request.
1315///
1316/// Returns the [`RequestBuilder`] and the reporter progress bar ID.
1317async fn build_upload_request<'a>(
1318    group: &UploadDistribution,
1319    registry: &DisplaySafeUrl,
1320    client: &'a BaseClient,
1321    credentials: &Credentials,
1322    form_metadata: &FormMetadata,
1323    reporter: Arc<impl Reporter>,
1324) -> Result<(RequestBuilder<'a>, usize), PublishPrepareError> {
1325    let mut form = reqwest::multipart::Form::new();
1326    for (key, value) in form_metadata.iter() {
1327        form = form.text(*key, value.clone());
1328    }
1329
1330    let file = File::open(&group.file).await?;
1331    let file_size = file.metadata().await?.len();
1332    let idx = reporter.on_upload_start(&group.filename.to_string(), Some(file_size));
1333    let reader = ProgressReader::new(file, move |read| {
1334        reporter.on_upload_progress(idx, read as u64);
1335    });
1336    // Stream wrapping puts a static lifetime requirement on the reader (so the request doesn't have
1337    // a lifetime) -> callback needs to be static -> reporter reference needs to be Arc'd.
1338    let file_reader = Body::wrap_stream(ReaderStream::new(reader));
1339    // See [`files_for_publishing`] on `raw_filename`
1340    let part =
1341        Part::stream_with_length(file_reader, file_size).file_name(group.raw_filename.clone());
1342    form = form.part("content", part);
1343
1344    let mut attestations = vec![];
1345    for attestation_path in &group.attestations {
1346        let contents = fs_err::read_to_string(attestation_path)?;
1347        // NOTE: We don't currently validate the interior structure of an attestation beyond being
1348        // valid JSON. We could validate it pretty easily in the future.
1349        let raw_attestation = serde_json::from_str::<serde_json::Value>(&contents)
1350            .map_err(|err| PublishPrepareError::InvalidAttestation(attestation_path.into(), err))?;
1351        attestations.push(raw_attestation);
1352    }
1353
1354    if !attestations.is_empty() {
1355        // PEP 740 specifies the `attestations` field as a JSON array of attestation objects.
1356        let attestations_json =
1357            serde_json::to_string(&attestations).expect("Round-trip of PEP 740 attestation failed");
1358        form = form.text("attestations", attestations_json);
1359    }
1360
1361    // If we have a username but no password, attach the username to the URL so the authentication
1362    // middleware can find the matching password.
1363    let url = if let Some(username) = credentials
1364        .username()
1365        .filter(|_| credentials.password().is_none())
1366    {
1367        let mut url = registry.clone();
1368        let _ = url.set_username(username);
1369        url
1370    } else {
1371        registry.clone()
1372    };
1373
1374    let mut request = client
1375        .for_host(&url)
1376        .post(Url::from(url))
1377        .multipart(form)
1378        // Ask PyPI for a structured error messages instead of HTML-markup error messages.
1379        // For other registries, we ask them to return plain text over HTML. See
1380        // [`PublishSendError::extract_remote_error`].
1381        .header(
1382            reqwest::header::ACCEPT,
1383            "application/json;q=0.9, text/plain;q=0.8, text/html;q=0.7",
1384        );
1385
1386    match credentials {
1387        Credentials::Basic { password, .. } => {
1388            if password.is_some() {
1389                debug!("Using HTTP Basic authentication");
1390                request = request.header(AUTHORIZATION, credentials.to_header_value());
1391            }
1392        }
1393        Credentials::Bearer { .. } => {
1394            debug!("Using Bearer token authentication");
1395            request = request.header(AUTHORIZATION, credentials.to_header_value());
1396        }
1397    }
1398
1399    Ok((request, idx))
1400}
1401
1402/// Build a request with form metadata but without the file content.
1403fn build_metadata_request<'a>(
1404    raw_filename: &str,
1405    registry: &DisplaySafeUrl,
1406    client: &'a BaseClient,
1407    credentials: &Credentials,
1408    form_metadata: &FormMetadata,
1409) -> RequestBuilder<'a> {
1410    let mut form = reqwest::multipart::Form::new();
1411    for (key, value) in form_metadata.iter() {
1412        form = form.text(*key, value.clone());
1413    }
1414    form = form.text("filename", raw_filename.to_owned());
1415
1416    // If we have a username but no password, attach the username to the URL so the authentication
1417    // middleware can find the matching password.
1418    let url = if let Some(username) = credentials
1419        .username()
1420        .filter(|_| credentials.password().is_none())
1421    {
1422        let mut url = registry.clone();
1423        let _ = url.set_username(username);
1424        url
1425    } else {
1426        registry.clone()
1427    };
1428
1429    let mut request = client
1430        .for_host(&url)
1431        .post(Url::from(url))
1432        .multipart(form)
1433        // Ask PyPI for a structured error messages instead of HTML-markup error messages.
1434        // For other registries, we ask them to return plain text over HTML. See
1435        // [`PublishSendError::extract_remote_error`].
1436        .header(
1437            reqwest::header::ACCEPT,
1438            "application/json;q=0.9, text/plain;q=0.8, text/html;q=0.7",
1439        );
1440
1441    match credentials {
1442        Credentials::Basic { password, .. } => {
1443            if password.is_some() {
1444                debug!("Using HTTP Basic authentication");
1445                request = request.header(AUTHORIZATION, credentials.to_header_value());
1446            }
1447        }
1448        Credentials::Bearer { .. } => {
1449            debug!("Using Bearer token authentication");
1450            request = request.header(AUTHORIZATION, credentials.to_header_value());
1451        }
1452    }
1453
1454    request
1455}
1456
1457/// Log response information and map response to an error variant if not successful.
1458async fn handle_response(
1459    registry: &DisplaySafeUrl,
1460    response: Response,
1461) -> Result<(), PublishSendError> {
1462    let status_code = response.status();
1463    debug!("Response code for {registry}: {status_code}");
1464    trace!("Response headers for {registry}: {response:?}");
1465
1466    if status_code.is_success() {
1467        if enabled!(Level::TRACE) {
1468            match response.text().await {
1469                Ok(response_content) => {
1470                    trace!("Response content for {registry}: {response_content}");
1471                }
1472                Err(err) => {
1473                    trace!("Failed to read response content for {registry}: {err}");
1474                }
1475            }
1476        }
1477        return Ok(());
1478    }
1479
1480    let content_type = response
1481        .headers()
1482        .get(reqwest::header::CONTENT_TYPE)
1483        .and_then(|content_type| content_type.to_str().ok())
1484        .map(ToString::to_string);
1485    let upload_error = response.bytes().await.map_err(|err| {
1486        if status_code == StatusCode::METHOD_NOT_ALLOWED {
1487            PublishSendError::MethodNotAllowedNoBody
1488        } else {
1489            PublishSendError::StatusNoBody(status_code, err)
1490        }
1491    })?;
1492    let upload_error = String::from_utf8_lossy(&upload_error);
1493
1494    trace!("Response content for non-200 response for {registry}: {upload_error}");
1495
1496    debug!("Upload error response: {upload_error}");
1497
1498    // That's most likely the simple index URL, not the upload URL.
1499    if status_code == StatusCode::METHOD_NOT_ALLOWED {
1500        return Err(PublishSendError::MethodNotAllowed(
1501            PublishSendError::extract_error_message(
1502                upload_error.to_string(),
1503                content_type.as_deref(),
1504            ),
1505        ));
1506    }
1507
1508    // Try to parse as RFC 9457 Problem Details (e.g., from pyx).
1509    if content_type.as_deref() == Some(uv_client::ProblemDetails::CONTENT_TYPE)
1510        && let Some(problem) =
1511            uv_client::ProblemDetails::try_from_response_body(upload_error.as_bytes())
1512        && let Some(description) = problem.description()
1513    {
1514        return Err(PublishSendError::StatusProblemDetails(
1515            status_code,
1516            description,
1517        ));
1518    }
1519
1520    // Raced uploads of the same file are handled by the caller.
1521    Err(PublishSendError::Status(
1522        status_code,
1523        PublishSendError::extract_error_message(upload_error.to_string(), content_type.as_deref()),
1524    ))
1525}
1526
1527#[cfg(test)]
1528mod tests {
1529    use std::path::PathBuf;
1530    use std::sync::Arc;
1531
1532    use insta::{allow_duplicates, assert_debug_snapshot, assert_snapshot};
1533    use itertools::Itertools;
1534    use uv_auth::Credentials;
1535    use uv_client::{AuthIntegration, BaseClientBuilder, RedirectPolicy};
1536    use uv_distribution_filename::DistFilename;
1537    use uv_pypi_types::{HashDigest, Metadata23};
1538    use uv_redacted::DisplaySafeUrl;
1539
1540    use crate::{
1541        FormMetadata, PublishError, Reporter, UploadDistribution, build_upload_request,
1542        group_files, upload,
1543    };
1544    use tokio::sync::Semaphore;
1545    use uv_errors::{ErrorOptions, write_error_chain_with_options};
1546    use wiremock::matchers::{method, path};
1547    use wiremock::{Mock, MockServer, ResponseTemplate};
1548
1549    struct DummyReporter;
1550
1551    impl Reporter for DummyReporter {
1552        fn on_progress(&self, _name: &str, _id: usize) {}
1553        fn on_upload_start(&self, _name: &str, _size: Option<u64>) -> usize {
1554            0
1555        }
1556        fn on_upload_progress(&self, _id: usize, _inc: u64) {}
1557        fn on_upload_complete(&self, _id: usize) {}
1558        fn on_hash_start(&self, _name: &DistFilename, _size: Option<u64>) -> usize {
1559            0
1560        }
1561        fn on_hash_progress(&self, _id: usize, _inc: u64) {}
1562        fn on_hash_complete(&self, _id: usize) {}
1563    }
1564
1565    async fn mock_server_upload(mock_server: &MockServer) -> Result<bool, PublishError> {
1566        let raw_filename = "tqdm-4.66.1-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl";
1567        let file = PathBuf::from("../../test/links/").join(raw_filename);
1568        let filename = DistFilename::try_from_normalized_filename(raw_filename).unwrap();
1569
1570        let group = UploadDistribution {
1571            file,
1572            raw_filename: raw_filename.to_string(),
1573            filename,
1574            attestations: vec![],
1575        };
1576
1577        let form_metadata =
1578            FormMetadata::read_from_file(&group.file, &group.filename, Arc::new(DummyReporter))
1579                .await
1580                .unwrap();
1581
1582        let client = BaseClientBuilder::default()
1583            .redirect(RedirectPolicy::NoRedirect)
1584            .retries(0)
1585            .auth_integration(AuthIntegration::NoAuthMiddleware)
1586            .build()
1587            .expect("failed to build base client");
1588
1589        let download_concurrency = Arc::new(Semaphore::new(1));
1590        let registry = DisplaySafeUrl::parse(&format!("{}/final", mock_server.uri())).unwrap();
1591        upload(
1592            &group,
1593            &form_metadata,
1594            &registry,
1595            &client,
1596            client.retry_policy(),
1597            &Credentials::basic(Some("ferris".to_string()), Some("F3RR!S".to_string())),
1598            None,
1599            &download_concurrency,
1600            Arc::new(DummyReporter),
1601        )
1602        .await
1603    }
1604
1605    #[test]
1606    fn test_group_files() {
1607        // Fisher-Yates shuffle.
1608        fn shuffle<T>(vec: &mut [T]) {
1609            let n: usize = vec.len();
1610            for i in 0..(n - 1) {
1611                let j = (fastrand::usize(..)) % (n - i) + i;
1612                vec.swap(i, j);
1613            }
1614        }
1615
1616        let valid_sdist = "dist/acme-1.2.3.tar.gz";
1617        let valid_sdist_publish_attestation = format!("{valid_sdist}.publish.attestation");
1618        let valid_sdist_build_attestation = format!("{valid_sdist}.build.attestation");
1619        let valid_sdist_frob_attestation = format!("{valid_sdist}.frob.attestation");
1620
1621        let valid_wheel = "dist/acme-1.2.3-py3-none-any.whl";
1622        let valid_wheel_publish_attestation = format!("{valid_wheel}.publish.attestation");
1623        let valid_wheel_build_attestation = format!("{valid_wheel}.build.attestation");
1624        let valid_wheel_frob_attestation = format!("{valid_wheel}.frob.attestation");
1625
1626        let invalid_sdist = "dist/nudnik.tar.gz";
1627        let invalid_wheel = "dist/nudnik.whl";
1628        let valid_sdist_invalid_attestation = format!("{valid_sdist}.attestation");
1629        let invalid_attestation = "dist/nudnik.attestation";
1630
1631        // Valid sdists/wheels without attestations
1632        {
1633            let dists = [valid_sdist, valid_wheel];
1634
1635            let mut groups = group_files(dists.iter().map(PathBuf::from).collect(), false);
1636            groups.sort_by_key(|group| group.raw_filename.clone());
1637
1638            assert_debug_snapshot!(groups, @r#"
1639            [
1640                UploadDistribution {
1641                    file: "dist/acme-1.2.3-py3-none-any.whl",
1642                    raw_filename: "acme-1.2.3-py3-none-any.whl",
1643                    filename: WheelFilename(
1644                        WheelFilename {
1645                            name: PackageName(
1646                                "acme",
1647                            ),
1648                            version: "1.2.3",
1649                            tags: Small {
1650                                small: WheelTagSmall {
1651                                    python_tag: Python {
1652                                        major: 3,
1653                                        minor: None,
1654                                    },
1655                                    abi_tag: None,
1656                                    platform_tag: Any,
1657                                },
1658                            },
1659                        },
1660                    ),
1661                    attestations: [],
1662                },
1663                UploadDistribution {
1664                    file: "dist/acme-1.2.3.tar.gz",
1665                    raw_filename: "acme-1.2.3.tar.gz",
1666                    filename: SourceDistFilename(
1667                        SourceDistFilename {
1668                            name: PackageName(
1669                                "acme",
1670                            ),
1671                            version: "1.2.3",
1672                            extension: TarGz,
1673                        },
1674                    ),
1675                    attestations: [],
1676                },
1677            ]
1678            "#);
1679        }
1680
1681        // Valid sdists/wheels with attestations in various orders.
1682        {
1683            let mut dists = vec![
1684                valid_sdist,
1685                &valid_sdist_publish_attestation,
1686                &valid_sdist_build_attestation,
1687                &valid_sdist_frob_attestation,
1688                valid_wheel,
1689                &valid_wheel_build_attestation,
1690                &valid_wheel_publish_attestation,
1691                &valid_wheel_frob_attestation,
1692            ];
1693
1694            allow_duplicates! {
1695                for _ in 0..5 {
1696                    shuffle(&mut dists);
1697
1698                    let mut groups =
1699                        group_files(dists.iter().map(PathBuf::from).collect(), false);
1700                    groups.sort_by_key(|group| group.raw_filename.clone());
1701
1702                    assert_debug_snapshot!(groups, @r#"
1703                    [
1704                        UploadDistribution {
1705                            file: "dist/acme-1.2.3-py3-none-any.whl",
1706                            raw_filename: "acme-1.2.3-py3-none-any.whl",
1707                            filename: WheelFilename(
1708                                WheelFilename {
1709                                    name: PackageName(
1710                                        "acme",
1711                                    ),
1712                                    version: "1.2.3",
1713                                    tags: Small {
1714                                        small: WheelTagSmall {
1715                                            python_tag: Python {
1716                                                major: 3,
1717                                                minor: None,
1718                                            },
1719                                            abi_tag: None,
1720                                            platform_tag: Any,
1721                                        },
1722                                    },
1723                                },
1724                            ),
1725                            attestations: [
1726                                "dist/acme-1.2.3-py3-none-any.whl.build.attestation",
1727                                "dist/acme-1.2.3-py3-none-any.whl.frob.attestation",
1728                                "dist/acme-1.2.3-py3-none-any.whl.publish.attestation",
1729                            ],
1730                        },
1731                        UploadDistribution {
1732                            file: "dist/acme-1.2.3.tar.gz",
1733                            raw_filename: "acme-1.2.3.tar.gz",
1734                            filename: SourceDistFilename(
1735                                SourceDistFilename {
1736                                    name: PackageName(
1737                                        "acme",
1738                                    ),
1739                                    version: "1.2.3",
1740                                    extension: TarGz,
1741                                },
1742                            ),
1743                            attestations: [
1744                                "dist/acme-1.2.3.tar.gz.build.attestation",
1745                                "dist/acme-1.2.3.tar.gz.frob.attestation",
1746                                "dist/acme-1.2.3.tar.gz.publish.attestation",
1747                            ],
1748                        },
1749                    ]
1750                    "#);
1751                }
1752            }
1753        }
1754
1755        // Valid sdists/wheels with attestations in various orders, but
1756        // attestations are disabled while grouping.
1757        {
1758            let mut dists = vec![
1759                valid_sdist,
1760                &valid_sdist_publish_attestation,
1761                &valid_sdist_build_attestation,
1762                &valid_sdist_frob_attestation,
1763                valid_wheel,
1764                &valid_wheel_build_attestation,
1765                &valid_wheel_publish_attestation,
1766                &valid_wheel_frob_attestation,
1767            ];
1768
1769            allow_duplicates! {
1770                for _ in 0..5 {
1771                    shuffle(&mut dists);
1772
1773                    let mut groups =
1774                        group_files(dists.iter().map(PathBuf::from).collect(), true);
1775                    groups.sort_by_key(|group| group.raw_filename.clone());
1776
1777                    assert_debug_snapshot!(groups, @r#"
1778                    [
1779                        UploadDistribution {
1780                            file: "dist/acme-1.2.3-py3-none-any.whl",
1781                            raw_filename: "acme-1.2.3-py3-none-any.whl",
1782                            filename: WheelFilename(
1783                                WheelFilename {
1784                                    name: PackageName(
1785                                        "acme",
1786                                    ),
1787                                    version: "1.2.3",
1788                                    tags: Small {
1789                                        small: WheelTagSmall {
1790                                            python_tag: Python {
1791                                                major: 3,
1792                                                minor: None,
1793                                            },
1794                                            abi_tag: None,
1795                                            platform_tag: Any,
1796                                        },
1797                                    },
1798                                },
1799                            ),
1800                            attestations: [],
1801                        },
1802                        UploadDistribution {
1803                            file: "dist/acme-1.2.3.tar.gz",
1804                            raw_filename: "acme-1.2.3.tar.gz",
1805                            filename: SourceDistFilename(
1806                                SourceDistFilename {
1807                                    name: PackageName(
1808                                        "acme",
1809                                    ),
1810                                    version: "1.2.3",
1811                                    extension: TarGz,
1812                                },
1813                            ),
1814                            attestations: [],
1815                        },
1816                    ]
1817                    "#);
1818                }
1819            }
1820        }
1821
1822        // Invalid dist/attestation filenames get ignored.
1823        {
1824            let dists = [
1825                valid_sdist,
1826                &valid_sdist_frob_attestation,
1827                valid_wheel,
1828                &valid_wheel_build_attestation,
1829                invalid_sdist,
1830                invalid_wheel,
1831                &valid_sdist_invalid_attestation,
1832                invalid_attestation,
1833            ];
1834
1835            let groups = group_files(dists.iter().map(PathBuf::from).collect(), false);
1836            assert_debug_snapshot!(groups, @r#"
1837            [
1838                UploadDistribution {
1839                    file: "dist/acme-1.2.3-py3-none-any.whl",
1840                    raw_filename: "acme-1.2.3-py3-none-any.whl",
1841                    filename: WheelFilename(
1842                        WheelFilename {
1843                            name: PackageName(
1844                                "acme",
1845                            ),
1846                            version: "1.2.3",
1847                            tags: Small {
1848                                small: WheelTagSmall {
1849                                    python_tag: Python {
1850                                        major: 3,
1851                                        minor: None,
1852                                    },
1853                                    abi_tag: None,
1854                                    platform_tag: Any,
1855                                },
1856                            },
1857                        },
1858                    ),
1859                    attestations: [
1860                        "dist/acme-1.2.3-py3-none-any.whl.build.attestation",
1861                    ],
1862                },
1863                UploadDistribution {
1864                    file: "dist/acme-1.2.3.tar.gz",
1865                    raw_filename: "acme-1.2.3.tar.gz",
1866                    filename: SourceDistFilename(
1867                        SourceDistFilename {
1868                            name: PackageName(
1869                                "acme",
1870                            ),
1871                            version: "1.2.3",
1872                            extension: TarGz,
1873                        },
1874                    ),
1875                    attestations: [
1876                        "dist/acme-1.2.3.tar.gz.frob.attestation",
1877                    ],
1878                },
1879            ]
1880            "#);
1881        }
1882    }
1883
1884    #[test]
1885    fn form_metadata_import_names() {
1886        let filename = DistFilename::try_from_normalized_filename("pkg-1.0.0.tar.gz").unwrap();
1887        let sha256_hash: HashDigest = "sha256:0123".parse().unwrap();
1888        let blake2b_hash: HashDigest = "blake2b:4567".parse().unwrap();
1889        let metadata = Metadata23 {
1890            metadata_version: "2.5".to_string(),
1891            name: "pkg".to_string(),
1892            version: "1.0.0".to_string(),
1893            requires_python: Some(">=3.12".to_string()),
1894            import_names: vec!["spam".to_string(), "spam.eggs; private".to_string()],
1895            import_namespaces: vec!["zope".to_string()],
1896            ..Default::default()
1897        };
1898
1899        let form_metadata =
1900            FormMetadata::from_metadata(metadata, &filename, &sha256_hash, &blake2b_hash);
1901        let formatted_metadata = form_metadata
1902            .iter()
1903            .map(|(key, value)| format!("{key}: {value}"))
1904            .join("\n");
1905
1906        assert_snapshot!(formatted_metadata, @r###"
1907        :action: file_upload
1908        sha256_digest: 0123
1909        blake2_256_digest: 4567
1910        protocol_version: 1
1911        metadata_version: 2.5
1912        name: pkg
1913        version: 1.0.0
1914        filetype: sdist
1915        pyversion: source
1916        requires_python: >=3.12
1917        import_names: spam
1918        import_names: spam.eggs; private
1919        import_namespaces: zope
1920        "###);
1921    }
1922
1923    /// Snapshot the data we send for an upload request for a source distribution.
1924    #[tokio::test]
1925    async fn upload_request_source_dist() {
1926        let group = {
1927            let raw_filename = "tqdm-999.0.0.tar.gz";
1928            let file = PathBuf::from("../../test/links/").join(raw_filename);
1929            let filename = DistFilename::try_from_normalized_filename(raw_filename).unwrap();
1930
1931            UploadDistribution {
1932                file,
1933                raw_filename: raw_filename.to_string(),
1934                filename,
1935                attestations: vec![],
1936            }
1937        };
1938
1939        let form_metadata =
1940            FormMetadata::read_from_file(&group.file, &group.filename, Arc::new(DummyReporter))
1941                .await
1942                .unwrap();
1943
1944        let formatted_metadata = form_metadata
1945            .iter()
1946            .map(|(k, v)| format!("{k}: {v}"))
1947            .join("\n");
1948        assert_snapshot!(&formatted_metadata, @"
1949        :action: file_upload
1950        sha256_digest: 89fa05cffa7f457658373b85de302d24d0c205ceda2819a8739e324b75e9430b
1951        blake2_256_digest: 40ab79b48c4e289e4990f7e689177adae4096c07a634034eb1d10c0b6700e4d2
1952        protocol_version: 1
1953        metadata_version: 2.3
1954        name: tqdm
1955        version: 999.0.0
1956        filetype: sdist
1957        pyversion: source
1958        author_email: Charlie Marsh <charlie.r.marsh@gmail.com>
1959        description: # tqdm
1960
1961        [![PyPI - Version](https://img.shields.io/pypi/v/tqdm.svg)](https://pypi.org/project/tqdm)
1962        [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/tqdm.svg)](https://pypi.org/project/tqdm)
1963
1964        -----
1965
1966        **Table of Contents**
1967
1968        - [Installation](#installation)
1969        - [License](#license)
1970
1971        ## Installation
1972
1973        ```console
1974        pip install tqdm
1975        ```
1976
1977        ## License
1978
1979        `tqdm` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.
1980
1981        description_content_type: text/markdown
1982        license_expression: MIT
1983        requires_python: >=3.8
1984        classifiers: Development Status :: 4 - Beta
1985        classifiers: Programming Language :: Python
1986        classifiers: Programming Language :: Python :: 3.8
1987        classifiers: Programming Language :: Python :: 3.9
1988        classifiers: Programming Language :: Python :: 3.10
1989        classifiers: Programming Language :: Python :: 3.11
1990        classifiers: Programming Language :: Python :: 3.12
1991        classifiers: Programming Language :: Python :: Implementation :: CPython
1992        classifiers: Programming Language :: Python :: Implementation :: PyPy
1993        license_file: LICENSE.txt
1994        project_urls: Documentation, https://github.com/unknown/tqdm#readme
1995        project_urls: Issues, https://github.com/unknown/tqdm/issues
1996        project_urls: Source, https://github.com/unknown/tqdm
1997        ");
1998
1999        let client = BaseClientBuilder::default()
2000            .build()
2001            .expect("failed to build base client");
2002        let (request, _) = build_upload_request(
2003            &group,
2004            &DisplaySafeUrl::parse("https://example.org/upload").unwrap(),
2005            &client,
2006            &Credentials::basic(Some("ferris".to_string()), Some("F3RR!S".to_string())),
2007            &form_metadata,
2008            Arc::new(DummyReporter),
2009        )
2010        .await
2011        .unwrap();
2012
2013        insta::with_settings!({
2014            filters => [("boundary=[0-9a-f-]+", "boundary=[...]")],
2015        }, {
2016            assert_debug_snapshot!(&request.raw_builder(), @r#"
2017            RequestBuilder {
2018                inner: RequestBuilder {
2019                    method: POST,
2020                    url: Url {
2021                        scheme: "https",
2022                        cannot_be_a_base: false,
2023                        username: "",
2024                        password: None,
2025                        host: Some(
2026                            Domain(
2027                                "example.org",
2028                            ),
2029                        ),
2030                        port: None,
2031                        path: "/upload",
2032                        query: None,
2033                        fragment: None,
2034                    },
2035                    headers: {
2036                        "content-type": "multipart/form-data; boundary=[...]",
2037                        "content-length": "7000",
2038                        "accept": "application/json;q=0.9, text/plain;q=0.8, text/html;q=0.7",
2039                        "authorization": Sensitive,
2040                    },
2041                },
2042                ..
2043            }
2044            "#);
2045        });
2046    }
2047
2048    /// Snapshot the data we send for an upload request for a wheel.
2049    #[tokio::test]
2050    async fn upload_request_wheel() {
2051        let group = {
2052            let raw_filename = "tqdm-4.66.1-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl";
2053            let file = PathBuf::from("../../test/links/").join(raw_filename);
2054            let filename = DistFilename::try_from_normalized_filename(raw_filename).unwrap();
2055
2056            UploadDistribution {
2057                file,
2058                raw_filename: raw_filename.to_string(),
2059                filename,
2060                attestations: vec![],
2061            }
2062        };
2063
2064        let form_metadata =
2065            FormMetadata::read_from_file(&group.file, &group.filename, Arc::new(DummyReporter))
2066                .await
2067                .unwrap();
2068
2069        let formatted_metadata = form_metadata
2070            .iter()
2071            .map(|(k, v)| format!("{k}: {v}"))
2072            .join("\n");
2073        assert_snapshot!(&formatted_metadata, @r#"
2074        :action: file_upload
2075        sha256_digest: 0d88ca657bc6b64995ca416e0c59c71af85cc10015d940fa446c42a8b485ee1c
2076        blake2_256_digest: 33d4e92517a16e3fa0c0893de0c7e4d46a2c38adab148dd2ff66eb47481d19cd
2077        protocol_version: 1
2078        metadata_version: 2.1
2079        name: tqdm
2080        version: 4.66.1
2081        filetype: bdist_wheel
2082        pyversion: py3
2083        description_content_type: text/x-rst
2084        keywords: progressbar,progressmeter,progress,bar,meter,rate,eta,console,terminal,time
2085        license: MPL-2.0 AND MIT
2086        maintainer_email: tqdm developers <devs@tqdm.ml>
2087        summary: Fast, Extensible Progress Meter
2088        requires_python: >=3.7
2089        classifiers: Development Status :: 5 - Production/Stable
2090        classifiers: Environment :: Console
2091        classifiers: Environment :: MacOS X
2092        classifiers: Environment :: Other Environment
2093        classifiers: Environment :: Win32 (MS Windows)
2094        classifiers: Environment :: X11 Applications
2095        classifiers: Framework :: IPython
2096        classifiers: Framework :: Jupyter
2097        classifiers: Intended Audience :: Developers
2098        classifiers: Intended Audience :: Education
2099        classifiers: Intended Audience :: End Users/Desktop
2100        classifiers: Intended Audience :: Other Audience
2101        classifiers: Intended Audience :: System Administrators
2102        classifiers: License :: OSI Approved :: MIT License
2103        classifiers: License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)
2104        classifiers: Operating System :: MacOS
2105        classifiers: Operating System :: MacOS :: MacOS X
2106        classifiers: Operating System :: Microsoft
2107        classifiers: Operating System :: Microsoft :: MS-DOS
2108        classifiers: Operating System :: Microsoft :: Windows
2109        classifiers: Operating System :: POSIX
2110        classifiers: Operating System :: POSIX :: BSD
2111        classifiers: Operating System :: POSIX :: BSD :: FreeBSD
2112        classifiers: Operating System :: POSIX :: Linux
2113        classifiers: Operating System :: POSIX :: SunOS/Solaris
2114        classifiers: Operating System :: Unix
2115        classifiers: Programming Language :: Python
2116        classifiers: Programming Language :: Python :: 3
2117        classifiers: Programming Language :: Python :: 3.7
2118        classifiers: Programming Language :: Python :: 3.8
2119        classifiers: Programming Language :: Python :: 3.9
2120        classifiers: Programming Language :: Python :: 3.10
2121        classifiers: Programming Language :: Python :: 3.11
2122        classifiers: Programming Language :: Python :: 3 :: Only
2123        classifiers: Programming Language :: Python :: Implementation
2124        classifiers: Programming Language :: Python :: Implementation :: IronPython
2125        classifiers: Programming Language :: Python :: Implementation :: PyPy
2126        classifiers: Programming Language :: Unix Shell
2127        classifiers: Topic :: Desktop Environment
2128        classifiers: Topic :: Education :: Computer Aided Instruction (CAI)
2129        classifiers: Topic :: Education :: Testing
2130        classifiers: Topic :: Office/Business
2131        classifiers: Topic :: Other/Nonlisted Topic
2132        classifiers: Topic :: Software Development :: Build Tools
2133        classifiers: Topic :: Software Development :: Libraries
2134        classifiers: Topic :: Software Development :: Libraries :: Python Modules
2135        classifiers: Topic :: Software Development :: Pre-processors
2136        classifiers: Topic :: Software Development :: User Interfaces
2137        classifiers: Topic :: System :: Installation/Setup
2138        classifiers: Topic :: System :: Logging
2139        classifiers: Topic :: System :: Monitoring
2140        classifiers: Topic :: System :: Shells
2141        classifiers: Topic :: Terminals
2142        classifiers: Topic :: Utilities
2143        license_file: LICENCE
2144        project_urls: homepage, https://tqdm.github.io
2145        project_urls: repository, https://github.com/tqdm/tqdm
2146        project_urls: changelog, https://tqdm.github.io/releases
2147        project_urls: wiki, https://github.com/tqdm/tqdm/wiki
2148        provides_extra: dev
2149        provides_extra: notebook
2150        provides_extra: slack
2151        provides_extra: telegram
2152        requires_dist: colorama ; platform_system == "Windows"
2153        requires_dist: pytest >=6 ; extra == 'dev'
2154        requires_dist: pytest-cov ; extra == 'dev'
2155        requires_dist: pytest-timeout ; extra == 'dev'
2156        requires_dist: pytest-xdist ; extra == 'dev'
2157        requires_dist: ipywidgets >=6 ; extra == 'notebook'
2158        requires_dist: slack-sdk ; extra == 'slack'
2159        requires_dist: requests ; extra == 'telegram'
2160        "#);
2161
2162        let client = BaseClientBuilder::default()
2163            .build()
2164            .expect("failed to build base client");
2165        let (request, _) = build_upload_request(
2166            &group,
2167            &DisplaySafeUrl::parse("https://example.org/upload").unwrap(),
2168            &client,
2169            &Credentials::basic(Some("ferris".to_string()), Some("F3RR!S".to_string())),
2170            &form_metadata,
2171            Arc::new(DummyReporter),
2172        )
2173        .await
2174        .unwrap();
2175
2176        insta::with_settings!({
2177            filters => [("boundary=[0-9a-f-]+", "boundary=[...]")],
2178        }, {
2179            assert_debug_snapshot!(&request.raw_builder(), @r#"
2180            RequestBuilder {
2181                inner: RequestBuilder {
2182                    method: POST,
2183                    url: Url {
2184                        scheme: "https",
2185                        cannot_be_a_base: false,
2186                        username: "",
2187                        password: None,
2188                        host: Some(
2189                            Domain(
2190                                "example.org",
2191                            ),
2192                        ),
2193                        port: None,
2194                        path: "/upload",
2195                        query: None,
2196                        fragment: None,
2197                    },
2198                    headers: {
2199                        "content-type": "multipart/form-data; boundary=[...]",
2200                        "content-length": "19527",
2201                        "accept": "application/json;q=0.9, text/plain;q=0.8, text/html;q=0.7",
2202                        "authorization": Sensitive,
2203                    },
2204                },
2205                ..
2206            }
2207            "#);
2208        });
2209    }
2210
2211    #[tokio::test]
2212    async fn upload_redirect_308() {
2213        let mock_server = MockServer::start().await;
2214        Mock::given(method("POST"))
2215            .and(path("/final"))
2216            .respond_with(
2217                ResponseTemplate::new(308)
2218                    .insert_header("Location", format!("{}/final/", mock_server.uri())),
2219            )
2220            .mount(&mock_server)
2221            .await;
2222        Mock::given(method("POST"))
2223            .and(path("/final/"))
2224            .respond_with(ResponseTemplate::new(200))
2225            .mount(&mock_server)
2226            .await;
2227
2228        assert!(mock_server_upload(&mock_server).await.unwrap());
2229    }
2230
2231    #[tokio::test]
2232    async fn upload_infinite_redirects() {
2233        let mock_server = MockServer::start().await;
2234        Mock::given(method("POST"))
2235            .and(path("/final"))
2236            .respond_with(
2237                ResponseTemplate::new(308)
2238                    .insert_header("Location", format!("{}/final/", mock_server.uri())),
2239            )
2240            .mount(&mock_server)
2241            .await;
2242        Mock::given(method("POST"))
2243            .and(path("/final/"))
2244            .respond_with(
2245                ResponseTemplate::new(308)
2246                    .insert_header("Location", format!("{}/final", mock_server.uri())),
2247            )
2248            .mount(&mock_server)
2249            .await;
2250
2251        let err = mock_server_upload(&mock_server).await.unwrap_err();
2252
2253        let mut capture = String::new();
2254        write_error_chain_with_options(&err, ErrorOptions::default().with_stream(&mut capture))
2255            .unwrap();
2256
2257        let capture = capture.replace(&mock_server.uri(), "[SERVER]");
2258        let capture = anstream::adapter::strip_str(&capture);
2259        assert_snapshot!(
2260            &capture,
2261            @"
2262        error: Failed to publish `../../test/links/tqdm-4.66.1-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl` to [SERVER]/final
2263          Caused by: Too many redirects, only 10 redirects are allowed
2264        "
2265        );
2266    }
2267
2268    #[tokio::test]
2269    async fn upload_redirect_different_realm() {
2270        let mock_server = MockServer::start().await;
2271        Mock::given(method("POST"))
2272            .and(path("/final"))
2273            .respond_with(
2274                ResponseTemplate::new(308)
2275                    .insert_header("Location", "https://different.auth.tld/final/"),
2276            )
2277            .mount(&mock_server)
2278            .await;
2279
2280        let err = mock_server_upload(&mock_server).await.unwrap_err();
2281
2282        let mut capture = String::new();
2283        write_error_chain_with_options(&err, ErrorOptions::default().with_stream(&mut capture))
2284            .unwrap();
2285
2286        let capture = capture.replace(&mock_server.uri(), "[SERVER]");
2287        let capture = anstream::adapter::strip_str(&capture);
2288        assert_snapshot!(
2289            &capture,
2290            @"
2291        error: Failed to publish `../../test/links/tqdm-4.66.1-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl` to https://different.auth.tld/final/
2292          Caused by: Redirected URL is not in the same realm. Redirected to: https://different.auth.tld/final/
2293        "
2294        );
2295    }
2296
2297    /// PyPI returns `application/json` with a `code` field.
2298    #[tokio::test]
2299    async fn upload_error_pypi_json() {
2300        let mock_server = MockServer::start().await;
2301        Mock::given(method("POST"))
2302            .and(path("/final"))
2303            .respond_with(
2304                ResponseTemplate::new(400)
2305                    .insert_header("content-type", "application/json")
2306                    .set_body_raw(
2307                        r#"{"message": "The server could not comply with the request since it is either malformed or otherwise incorrect.\n\n\nError: Use 'source' as Python version for an sdist.\n\n", "code": "400 Error: Use 'source' as Python version for an sdist.", "title": "Bad Request"}"#,
2308                        "application/json",
2309                    ),
2310            )
2311            .mount(&mock_server)
2312            .await;
2313
2314        let err = mock_server_upload(&mock_server).await.unwrap_err();
2315
2316        let mut capture = String::new();
2317        write_error_chain_with_options(&err, ErrorOptions::default().with_stream(&mut capture))
2318            .unwrap();
2319
2320        let capture = capture.replace(&mock_server.uri(), "[SERVER]");
2321        let capture = anstream::adapter::strip_str(&capture);
2322        assert_snapshot!(
2323            &capture,
2324            @"
2325        error: Failed to publish `../../test/links/tqdm-4.66.1-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl` to [SERVER]/final
2326          Caused by: Server returned status code 400 Bad Request. Server says: 400 Error: Use 'source' as Python version for an sdist.
2327        "
2328        );
2329    }
2330
2331    /// pyx returns `application/problem+json` with RFC 9457 Problem Details.
2332    #[tokio::test]
2333    async fn upload_error_problem_details() {
2334        let mock_server = MockServer::start().await;
2335        Mock::given(method("POST"))
2336            .and(path("/final"))
2337            .respond_with(
2338                ResponseTemplate::new(400)
2339                    .insert_header(
2340                        "content-type",
2341                        uv_client::ProblemDetails::CONTENT_TYPE,
2342                    )
2343                    .set_body_raw(
2344                        r#"{"type": "about:blank", "status": 400, "title": "Bad Request", "detail": "Missing required field `name`"}"#,
2345                        uv_client::ProblemDetails::CONTENT_TYPE,
2346                    ),
2347            )
2348            .mount(&mock_server)
2349            .await;
2350
2351        let err = mock_server_upload(&mock_server).await.unwrap_err();
2352
2353        let mut capture = String::new();
2354        write_error_chain_with_options(&err, ErrorOptions::default().with_stream(&mut capture))
2355            .unwrap();
2356
2357        let capture = capture.replace(&mock_server.uri(), "[SERVER]");
2358        let capture = anstream::adapter::strip_str(&capture);
2359        assert_snapshot!(
2360            &capture,
2361            @"
2362        error: Failed to publish `../../test/links/tqdm-4.66.1-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl` to [SERVER]/final
2363          Caused by: Server returned status code 400 Bad Request. Server message: Bad Request, Missing required field `name`
2364        "
2365        );
2366    }
2367}