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 990ea5f

Browse files
committedMar 19, 2025·
Use keyring --mode creds when authenticate = "always"
1 parent 5173b59 commit 990ea5f

File tree

7 files changed

+409
-107
lines changed

7 files changed

+409
-107
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
}

0 commit comments

Comments
 (0)
Please sign in to comment.