1use std::{io::Write, process::Stdio};
2use tokio::process::Command;
3use tracing::{debug, instrument, trace, warn};
4use uv_redacted::SafeUrl;
5use uv_warnings::{warn_user_once, warn_user_once_with_chain};
6
7use crate::credentials::Credentials;
8
9static UV_SERVICE_PREFIX: &str = "uv:";
11
12#[derive(Debug)]
17pub struct KeyringProvider {
18 backend: KeyringProviderBackend,
19}
20
21#[derive(thiserror::Error, Debug)]
22pub enum Error {
23 #[error(transparent)]
24 Keyring(#[from] uv_keyring::Error),
25
26 #[error("The '{0}' keyring provider does not support storing credentials")]
27 StoreUnsupported(&'static str),
28
29 #[error("The '{0}' keyring provider does not support removing credentials")]
30 RemoveUnsupported(&'static str),
31}
32
33#[derive(Debug)]
34enum KeyringProviderBackend {
35 Native,
37 Subprocess,
39 #[cfg(test)]
40 Dummy(Vec<(String, &'static str, &'static str)>),
41}
42
43impl KeyringProviderBackend {
44 fn name(&self) -> &'static str {
45 match self {
46 Self::Native => "native",
47 Self::Subprocess => "subprocess",
48 #[cfg(test)]
49 Self::Dummy(_) => "dummy",
50 }
51 }
52}
53
54impl std::fmt::Display for KeyringProviderBackend {
55 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56 f.write_str(self.name())
57 }
58}
59
60impl KeyringProvider {
61 pub(crate) fn native() -> Self {
63 Self {
64 backend: KeyringProviderBackend::Native,
65 }
66 }
67
68 pub fn subprocess() -> Self {
70 Self {
71 backend: KeyringProviderBackend::Subprocess,
72 }
73 }
74
75 #[instrument(skip_all, fields(url = % url.to_string(), username))]
79 pub async fn store(&self, url: &SafeUrl, credentials: &Credentials) -> Result<bool, Error> {
80 let Some(username) = credentials.username() else {
81 trace!("Unable to store credentials in keyring for `{url}` due to missing username");
82 return Ok(false);
83 };
84 let Some(password) = credentials.password() else {
85 trace!("Unable to store credentials in keyring for `{url}` due to missing password");
86 return Ok(false);
87 };
88
89 let url = url.without_credentials();
91
92 let target = if let Some(host) = url.host_str().filter(|_| !url.path().is_empty()) {
94 let mut target = String::new();
95 if url.scheme() != "https" {
96 target.push_str(url.scheme());
97 target.push_str("://");
98 }
99 target.push_str(host);
100 if let Some(port) = url.port() {
101 target.push(':');
102 target.push_str(&port.to_string());
103 }
104 target
105 } else {
106 url.as_str().to_owned()
107 };
108
109 match &self.backend {
110 KeyringProviderBackend::Native => {
111 self.store_native(&target, username, password).await?;
112 Ok(true)
113 }
114 KeyringProviderBackend::Subprocess => Err(Error::StoreUnsupported(self.backend.name())),
115 #[cfg(test)]
116 KeyringProviderBackend::Dummy(_) => Err(Error::StoreUnsupported(self.backend.name())),
117 }
118 }
119
120 #[instrument(skip_all, fields(service = ?service, username = ?username))]
122 async fn store_native(
123 &self,
124 service: &str,
125 username: &str,
126 password: &str,
127 ) -> Result<(), Error> {
128 let prefixed_service = format!("{UV_SERVICE_PREFIX}{service}");
129 let entry = uv_keyring::Entry::new(&prefixed_service, username)?;
130 entry.set_password(password).await?;
131 Ok(())
132 }
133
134 #[instrument(skip_all, fields(url = % url.to_string(), username))]
138 pub async fn remove(&self, url: &SafeUrl, username: &str) -> Result<(), Error> {
139 let url = url.without_credentials();
141
142 let target = if let Some(host) = url.host_str().filter(|_| !url.path().is_empty()) {
144 let mut target = String::new();
145 if url.scheme() != "https" {
146 target.push_str(url.scheme());
147 target.push_str("://");
148 }
149 target.push_str(host);
150 if let Some(port) = url.port() {
151 target.push(':');
152 target.push_str(&port.to_string());
153 }
154 target
155 } else {
156 url.as_str().to_owned()
157 };
158
159 match &self.backend {
160 KeyringProviderBackend::Native => {
161 self.remove_native(&target, username).await?;
162 Ok(())
163 }
164 KeyringProviderBackend::Subprocess => {
165 Err(Error::RemoveUnsupported(self.backend.name()))
166 }
167 #[cfg(test)]
168 KeyringProviderBackend::Dummy(_) => Err(Error::RemoveUnsupported(self.backend.name())),
169 }
170 }
171
172 #[instrument(skip(self))]
175 async fn remove_native(
176 &self,
177 service_name: &str,
178 username: &str,
179 ) -> Result<(), uv_keyring::Error> {
180 let prefixed_service = format!("{UV_SERVICE_PREFIX}{service_name}");
181 let entry = uv_keyring::Entry::new(&prefixed_service, username)?;
182 entry.delete_credential().await?;
183 trace!("Removed credentials for {username}@{service_name} from system keyring");
184 Ok(())
185 }
186
187 #[instrument(skip_all, fields(url = % url.to_string(), username))]
192 pub async fn fetch(&self, url: &SafeUrl, username: Option<&str>) -> Option<Credentials> {
193 debug_assert!(
195 url.host_str().is_some(),
196 "Should only use keyring for URLs with host"
197 );
198 debug_assert!(
199 url.password().is_none(),
200 "Should only use keyring for URLs without a password"
201 );
202 debug_assert!(
203 username.is_none_or(|username| !username.is_empty()),
204 "Should only use keyring with a non-empty username"
205 );
206
207 trace!("Checking keyring for URL `{url}`");
210 let mut credentials = match self.backend {
211 KeyringProviderBackend::Native => self.fetch_native(url.as_str(), username).await,
212 KeyringProviderBackend::Subprocess => {
213 self.fetch_subprocess(url.as_str(), username).await
214 }
215 #[cfg(test)]
216 KeyringProviderBackend::Dummy(ref store) => {
217 Self::fetch_dummy(store, url.as_str(), username)
218 }
219 };
220 if credentials.is_none() {
222 let host = if let Some(port) = url.port() {
223 format!("{}:{}", url.host_str()?, port)
224 } else {
225 url.host_str()?.to_string()
226 };
227 trace!("Checking keyring for host {host}");
228 credentials = match self.backend {
229 KeyringProviderBackend::Native => self.fetch_native(&host, username).await,
230 KeyringProviderBackend::Subprocess => self.fetch_subprocess(&host, username).await,
231 #[cfg(test)]
232 KeyringProviderBackend::Dummy(ref store) => {
233 Self::fetch_dummy(store, &host, username)
234 }
235 };
236
237 if credentials.is_none() && url.scheme() != "https" {
241 let scheme_host = format!("{}://{host}", url.scheme());
242 trace!("Checking keyring for scheme+host {scheme_host}");
243 credentials = match self.backend {
244 KeyringProviderBackend::Native => {
245 self.fetch_native(&scheme_host, username).await
246 }
247 KeyringProviderBackend::Subprocess => {
248 self.fetch_subprocess(&scheme_host, username).await
249 }
250 #[cfg(test)]
251 KeyringProviderBackend::Dummy(ref store) => {
252 Self::fetch_dummy(store, &scheme_host, username)
253 }
254 };
255 }
256 }
257
258 credentials.map(|(username, password)| Credentials::basic(Some(username), Some(password)))
259 }
260
261 #[instrument(skip(self))]
262 async fn fetch_subprocess(
263 &self,
264 service_name: &str,
265 username: Option<&str>,
266 ) -> Option<(String, String)> {
267 let mut command = Command::new("keyring");
269 command.arg("get").arg(service_name);
270
271 if let Some(username) = username {
272 command.arg(username);
273 } else {
274 command.arg("--mode").arg("creds");
275 }
276
277 let child = command
278 .stdin(Stdio::null())
279 .stdout(Stdio::piped())
280 .stderr(if username.is_some() {
284 Stdio::inherit()
285 } else {
286 Stdio::piped()
287 })
288 .spawn()
289 .inspect_err(|err| warn!("Failure running `keyring` command: {err}"))
290 .ok()?;
291
292 let output = child
293 .wait_with_output()
294 .await
295 .inspect_err(|err| warn!("Failed to wait for `keyring` output: {err}"))
296 .ok()?;
297
298 if output.status.success() {
299 std::io::stderr().write_all(&output.stderr).ok();
305
306 let output = String::from_utf8(output.stdout)
308 .inspect_err(|err| warn!("Failed to parse response from `keyring` command: {err}"))
309 .ok()?;
310
311 let (username, password) = if let Some(username) = username {
312 let password = output
314 .strip_suffix("\r\n")
315 .or_else(|| output.strip_suffix('\n'))
316 .unwrap_or(&output);
317 (username, password)
318 } else {
319 let mut lines = output.lines();
321 let username = lines.next()?;
322 let Some(password) = lines.next() else {
323 warn!(
324 "Got username without password for `{service_name}` from `keyring` command"
325 );
326 return None;
327 };
328 (username, password)
329 };
330
331 if password.is_empty() {
332 warn!("Got empty password for `{username}@{service_name}` from `keyring` command");
336 }
337
338 Some((username.to_string(), password.to_string()))
339 } else {
340 let stderr = std::str::from_utf8(&output.stderr).ok()?;
342 if stderr.contains("unrecognized arguments: --mode") {
343 warn_user_once!(
346 "Attempted to fetch credentials using the `keyring` command, but it does not support `--mode creds`; upgrade to `keyring>=v25.2.1` or provide a username"
347 );
348 } else if username.is_none() {
349 std::io::stderr().write_all(&output.stderr).ok();
351 }
352 None
353 }
354 }
355
356 #[instrument(skip(self))]
357 async fn fetch_native(
358 &self,
359 service: &str,
360 username: Option<&str>,
361 ) -> Option<(String, String)> {
362 let prefixed_service = format!("{UV_SERVICE_PREFIX}{service}");
363 let username = username?;
364 let Ok(entry) = uv_keyring::Entry::new(&prefixed_service, username) else {
365 return None;
366 };
367 match entry.get_password().await {
368 Ok(password) => return Some((username.to_string(), password)),
369 Err(uv_keyring::Error::NoEntry) => {
370 debug!("No entry found in system keyring for {service}");
371 }
372 Err(err) => {
373 warn_user_once_with_chain!(
374 anyhow::Error::from(err)
375 .context(format!(
376 "Unable to fetch credentials for {service} from system keyring"
377 ))
378 .as_ref()
379 );
380 }
381 }
382 None
383 }
384
385 #[cfg(test)]
386 fn fetch_dummy(
387 store: &Vec<(String, &'static str, &'static str)>,
388 service_name: &str,
389 username: Option<&str>,
390 ) -> Option<(String, String)> {
391 store.iter().find_map(|(service, user, password)| {
392 if service == service_name && username.is_none_or(|username| username == *user) {
393 Some(((*user).to_string(), (*password).to_string()))
394 } else {
395 None
396 }
397 })
398 }
399
400 #[cfg(test)]
402 pub(crate) fn dummy<
403 S: Into<String>,
404 T: IntoIterator<Item = (S, &'static str, &'static str)>,
405 >(
406 iter: T,
407 ) -> Self {
408 Self {
409 backend: KeyringProviderBackend::Dummy(
410 iter.into_iter()
411 .map(|(service, username, password)| (service.into(), username, password))
412 .collect(),
413 ),
414 }
415 }
416
417 #[cfg(test)]
419 fn empty() -> Self {
420 Self {
421 backend: KeyringProviderBackend::Dummy(Vec::new()),
422 }
423 }
424}
425
426#[cfg(test)]
427mod tests {
428 use super::*;
429
430 #[tokio::test]
431 #[cfg_attr(
432 debug_assertions,
433 should_panic(expected = "Should only use keyring for URLs with host")
434 )]
435 async fn fetch_url_no_host() {
436 let url = SafeUrl::parse("file:/etc/bin/").unwrap();
437 let keyring = KeyringProvider::empty();
438 assert_eq!(keyring.fetch(&url, Some("user")).await, None);
440 }
441
442 #[tokio::test]
443 #[cfg_attr(
444 debug_assertions,
445 should_panic(expected = "Should only use keyring for URLs without a password")
446 )]
447 async fn fetch_url_with_password() {
448 let url = SafeUrl::parse("https://user:password@example.com").unwrap();
449 let keyring = KeyringProvider::empty();
450 assert_eq!(keyring.fetch(&url, Some(url.username())).await, None);
452 }
453
454 #[tokio::test]
455 #[cfg_attr(
456 debug_assertions,
457 should_panic(expected = "Should only use keyring with a non-empty username")
458 )]
459 async fn fetch_url_with_empty_username() {
460 let url = SafeUrl::parse("https://example.com").unwrap();
461 let keyring = KeyringProvider::empty();
462 assert_eq!(keyring.fetch(&url, Some(url.username())).await, None);
464 }
465
466 #[tokio::test]
467 async fn fetch_url_no_auth() {
468 let url = SafeUrl::parse("https://example.com").unwrap();
469 let keyring = KeyringProvider::empty();
470 let credentials = keyring.fetch(&url, Some("user"));
471 assert!(credentials.await.is_none());
472 }
473
474 #[tokio::test]
475 async fn fetch_url() {
476 let url = SafeUrl::parse("https://example.com").unwrap();
477 let keyring = KeyringProvider::dummy([(url.host_str().unwrap(), "user", "password")]);
478 assert_eq!(
479 keyring.fetch(&url, Some("user")).await,
480 Some(Credentials::basic(
481 Some("user".to_string()),
482 Some("password".to_string())
483 ))
484 );
485 assert_eq!(
486 keyring
487 .fetch(&url.join("test").unwrap(), Some("user"))
488 .await,
489 Some(Credentials::basic(
490 Some("user".to_string()),
491 Some("password".to_string())
492 ))
493 );
494 }
495
496 #[tokio::test]
497 async fn fetch_url_no_match() {
498 let url = SafeUrl::parse("https://example.com").unwrap();
499 let keyring = KeyringProvider::dummy([("other.com", "user", "password")]);
500 let credentials = keyring.fetch(&url, Some("user")).await;
501 assert_eq!(credentials, None);
502 }
503
504 #[tokio::test]
505 async fn fetch_url_prefers_url_to_host() {
506 let url = SafeUrl::parse("https://example.com/").unwrap();
507 let keyring = KeyringProvider::dummy([
508 (url.join("foo").unwrap().as_str(), "user", "password"),
509 (url.host_str().unwrap(), "user", "other-password"),
510 ]);
511 assert_eq!(
512 keyring.fetch(&url.join("foo").unwrap(), Some("user")).await,
513 Some(Credentials::basic(
514 Some("user".to_string()),
515 Some("password".to_string())
516 ))
517 );
518 assert_eq!(
519 keyring.fetch(&url, Some("user")).await,
520 Some(Credentials::basic(
521 Some("user".to_string()),
522 Some("other-password".to_string())
523 ))
524 );
525 assert_eq!(
526 keyring.fetch(&url.join("bar").unwrap(), Some("user")).await,
527 Some(Credentials::basic(
528 Some("user".to_string()),
529 Some("other-password".to_string())
530 ))
531 );
532 }
533
534 #[tokio::test]
535 async fn fetch_url_username() {
536 let url = SafeUrl::parse("https://example.com").unwrap();
537 let keyring = KeyringProvider::dummy([(url.host_str().unwrap(), "user", "password")]);
538 let credentials = keyring.fetch(&url, Some("user")).await;
539 assert_eq!(
540 credentials,
541 Some(Credentials::basic(
542 Some("user".to_string()),
543 Some("password".to_string())
544 ))
545 );
546 }
547
548 #[tokio::test]
549 async fn fetch_url_no_username() {
550 let url = SafeUrl::parse("https://example.com").unwrap();
551 let keyring = KeyringProvider::dummy([(url.host_str().unwrap(), "user", "password")]);
552 let credentials = keyring.fetch(&url, None).await;
553 assert_eq!(
554 credentials,
555 Some(Credentials::basic(
556 Some("user".to_string()),
557 Some("password".to_string())
558 ))
559 );
560 }
561
562 #[tokio::test]
563 async fn fetch_url_username_no_match() {
564 let url = SafeUrl::parse("https://example.com").unwrap();
565 let keyring = KeyringProvider::dummy([(url.host_str().unwrap(), "foo", "password")]);
566 let credentials = keyring.fetch(&url, Some("bar")).await;
567 assert_eq!(credentials, None);
568
569 let url = SafeUrl::parse("https://foo@example.com").unwrap();
571 let credentials = keyring.fetch(&url, Some("bar")).await;
572 assert_eq!(credentials, None);
573 }
574
575 #[tokio::test]
576 async fn fetch_http_scheme_host_fallback() {
577 let url = SafeUrl::parse("http://127.0.0.1:8080/basic-auth/simple/anyio/").unwrap();
580 let keyring = KeyringProvider::dummy([("http://127.0.0.1:8080", "user", "password")]);
581 let credentials = keyring.fetch(&url, Some("user")).await;
582 assert_eq!(
583 credentials,
584 Some(Credentials::basic(
585 Some("user".to_string()),
586 Some("password".to_string())
587 ))
588 );
589 }
590
591 #[tokio::test]
592 async fn fetch_http_scheme_host_no_cross_scheme() {
593 let url = SafeUrl::parse("https://127.0.0.1:8080/basic-auth/simple/anyio/").unwrap();
595 let keyring = KeyringProvider::dummy([("http://127.0.0.1:8080", "user", "password")]);
596 let credentials = keyring.fetch(&url, Some("user")).await;
597 assert_eq!(credentials, None);
598 }
599}