1use std::{io::Write, process::Stdio};
2use tokio::process::Command;
3use tracing::{debug, instrument, trace, warn};
4use uv_redacted::DisplaySafeUrl;
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(
80 &self,
81 url: &DisplaySafeUrl,
82 credentials: &Credentials,
83 ) -> Result<bool, Error> {
84 let Some(username) = credentials.username() else {
85 trace!("Unable to store credentials in keyring for `{url}` due to missing username");
86 return Ok(false);
87 };
88 let Some(password) = credentials.password() else {
89 trace!("Unable to store credentials in keyring for `{url}` due to missing password");
90 return Ok(false);
91 };
92
93 let url = url.without_credentials();
95
96 let target = if let Some(host) = url.host_str().filter(|_| !url.path().is_empty()) {
98 let mut target = String::new();
99 if url.scheme() != "https" {
100 target.push_str(url.scheme());
101 target.push_str("://");
102 }
103 target.push_str(host);
104 if let Some(port) = url.port() {
105 target.push(':');
106 target.push_str(&port.to_string());
107 }
108 target
109 } else {
110 url.to_string()
111 };
112
113 match &self.backend {
114 KeyringProviderBackend::Native => {
115 self.store_native(&target, username, password).await?;
116 Ok(true)
117 }
118 KeyringProviderBackend::Subprocess => Err(Error::StoreUnsupported(self.backend.name())),
119 #[cfg(test)]
120 KeyringProviderBackend::Dummy(_) => Err(Error::StoreUnsupported(self.backend.name())),
121 }
122 }
123
124 #[instrument(skip_all, fields(service = ?service, username = ?username))]
126 async fn store_native(
127 &self,
128 service: &str,
129 username: &str,
130 password: &str,
131 ) -> Result<(), Error> {
132 let prefixed_service = format!("{UV_SERVICE_PREFIX}{service}");
133 let entry = uv_keyring::Entry::new(&prefixed_service, username)?;
134 entry.set_password(password).await?;
135 Ok(())
136 }
137
138 #[instrument(skip_all, fields(url = % url.to_string(), username))]
142 pub async fn remove(&self, url: &DisplaySafeUrl, username: &str) -> Result<(), Error> {
143 let url = url.without_credentials();
145
146 let target = if let Some(host) = url.host_str().filter(|_| !url.path().is_empty()) {
148 let mut target = String::new();
149 if url.scheme() != "https" {
150 target.push_str(url.scheme());
151 target.push_str("://");
152 }
153 target.push_str(host);
154 if let Some(port) = url.port() {
155 target.push(':');
156 target.push_str(&port.to_string());
157 }
158 target
159 } else {
160 url.to_string()
161 };
162
163 match &self.backend {
164 KeyringProviderBackend::Native => {
165 self.remove_native(&target, username).await?;
166 Ok(())
167 }
168 KeyringProviderBackend::Subprocess => {
169 Err(Error::RemoveUnsupported(self.backend.name()))
170 }
171 #[cfg(test)]
172 KeyringProviderBackend::Dummy(_) => Err(Error::RemoveUnsupported(self.backend.name())),
173 }
174 }
175
176 #[instrument(skip(self))]
179 async fn remove_native(
180 &self,
181 service_name: &str,
182 username: &str,
183 ) -> Result<(), uv_keyring::Error> {
184 let prefixed_service = format!("{UV_SERVICE_PREFIX}{service_name}");
185 let entry = uv_keyring::Entry::new(&prefixed_service, username)?;
186 entry.delete_credential().await?;
187 trace!("Removed credentials for {username}@{service_name} from system keyring");
188 Ok(())
189 }
190
191 #[instrument(skip_all, fields(url = % url.to_string(), username))]
196 pub async fn fetch(&self, url: &DisplaySafeUrl, username: Option<&str>) -> Option<Credentials> {
197 debug_assert!(
199 url.host_str().is_some(),
200 "Should only use keyring for URLs with host"
201 );
202 debug_assert!(
203 url.password().is_none(),
204 "Should only use keyring for URLs without a password"
205 );
206 debug_assert!(
207 username.is_none_or(|username| !username.is_empty()),
208 "Should only use keyring with a non-empty username"
209 );
210
211 trace!("Checking keyring for URL `{url}`");
214 let mut credentials = match self.backend {
215 KeyringProviderBackend::Native => self.fetch_native(url.as_str(), username).await,
216 KeyringProviderBackend::Subprocess => {
217 self.fetch_subprocess(url.as_str(), username).await
218 }
219 #[cfg(test)]
220 KeyringProviderBackend::Dummy(ref store) => {
221 Self::fetch_dummy(store, url.as_str(), username)
222 }
223 };
224 if credentials.is_none() {
226 let host = if let Some(port) = url.port() {
227 format!("{}:{}", url.host_str()?, port)
228 } else {
229 url.host_str()?.to_string()
230 };
231 trace!("Checking keyring for host {host}");
232 credentials = match self.backend {
233 KeyringProviderBackend::Native => self.fetch_native(&host, username).await,
234 KeyringProviderBackend::Subprocess => self.fetch_subprocess(&host, username).await,
235 #[cfg(test)]
236 KeyringProviderBackend::Dummy(ref store) => {
237 Self::fetch_dummy(store, &host, username)
238 }
239 };
240
241 if credentials.is_none() && url.scheme() != "https" {
245 let scheme_host = format!("{}://{host}", url.scheme());
246 trace!("Checking keyring for scheme+host {scheme_host}");
247 credentials = match self.backend {
248 KeyringProviderBackend::Native => {
249 self.fetch_native(&scheme_host, username).await
250 }
251 KeyringProviderBackend::Subprocess => {
252 self.fetch_subprocess(&scheme_host, username).await
253 }
254 #[cfg(test)]
255 KeyringProviderBackend::Dummy(ref store) => {
256 Self::fetch_dummy(store, &scheme_host, username)
257 }
258 };
259 }
260 }
261
262 credentials.map(|(username, password)| Credentials::basic(Some(username), Some(password)))
263 }
264
265 #[instrument(skip(self))]
266 async fn fetch_subprocess(
267 &self,
268 service_name: &str,
269 username: Option<&str>,
270 ) -> Option<(String, String)> {
271 let mut command = Command::new("keyring");
273 command.arg("get").arg(service_name);
274
275 if let Some(username) = username {
276 command.arg(username);
277 } else {
278 command.arg("--mode").arg("creds");
279 }
280
281 let child = command
282 .stdin(Stdio::null())
283 .stdout(Stdio::piped())
284 .stderr(if username.is_some() {
288 Stdio::inherit()
289 } else {
290 Stdio::piped()
291 })
292 .spawn()
293 .inspect_err(|err| warn!("Failure running `keyring` command: {err}"))
294 .ok()?;
295
296 let output = child
297 .wait_with_output()
298 .await
299 .inspect_err(|err| warn!("Failed to wait for `keyring` output: {err}"))
300 .ok()?;
301
302 if output.status.success() {
303 std::io::stderr().write_all(&output.stderr).ok();
309
310 let output = String::from_utf8(output.stdout)
312 .inspect_err(|err| warn!("Failed to parse response from `keyring` command: {err}"))
313 .ok()?;
314
315 let (username, password) = if let Some(username) = username {
316 let password = output
318 .strip_suffix("\r\n")
319 .or_else(|| output.strip_suffix('\n'))
320 .unwrap_or(&output);
321 (username, password)
322 } else {
323 let mut lines = output.lines();
325 let username = lines.next()?;
326 let Some(password) = lines.next() else {
327 warn!(
328 "Got username without password for `{service_name}` from `keyring` command"
329 );
330 return None;
331 };
332 (username, password)
333 };
334
335 if password.is_empty() {
336 warn!("Got empty password for `{username}@{service_name}` from `keyring` command");
340 }
341
342 Some((username.to_string(), password.to_string()))
343 } else {
344 let stderr = std::str::from_utf8(&output.stderr).ok()?;
346 if stderr.contains("unrecognized arguments: --mode") {
347 warn_user_once!(
350 "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"
351 );
352 } else if username.is_none() {
353 std::io::stderr().write_all(&output.stderr).ok();
355 }
356 None
357 }
358 }
359
360 #[instrument(skip(self))]
361 async fn fetch_native(
362 &self,
363 service: &str,
364 username: Option<&str>,
365 ) -> Option<(String, String)> {
366 let prefixed_service = format!("{UV_SERVICE_PREFIX}{service}");
367 let username = username?;
368 let Ok(entry) = uv_keyring::Entry::new(&prefixed_service, username) else {
369 return None;
370 };
371 match entry.get_password().await {
372 Ok(password) => return Some((username.to_string(), password)),
373 Err(uv_keyring::Error::NoEntry) => {
374 debug!("No entry found in system keyring for {service}");
375 }
376 Err(err) => {
377 warn_user_once_with_chain!(
378 anyhow::Error::from(err)
379 .context(format!(
380 "Unable to fetch credentials for {service} from system keyring"
381 ))
382 .as_ref()
383 );
384 }
385 }
386 None
387 }
388
389 #[cfg(test)]
390 fn fetch_dummy(
391 store: &Vec<(String, &'static str, &'static str)>,
392 service_name: &str,
393 username: Option<&str>,
394 ) -> Option<(String, String)> {
395 store.iter().find_map(|(service, user, password)| {
396 if service == service_name && username.is_none_or(|username| username == *user) {
397 Some(((*user).to_string(), (*password).to_string()))
398 } else {
399 None
400 }
401 })
402 }
403
404 #[cfg(test)]
406 pub(crate) fn dummy<
407 S: Into<String>,
408 T: IntoIterator<Item = (S, &'static str, &'static str)>,
409 >(
410 iter: T,
411 ) -> Self {
412 Self {
413 backend: KeyringProviderBackend::Dummy(
414 iter.into_iter()
415 .map(|(service, username, password)| (service.into(), username, password))
416 .collect(),
417 ),
418 }
419 }
420
421 #[cfg(test)]
423 fn empty() -> Self {
424 Self {
425 backend: KeyringProviderBackend::Dummy(Vec::new()),
426 }
427 }
428}
429
430#[cfg(test)]
431mod tests {
432 use super::*;
433 use url::Url;
434
435 #[tokio::test]
436 #[cfg_attr(
437 debug_assertions,
438 should_panic(expected = "Should only use keyring for URLs with host")
439 )]
440 async fn fetch_url_no_host() {
441 let url = Url::parse("file:/etc/bin/").unwrap();
442 let keyring = KeyringProvider::empty();
443 assert_eq!(
445 keyring
446 .fetch(DisplaySafeUrl::ref_cast(&url), Some("user"))
447 .await,
448 None
449 );
450 }
451
452 #[tokio::test]
453 #[cfg_attr(
454 debug_assertions,
455 should_panic(expected = "Should only use keyring for URLs without a password")
456 )]
457 async fn fetch_url_with_password() {
458 let url = Url::parse("https://user:password@example.com").unwrap();
459 let keyring = KeyringProvider::empty();
460 assert_eq!(
462 keyring
463 .fetch(DisplaySafeUrl::ref_cast(&url), Some(url.username()))
464 .await,
465 None
466 );
467 }
468
469 #[tokio::test]
470 #[cfg_attr(
471 debug_assertions,
472 should_panic(expected = "Should only use keyring with a non-empty username")
473 )]
474 async fn fetch_url_with_empty_username() {
475 let url = Url::parse("https://example.com").unwrap();
476 let keyring = KeyringProvider::empty();
477 assert_eq!(
479 keyring
480 .fetch(DisplaySafeUrl::ref_cast(&url), Some(url.username()))
481 .await,
482 None
483 );
484 }
485
486 #[tokio::test]
487 async fn fetch_url_no_auth() {
488 let url = Url::parse("https://example.com").unwrap();
489 let url = DisplaySafeUrl::ref_cast(&url);
490 let keyring = KeyringProvider::empty();
491 let credentials = keyring.fetch(url, Some("user"));
492 assert!(credentials.await.is_none());
493 }
494
495 #[tokio::test]
496 async fn fetch_url() {
497 let url = Url::parse("https://example.com").unwrap();
498 let keyring = KeyringProvider::dummy([(url.host_str().unwrap(), "user", "password")]);
499 assert_eq!(
500 keyring
501 .fetch(DisplaySafeUrl::ref_cast(&url), Some("user"))
502 .await,
503 Some(Credentials::basic(
504 Some("user".to_string()),
505 Some("password".to_string())
506 ))
507 );
508 assert_eq!(
509 keyring
510 .fetch(
511 DisplaySafeUrl::ref_cast(&url.join("test").unwrap()),
512 Some("user")
513 )
514 .await,
515 Some(Credentials::basic(
516 Some("user".to_string()),
517 Some("password".to_string())
518 ))
519 );
520 }
521
522 #[tokio::test]
523 async fn fetch_url_no_match() {
524 let url = Url::parse("https://example.com").unwrap();
525 let keyring = KeyringProvider::dummy([("other.com", "user", "password")]);
526 let credentials = keyring
527 .fetch(DisplaySafeUrl::ref_cast(&url), Some("user"))
528 .await;
529 assert_eq!(credentials, None);
530 }
531
532 #[tokio::test]
533 async fn fetch_url_prefers_url_to_host() {
534 let url = Url::parse("https://example.com/").unwrap();
535 let keyring = KeyringProvider::dummy([
536 (url.join("foo").unwrap().as_str(), "user", "password"),
537 (url.host_str().unwrap(), "user", "other-password"),
538 ]);
539 assert_eq!(
540 keyring
541 .fetch(
542 DisplaySafeUrl::ref_cast(&url.join("foo").unwrap()),
543 Some("user")
544 )
545 .await,
546 Some(Credentials::basic(
547 Some("user".to_string()),
548 Some("password".to_string())
549 ))
550 );
551 assert_eq!(
552 keyring
553 .fetch(DisplaySafeUrl::ref_cast(&url), Some("user"))
554 .await,
555 Some(Credentials::basic(
556 Some("user".to_string()),
557 Some("other-password".to_string())
558 ))
559 );
560 assert_eq!(
561 keyring
562 .fetch(
563 DisplaySafeUrl::ref_cast(&url.join("bar").unwrap()),
564 Some("user")
565 )
566 .await,
567 Some(Credentials::basic(
568 Some("user".to_string()),
569 Some("other-password".to_string())
570 ))
571 );
572 }
573
574 #[tokio::test]
575 async fn fetch_url_username() {
576 let url = Url::parse("https://example.com").unwrap();
577 let keyring = KeyringProvider::dummy([(url.host_str().unwrap(), "user", "password")]);
578 let credentials = keyring
579 .fetch(DisplaySafeUrl::ref_cast(&url), Some("user"))
580 .await;
581 assert_eq!(
582 credentials,
583 Some(Credentials::basic(
584 Some("user".to_string()),
585 Some("password".to_string())
586 ))
587 );
588 }
589
590 #[tokio::test]
591 async fn fetch_url_no_username() {
592 let url = Url::parse("https://example.com").unwrap();
593 let keyring = KeyringProvider::dummy([(url.host_str().unwrap(), "user", "password")]);
594 let credentials = keyring.fetch(DisplaySafeUrl::ref_cast(&url), None).await;
595 assert_eq!(
596 credentials,
597 Some(Credentials::basic(
598 Some("user".to_string()),
599 Some("password".to_string())
600 ))
601 );
602 }
603
604 #[tokio::test]
605 async fn fetch_url_username_no_match() {
606 let url = Url::parse("https://example.com").unwrap();
607 let keyring = KeyringProvider::dummy([(url.host_str().unwrap(), "foo", "password")]);
608 let credentials = keyring
609 .fetch(DisplaySafeUrl::ref_cast(&url), Some("bar"))
610 .await;
611 assert_eq!(credentials, None);
612
613 let url = Url::parse("https://foo@example.com").unwrap();
615 let credentials = keyring
616 .fetch(DisplaySafeUrl::ref_cast(&url), Some("bar"))
617 .await;
618 assert_eq!(credentials, None);
619 }
620
621 #[tokio::test]
622 async fn fetch_http_scheme_host_fallback() {
623 let url = Url::parse("http://127.0.0.1:8080/basic-auth/simple/anyio/").unwrap();
626 let keyring = KeyringProvider::dummy([("http://127.0.0.1:8080", "user", "password")]);
627 let credentials = keyring
628 .fetch(DisplaySafeUrl::ref_cast(&url), Some("user"))
629 .await;
630 assert_eq!(
631 credentials,
632 Some(Credentials::basic(
633 Some("user".to_string()),
634 Some("password".to_string())
635 ))
636 );
637 }
638
639 #[tokio::test]
640 async fn fetch_http_scheme_host_no_cross_scheme() {
641 let url = Url::parse("https://127.0.0.1:8080/basic-auth/simple/anyio/").unwrap();
643 let keyring = KeyringProvider::dummy([("http://127.0.0.1:8080", "user", "password")]);
644 let credentials = keyring
645 .fetch(DisplaySafeUrl::ref_cast(&url), Some("user"))
646 .await;
647 assert_eq!(credentials, None);
648 }
649}