Recommend --native-tls on SSL errors (#10605)

## Summary

Closes https://github.com/astral-sh/uv/issues/10574.

## Test Plan

```
❯ SSL_CERT_FILE=a cargo run pip install flask -n
   Compiling uv v0.5.18 (/Users/crmarsh/workspace/uv/crates/uv)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 8.33s
     Running `target/debug/uv pip install flask -n`
⠦ Resolving dependencies...                                                                                                                                                                                                                     × Failed to fetch: `https://pypi.org/simple/flask/`
  ├─▶ Request failed after 3 retries
  ├─▶ error sending request for url (https://pypi.org/simple/flask/)
  ├─▶ client error (Connect)
  ╰─▶ invalid peer certificate: UnknownIssuer
  help: Consider enabling native TLS support via the `--native-tls` command-line flag
```
This commit is contained in:
Charlie Marsh 2025-01-14 13:17:19 -05:00 committed by GitHub
parent e1e9b0447c
commit 325b060829
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 124 additions and 38 deletions

View file

@ -51,6 +51,11 @@ impl Error {
matches!(err.kind(), std::io::ErrorKind::NotFound)
}
/// Returns `true` if the error is due to an SSL error.
pub fn is_ssl(&self) -> bool {
matches!(&*self.kind, ErrorKind::WrappedReqwestError(.., err) if err.is_ssl())
}
/// Returns `true` if the error is due to the server not supporting HTTP range requests.
pub fn is_http_range_requests_unsupported(&self) -> bool {
match &*self.kind {
@ -260,13 +265,9 @@ impl ErrorKind {
pub struct WrappedReqwestError(reqwest_middleware::Error);
impl WrappedReqwestError {
/// Check if the error chain contains a reqwest error that looks like this:
/// * error sending request for url (...)
/// * client error (Connect)
/// * dns error: failed to lookup address information: Name or service not known
/// * failed to lookup address information: Name or service not known
fn is_likely_offline(&self) -> bool {
let reqwest_err = match &self.0 {
/// Return the inner [`reqwest::Error`] from the error chain, if it exists.
fn inner(&self) -> Option<&reqwest::Error> {
match &self.0 {
reqwest_middleware::Error::Reqwest(err) => Some(err),
reqwest_middleware::Error::Middleware(err) => err.chain().find_map(|err| {
if let Some(err) = err.downcast_ref::<reqwest::Error>() {
@ -279,9 +280,16 @@ impl WrappedReqwestError {
None
}
}),
};
}
}
if let Some(reqwest_err) = reqwest_err {
/// Check if the error chain contains a `reqwest` error that looks like this:
/// * error sending request for url (...)
/// * client error (Connect)
/// * dns error: failed to lookup address information: Name or service not known
/// * failed to lookup address information: Name or service not known
fn is_likely_offline(&self) -> bool {
if let Some(reqwest_err) = self.inner() {
if !reqwest_err.is_connect() {
return false;
}
@ -297,6 +305,26 @@ impl WrappedReqwestError {
}
false
}
/// Check if the error chain contains a `reqwest` error that looks like this:
/// * invalid peer certificate: `UnknownIssuer`
fn is_ssl(&self) -> bool {
if let Some(reqwest_err) = self.inner() {
if !reqwest_err.is_connect() {
return false;
}
// Self is "error sending request for url", the first source is "error trying to connect",
// the second source is "dns error". We have to check for the string because hyper errors
// are opaque.
if std::error::Error::source(&reqwest_err)
.and_then(|err| err.source())
.is_some_and(|err| err.to_string().starts_with("invalid peer certificate: "))
{
return true;
}
}
false
}
}
impl From<reqwest::Error> for WrappedReqwestError {

View file

@ -34,26 +34,37 @@ static SUGGESTIONS: LazyLock<FxHashMap<PackageName, PackageName>> = LazyLock::ne
pub(crate) struct OperationDiagnostic {
/// The hint to display to the user upon resolution failure.
pub(crate) hint: Option<String>,
/// Whether native TLS is enabled.
pub(crate) native_tls: bool,
/// The context to display to the user upon resolution failure.
pub(crate) context: Option<&'static str>,
}
impl OperationDiagnostic {
/// Create an [`OperationDiagnostic`] with the given native TLS setting.
#[must_use]
pub(crate) fn native_tls(native_tls: bool) -> Self {
Self {
native_tls,
..Default::default()
}
}
/// Set the hint to display to the user upon resolution failure.
#[must_use]
pub(crate) fn with_hint(hint: String) -> Self {
pub(crate) fn with_hint(self, hint: String) -> Self {
Self {
hint: Some(hint),
..Default::default()
..self
}
}
/// Set the context to display to the user upon resolution failure.
#[must_use]
pub(crate) fn with_context(context: &'static str) -> Self {
pub(crate) fn with_context(self, context: &'static str) -> Self {
Self {
context: Some(context),
..Default::default()
..self
}
}
@ -106,6 +117,12 @@ impl OperationDiagnostic {
Some(pip::operations::Error::Requirements(err))
}
}
pip::operations::Error::Resolve(uv_resolver::ResolveError::Client(err))
if !self.native_tls && err.is_ssl() =>
{
native_tls_hint(err);
None
}
err => Some(err),
}
}
@ -236,6 +253,41 @@ pub(crate) fn no_solution_hint(err: uv_resolver::NoSolutionError, help: String)
anstream::eprint!("{report:?}");
}
/// Render a [`uv_resolver::NoSolutionError`] with a help message.
pub(crate) fn native_tls_hint(err: uv_client::Error) {
#[derive(Debug, miette::Diagnostic)]
#[diagnostic()]
struct Error {
/// The underlying error.
err: uv_client::Error,
/// The help message to display.
#[help]
help: String,
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.err)
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
self.err.source()
}
}
let report = miette::Report::new(Error {
err,
help: format!(
"Consider enabling use of system TLS certificates with the `{}` command-line flag",
"--native-tls".green()
),
});
anstream::eprint!("{report:?}");
}
/// Format a [`DerivationChain`] as a human-readable error message.
fn format_chain(name: &PackageName, version: Option<&Version>, chain: &DerivationChain) -> String {
/// Format a step in the [`DerivationChain`] as a human-readable error message.

View file

@ -401,7 +401,7 @@ pub(crate) async fn pip_compile(
{
Ok(resolution) => resolution,
Err(err) => {
return diagnostics::OperationDiagnostic::default()
return diagnostics::OperationDiagnostic::native_tls(native_tls)
.report(err)
.map_or(Ok(ExitStatus::Failure), |err| Err(err.into()))
}

View file

@ -428,7 +428,7 @@ pub(crate) async fn pip_install(
{
Ok(graph) => Resolution::from(graph),
Err(err) => {
return diagnostics::OperationDiagnostic::default()
return diagnostics::OperationDiagnostic::native_tls(native_tls)
.report(err)
.map_or(Ok(ExitStatus::Failure), |err| Err(err.into()))
}
@ -462,7 +462,7 @@ pub(crate) async fn pip_install(
{
Ok(_) => {}
Err(err) => {
return diagnostics::OperationDiagnostic::default()
return diagnostics::OperationDiagnostic::native_tls(native_tls)
.report(err)
.map_or(Ok(ExitStatus::Failure), |err| Err(err.into()))
}

View file

@ -373,7 +373,7 @@ pub(crate) async fn pip_sync(
{
Ok(resolution) => Resolution::from(resolution),
Err(err) => {
return diagnostics::OperationDiagnostic::default()
return diagnostics::OperationDiagnostic::native_tls(native_tls)
.report(err)
.map_or(Ok(ExitStatus::Failure), |err| Err(err.into()))
}
@ -407,7 +407,7 @@ pub(crate) async fn pip_sync(
{
Ok(_) => {}
Err(err) => {
return diagnostics::OperationDiagnostic::default()
return diagnostics::OperationDiagnostic::native_tls(native_tls)
.report(err)
.map_or(Ok(ExitStatus::Failure), |err| Err(err.into()))
}

View file

@ -632,7 +632,7 @@ pub(crate) async fn add(
let _ = snapshot.revert();
}
match err {
ProjectError::Operation(err) => diagnostics::OperationDiagnostic::with_hint(format!("If you want to add the package regardless of the failed resolution, provide the `{}` flag to skip locking and syncing.", "--frozen".green()))
ProjectError::Operation(err) => diagnostics::OperationDiagnostic::native_tls(native_tls).with_hint(format!("If you want to add the package regardless of the failed resolution, provide the `{}` flag to skip locking and syncing.", "--frozen".green()))
.report(err)
.map_or(Ok(ExitStatus::Failure), |err| Err(err.into())),
err => Err(err.into()),

View file

@ -209,7 +209,7 @@ pub(crate) async fn export(
{
Ok(result) => result.into_lock(),
Err(ProjectError::Operation(err)) => {
return diagnostics::OperationDiagnostic::default()
return diagnostics::OperationDiagnostic::native_tls(native_tls)
.report(err)
.map_or(Ok(ExitStatus::Failure), |err| Err(err.into()))
}

View file

@ -193,9 +193,11 @@ pub(crate) async fn lock(
Ok(ExitStatus::Success)
}
Err(ProjectError::Operation(err)) => diagnostics::OperationDiagnostic::default()
.report(err)
.map_or(Ok(ExitStatus::Failure), |err| Err(err.into())),
Err(ProjectError::Operation(err)) => {
diagnostics::OperationDiagnostic::native_tls(native_tls)
.report(err)
.map_or(Ok(ExitStatus::Failure), |err| Err(err.into()))
}
Err(err) => Err(err.into()),
}
}

View file

@ -288,7 +288,7 @@ pub(crate) async fn remove(
{
Ok(result) => result.into_lock(),
Err(ProjectError::Operation(err)) => {
return diagnostics::OperationDiagnostic::default()
return diagnostics::OperationDiagnostic::native_tls(native_tls)
.report(err)
.map_or(Ok(ExitStatus::Failure), |err| Err(err.into()))
}
@ -349,7 +349,7 @@ pub(crate) async fn remove(
{
Ok(()) => {}
Err(ProjectError::Operation(err)) => {
return diagnostics::OperationDiagnostic::default()
return diagnostics::OperationDiagnostic::native_tls(native_tls)
.report(err)
.map_or(Ok(ExitStatus::Failure), |err| Err(err.into()))
}

View file

@ -284,7 +284,8 @@ pub(crate) async fn run(
let environment = match result {
Ok(resolution) => resolution,
Err(ProjectError::Operation(err)) => {
return diagnostics::OperationDiagnostic::with_context("script")
return diagnostics::OperationDiagnostic::native_tls(native_tls)
.with_context("script")
.report(err)
.map_or(Ok(ExitStatus::Failure), |err| Err(err.into()))
}
@ -415,7 +416,8 @@ pub(crate) async fn run(
let environment = match result {
Ok(resolution) => resolution,
Err(ProjectError::Operation(err)) => {
return diagnostics::OperationDiagnostic::with_context("script")
return diagnostics::OperationDiagnostic::native_tls(native_tls)
.with_context("script")
.report(err)
.map_or(Ok(ExitStatus::Failure), |err| Err(err.into()))
}
@ -738,7 +740,7 @@ pub(crate) async fn run(
{
Ok(result) => result,
Err(ProjectError::Operation(err)) => {
return diagnostics::OperationDiagnostic::default()
return diagnostics::OperationDiagnostic::native_tls(native_tls)
.report(err)
.map_or(Ok(ExitStatus::Failure), |err| Err(err.into()))
}
@ -819,7 +821,7 @@ pub(crate) async fn run(
{
Ok(()) => {}
Err(ProjectError::Operation(err)) => {
return diagnostics::OperationDiagnostic::default()
return diagnostics::OperationDiagnostic::native_tls(native_tls)
.report(err)
.map_or(Ok(ExitStatus::Failure), |err| Err(err.into()))
}
@ -972,7 +974,8 @@ pub(crate) async fn run(
let environment = match result {
Ok(resolution) => resolution,
Err(ProjectError::Operation(err)) => {
return diagnostics::OperationDiagnostic::with_context("`--with`")
return diagnostics::OperationDiagnostic::native_tls(native_tls)
.with_context("`--with`")
.report(err)
.map_or(Ok(ExitStatus::Failure), |err| Err(err.into()))
}

View file

@ -160,7 +160,7 @@ pub(crate) async fn sync(
{
Ok(result) => result.into_lock(),
Err(ProjectError::Operation(err)) => {
return diagnostics::OperationDiagnostic::default()
return diagnostics::OperationDiagnostic::native_tls(native_tls)
.report(err)
.map_or(Ok(ExitStatus::Failure), |err| Err(err.into()))
}
@ -236,7 +236,7 @@ pub(crate) async fn sync(
{
Ok(()) => {}
Err(ProjectError::Operation(err)) => {
return diagnostics::OperationDiagnostic::default()
return diagnostics::OperationDiagnostic::native_tls(native_tls)
.report(err)
.map_or(Ok(ExitStatus::Failure), |err| Err(err.into()))
}

View file

@ -162,7 +162,7 @@ pub(crate) async fn tree(
{
Ok(result) => result.into_lock(),
Err(ProjectError::Operation(err)) => {
return diagnostics::OperationDiagnostic::default()
return diagnostics::OperationDiagnostic::native_tls(native_tls)
.report(err)
.map_or(Ok(ExitStatus::Failure), |err| Err(err.into()))
}

View file

@ -431,7 +431,7 @@ pub(crate) async fn install(
{
Ok(update) => update.into_environment(),
Err(ProjectError::Operation(err)) => {
return diagnostics::OperationDiagnostic::default()
return diagnostics::OperationDiagnostic::native_tls(native_tls)
.report(err)
.map_or(Ok(ExitStatus::Failure), |err| Err(err.into()))
}
@ -491,7 +491,7 @@ pub(crate) async fn install(
.await
.ok()
.flatten() else {
return diagnostics::OperationDiagnostic::default()
return diagnostics::OperationDiagnostic::native_tls(native_tls)
.report(err)
.map_or(Ok(ExitStatus::Failure), |err| Err(err.into()));
};
@ -520,7 +520,7 @@ pub(crate) async fn install(
{
Ok(resolution) => resolution,
Err(ProjectError::Operation(err)) => {
return diagnostics::OperationDiagnostic::default()
return diagnostics::OperationDiagnostic::native_tls(native_tls)
.report(err)
.map_or(Ok(ExitStatus::Failure), |err| Err(err.into()));
}
@ -563,7 +563,7 @@ pub(crate) async fn install(
}) {
Ok(environment) => environment,
Err(ProjectError::Operation(err)) => {
return diagnostics::OperationDiagnostic::default()
return diagnostics::OperationDiagnostic::native_tls(native_tls)
.report(err)
.map_or(Ok(ExitStatus::Failure), |err| Err(err.into()))
}

View file

@ -135,7 +135,8 @@ pub(crate) async fn run(
let (from, environment) = match result {
Ok(resolution) => resolution,
Err(ProjectError::Operation(err)) => {
return diagnostics::OperationDiagnostic::with_context("tool")
return diagnostics::OperationDiagnostic::native_tls(native_tls)
.with_context("tool")
.report(err)
.map_or(Ok(ExitStatus::Failure), |err| Err(err.into()))
}