Skip to content

Commit

Permalink
Rewrite authentication caching and middleware handling
Browse files Browse the repository at this point in the history
# Conflicts:
#	Cargo.lock
#	crates/uv-configuration/Cargo.toml
#	crates/uv/src/commands/pip_compile.rs
#	crates/uv/src/commands/venv.rs
#	crates/uv/src/main.rs

# Conflicts:
#	crates/uv-auth/src/keyring.rs
  • Loading branch information
zanieb committed Apr 15, 2024
1 parent 0ed578b commit c68e397
Show file tree
Hide file tree
Showing 21 changed files with 578 additions and 517 deletions.
3 changes: 2 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion crates/uv-auth/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ edition = "2021"
[dependencies]
async-trait = { workspace = true }
base64 = { workspace = true }
clap = { workspace = true, features = ["derive", "env"], optional = true }
http = { workspace = true }
once_cell = { workspace = true }
reqwest = { workspace = true }
Expand Down
143 changes: 143 additions & 0 deletions crates/uv-auth/src/cache.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
use std::sync::Arc;
use std::{collections::HashMap, sync::Mutex};

use crate::credentials::Credentials;
use crate::NetLoc;
use reqwest::Request;
use tracing::debug;

pub struct CredentialsCache {
store: Mutex<HashMap<(NetLoc, Option<String>), Arc<Credentials>>>,
}

impl Default for CredentialsCache {
fn default() -> Self {
Self::new()
}
}

impl CredentialsCache {
/// Create a new cache.
pub fn new() -> Self {
Self {
store: Mutex::new(HashMap::new()),
}
}

/// Return the credentials that should be used for a request, if any.
///
/// If the request contains credentials, they may be added to the cache.
///
/// Generally this prefers the credentials already attached to the request, but
/// if the request has credentials with just a username we may still attempt to
/// find a password.
pub(crate) fn credentials_for_request(&self, request: &Request) -> Option<Arc<Credentials>> {
let netloc = NetLoc::from(request.url());
let mut store = self.store.lock().unwrap();

let credentials = Credentials::from_request(request);

if let Some(credentials) = credentials {
debug!("Found credentials on request, checking if cache should be updated...");

if credentials.username().is_some() {
let existing = store.get(&(netloc.clone(), None));

// Replace the existing entry for the "no username" case if
// - There is no entry
// - The entry exists but has no password (and we have a password now)
if existing.is_none()
|| (credentials.password().is_some()
&& existing.is_some_and(|credentials| credentials.password().is_none()))
{
debug!("Updating cached credentials for {netloc:?} with no username");
store.insert((netloc.clone(), None), Arc::new(credentials.clone()));
}
}

let existing = store.get(&(netloc.clone(), credentials.username().map(str::to_string)));
if existing.is_none()
|| (credentials.password().is_some()
&& existing.is_some_and(|credentials| credentials.password().is_none()))
{
debug!(
"Updating cached credentials for {netloc:?} with username {:?}",
credentials.username()
);
store.insert(
(netloc.clone(), credentials.username().map(str::to_string)),
Arc::new(credentials.clone()),
);
Some(Arc::new(credentials))
} else if credentials.password().is_none() {
debug!("Using cached credentials for request {existing:?}");
existing.cloned()
} else {
Some(Arc::new(credentials))
}
} else {
debug!("No credentials on request, checking cache...");
let credentials = store.get(&(netloc.clone(), None)).cloned();
if credentials.is_some() {
debug!("Found cached credentials: {credentials:?}");
} else {
debug!("No credentials in cache");
}
credentials
}
}
}

#[cfg(test)]
mod test {

// #[test]
// fn get_does_not_exist() {
// let store = AuthenticationStore::new();

// // An empty store should return `None`
// let url = Url::parse("https://example.com/simple/").unwrap();
// assert!(store.get(&url, None).is_none());
// }

// #[test]
// fn set_get_username() {
// let store = AuthenticationStore::new();
// let url = &Url::parse("https://example.com/simple/first/").unwrap();
// let credentials = Credentials::new(Some("user".to_string()), None);
// store.set(url, credentials.clone());
// assert_eq!(
// store.get(url, Some("user".to_string())).as_deref(),
// Some(&credentials),
// "Credentials should be retrieved"
// );
// assert_eq!(
// store.get(url, Some("other_user".to_string())).as_deref(),
// None,
// "Another username should not match"
// );
// assert_eq!(
// store.get(url, None).as_deref(),
// Some(&credentials),
// "When no username is provided, we should return the first match"
// );
// }

// #[test]
// fn set_get_password() {
// let store = AuthenticationStore::new();
// let url = &Url::parse("https://example.com/simple/first/").unwrap();
// let credentials = Credentials::new(None, Some("p".to_string()));
// store.set(url, credentials.clone());
// assert_eq!(store.get(url, None).as_deref(), Some(&credentials));
// }

// #[test]
// fn set_get_username_and_password() {
// let store = AuthenticationStore::new();
// let url = &Url::parse("https://example.com/simple/first/").unwrap();
// let credentials = Credentials::new(None, Some("p".to_string()));
// store.set(url, credentials.clone());
// assert_eq!(store.get(url, None).as_deref(), Some(&credentials));
// }
}
86 changes: 61 additions & 25 deletions crates/uv-auth/src/credentials.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use base64::prelude::BASE64_STANDARD;
use base64::read::DecoderReader;
use base64::write::EncoderWriter;
use netrc::Authenticator;

use netrc::Netrc;
use reqwest::header::HeaderValue;
use reqwest::Request;
Expand All @@ -11,43 +11,63 @@ use url::Url;

#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Credentials {
username: String,
username: Option<String>,
password: Option<String>,
}

impl Credentials {
pub fn new(username: String, password: Option<String>) -> Self {
pub fn new(username: Option<String>, password: Option<String>) -> Self {
Self { username, password }
}

pub fn username(&self) -> &str {
&self.username
pub fn username(&self) -> Option<&str> {
self.username.as_deref()
}

pub fn password(&self) -> Option<&str> {
self.password.as_deref()
}

/// Return [`Credentials`] for a [`Url`] from a [`Netrc`] file, if any.
pub fn from_netrc(netrc: &Netrc, url: &Url) -> Option<Self> {
url.host_str()
.and_then(|host| netrc.hosts.get(host).or_else(|| netrc.hosts.get("default")))
.map(Self::from)
///
/// If a username is provided, it must match the login in the netrc file or [`None`] is returned.
pub fn from_netrc(netrc: &Netrc, url: &Url, username: Option<&str>) -> Option<Self> {
let host = url.host_str()?;
let entry = netrc
.hosts
.get(host)
.or_else(|| netrc.hosts.get("default"))?;

// Ensure the username matches if provided
if username.is_some_and(|username| username != entry.login) {
return None;
};

Some(Credentials {
username: Some(entry.login.clone()),
password: Some(entry.password.clone()),
})
}

/// Parse [`Credentials`] from a URL, if any.
///
/// Returns [`None`] if both `username` and `password` are not populated.
/// Returns [`None`] if both [`Url::username`] and [`Url::password`] are not populated.
pub fn from_url(url: &Url) -> Option<Self> {
if url.username().is_empty() && url.password().is_none() {
return None;
}
Some(Self {
// Remove percent-encoding from URL credentials
// See <https://github.com/pypa/pip/blob/06d21db4ff1ab69665c22a88718a4ea9757ca293/src/pip/_internal/utils/misc.py#L497-L499>
username: urlencoding::decode(url.username())
.expect("An encoded username should always decode")
.into_owned(),
username: if url.username().is_empty() {
None
} else {
Some(
urlencoding::decode(url.username())
.expect("An encoded username should always decode")
.into_owned(),
)
},
password: url.password().map(|password| {
urlencoding::decode(password)
.expect("An encoded password should always decode")
Expand Down Expand Up @@ -81,12 +101,17 @@ impl Credentials {
let mut buf = String::new();
decoder.read_to_string(&mut buf).ok()?;
let (username, password) = buf.split_once(':')?;
let username = if username.is_empty() {
None
} else {
Some(username.to_string())
};
let password = if password.is_empty() {
None
} else {
Some(password.to_string())
};
Some(Self::new(username.to_string(), password))
Some(Self::new(username, password))
}

/// Create an HTTP Basic Authentication header for the credentials.
Expand All @@ -95,7 +120,7 @@ impl Credentials {
let mut buf = b"Basic ".to_vec();
{
let mut encoder = EncoderWriter::new(&mut buf, &BASE64_STANDARD);
let _ = write!(encoder, "{}:", self.username());
let _ = write!(encoder, "{}:", self.username().unwrap_or_default());
if let Some(password) = self.password() {
let _ = write!(encoder, "{}", password);
}
Expand All @@ -117,15 +142,6 @@ impl Credentials {
}
}

impl From<&Authenticator> for Credentials {
fn from(auth: &Authenticator) -> Self {
Credentials {
username: auth.login.clone(),
password: Some(auth.password.clone()),
}
}
}

#[cfg(test)]
mod test {
use insta::assert_debug_snapshot;
Expand All @@ -145,10 +161,30 @@ mod test {
auth_url.set_username("user").unwrap();
auth_url.set_password(Some("password")).unwrap();
let credentials = Credentials::from_url(&auth_url).unwrap();
assert_eq!(credentials.username(), "user");
assert_eq!(credentials.username(), Some("user"));
assert_eq!(credentials.password(), Some("password"));
}

#[test]
fn from_url_no_username() {
let url = &Url::parse("https://example.com/simple/first/").unwrap();
let mut auth_url = url.clone();
auth_url.set_password(Some("password")).unwrap();
let credentials = Credentials::from_url(&auth_url).unwrap();
assert_eq!(credentials.username(), None);
assert_eq!(credentials.password(), Some("password"));
}

#[test]
fn from_url_no_password() {
let url = &Url::parse("https://example.com/simple/first/").unwrap();
let mut auth_url = url.clone();
auth_url.set_username("user").unwrap();
let credentials = Credentials::from_url(&auth_url).unwrap();
assert_eq!(credentials.username(), Some("user"));
assert_eq!(credentials.password(), None);
}

#[test]
fn authenticated_request_from_url() {
let url = Url::parse("https://example.com/simple/first/").unwrap();
Expand Down
Loading

0 comments on commit c68e397

Please sign in to comment.