Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 3c11beb

Browse files
committedMar 19, 2025·
Use keyring --mode creds when authenticate = "always"
1 parent bf12cdb commit 3c11beb

File tree

7 files changed

+406
-106
lines changed

7 files changed

+406
-106
lines changed
 

‎Cargo.lock

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎crates/uv-auth/Cargo.toml

+1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ workspace = true
1313
uv-once-map = { workspace = true }
1414
uv-small-str = { workspace = true }
1515
uv-static = { workspace = true }
16+
uv-warnings = { workspace = true }
1617

1718
anyhow = { workspace = true }
1819
async-trait = { workspace = true }

‎crates/uv-auth/src/keyring.rs

+118-49
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
use std::process::Stdio;
1+
use std::{io::Write, process::Stdio};
22
use tokio::process::Command;
33
use tracing::{instrument, trace, warn};
44
use url::Url;
5+
use uv_warnings::warn_user_once;
56

67
use crate::credentials::Credentials;
78

@@ -19,7 +20,7 @@ pub(crate) enum KeyringProviderBackend {
1920
/// Use the `keyring` command to fetch credentials.
2021
Subprocess,
2122
#[cfg(test)]
22-
Dummy(std::collections::HashMap<(String, &'static str), &'static str>),
23+
Dummy(Vec<(String, &'static str, &'static str)>),
2324
}
2425

2526
impl KeyringProvider {
@@ -35,7 +36,7 @@ impl KeyringProvider {
3536
/// Returns [`None`] if no password was found for the username or if any errors
3637
/// are encountered in the keyring backend.
3738
#[instrument(skip_all, fields(url = % url.to_string(), username))]
38-
pub async fn fetch(&self, url: &Url, username: &str) -> Option<Credentials> {
39+
pub async fn fetch(&self, url: &Url, username: Option<&str>) -> Option<Credentials> {
3940
// Validate the request
4041
debug_assert!(
4142
url.host_str().is_some(),
@@ -46,14 +47,14 @@ impl KeyringProvider {
4647
"Should only use keyring for urls without a password"
4748
);
4849
debug_assert!(
49-
!username.is_empty(),
50-
"Should only use keyring with a username"
50+
!username.map(str::is_empty).unwrap_or(false),
51+
"Should only use keyring with a non-empty username"
5152
);
5253

5354
// Check the full URL first
5455
// <https://github.com/pypa/pip/blob/ae5fff36b0aad6e5e0037884927eaa29163c0611/src/pip/_internal/network/auth.py#L376C1-L379C14>
5556
trace!("Checking keyring for URL {url}");
56-
let mut password = match self.backend {
57+
let mut credentials = match self.backend {
5758
KeyringProviderBackend::Subprocess => {
5859
self.fetch_subprocess(url.as_str(), username).await
5960
}
@@ -63,14 +64,14 @@ impl KeyringProvider {
6364
}
6465
};
6566
// And fallback to a check for the host
66-
if password.is_none() {
67+
if credentials.is_none() {
6768
let host = if let Some(port) = url.port() {
6869
format!("{}:{}", url.host_str()?, port)
6970
} else {
7071
url.host_str()?.to_string()
7172
};
7273
trace!("Checking keyring for host {host}");
73-
password = match self.backend {
74+
credentials = match self.backend {
7475
KeyringProviderBackend::Subprocess => self.fetch_subprocess(&host, username).await,
7576
#[cfg(test)]
7677
KeyringProviderBackend::Dummy(ref store) => {
@@ -79,19 +80,36 @@ impl KeyringProvider {
7980
};
8081
}
8182

82-
password.map(|password| Credentials::new(Some(username.to_string()), Some(password)))
83+
credentials.map(|(username, password)| Credentials::new(Some(username), Some(password)))
8384
}
8485

8586
#[instrument(skip(self))]
86-
async fn fetch_subprocess(&self, service_name: &str, username: &str) -> Option<String> {
87+
async fn fetch_subprocess(
88+
&self,
89+
service_name: &str,
90+
username: Option<&str>,
91+
) -> Option<(String, String)> {
8792
// https://github.com/pypa/pip/blob/24.0/src/pip/_internal/network/auth.py#L136-L141
88-
let child = Command::new("keyring")
89-
.arg("get")
90-
.arg(service_name)
91-
.arg(username)
93+
let mut command = Command::new("keyring");
94+
command.arg("get").arg(service_name);
95+
96+
if let Some(username) = username {
97+
command.arg(username);
98+
} else {
99+
command.arg("--mode").arg("creds");
100+
}
101+
102+
let child = command
92103
.stdin(Stdio::null())
93104
.stdout(Stdio::piped())
94-
.stderr(Stdio::inherit())
105+
// If we're using `--mode creds`, we need to capture the output in order to avoid
106+
// showing users an "unrecognized arguments: --mode" error; otherwise, we stream stderr
107+
// so the user has visibility into keyring's behavior if it's doing something slow
108+
.stderr(if username.is_some() {
109+
Stdio::inherit()
110+
} else {
111+
Stdio::piped()
112+
})
95113
.spawn()
96114
.inspect_err(|err| warn!("Failure running `keyring` command: {err}"))
97115
.ok()?;
@@ -103,37 +121,74 @@ impl KeyringProvider {
103121
.ok()?;
104122

105123
if output.status.success() {
124+
// If we captured stderr, display it in case it's helpful to the user
125+
// TODO(zanieb): This was done when we added `--mode creds` support for parity with the
126+
// existing behavior, but it might be a better UX to hide this on success? It also
127+
// might be problematic that we're not streaming it. We could change this given some
128+
// user feedback.
129+
std::io::stderr().write_all(&output.stderr).ok();
130+
106131
// On success, parse the newline terminated password
107-
String::from_utf8(output.stdout)
132+
let output = String::from_utf8(output.stdout)
108133
.inspect_err(|err| warn!("Failed to parse response from `keyring` command: {err}"))
109-
.ok()
110-
.map(|password| password.trim_end().to_string())
134+
.ok();
135+
136+
if let Some(username) = username {
137+
// We're only expecting a password
138+
output.map(|password| (username.to_string(), password.trim_end().to_string()))
139+
} else {
140+
// We're expecting a username and password
141+
output.and_then(|output| {
142+
let mut lines = output.lines();
143+
lines.next().and_then(|username| {
144+
lines
145+
.next()
146+
.map(|password| (username.to_string(), password.to_string()))
147+
})
148+
})
149+
}
111150
} else {
112151
// On failure, no password was available
152+
let stderr = std::str::from_utf8(&output.stderr).ok()?;
153+
if stderr.contains("unrecognized arguments: --mode") {
154+
// N.B. We do not show the `service_name` here because we'll show the warning twice
155+
// otherwise, once for the URL and once for the realm.
156+
warn_user_once!(
157+
"Attempted to fetch credentials using the `keyring` command, but it does not support `--mode creds`; upgrade to `keyring>=v25.2.1` for support or provide a username"
158+
);
159+
} else if username.is_none() {
160+
// If we captured stderr, display it in case it's helpful to the user
161+
std::io::stderr().write_all(&output.stderr).ok();
162+
}
113163
None
114164
}
115165
}
116166

117167
#[cfg(test)]
118168
fn fetch_dummy(
119-
store: &std::collections::HashMap<(String, &'static str), &'static str>,
169+
store: &Vec<(String, &'static str, &'static str)>,
120170
service_name: &str,
121-
username: &str,
122-
) -> Option<String> {
123-
store
124-
.get(&(service_name.to_string(), username))
125-
.map(|password| (*password).to_string())
171+
username: Option<&str>,
172+
) -> Option<(String, String)> {
173+
store.iter().find_map(|(service, user, password)| {
174+
if service == service_name && username.map(|username| username == *user).unwrap_or(true)
175+
{
176+
Some(((*user).to_string(), (*password).to_string()))
177+
} else {
178+
None
179+
}
180+
})
126181
}
127182

128183
/// Create a new provider with [`KeyringProviderBackend::Dummy`].
129184
#[cfg(test)]
130-
pub fn dummy<S: Into<String>, T: IntoIterator<Item = ((S, &'static str), &'static str)>>(
185+
pub fn dummy<S: Into<String>, T: IntoIterator<Item = (S, &'static str, &'static str)>>(
131186
iter: T,
132187
) -> Self {
133188
Self {
134189
backend: KeyringProviderBackend::Dummy(
135190
iter.into_iter()
136-
.map(|((service, username), password)| ((service.into(), username), password))
191+
.map(|(service, username, password)| (service.into(), username, password))
137192
.collect(),
138193
),
139194
}
@@ -142,10 +197,8 @@ impl KeyringProvider {
142197
/// Create a new provider with no credentials available.
143198
#[cfg(test)]
144199
pub fn empty() -> Self {
145-
use std::collections::HashMap;
146-
147200
Self {
148-
backend: KeyringProviderBackend::Dummy(HashMap::new()),
201+
backend: KeyringProviderBackend::Dummy(Vec::new()),
149202
}
150203
}
151204
}
@@ -160,7 +213,7 @@ mod tests {
160213
let url = Url::parse("file:/etc/bin/").unwrap();
161214
let keyring = KeyringProvider::empty();
162215
// Panics due to debug assertion; returns `None` in production
163-
let result = std::panic::AssertUnwindSafe(keyring.fetch(&url, "user"))
216+
let result = std::panic::AssertUnwindSafe(keyring.fetch(&url, Some("user")))
164217
.catch_unwind()
165218
.await;
166219
assert!(result.is_err());
@@ -171,18 +224,18 @@ mod tests {
171224
let url = Url::parse("https://user:password@example.com").unwrap();
172225
let keyring = KeyringProvider::empty();
173226
// Panics due to debug assertion; returns `None` in production
174-
let result = std::panic::AssertUnwindSafe(keyring.fetch(&url, url.username()))
227+
let result = std::panic::AssertUnwindSafe(keyring.fetch(&url, Some(url.username())))
175228
.catch_unwind()
176229
.await;
177230
assert!(result.is_err());
178231
}
179232

180233
#[tokio::test]
181-
async fn fetch_url_with_no_username() {
234+
async fn fetch_url_with_empty_username() {
182235
let url = Url::parse("https://example.com").unwrap();
183236
let keyring = KeyringProvider::empty();
184237
// Panics due to debug assertion; returns `None` in production
185-
let result = std::panic::AssertUnwindSafe(keyring.fetch(&url, url.username()))
238+
let result = std::panic::AssertUnwindSafe(keyring.fetch(&url, Some(url.username())))
186239
.catch_unwind()
187240
.await;
188241
assert!(result.is_err());
@@ -192,23 +245,25 @@ mod tests {
192245
async fn fetch_url_no_auth() {
193246
let url = Url::parse("https://example.com").unwrap();
194247
let keyring = KeyringProvider::empty();
195-
let credentials = keyring.fetch(&url, "user");
248+
let credentials = keyring.fetch(&url, Some("user"));
196249
assert!(credentials.await.is_none());
197250
}
198251

199252
#[tokio::test]
200253
async fn fetch_url() {
201254
let url = Url::parse("https://example.com").unwrap();
202-
let keyring = KeyringProvider::dummy([((url.host_str().unwrap(), "user"), "password")]);
255+
let keyring = KeyringProvider::dummy([(url.host_str().unwrap(), "user", "password")]);
203256
assert_eq!(
204-
keyring.fetch(&url, "user").await,
257+
keyring.fetch(&url, Some("user")).await,
205258
Some(Credentials::new(
206259
Some("user".to_string()),
207260
Some("password".to_string())
208261
))
209262
);
210263
assert_eq!(
211-
keyring.fetch(&url.join("test").unwrap(), "user").await,
264+
keyring
265+
.fetch(&url.join("test").unwrap(), Some("user"))
266+
.await,
212267
Some(Credentials::new(
213268
Some("user".to_string()),
214269
Some("password".to_string())
@@ -219,34 +274,34 @@ mod tests {
219274
#[tokio::test]
220275
async fn fetch_url_no_match() {
221276
let url = Url::parse("https://example.com").unwrap();
222-
let keyring = KeyringProvider::dummy([(("other.com", "user"), "password")]);
223-
let credentials = keyring.fetch(&url, "user").await;
277+
let keyring = KeyringProvider::dummy([("other.com", "user", "password")]);
278+
let credentials = keyring.fetch(&url, Some("user")).await;
224279
assert_eq!(credentials, None);
225280
}
226281

227282
#[tokio::test]
228283
async fn fetch_url_prefers_url_to_host() {
229284
let url = Url::parse("https://example.com/").unwrap();
230285
let keyring = KeyringProvider::dummy([
231-
((url.join("foo").unwrap().as_str(), "user"), "password"),
232-
((url.host_str().unwrap(), "user"), "other-password"),
286+
(url.join("foo").unwrap().as_str(), "user", "password"),
287+
(url.host_str().unwrap(), "user", "other-password"),
233288
]);
234289
assert_eq!(
235-
keyring.fetch(&url.join("foo").unwrap(), "user").await,
290+
keyring.fetch(&url.join("foo").unwrap(), Some("user")).await,
236291
Some(Credentials::new(
237292
Some("user".to_string()),
238293
Some("password".to_string())
239294
))
240295
);
241296
assert_eq!(
242-
keyring.fetch(&url, "user").await,
297+
keyring.fetch(&url, Some("user")).await,
243298
Some(Credentials::new(
244299
Some("user".to_string()),
245300
Some("other-password".to_string())
246301
))
247302
);
248303
assert_eq!(
249-
keyring.fetch(&url.join("bar").unwrap(), "user").await,
304+
keyring.fetch(&url.join("bar").unwrap(), Some("user")).await,
250305
Some(Credentials::new(
251306
Some("user".to_string()),
252307
Some("other-password".to_string())
@@ -257,8 +312,22 @@ mod tests {
257312
#[tokio::test]
258313
async fn fetch_url_username() {
259314
let url = Url::parse("https://example.com").unwrap();
260-
let keyring = KeyringProvider::dummy([((url.host_str().unwrap(), "user"), "password")]);
261-
let credentials = keyring.fetch(&url, "user").await;
315+
let keyring = KeyringProvider::dummy([(url.host_str().unwrap(), "user", "password")]);
316+
let credentials = keyring.fetch(&url, Some("user")).await;
317+
assert_eq!(
318+
credentials,
319+
Some(Credentials::new(
320+
Some("user".to_string()),
321+
Some("password".to_string())
322+
))
323+
);
324+
}
325+
326+
#[tokio::test]
327+
async fn fetch_url_no_username() {
328+
let url = Url::parse("https://example.com").unwrap();
329+
let keyring = KeyringProvider::dummy([(url.host_str().unwrap(), "user", "password")]);
330+
let credentials = keyring.fetch(&url, None).await;
262331
assert_eq!(
263332
credentials,
264333
Some(Credentials::new(
@@ -271,13 +340,13 @@ mod tests {
271340
#[tokio::test]
272341
async fn fetch_url_username_no_match() {
273342
let url = Url::parse("https://example.com").unwrap();
274-
let keyring = KeyringProvider::dummy([((url.host_str().unwrap(), "foo"), "password")]);
275-
let credentials = keyring.fetch(&url, "bar").await;
343+
let keyring = KeyringProvider::dummy([(url.host_str().unwrap(), "foo", "password")]);
344+
let credentials = keyring.fetch(&url, Some("bar")).await;
276345
assert_eq!(credentials, None);
277346

278347
// Still fails if we have `foo` in the URL itself
279348
let url = Url::parse("https://foo@example.com").unwrap();
280-
let credentials = keyring.fetch(&url, "bar").await;
349+
let credentials = keyring.fetch(&url, Some("bar")).await;
281350
assert_eq!(credentials, None);
282351
}
283352
}

‎crates/uv-auth/src/middleware.rs

+109-53
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,7 @@ impl Middleware for AuthMiddleware {
297297
// Then, fetch from external services.
298298
// Here, we use the username from the cache if present.
299299
if let Some(credentials) = self
300-
.fetch_credentials(credentials.as_deref(), retry_request.url())
300+
.fetch_credentials(credentials.as_deref(), retry_request.url(), auth_policy)
301301
.await
302302
{
303303
retry_request = credentials.authenticate(retry_request);
@@ -404,7 +404,7 @@ impl AuthMiddleware {
404404
// Do not insert already-cached credentials
405405
None
406406
} else if let Some(credentials) = self
407-
.fetch_credentials(Some(&credentials), request.url())
407+
.fetch_credentials(Some(&credentials), request.url(), auth_policy)
408408
.await
409409
{
410410
request = credentials.authenticate(request);
@@ -426,6 +426,7 @@ impl AuthMiddleware {
426426
&self,
427427
credentials: Option<&Credentials>,
428428
url: &Url,
429+
auth_policy: AuthPolicy,
429430
) -> Option<Arc<Credentials>> {
430431
// Fetches can be expensive, so we will only run them _once_ per realm and username combination
431432
// All other requests for the same realm will wait until the first one completes
@@ -467,17 +468,25 @@ impl AuthMiddleware {
467468
}) {
468469
debug!("Found credentials in netrc file for {url}");
469470
Some(credentials)
470-
// N.B. The keyring provider performs lookups for the exact URL then
471-
// falls back to the host, but we cache the result per realm so if a keyring
472-
// implementation returns different credentials for different URLs in the
473-
// same realm we will use the wrong credentials.
471+
472+
// N.B. The keyring provider performs lookups for the exact URL then falls back to the host,
473+
// but we cache the result per realm so if a keyring implementation returns different
474+
// credentials for different URLs in the same realm we will use the wrong credentials.
474475
} else if let Some(credentials) = match self.keyring {
475476
Some(ref keyring) => {
477+
// The subprocess keyring provider is _slow_ so we do not perform fetches for all
478+
// URLs; instead, we fetch if there's a username or if the user has requested to
479+
// always authenticate.
476480
if let Some(username) = credentials.and_then(|credentials| credentials.username()) {
477481
debug!("Checking keyring for credentials for {username}@{url}");
478-
keyring.fetch(url, username).await
482+
keyring.fetch(url, Some(username)).await
483+
} else if matches!(auth_policy, AuthPolicy::Always) {
484+
debug!(
485+
"Checking keyring for credentials for {url} without username due to `authenticate = always`"
486+
);
487+
keyring.fetch(url, None).await
479488
} else {
480-
debug!("Skipping keyring lookup for {url} with no username");
489+
debug!("Skipping keyring fetch for {url} without username; use `authenticate = always` to force");
481490
None
482491
}
483492
}
@@ -930,14 +939,12 @@ mod tests {
930939
AuthMiddleware::new()
931940
.with_cache(CredentialsCache::new())
932941
.with_keyring(Some(KeyringProvider::dummy([(
933-
(
934-
format!(
935-
"{}:{}",
936-
base_url.host_str().unwrap(),
937-
base_url.port().unwrap()
938-
),
939-
username,
942+
format!(
943+
"{}:{}",
944+
base_url.host_str().unwrap(),
945+
base_url.port().unwrap()
940946
),
947+
username,
941948
password,
942949
)]))),
943950
)
@@ -985,6 +992,64 @@ mod tests {
985992
Ok(())
986993
}
987994

995+
#[test(tokio::test)]
996+
async fn test_keyring_always_authenticate() -> Result<(), Error> {
997+
let username = "user";
998+
let password = "password";
999+
let server = start_test_server(username, password).await;
1000+
let base_url = Url::parse(&server.uri())?;
1001+
1002+
let auth_policies = auth_policies_for(&base_url, AuthPolicy::Always);
1003+
let client = test_client_builder()
1004+
.with(
1005+
AuthMiddleware::new()
1006+
.with_cache(CredentialsCache::new())
1007+
.with_keyring(Some(KeyringProvider::dummy([(
1008+
format!(
1009+
"{}:{}",
1010+
base_url.host_str().unwrap(),
1011+
base_url.port().unwrap()
1012+
),
1013+
username,
1014+
password,
1015+
)])))
1016+
.with_url_auth_policies(auth_policies),
1017+
)
1018+
.build();
1019+
1020+
assert_eq!(
1021+
client.get(server.uri()).send().await?.status(),
1022+
200,
1023+
"Credentials (including a username) should be pulled from the keyring"
1024+
);
1025+
1026+
let mut url = base_url.clone();
1027+
url.set_username(username).unwrap();
1028+
assert_eq!(
1029+
client.get(url).send().await?.status(),
1030+
200,
1031+
"The password for the username should be pulled from the keyring"
1032+
);
1033+
1034+
let mut url = base_url.clone();
1035+
url.set_username(username).unwrap();
1036+
url.set_password(Some("invalid")).unwrap();
1037+
assert_eq!(
1038+
client.get(url).send().await?.status(),
1039+
401,
1040+
"Password in the URL should take precedence and fail"
1041+
);
1042+
1043+
let mut url = base_url.clone();
1044+
url.set_username("other_user").unwrap();
1045+
assert!(
1046+
matches!(client.get(url).send().await, Err(reqwest_middleware::Error::Middleware(_))),
1047+
"If the username does not match, a password should not be fetched, and the middleware should fail eagerly since `authenticate = always` is not satisfied"
1048+
);
1049+
1050+
Ok(())
1051+
}
1052+
9881053
/// We include ports in keyring requests, e.g., `localhost:8000` should be distinct from `localhost`,
9891054
/// unless the server is running on a default port, e.g., `localhost:80` is equivalent to `localhost`.
9901055
/// We don't unit test the latter case because it's possible to collide with a server a developer is
@@ -1002,7 +1067,8 @@ mod tests {
10021067
.with_cache(CredentialsCache::new())
10031068
.with_keyring(Some(KeyringProvider::dummy([(
10041069
// Omit the port from the keyring entry
1005-
(base_url.host_str().unwrap(), username),
1070+
base_url.host_str().unwrap(),
1071+
username,
10061072
password,
10071073
)]))),
10081074
)
@@ -1037,14 +1103,12 @@ mod tests {
10371103
let client = test_client_builder()
10381104
.with(AuthMiddleware::new().with_cache(cache).with_keyring(Some(
10391105
KeyringProvider::dummy([(
1040-
(
1041-
format!(
1042-
"{}:{}",
1043-
base_url.host_str().unwrap(),
1044-
base_url.port().unwrap()
1045-
),
1046-
username,
1106+
format!(
1107+
"{}:{}",
1108+
base_url.host_str().unwrap(),
1109+
base_url.port().unwrap()
10471110
),
1111+
username,
10481112
password,
10491113
)]),
10501114
)))
@@ -1152,25 +1216,21 @@ mod tests {
11521216
.with_cache(CredentialsCache::new())
11531217
.with_keyring(Some(KeyringProvider::dummy([
11541218
(
1155-
(
1156-
format!(
1157-
"{}:{}",
1158-
base_url_1.host_str().unwrap(),
1159-
base_url_1.port().unwrap()
1160-
),
1161-
username_1,
1219+
format!(
1220+
"{}:{}",
1221+
base_url_1.host_str().unwrap(),
1222+
base_url_1.port().unwrap()
11621223
),
1224+
username_1,
11631225
password_1,
11641226
),
11651227
(
1166-
(
1167-
format!(
1168-
"{}:{}",
1169-
base_url_2.host_str().unwrap(),
1170-
base_url_2.port().unwrap()
1171-
),
1172-
username_2,
1228+
format!(
1229+
"{}:{}",
1230+
base_url_2.host_str().unwrap(),
1231+
base_url_2.port().unwrap()
11731232
),
1233+
username_2,
11741234
password_2,
11751235
),
11761236
]))),
@@ -1406,25 +1466,21 @@ mod tests {
14061466
.with_cache(CredentialsCache::new())
14071467
.with_keyring(Some(KeyringProvider::dummy([
14081468
(
1409-
(
1410-
format!(
1411-
"{}:{}",
1412-
base_url_1.host_str().unwrap(),
1413-
base_url_1.port().unwrap()
1414-
),
1415-
username_1,
1469+
format!(
1470+
"{}:{}",
1471+
base_url_1.host_str().unwrap(),
1472+
base_url_1.port().unwrap()
14161473
),
1474+
username_1,
14171475
password_1,
14181476
),
14191477
(
1420-
(
1421-
format!(
1422-
"{}:{}",
1423-
base_url_2.host_str().unwrap(),
1424-
base_url_2.port().unwrap()
1425-
),
1426-
username_2,
1478+
format!(
1479+
"{}:{}",
1480+
base_url_2.host_str().unwrap(),
1481+
base_url_2.port().unwrap()
14271482
),
1483+
username_2,
14281484
password_2,
14291485
),
14301486
]))),
@@ -1540,8 +1596,8 @@ mod tests {
15401596
AuthMiddleware::new()
15411597
.with_cache(CredentialsCache::new())
15421598
.with_keyring(Some(KeyringProvider::dummy([
1543-
((base_url_1.clone(), username), password_1),
1544-
((base_url_2.clone(), username), password_2),
1599+
(base_url_1.clone(), username, password_1),
1600+
(base_url_2.clone(), username, password_2),
15451601
]))),
15461602
)
15471603
.build();

‎crates/uv/src/commands/publish.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,7 @@ async fn gather_credentials(
295295
if let Some(username) = &username {
296296
debug!("Fetching password from keyring");
297297
if let Some(keyring_password) = keyring_provider
298-
.fetch(&publish_url, username)
298+
.fetch(&publish_url, Some(username))
299299
.await
300300
.as_ref()
301301
.and_then(|credentials| credentials.password())

‎crates/uv/tests/it/lock.rs

+161
Original file line numberDiff line numberDiff line change
@@ -18312,6 +18312,167 @@ fn lock_keyring_credentials() -> Result<()> {
1831218312
Ok(())
1831318313
}
1831418314

18315+
/// Fetch credentials (including a username) for a named index via the keyring using `authenticate =
18316+
/// always`
18317+
#[test]
18318+
fn lock_keyring_credentials_always_authenticate_fetches_username() -> Result<()> {
18319+
let keyring_context = TestContext::new("3.12");
18320+
18321+
// Install our keyring plugin
18322+
keyring_context
18323+
.pip_install()
18324+
.arg(
18325+
keyring_context
18326+
.workspace_root
18327+
.join("scripts")
18328+
.join("packages")
18329+
.join("keyring_test_plugin"),
18330+
)
18331+
// We need a newer version of keyring that supports `--mode`, so unset `EXCLUDE_NEWER` and
18332+
// pin the dependencies
18333+
.env_remove(EnvVars::UV_EXCLUDE_NEWER)
18334+
// (from `echo "keyring==v25.6.0" | uv pip compile - --no-annotate --no-header -q`)
18335+
.arg("jaraco-classes==3.4.0")
18336+
.arg("jaraco-context==6.0.1")
18337+
.arg("jaraco-functools==4.1.0")
18338+
.arg("keyring==25.6.0")
18339+
.arg("more-itertools==10.6.0")
18340+
.assert()
18341+
.success();
18342+
18343+
let context = TestContext::new("3.12");
18344+
18345+
let pyproject_toml = context.temp_dir.child("pyproject.toml");
18346+
pyproject_toml.write_str(
18347+
r#"
18348+
[project]
18349+
name = "foo"
18350+
version = "0.1.0"
18351+
requires-python = ">=3.12"
18352+
dependencies = ["iniconfig"]
18353+
18354+
[tool.uv]
18355+
keyring-provider = "subprocess"
18356+
18357+
[[tool.uv.index]]
18358+
name = "proxy"
18359+
url = "https://pypi-proxy.fly.dev/basic-auth/simple"
18360+
default = true
18361+
authenticate = "always"
18362+
"#,
18363+
)?;
18364+
18365+
uv_snapshot!(context.filters(), context.lock()
18366+
.env(EnvVars::KEYRING_TEST_CREDENTIALS, r#"{"pypi-proxy.fly.dev": {"public": "heron"}}"#)
18367+
.env(EnvVars::PATH, venv_bin_path(&keyring_context.venv)), @r"
18368+
success: true
18369+
exit_code: 0
18370+
----- stdout -----
18371+
18372+
----- stderr -----
18373+
Request for https://pypi-proxy.fly.dev/basic-auth/simple/iniconfig/
18374+
Request for pypi-proxy.fly.dev
18375+
Resolved 2 packages in [TIME]
18376+
");
18377+
18378+
let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock")).unwrap();
18379+
18380+
// The lockfile shout omit the credentials.
18381+
insta::with_settings!({
18382+
filters => context.filters(),
18383+
}, {
18384+
assert_snapshot!(
18385+
lock, @r#"
18386+
version = 1
18387+
revision = 1
18388+
requires-python = ">=3.12"
18389+
18390+
[options]
18391+
exclude-newer = "2024-03-25T00:00:00Z"
18392+
18393+
[[package]]
18394+
name = "foo"
18395+
version = "0.1.0"
18396+
source = { virtual = "." }
18397+
dependencies = [
18398+
{ name = "iniconfig" },
18399+
]
18400+
18401+
[package.metadata]
18402+
requires-dist = [{ name = "iniconfig" }]
18403+
18404+
[[package]]
18405+
name = "iniconfig"
18406+
version = "2.0.0"
18407+
source = { registry = "https://pypi-proxy.fly.dev/basic-auth/simple" }
18408+
sdist = { url = "https://pypi-proxy.fly.dev/basic-auth/files/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 }
18409+
wheels = [
18410+
{ url = "https://pypi-proxy.fly.dev/basic-auth/files/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 },
18411+
]
18412+
"#
18413+
);
18414+
});
18415+
18416+
Ok(())
18417+
}
18418+
18419+
/// Fetch credentials (including a username) for a named index via the keyring using `authenticate =
18420+
/// always` — but the keyring version installed does not support `--mode creds`
18421+
#[test]
18422+
fn lock_keyring_credentials_always_authenticate_unsupported_mode() -> Result<()> {
18423+
let keyring_context = TestContext::new("3.12");
18424+
18425+
// Install our keyring plugin
18426+
keyring_context
18427+
.pip_install()
18428+
.arg(
18429+
keyring_context
18430+
.workspace_root
18431+
.join("scripts")
18432+
.join("packages")
18433+
.join("keyring_test_plugin"),
18434+
)
18435+
.assert()
18436+
.success();
18437+
18438+
let context = TestContext::new("3.12");
18439+
18440+
let pyproject_toml = context.temp_dir.child("pyproject.toml");
18441+
pyproject_toml.write_str(
18442+
r#"
18443+
[project]
18444+
name = "foo"
18445+
version = "0.1.0"
18446+
requires-python = ">=3.12"
18447+
dependencies = ["iniconfig"]
18448+
18449+
[tool.uv]
18450+
keyring-provider = "subprocess"
18451+
18452+
[[tool.uv.index]]
18453+
name = "proxy"
18454+
url = "https://pypi-proxy.fly.dev/basic-auth/simple"
18455+
default = true
18456+
authenticate = "always"
18457+
"#,
18458+
)?;
18459+
18460+
uv_snapshot!(context.filters(), context.lock()
18461+
.env(EnvVars::KEYRING_TEST_CREDENTIALS, r#"{"pypi-proxy.fly.dev": {"public": "heron"}}"#)
18462+
.env(EnvVars::PATH, venv_bin_path(&keyring_context.venv)), @r"
18463+
success: false
18464+
exit_code: 2
18465+
----- stdout -----
18466+
18467+
----- stderr -----
18468+
warning: Attempted to fetch credentials using the `keyring` command, but it does not support `--mode creds`; upgrade to `keyring>=v25.2.1` for support or provide a username
18469+
error: Failed to fetch: `https://pypi-proxy.fly.dev/basic-auth/simple/iniconfig/`
18470+
Caused by: Missing credentials for https://pypi-proxy.fly.dev/basic-auth/simple/iniconfig/
18471+
");
18472+
18473+
Ok(())
18474+
}
18475+
1831518476
#[test]
1831618477
fn lock_multiple_sources() -> Result<()> {
1831718478
let context = TestContext::new("3.12");

‎scripts/packages/keyring_test_plugin/keyrings/test_keyring.py

+15-3
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,16 @@
33
import sys
44

55
from keyring import backend
6+
from keyring import credentials
67

78

89
class KeyringTest(backend.KeyringBackend):
910
priority = 9
1011

1112
def get_password(self, service, username):
1213
print(f"Request for {username}@{service}", file=sys.stderr)
13-
credentials = json.loads(os.environ.get("KEYRING_TEST_CREDENTIALS", "{}"))
14-
return credentials.get(service, {}).get(username)
14+
entries = json.loads(os.environ.get("KEYRING_TEST_CREDENTIALS", "{}"))
15+
return entries.get(service, {}).get(username)
1516

1617
def set_password(self, service, username, password):
1718
raise NotImplementedError()
@@ -20,4 +21,15 @@ def delete_password(self, service, username):
2021
raise NotImplementedError()
2122

2223
def get_credential(self, service, username):
23-
raise NotImplementedError()
24+
print(f"Request for {service}", file=sys.stderr)
25+
entries = json.loads(os.environ.get("KEYRING_TEST_CREDENTIALS", "{}"))
26+
service_entries = entries.get(service, {})
27+
if not service_entries:
28+
return None
29+
if username:
30+
password = service_entries.get(username)
31+
if not password:
32+
return None
33+
return credentials.SimpleCredential(username, password)
34+
else:
35+
return credentials.SimpleCredential(*list(service_entries.items())[0])

0 commit comments

Comments
 (0)
Please sign in to comment.