mirror of https://github.com/astral-sh/uv
Support redirects in `uv publish` (#17130)
<!-- Thank you for contributing to uv! To help us out with reviewing, please consider the following: - Does this pull request include a summary of the change? (See below.) - Does this pull request include a descriptive title? - Does this pull request include references to any relevant issues? --> ## Summary Follow redirects for `uv publish`. Related issue: https://github.com/astral-sh/uv/issues/17126. ## Test Plan <!-- How was it tested? --> Added a unit test to test the custom redirect logic. --------- Co-authored-by: konstin <konstin@mailbox.org>
This commit is contained in:
parent
13e7ad62cb
commit
b58f543e5e
|
|
@ -6464,6 +6464,7 @@ name = "uv-publish"
|
||||||
version = "0.0.7"
|
version = "0.0.7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ambient-id",
|
"ambient-id",
|
||||||
|
"anstream",
|
||||||
"astral-reqwest-middleware",
|
"astral-reqwest-middleware",
|
||||||
"astral-reqwest-retry",
|
"astral-reqwest-retry",
|
||||||
"astral-tokio-tar",
|
"astral-tokio-tar",
|
||||||
|
|
@ -6497,6 +6498,7 @@ dependencies = [
|
||||||
"uv-redacted",
|
"uv-redacted",
|
||||||
"uv-static",
|
"uv-static",
|
||||||
"uv-warnings",
|
"uv-warnings",
|
||||||
|
"wiremock",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ pub const DEFAULT_RETRIES: u32 = 3;
|
||||||
/// Maximum number of redirects to follow before giving up.
|
/// Maximum number of redirects to follow before giving up.
|
||||||
///
|
///
|
||||||
/// This is the default used by [`reqwest`].
|
/// This is the default used by [`reqwest`].
|
||||||
const DEFAULT_MAX_REDIRECTS: u32 = 10;
|
pub const DEFAULT_MAX_REDIRECTS: u32 = 10;
|
||||||
|
|
||||||
/// Selectively skip parts or the entire auth middleware.
|
/// Selectively skip parts or the entire auth middleware.
|
||||||
#[derive(Debug, Clone, Copy, Default)]
|
#[derive(Debug, Clone, Copy, Default)]
|
||||||
|
|
@ -104,6 +104,8 @@ pub enum RedirectPolicy {
|
||||||
BypassMiddleware,
|
BypassMiddleware,
|
||||||
/// Handle redirects manually, re-triggering our custom middleware for each request.
|
/// Handle redirects manually, re-triggering our custom middleware for each request.
|
||||||
RetriggerMiddleware,
|
RetriggerMiddleware,
|
||||||
|
/// No redirect for non-cloneable (e.g., streaming) requests with custom redirect logic.
|
||||||
|
NoRedirect,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RedirectPolicy {
|
impl RedirectPolicy {
|
||||||
|
|
@ -111,6 +113,7 @@ impl RedirectPolicy {
|
||||||
match self {
|
match self {
|
||||||
Self::BypassMiddleware => reqwest::redirect::Policy::default(),
|
Self::BypassMiddleware => reqwest::redirect::Policy::default(),
|
||||||
Self::RetriggerMiddleware => reqwest::redirect::Policy::none(),
|
Self::RetriggerMiddleware => reqwest::redirect::Policy::none(),
|
||||||
|
Self::NoRedirect => reqwest::redirect::Policy::none(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -729,6 +732,7 @@ impl RedirectClientWithMiddleware {
|
||||||
match self.redirect_policy {
|
match self.redirect_policy {
|
||||||
RedirectPolicy::BypassMiddleware => self.client.execute(req).await,
|
RedirectPolicy::BypassMiddleware => self.client.execute(req).await,
|
||||||
RedirectPolicy::RetriggerMiddleware => self.execute_with_redirect_handling(req).await,
|
RedirectPolicy::RetriggerMiddleware => self.execute_with_redirect_handling(req).await,
|
||||||
|
RedirectPolicy::NoRedirect => self.client.execute(req).await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
pub use base_client::{
|
pub use base_client::{
|
||||||
AuthIntegration, BaseClient, BaseClientBuilder, DEFAULT_RETRIES, ExtraMiddleware,
|
AuthIntegration, BaseClient, BaseClientBuilder, DEFAULT_MAX_REDIRECTS, DEFAULT_RETRIES,
|
||||||
RedirectClientWithMiddleware, RequestBuilder, RetryParsingError, UvRetryableStrategy,
|
ExtraMiddleware, RedirectClientWithMiddleware, RedirectPolicy, RequestBuilder,
|
||||||
is_transient_network_error,
|
RetryParsingError, UvRetryableStrategy, is_transient_network_error,
|
||||||
};
|
};
|
||||||
pub use cached_client::{CacheControl, CachedClient, CachedClientError, DataWithCachePolicy};
|
pub use cached_client::{CacheControl, CachedClient, CachedClientError, DataWithCachePolicy};
|
||||||
pub use error::{Error, ErrorKind, WrappedReqwestError};
|
pub use error::{Error, ErrorKind, WrappedReqwestError};
|
||||||
|
|
|
||||||
|
|
@ -46,8 +46,10 @@ tokio = { workspace = true }
|
||||||
tokio-util = { workspace = true, features = ["io"] }
|
tokio-util = { workspace = true, features = ["io"] }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
url = { workspace = true }
|
url = { workspace = true }
|
||||||
|
wiremock = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
anstream = { workspace = true }
|
||||||
insta = { workspace = true }
|
insta = { workspace = true }
|
||||||
fastrand = { workspace = true }
|
fastrand = { workspace = true }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ use fs_err::tokio::File;
|
||||||
use futures::TryStreamExt;
|
use futures::TryStreamExt;
|
||||||
use glob::{GlobError, PatternError, glob};
|
use glob::{GlobError, PatternError, glob};
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use reqwest::header::AUTHORIZATION;
|
use reqwest::header::{AUTHORIZATION, LOCATION, ToStrError};
|
||||||
use reqwest::multipart::Part;
|
use reqwest::multipart::Part;
|
||||||
use reqwest::{Body, Response, StatusCode};
|
use reqwest::{Body, Response, StatusCode};
|
||||||
use reqwest_retry::RetryPolicy;
|
use reqwest_retry::RetryPolicy;
|
||||||
|
|
@ -24,11 +24,11 @@ use tokio_util::io::ReaderStream;
|
||||||
use tracing::{Level, debug, enabled, trace, warn};
|
use tracing::{Level, debug, enabled, trace, warn};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
use uv_auth::{Credentials, PyxTokenStore};
|
use uv_auth::{Credentials, PyxTokenStore, Realm};
|
||||||
use uv_cache::{Cache, Refresh};
|
use uv_cache::{Cache, Refresh};
|
||||||
use uv_client::{
|
use uv_client::{
|
||||||
BaseClient, MetadataFormat, OwnedArchive, RegistryClientBuilder, RequestBuilder,
|
BaseClient, DEFAULT_MAX_REDIRECTS, MetadataFormat, OwnedArchive, RegistryClientBuilder,
|
||||||
RetryParsingError, is_transient_network_error,
|
RequestBuilder, RetryParsingError, is_transient_network_error,
|
||||||
};
|
};
|
||||||
use uv_configuration::{KeyringProviderType, TrustedPublishing};
|
use uv_configuration::{KeyringProviderType, TrustedPublishing};
|
||||||
use uv_distribution_filename::{DistFilename, SourceDistExtension, SourceDistFilename};
|
use uv_distribution_filename::{DistFilename, SourceDistExtension, SourceDistFilename};
|
||||||
|
|
@ -37,7 +37,7 @@ use uv_extract::hash::{HashReader, Hasher};
|
||||||
use uv_fs::{ProgressReader, Simplified};
|
use uv_fs::{ProgressReader, Simplified};
|
||||||
use uv_metadata::read_metadata_async_seek;
|
use uv_metadata::read_metadata_async_seek;
|
||||||
use uv_pypi_types::{HashAlgorithm, HashDigest, Metadata23, MetadataError};
|
use uv_pypi_types::{HashAlgorithm, HashDigest, Metadata23, MetadataError};
|
||||||
use uv_redacted::DisplaySafeUrl;
|
use uv_redacted::{DisplaySafeUrl, DisplaySafeUrlError};
|
||||||
use uv_warnings::warn_user;
|
use uv_warnings::warn_user;
|
||||||
|
|
||||||
use crate::trusted_publishing::{TrustedPublishingError, TrustedPublishingToken};
|
use crate::trusted_publishing::{TrustedPublishingError, TrustedPublishingToken};
|
||||||
|
|
@ -132,11 +132,16 @@ pub enum PublishSendError {
|
||||||
/// The registry returned a "403 Forbidden".
|
/// The registry returned a "403 Forbidden".
|
||||||
#[error("Permission denied (status code {0}): {1}")]
|
#[error("Permission denied (status code {0}): {1}")]
|
||||||
PermissionDenied(StatusCode, String),
|
PermissionDenied(StatusCode, String),
|
||||||
/// See inline comment.
|
#[error("Too many redirects, only {0} redirects are allowed")]
|
||||||
#[error(
|
TooManyRedirects(u32),
|
||||||
"The request was redirected, but redirects are not allowed when publishing, please use the canonical URL: `{0}`"
|
#[error("Redirected URL is not in the same realm. Redirected to: {0}")]
|
||||||
)]
|
RedirectRealmMismatch(String),
|
||||||
RedirectError(Url),
|
#[error("Request was redirected, but no location header was provided")]
|
||||||
|
RedirectNoLocation,
|
||||||
|
#[error("Request was redirected, but location header is not a UTF-8 string")]
|
||||||
|
RedirectLocationInvalidStr(#[source] ToStrError),
|
||||||
|
#[error("Request was redirected, but location header is not a URL")]
|
||||||
|
RedirectInvalidLocation(#[source] DisplaySafeUrlError),
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait Reporter: Send + Sync + 'static {
|
pub trait Reporter: Send + Sync + 'static {
|
||||||
|
|
@ -470,11 +475,15 @@ pub async fn upload(
|
||||||
reporter: Arc<impl Reporter>,
|
reporter: Arc<impl Reporter>,
|
||||||
) -> Result<bool, PublishError> {
|
) -> Result<bool, PublishError> {
|
||||||
let mut n_past_retries = 0;
|
let mut n_past_retries = 0;
|
||||||
|
let mut n_past_redirections = 0;
|
||||||
|
let max_redirects = DEFAULT_MAX_REDIRECTS;
|
||||||
let start_time = SystemTime::now();
|
let start_time = SystemTime::now();
|
||||||
|
let mut current_registry = registry.clone();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let (request, idx) = build_upload_request(
|
let (request, idx) = build_upload_request(
|
||||||
group,
|
group,
|
||||||
registry,
|
¤t_registry,
|
||||||
client,
|
client,
|
||||||
credentials,
|
credentials,
|
||||||
form_metadata,
|
form_metadata,
|
||||||
|
|
@ -486,6 +495,60 @@ pub async fn upload(
|
||||||
let result = request.send().await;
|
let result = request.send().await;
|
||||||
let response = match result {
|
let response = match result {
|
||||||
Ok(response) => {
|
Ok(response) => {
|
||||||
|
// When the user accidentally uses https://test.pypi.org/legacy (no slash) as publish URL, we
|
||||||
|
// get a redirect to https://test.pypi.org/legacy/ (the canonical index URL).
|
||||||
|
// In the above case we get 308, where reqwest or `RedirectClientWithMiddleware` would try
|
||||||
|
// cloning the streaming body, which is not possible.
|
||||||
|
// For https://test.pypi.org/simple (no slash), we get 301, which means we should make a GET request:
|
||||||
|
// https://fetch.spec.whatwg.org/#http-redirect-fetch).
|
||||||
|
// Reqwest doesn't support redirect policies conditional on the HTTP
|
||||||
|
// method (https://github.com/seanmonstar/reqwest/issues/1777#issuecomment-2303386160), so we're
|
||||||
|
// implementing our custom redirection logic.
|
||||||
|
if response.status().is_redirection() {
|
||||||
|
if n_past_redirections >= max_redirects {
|
||||||
|
return Err(PublishError::PublishSend(
|
||||||
|
group.file.clone(),
|
||||||
|
current_registry.clone().into(),
|
||||||
|
PublishSendError::TooManyRedirects(n_past_redirections).into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let location = response
|
||||||
|
.headers()
|
||||||
|
.get(LOCATION)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
PublishError::PublishSend(
|
||||||
|
group.file.clone(),
|
||||||
|
current_registry.clone().into(),
|
||||||
|
PublishSendError::RedirectNoLocation.into(),
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.to_str()
|
||||||
|
.map_err(|err| {
|
||||||
|
PublishError::PublishSend(
|
||||||
|
group.file.clone(),
|
||||||
|
current_registry.clone().into(),
|
||||||
|
PublishSendError::RedirectLocationInvalidStr(err).into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
current_registry = DisplaySafeUrl::parse(location).map_err(|err| {
|
||||||
|
PublishError::PublishSend(
|
||||||
|
group.file.clone(),
|
||||||
|
current_registry.clone().into(),
|
||||||
|
PublishSendError::RedirectInvalidLocation(err).into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if Realm::from(¤t_registry) != Realm::from(registry) {
|
||||||
|
return Err(PublishError::PublishSend(
|
||||||
|
group.file.clone(),
|
||||||
|
current_registry.clone().into(),
|
||||||
|
PublishSendError::RedirectRealmMismatch(current_registry.to_string())
|
||||||
|
.into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
debug!("Redirecting the request to: {}", current_registry);
|
||||||
|
n_past_redirections += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
reporter.on_upload_complete(idx);
|
reporter.on_upload_complete(idx);
|
||||||
response
|
response
|
||||||
}
|
}
|
||||||
|
|
@ -498,7 +561,7 @@ pub async fn upload(
|
||||||
.unwrap_or_else(|_| Duration::default());
|
.unwrap_or_else(|_| Duration::default());
|
||||||
warn_user!(
|
warn_user!(
|
||||||
"Transient failure while handling response for {}; retrying after {}s...",
|
"Transient failure while handling response for {}; retrying after {}s...",
|
||||||
registry,
|
current_registry,
|
||||||
duration.as_secs()
|
duration.as_secs()
|
||||||
);
|
);
|
||||||
tokio::time::sleep(duration).await;
|
tokio::time::sleep(duration).await;
|
||||||
|
|
@ -509,13 +572,13 @@ pub async fn upload(
|
||||||
|
|
||||||
return Err(PublishError::PublishSend(
|
return Err(PublishError::PublishSend(
|
||||||
group.file.clone(),
|
group.file.clone(),
|
||||||
registry.clone().into(),
|
current_registry.clone().into(),
|
||||||
PublishSendError::ReqwestMiddleware(err).into(),
|
PublishSendError::ReqwestMiddleware(err).into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return match handle_response(registry, response).await {
|
return match handle_response(¤t_registry, response).await {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
// Upload successful; for PyPI this can also mean a hash match in a raced upload
|
// Upload successful; for PyPI this can also mean a hash match in a raced upload
|
||||||
// (but it doesn't tell us), for other registries it should mean a fresh upload.
|
// (but it doesn't tell us), for other registries it should mean a fresh upload.
|
||||||
|
|
@ -543,7 +606,7 @@ pub async fn upload(
|
||||||
}
|
}
|
||||||
Err(PublishError::PublishSend(
|
Err(PublishError::PublishSend(
|
||||||
group.file.clone(),
|
group.file.clone(),
|
||||||
registry.clone().into(),
|
current_registry.clone().into(),
|
||||||
err.into(),
|
err.into(),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
@ -1075,17 +1138,6 @@ async fn handle_response(registry: &Url, response: Response) -> Result<(), Publi
|
||||||
debug!("Response code for {registry}: {status_code}");
|
debug!("Response code for {registry}: {status_code}");
|
||||||
trace!("Response headers for {registry}: {response:?}");
|
trace!("Response headers for {registry}: {response:?}");
|
||||||
|
|
||||||
// When the user accidentally uses https://test.pypi.org/simple (no slash) as publish URL, we
|
|
||||||
// get a redirect to https://test.pypi.org/simple/ (the canonical index URL), while changing the
|
|
||||||
// method to GET (see https://en.wikipedia.org/wiki/Post/Redirect/Get and
|
|
||||||
// https://fetch.spec.whatwg.org/#http-redirect-fetch). The user gets a 200 OK while we actually
|
|
||||||
// didn't upload anything! Reqwest doesn't support redirect policies conditional on the HTTP
|
|
||||||
// method (https://github.com/seanmonstar/reqwest/issues/1777#issuecomment-2303386160), so we're
|
|
||||||
// checking after the fact.
|
|
||||||
if response.url() != registry {
|
|
||||||
return Err(PublishSendError::RedirectError(response.url().clone()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if status_code.is_success() {
|
if status_code.is_success() {
|
||||||
if enabled!(Level::TRACE) {
|
if enabled!(Level::TRACE) {
|
||||||
match response.text().await {
|
match response.text().await {
|
||||||
|
|
@ -1142,13 +1194,21 @@ mod tests {
|
||||||
|
|
||||||
use insta::{allow_duplicates, assert_debug_snapshot, assert_snapshot};
|
use insta::{allow_duplicates, assert_debug_snapshot, assert_snapshot};
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
|
use thiserror::__private17::AsDynError;
|
||||||
use uv_auth::Credentials;
|
use uv_auth::Credentials;
|
||||||
use uv_client::BaseClientBuilder;
|
use uv_client::{AuthIntegration, BaseClientBuilder, RedirectPolicy};
|
||||||
use uv_distribution_filename::DistFilename;
|
use uv_distribution_filename::DistFilename;
|
||||||
use uv_redacted::DisplaySafeUrl;
|
use uv_redacted::DisplaySafeUrl;
|
||||||
|
|
||||||
use crate::{FormMetadata, Reporter, UploadDistribution, build_upload_request, group_files};
|
use crate::{
|
||||||
|
FormMetadata, PublishError, Reporter, UploadDistribution, build_upload_request,
|
||||||
|
group_files, upload,
|
||||||
|
};
|
||||||
|
use tokio::sync::Semaphore;
|
||||||
|
use uv_warnings::owo_colors::AnsiColors;
|
||||||
|
use uv_warnings::write_error_chain;
|
||||||
|
use wiremock::matchers::{method, path};
|
||||||
|
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||||
|
|
||||||
struct DummyReporter;
|
struct DummyReporter;
|
||||||
|
|
||||||
|
|
@ -1161,6 +1221,44 @@ mod tests {
|
||||||
fn on_upload_complete(&self, _id: usize) {}
|
fn on_upload_complete(&self, _id: usize) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn mock_server_upload(mock_server: &MockServer) -> Result<bool, PublishError> {
|
||||||
|
let raw_filename = "tqdm-4.66.1-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl";
|
||||||
|
let file = PathBuf::from("../../test/links/").join(raw_filename);
|
||||||
|
let filename = DistFilename::try_from_normalized_filename(raw_filename).unwrap();
|
||||||
|
|
||||||
|
let group = UploadDistribution {
|
||||||
|
file,
|
||||||
|
raw_filename: raw_filename.to_string(),
|
||||||
|
filename,
|
||||||
|
attestations: vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
let form_metadata = FormMetadata::read_from_file(&group.file, &group.filename)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let client = BaseClientBuilder::default()
|
||||||
|
.redirect(RedirectPolicy::NoRedirect)
|
||||||
|
.retries(0)
|
||||||
|
.auth_integration(AuthIntegration::NoAuthMiddleware)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let download_concurrency = Arc::new(Semaphore::new(1));
|
||||||
|
let registry = DisplaySafeUrl::parse(&format!("{}/final", &mock_server.uri())).unwrap();
|
||||||
|
upload(
|
||||||
|
&group,
|
||||||
|
&form_metadata,
|
||||||
|
®istry,
|
||||||
|
&client,
|
||||||
|
client.retry_policy(),
|
||||||
|
&Credentials::basic(Some("ferris".to_string()), Some("F3RR!S".to_string())),
|
||||||
|
None,
|
||||||
|
&download_concurrency,
|
||||||
|
Arc::new(DummyReporter),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_group_files() {
|
fn test_group_files() {
|
||||||
// Fisher-Yates shuffle.
|
// Fisher-Yates shuffle.
|
||||||
|
|
@ -1722,4 +1820,88 @@ mod tests {
|
||||||
"#);
|
"#);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn upload_redirect_308() {
|
||||||
|
let mock_server = MockServer::start().await;
|
||||||
|
Mock::given(method("POST"))
|
||||||
|
.and(path("/final"))
|
||||||
|
.respond_with(
|
||||||
|
ResponseTemplate::new(308)
|
||||||
|
.insert_header("Location", format!("{}/final/", &mock_server.uri())),
|
||||||
|
)
|
||||||
|
.mount(&mock_server)
|
||||||
|
.await;
|
||||||
|
Mock::given(method("POST"))
|
||||||
|
.and(path("/final/"))
|
||||||
|
.respond_with(ResponseTemplate::new(200))
|
||||||
|
.mount(&mock_server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(mock_server_upload(&mock_server).await.unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn upload_infinite_redirects() {
|
||||||
|
let mock_server = MockServer::start().await;
|
||||||
|
Mock::given(method("POST"))
|
||||||
|
.and(path("/final"))
|
||||||
|
.respond_with(
|
||||||
|
ResponseTemplate::new(308)
|
||||||
|
.insert_header("Location", format!("{}/final/", &mock_server.uri())),
|
||||||
|
)
|
||||||
|
.mount(&mock_server)
|
||||||
|
.await;
|
||||||
|
Mock::given(method("POST"))
|
||||||
|
.and(path("/final/"))
|
||||||
|
.respond_with(
|
||||||
|
ResponseTemplate::new(308)
|
||||||
|
.insert_header("Location", format!("{}/final", &mock_server.uri())),
|
||||||
|
)
|
||||||
|
.mount(&mock_server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let err = mock_server_upload(&mock_server).await.unwrap_err();
|
||||||
|
|
||||||
|
let mut capture = String::new();
|
||||||
|
write_error_chain(err.as_dyn_error(), &mut capture, "error", AnsiColors::Red).unwrap();
|
||||||
|
|
||||||
|
let capture = capture.replace(&mock_server.uri(), "[SERVER]");
|
||||||
|
let capture = anstream::adapter::strip_str(&capture);
|
||||||
|
assert_snapshot!(
|
||||||
|
&capture,
|
||||||
|
@r"
|
||||||
|
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
|
||||||
|
Caused by: Too many redirects, only 10 redirects are allowed
|
||||||
|
"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn upload_redirect_different_realm() {
|
||||||
|
let mock_server = MockServer::start().await;
|
||||||
|
Mock::given(method("POST"))
|
||||||
|
.and(path("/final"))
|
||||||
|
.respond_with(
|
||||||
|
ResponseTemplate::new(308)
|
||||||
|
.insert_header("Location", "https://different.auth.tld/final/"),
|
||||||
|
)
|
||||||
|
.mount(&mock_server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let err = mock_server_upload(&mock_server).await.unwrap_err();
|
||||||
|
|
||||||
|
let mut capture = String::new();
|
||||||
|
write_error_chain(err.as_dyn_error(), &mut capture, "error", AnsiColors::Red).unwrap();
|
||||||
|
|
||||||
|
let capture = capture.replace(&mock_server.uri(), "[SERVER]");
|
||||||
|
let capture = anstream::adapter::strip_str(&capture);
|
||||||
|
assert_snapshot!(
|
||||||
|
&capture,
|
||||||
|
@r"
|
||||||
|
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/
|
||||||
|
Caused by: Redirected URL is not in the same realm. Redirected to: https://different.auth.tld/final/
|
||||||
|
"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,9 @@ use tokio::sync::Semaphore;
|
||||||
use tracing::{debug, info, trace};
|
use tracing::{debug, info, trace};
|
||||||
use uv_auth::{Credentials, DEFAULT_TOLERANCE_SECS, PyxTokenStore};
|
use uv_auth::{Credentials, DEFAULT_TOLERANCE_SECS, PyxTokenStore};
|
||||||
use uv_cache::Cache;
|
use uv_cache::Cache;
|
||||||
use uv_client::{AuthIntegration, BaseClient, BaseClientBuilder, RegistryClientBuilder};
|
use uv_client::{
|
||||||
|
AuthIntegration, BaseClient, BaseClientBuilder, RedirectPolicy, RegistryClientBuilder,
|
||||||
|
};
|
||||||
use uv_configuration::{KeyringProviderType, TrustedPublishing};
|
use uv_configuration::{KeyringProviderType, TrustedPublishing};
|
||||||
use uv_distribution_types::{IndexCapabilities, IndexLocations, IndexUrl};
|
use uv_distribution_types::{IndexCapabilities, IndexLocations, IndexUrl};
|
||||||
use uv_publish::{
|
use uv_publish::{
|
||||||
|
|
@ -123,6 +125,9 @@ pub(crate) async fn publish(
|
||||||
.keyring(keyring_provider)
|
.keyring(keyring_provider)
|
||||||
// Don't try cloning the request to make an unauthenticated request first.
|
// Don't try cloning the request to make an unauthenticated request first.
|
||||||
.auth_integration(AuthIntegration::OnlyAuthenticated)
|
.auth_integration(AuthIntegration::OnlyAuthenticated)
|
||||||
|
// Disable automatic redirect, as the streaming publish request is not cloneable.
|
||||||
|
// Rely on custom redirect logic instead.
|
||||||
|
.redirect(RedirectPolicy::NoRedirect)
|
||||||
.timeout(environment.upload_http_timeout)
|
.timeout(environment.upload_http_timeout)
|
||||||
.build();
|
.build();
|
||||||
let oidc_client = client_builder
|
let oidc_client = client_builder
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue