mirror of
https://github.com/astral-sh/uv.git
synced 2025-08-04 19:08:04 +00:00
Add DisplaySafeUrl
newtype to prevent leaking of credentials by default (#13560)
Prior to this PR, there were numerous places where uv would leak credentials in logs. We had a way to mask credentials by calling methods or a recently-added `redact_url` function, but this was not secure by default. There were a number of other types (like `GitUrl`) that would leak credentials on display. This PR adds a `DisplaySafeUrl` newtype to prevent leaking credentials when logging by default. It takes a maximalist approach, replacing the use of `Url` almost everywhere. This includes when first parsing config files, when storing URLs in types like `GitUrl`, and also when storing URLs in types that in practice will never contain credentials (like `DirectorySourceUrl`). The idea is to make it easy for developers to do the right thing and for the compiler to support this (and to minimize ever having to manually convert back and forth). Displaying credentials now requires an active step. Note that despite this maximalist approach, the use of the newtype should be zero cost. One conspicuous place this PR does not use `DisplaySafeUrl` is in the `uv-auth` crate. That would require new clones since there are calls to `request.url()` that return a `&Url`. One option would have been to make `DisplaySafeUrl` wrap a `Cow`, but this would lead to lifetime annotations all over the codebase. I've created a separate PR based on this one (#13576) that updates `uv-auth` to use `DisplaySafeUrl` with one new clone. We can discuss the tradeoffs there. Most of this PR just replaces `Url` with `DisplaySafeUrl`. The core is `uv_redacted/lib.rs`, where the newtype is implemented. To make it easier to review the rest, here are some points of note: * `DisplaySafeUrl` has a `Display` implementation that masks credentials. Currently, it will still display the username when there is both a username and password. If we think is the wrong choice, it can now be changed in one place. * `DisplaySafeUrl` has a `remove_credentials()` method and also a `.to_string_with_credentials()` method. This allows us to use it in a variety of scenarios. * `IndexUrl::redacted()` was renamed to `IndexUrl::removed_credentials()` to make it clearer that we are not masking. * We convert from a `DisplaySafeUrl` to a `Url` when calling `reqwest` methods like `.get()` and `.head()`. * We convert from a `DisplaySafeUrl` to a `Url` when creating a `uv_auth::Index`. That is because, as mentioned above, I will be updating the `uv_auth` crate to use this newtype in a separate PR. * A number of tests (e.g., in `pip_install.rs`) that formerly used filters to mask tokens in the test output no longer need those filters since tokens in URLs are now masked automatically. * The one place we are still knowingly writing credentials to `pyproject.toml` is when a URL with credentials is passed to `uv add` with `--raw`. Since displaying credentials is no longer automatic, I have added a `to_string_with_credentials()` method to the `Pep508Url` trait. This is used when `--raw` is passed. Adding it to that trait is a bit weird, but it's the simplest way to achieve the goal. I'm open to suggestions on how to improve this, but note that because of the way we're using generic bounds, it's not as simple as just creating a separate trait for that method.
This commit is contained in:
parent
b80cafd5e8
commit
c19a294a48
100 changed files with 1266 additions and 2249 deletions
|
@ -33,6 +33,7 @@ uv-pep440 = { workspace = true }
|
|||
uv-pep508 = { workspace = true }
|
||||
uv-platform-tags = { workspace = true }
|
||||
uv-pypi-types = { workspace = true }
|
||||
uv-redacted = { workspace = true }
|
||||
uv-types = { workspace = true }
|
||||
uv-workspace = { workspace = true }
|
||||
|
||||
|
|
|
@ -27,6 +27,7 @@ use uv_extract::hash::Hasher;
|
|||
use uv_fs::write_atomic;
|
||||
use uv_platform_tags::Tags;
|
||||
use uv_pypi_types::{HashDigest, HashDigests};
|
||||
use uv_redacted::DisplaySafeUrl;
|
||||
use uv_types::{BuildContext, BuildStack};
|
||||
|
||||
use crate::archive::Archive;
|
||||
|
@ -529,7 +530,7 @@ impl<'a, Context: BuildContext> DistributionDatabase<'a, Context> {
|
|||
/// Stream a wheel from a URL, unzipping it into the cache as it's downloaded.
|
||||
async fn stream_wheel(
|
||||
&self,
|
||||
url: Url,
|
||||
url: DisplaySafeUrl,
|
||||
filename: &WheelFilename,
|
||||
size: Option<u64>,
|
||||
wheel_entry: &CacheEntry,
|
||||
|
@ -666,7 +667,7 @@ impl<'a, Context: BuildContext> DistributionDatabase<'a, Context> {
|
|||
/// Download a wheel from a URL, then unzip it into the cache.
|
||||
async fn download_wheel(
|
||||
&self,
|
||||
url: Url,
|
||||
url: DisplaySafeUrl,
|
||||
filename: &WheelFilename,
|
||||
size: Option<u64>,
|
||||
wheel_entry: &CacheEntry,
|
||||
|
@ -980,11 +981,11 @@ impl<'a, Context: BuildContext> DistributionDatabase<'a, Context> {
|
|||
}
|
||||
|
||||
/// Returns a GET [`reqwest::Request`] for the given URL.
|
||||
fn request(&self, url: Url) -> Result<reqwest::Request, reqwest::Error> {
|
||||
fn request(&self, url: DisplaySafeUrl) -> Result<reqwest::Request, reqwest::Error> {
|
||||
self.client
|
||||
.unmanaged
|
||||
.uncached_client(&url)
|
||||
.get(url)
|
||||
.get(Url::from(url))
|
||||
.header(
|
||||
// `reqwest` defaults to accepting compressed responses.
|
||||
// Specify identity encoding to get consistent .whl downloading
|
||||
|
|
|
@ -2,7 +2,6 @@ use std::path::PathBuf;
|
|||
|
||||
use owo_colors::OwoColorize;
|
||||
use tokio::task::JoinError;
|
||||
use url::Url;
|
||||
use zip::result::ZipError;
|
||||
|
||||
use crate::metadata::MetadataError;
|
||||
|
@ -13,6 +12,7 @@ use uv_fs::Simplified;
|
|||
use uv_normalize::PackageName;
|
||||
use uv_pep440::{Version, VersionSpecifiers};
|
||||
use uv_pypi_types::{HashAlgorithm, HashDigest};
|
||||
use uv_redacted::DisplaySafeUrl;
|
||||
use uv_types::AnyErrorBuild;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
|
@ -28,7 +28,7 @@ pub enum Error {
|
|||
#[error(transparent)]
|
||||
JoinRelativeUrl(#[from] uv_pypi_types::JoinRelativeError),
|
||||
#[error("Expected a file URL, but received: {0}")]
|
||||
NonFileUrl(Url),
|
||||
NonFileUrl(DisplaySafeUrl),
|
||||
#[error(transparent)]
|
||||
Git(#[from] uv_git::GitResolverError),
|
||||
#[error(transparent)]
|
||||
|
@ -89,7 +89,7 @@ pub enum Error {
|
|||
#[error("The source distribution is missing a `PKG-INFO` file")]
|
||||
MissingPkgInfo,
|
||||
#[error("The source distribution `{}` has no subdirectory `{}`", _0, _1.display())]
|
||||
MissingSubdirectory(Url, PathBuf),
|
||||
MissingSubdirectory(DisplaySafeUrl, PathBuf),
|
||||
#[error("Failed to extract static metadata from `PKG-INFO`")]
|
||||
PkgInfo(#[source] uv_pypi_types::MetadataError),
|
||||
#[error("Failed to extract metadata from `requires.txt`")]
|
||||
|
@ -103,7 +103,7 @@ pub enum Error {
|
|||
#[error(transparent)]
|
||||
MetadataLowering(#[from] MetadataError),
|
||||
#[error("Distribution not found at: {0}")]
|
||||
NotFound(Url),
|
||||
NotFound(DisplaySafeUrl),
|
||||
#[error("Attempted to re-extract the source distribution for `{}`, but the {} hash didn't match. Run `{}` to clear the cache.", _0, _1, "uv cache clean".green())]
|
||||
CacheHeal(String, HashAlgorithm),
|
||||
#[error("The source distribution requires Python {0}, but {1} is installed")]
|
||||
|
|
|
@ -4,7 +4,6 @@ use std::path::{Path, PathBuf};
|
|||
|
||||
use either::Either;
|
||||
use thiserror::Error;
|
||||
use url::Url;
|
||||
|
||||
use uv_distribution_filename::DistExtension;
|
||||
use uv_distribution_types::{
|
||||
|
@ -15,6 +14,7 @@ use uv_normalize::{ExtraName, GroupName, PackageName};
|
|||
use uv_pep440::VersionSpecifiers;
|
||||
use uv_pep508::{MarkerTree, VerbatimUrl, VersionOrUrl, looks_like_git_repository};
|
||||
use uv_pypi_types::{ConflictItem, ParsedUrlError, VerbatimParsedUrl};
|
||||
use uv_redacted::DisplaySafeUrl;
|
||||
use uv_workspace::Workspace;
|
||||
use uv_workspace::pyproject::{PyProjectToml, Source, Sources};
|
||||
|
||||
|
@ -528,11 +528,11 @@ pub enum LoweringError {
|
|||
#[error(transparent)]
|
||||
InvalidVerbatimUrl(#[from] uv_pep508::VerbatimUrlError),
|
||||
#[error("Fragments are not allowed in URLs: `{0}`")]
|
||||
ForbiddenFragment(Url),
|
||||
ForbiddenFragment(DisplaySafeUrl),
|
||||
#[error(
|
||||
"`{0}` is associated with a URL source, but references a Git repository. Consider using a Git source instead (e.g., `{0} = {{ git = \"{1}\" }}`)"
|
||||
)]
|
||||
MissingGitSource(PackageName, Url),
|
||||
MissingGitSource(PackageName, DisplaySafeUrl),
|
||||
#[error("`workspace = false` is not yet supported")]
|
||||
WorkspaceFalse,
|
||||
#[error("Source with `editable = true` must refer to a local directory, not a file: `{0}`")]
|
||||
|
@ -572,7 +572,7 @@ impl std::fmt::Display for SourceKind {
|
|||
|
||||
/// Convert a Git source into a [`RequirementSource`].
|
||||
fn git_source(
|
||||
git: &Url,
|
||||
git: &DisplaySafeUrl,
|
||||
subdirectory: Option<Box<Path>>,
|
||||
rev: Option<String>,
|
||||
tag: Option<String>,
|
||||
|
@ -587,9 +587,10 @@ fn git_source(
|
|||
};
|
||||
|
||||
// Create a PEP 508-compatible URL.
|
||||
let mut url = Url::parse(&format!("git+{git}"))?;
|
||||
let mut url = DisplaySafeUrl::parse(&format!("git+{git}"))?;
|
||||
if let Some(rev) = reference.as_str() {
|
||||
url.set_path(&format!("{}@{}", url.path(), rev));
|
||||
let path = format!("{}@{}", url.path(), rev);
|
||||
url.set_path(&path);
|
||||
}
|
||||
if let Some(subdirectory) = subdirectory.as_ref() {
|
||||
let subdirectory = subdirectory
|
||||
|
@ -611,7 +612,7 @@ fn git_source(
|
|||
/// Convert a URL source into a [`RequirementSource`].
|
||||
fn url_source(
|
||||
requirement: &uv_pep508::Requirement<VerbatimParsedUrl>,
|
||||
url: Url,
|
||||
url: DisplaySafeUrl,
|
||||
subdirectory: Option<Box<Path>>,
|
||||
) -> Result<RequirementSource, LoweringError> {
|
||||
let mut verbatim_url = url.clone();
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use url::Url;
|
||||
|
||||
use uv_distribution_types::BuildableSource;
|
||||
use uv_pep508::PackageName;
|
||||
use uv_redacted::DisplaySafeUrl;
|
||||
|
||||
pub trait Reporter: Send + Sync {
|
||||
/// Callback to invoke when a source distribution build is kicked off.
|
||||
|
@ -13,10 +12,10 @@ pub trait Reporter: Send + Sync {
|
|||
fn on_build_complete(&self, source: &BuildableSource, id: usize);
|
||||
|
||||
/// Callback to invoke when a repository checkout begins.
|
||||
fn on_checkout_start(&self, url: &Url, rev: &str) -> usize;
|
||||
fn on_checkout_start(&self, url: &DisplaySafeUrl, rev: &str) -> usize;
|
||||
|
||||
/// Callback to invoke when a repository checkout completes.
|
||||
fn on_checkout_complete(&self, url: &Url, rev: &str, id: usize);
|
||||
fn on_checkout_complete(&self, url: &DisplaySafeUrl, rev: &str, id: usize);
|
||||
|
||||
/// Callback to invoke when a download is kicked off.
|
||||
fn on_download_start(&self, name: &PackageName, size: Option<u64>) -> usize;
|
||||
|
@ -44,11 +43,11 @@ struct Facade {
|
|||
}
|
||||
|
||||
impl uv_git::Reporter for Facade {
|
||||
fn on_checkout_start(&self, url: &Url, rev: &str) -> usize {
|
||||
fn on_checkout_start(&self, url: &DisplaySafeUrl, rev: &str) -> usize {
|
||||
self.reporter.on_checkout_start(url, rev)
|
||||
}
|
||||
|
||||
fn on_checkout_complete(&self, url: &Url, rev: &str, id: usize) {
|
||||
fn on_checkout_complete(&self, url: &DisplaySafeUrl, rev: &str, id: usize) {
|
||||
self.reporter.on_checkout_complete(url, rev, id);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,6 +20,7 @@ use reqwest::{Response, StatusCode};
|
|||
use tokio_util::compat::FuturesAsyncReadCompatExt;
|
||||
use tracing::{Instrument, debug, info_span, instrument, warn};
|
||||
use url::Url;
|
||||
use uv_redacted::DisplaySafeUrl;
|
||||
use zip::ZipArchive;
|
||||
|
||||
use uv_cache::{Cache, CacheBucket, CacheEntry, CacheShard, Removal, WheelCache};
|
||||
|
@ -386,7 +387,7 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
|
|||
async fn url<'data>(
|
||||
&self,
|
||||
source: &BuildableSource<'data>,
|
||||
url: &'data Url,
|
||||
url: &'data DisplaySafeUrl,
|
||||
cache_shard: &CacheShard,
|
||||
subdirectory: Option<&'data Path>,
|
||||
ext: SourceDistExtension,
|
||||
|
@ -582,7 +583,7 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
|
|||
if let Some(subdirectory) = subdirectory {
|
||||
if !source_dist_entry.path().join(subdirectory).is_dir() {
|
||||
return Err(Error::MissingSubdirectory(
|
||||
url.clone(),
|
||||
DisplaySafeUrl::from(url.clone()),
|
||||
subdirectory.to_path_buf(),
|
||||
));
|
||||
}
|
||||
|
@ -715,7 +716,7 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
|
|||
.boxed_local()
|
||||
.instrument(info_span!("download", source_dist = %source))
|
||||
};
|
||||
let req = Self::request(url.clone(), client.unmanaged)?;
|
||||
let req = Self::request(DisplaySafeUrl::from(url.clone()), client.unmanaged)?;
|
||||
let revision = client
|
||||
.managed(|client| {
|
||||
client.cached_client().get_serde_with_retry(
|
||||
|
@ -740,7 +741,7 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
|
|||
client
|
||||
.cached_client()
|
||||
.skip_cache_with_retry(
|
||||
Self::request(url.clone(), client)?,
|
||||
Self::request(DisplaySafeUrl::from(url.clone()), client)?,
|
||||
&cache_entry,
|
||||
download,
|
||||
)
|
||||
|
@ -2077,7 +2078,7 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
|
|||
client
|
||||
.cached_client()
|
||||
.skip_cache_with_retry(
|
||||
Self::request(url.clone(), client)?,
|
||||
Self::request(DisplaySafeUrl::from(url.clone()), client)?,
|
||||
&cache_entry,
|
||||
download,
|
||||
)
|
||||
|
@ -2402,10 +2403,13 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
|
|||
}
|
||||
|
||||
/// Returns a GET [`reqwest::Request`] for the given URL.
|
||||
fn request(url: Url, client: &RegistryClient) -> Result<reqwest::Request, reqwest::Error> {
|
||||
fn request(
|
||||
url: DisplaySafeUrl,
|
||||
client: &RegistryClient,
|
||||
) -> Result<reqwest::Request, reqwest::Error> {
|
||||
client
|
||||
.uncached_client(&url)
|
||||
.get(url)
|
||||
.get(Url::from(url))
|
||||
.header(
|
||||
// `reqwest` defaults to accepting compressed responses.
|
||||
// Specify identity encoding to get consistent .whl downloading
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue