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;
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(KeyringProviderBackend),
28
29 #[error("The '{0}' keyring provider does not support removing credentials")]
30 RemoveUnsupported(KeyringProviderBackend),
31}
32
33#[derive(Debug, Clone)]
34pub enum KeyringProviderBackend {
35 Native,
37 Subprocess,
39 #[cfg(test)]
40 Dummy(Vec<(String, &'static str, &'static str)>),
41}
42
43impl std::fmt::Display for KeyringProviderBackend {
44 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45 match self {
46 Self::Native => write!(f, "native"),
47 Self::Subprocess => write!(f, "subprocess"),
48 #[cfg(test)]
49 Self::Dummy(_) => write!(f, "dummy"),
50 }
51 }
52}
53
54impl KeyringProvider {
55 pub fn native() -> Self {
57 Self {
58 backend: KeyringProviderBackend::Native,
59 }
60 }
61
62 pub fn subprocess() -> Self {
64 Self {
65 backend: KeyringProviderBackend::Subprocess,
66 }
67 }
68
69 #[instrument(skip_all, fields(url = % url.to_string(), username))]
73 pub async fn store(
74 &self,
75 url: &DisplaySafeUrl,
76 credentials: &Credentials,
77 ) -> Result<bool, Error> {
78 let Some(username) = credentials.username() else {
79 trace!("Unable to store credentials in keyring for {url} due to missing username");
80 return Ok(false);
81 };
82 let Some(password) = credentials.password() else {
83 trace!("Unable to store credentials in keyring for {url} due to missing password");
84 return Ok(false);
85 };
86
87 let url = url.without_credentials();
89
90 let target = if let Some(host) = url.host_str().filter(|_| !url.path().is_empty()) {
92 let mut target = String::new();
93 if url.scheme() != "https" {
94 target.push_str(url.scheme());
95 target.push_str("://");
96 }
97 target.push_str(host);
98 if let Some(port) = url.port() {
99 target.push(':');
100 target.push_str(&port.to_string());
101 }
102 target
103 } else {
104 url.to_string()
105 };
106
107 match &self.backend {
108 KeyringProviderBackend::Native => {
109 self.store_native(&target, username, password).await?;
110 Ok(true)
111 }
112 KeyringProviderBackend::Subprocess => {
113 Err(Error::StoreUnsupported(self.backend.clone()))
114 }
115 #[cfg(test)]
116 KeyringProviderBackend::Dummy(_) => Err(Error::StoreUnsupported(self.backend.clone())),
117 }
118 }
119
120 #[instrument(skip(self))]
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: &DisplaySafeUrl, 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.to_string()
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.clone()))
166 }
167 #[cfg(test)]
168 KeyringProviderBackend::Dummy(_) => Err(Error::RemoveUnsupported(self.backend.clone())),
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: &DisplaySafeUrl, 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.map(str::is_empty).unwrap_or(false),
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.trim_end();
314 (username, password)
315 } else {
316 let mut lines = output.lines();
318 let username = lines.next()?;
319 let Some(password) = lines.next() else {
320 warn!(
321 "Got username without password for `{service_name}` from `keyring` command"
322 );
323 return None;
324 };
325 (username, password)
326 };
327
328 if password.is_empty() {
329 warn!("Got empty password for `{username}@{service_name}` from `keyring` command");
333 }
334
335 Some((username.to_string(), password.to_string()))
336 } else {
337 let stderr = std::str::from_utf8(&output.stderr).ok()?;
339 if stderr.contains("unrecognized arguments: --mode") {
340 warn_user_once!(
343 "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"
344 );
345 } else if username.is_none() {
346 std::io::stderr().write_all(&output.stderr).ok();
348 }
349 None
350 }
351 }
352
353 #[instrument(skip(self))]
354 async fn fetch_native(
355 &self,
356 service: &str,
357 username: Option<&str>,
358 ) -> Option<(String, String)> {
359 let prefixed_service = format!("{UV_SERVICE_PREFIX}{service}");
360 let username = username?;
361 let Ok(entry) = uv_keyring::Entry::new(&prefixed_service, username) else {
362 return None;
363 };
364 match entry.get_password().await {
365 Ok(password) => return Some((username.to_string(), password)),
366 Err(uv_keyring::Error::NoEntry) => {
367 debug!("No entry found in system keyring for {service}");
368 }
369 Err(err) => {
370 warn_user_once!(
371 "Unable to fetch credentials for {service} from system keyring: {err}"
372 );
373 }
374 }
375 None
376 }
377
378 #[cfg(test)]
379 fn fetch_dummy(
380 store: &Vec<(String, &'static str, &'static str)>,
381 service_name: &str,
382 username: Option<&str>,
383 ) -> Option<(String, String)> {
384 store.iter().find_map(|(service, user, password)| {
385 if service == service_name && username.is_none_or(|username| username == *user) {
386 Some(((*user).to_string(), (*password).to_string()))
387 } else {
388 None
389 }
390 })
391 }
392
393 #[cfg(test)]
395 pub fn dummy<S: Into<String>, T: IntoIterator<Item = (S, &'static str, &'static str)>>(
396 iter: T,
397 ) -> Self {
398 Self {
399 backend: KeyringProviderBackend::Dummy(
400 iter.into_iter()
401 .map(|(service, username, password)| (service.into(), username, password))
402 .collect(),
403 ),
404 }
405 }
406
407 #[cfg(test)]
409 pub fn empty() -> Self {
410 Self {
411 backend: KeyringProviderBackend::Dummy(Vec::new()),
412 }
413 }
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419 use futures::FutureExt;
420 use url::Url;
421
422 #[tokio::test]
423 async fn fetch_url_no_host() {
424 let url = Url::parse("file:/etc/bin/").unwrap();
425 let keyring = KeyringProvider::empty();
426 let fetch = keyring.fetch(DisplaySafeUrl::ref_cast(&url), Some("user"));
428 if cfg!(debug_assertions) {
429 let result = std::panic::AssertUnwindSafe(fetch).catch_unwind().await;
430 assert!(result.is_err());
431 } else {
432 assert_eq!(fetch.await, None);
433 }
434 }
435
436 #[tokio::test]
437 async fn fetch_url_with_password() {
438 let url = Url::parse("https://user:password@example.com").unwrap();
439 let keyring = KeyringProvider::empty();
440 let fetch = keyring.fetch(DisplaySafeUrl::ref_cast(&url), Some(url.username()));
442 if cfg!(debug_assertions) {
443 let result = std::panic::AssertUnwindSafe(fetch).catch_unwind().await;
444 assert!(result.is_err());
445 } else {
446 assert_eq!(fetch.await, None);
447 }
448 }
449
450 #[tokio::test]
451 async fn fetch_url_with_empty_username() {
452 let url = Url::parse("https://example.com").unwrap();
453 let keyring = KeyringProvider::empty();
454 let fetch = keyring.fetch(DisplaySafeUrl::ref_cast(&url), Some(url.username()));
456 if cfg!(debug_assertions) {
457 let result = std::panic::AssertUnwindSafe(fetch).catch_unwind().await;
458 assert!(result.is_err());
459 } else {
460 assert_eq!(fetch.await, None);
461 }
462 }
463
464 #[tokio::test]
465 async fn fetch_url_no_auth() {
466 let url = Url::parse("https://example.com").unwrap();
467 let url = DisplaySafeUrl::ref_cast(&url);
468 let keyring = KeyringProvider::empty();
469 let credentials = keyring.fetch(url, Some("user"));
470 assert!(credentials.await.is_none());
471 }
472
473 #[tokio::test]
474 async fn fetch_url() {
475 let url = Url::parse("https://example.com").unwrap();
476 let keyring = KeyringProvider::dummy([(url.host_str().unwrap(), "user", "password")]);
477 assert_eq!(
478 keyring
479 .fetch(DisplaySafeUrl::ref_cast(&url), Some("user"))
480 .await,
481 Some(Credentials::basic(
482 Some("user".to_string()),
483 Some("password".to_string())
484 ))
485 );
486 assert_eq!(
487 keyring
488 .fetch(
489 DisplaySafeUrl::ref_cast(&url.join("test").unwrap()),
490 Some("user")
491 )
492 .await,
493 Some(Credentials::basic(
494 Some("user".to_string()),
495 Some("password".to_string())
496 ))
497 );
498 }
499
500 #[tokio::test]
501 async fn fetch_url_no_match() {
502 let url = Url::parse("https://example.com").unwrap();
503 let keyring = KeyringProvider::dummy([("other.com", "user", "password")]);
504 let credentials = keyring
505 .fetch(DisplaySafeUrl::ref_cast(&url), Some("user"))
506 .await;
507 assert_eq!(credentials, None);
508 }
509
510 #[tokio::test]
511 async fn fetch_url_prefers_url_to_host() {
512 let url = Url::parse("https://example.com/").unwrap();
513 let keyring = KeyringProvider::dummy([
514 (url.join("foo").unwrap().as_str(), "user", "password"),
515 (url.host_str().unwrap(), "user", "other-password"),
516 ]);
517 assert_eq!(
518 keyring
519 .fetch(
520 DisplaySafeUrl::ref_cast(&url.join("foo").unwrap()),
521 Some("user")
522 )
523 .await,
524 Some(Credentials::basic(
525 Some("user".to_string()),
526 Some("password".to_string())
527 ))
528 );
529 assert_eq!(
530 keyring
531 .fetch(DisplaySafeUrl::ref_cast(&url), Some("user"))
532 .await,
533 Some(Credentials::basic(
534 Some("user".to_string()),
535 Some("other-password".to_string())
536 ))
537 );
538 assert_eq!(
539 keyring
540 .fetch(
541 DisplaySafeUrl::ref_cast(&url.join("bar").unwrap()),
542 Some("user")
543 )
544 .await,
545 Some(Credentials::basic(
546 Some("user".to_string()),
547 Some("other-password".to_string())
548 ))
549 );
550 }
551
552 #[tokio::test]
553 async fn fetch_url_username() {
554 let url = Url::parse("https://example.com").unwrap();
555 let keyring = KeyringProvider::dummy([(url.host_str().unwrap(), "user", "password")]);
556 let credentials = keyring
557 .fetch(DisplaySafeUrl::ref_cast(&url), Some("user"))
558 .await;
559 assert_eq!(
560 credentials,
561 Some(Credentials::basic(
562 Some("user".to_string()),
563 Some("password".to_string())
564 ))
565 );
566 }
567
568 #[tokio::test]
569 async fn fetch_url_no_username() {
570 let url = Url::parse("https://example.com").unwrap();
571 let keyring = KeyringProvider::dummy([(url.host_str().unwrap(), "user", "password")]);
572 let credentials = keyring.fetch(DisplaySafeUrl::ref_cast(&url), None).await;
573 assert_eq!(
574 credentials,
575 Some(Credentials::basic(
576 Some("user".to_string()),
577 Some("password".to_string())
578 ))
579 );
580 }
581
582 #[tokio::test]
583 async fn fetch_url_username_no_match() {
584 let url = Url::parse("https://example.com").unwrap();
585 let keyring = KeyringProvider::dummy([(url.host_str().unwrap(), "foo", "password")]);
586 let credentials = keyring
587 .fetch(DisplaySafeUrl::ref_cast(&url), Some("bar"))
588 .await;
589 assert_eq!(credentials, None);
590
591 let url = Url::parse("https://foo@example.com").unwrap();
593 let credentials = keyring
594 .fetch(DisplaySafeUrl::ref_cast(&url), Some("bar"))
595 .await;
596 assert_eq!(credentials, None);
597 }
598
599 #[tokio::test]
600 async fn fetch_http_scheme_host_fallback() {
601 let url = Url::parse("http://127.0.0.1:8080/basic-auth/simple/anyio/").unwrap();
604 let keyring = KeyringProvider::dummy([("http://127.0.0.1:8080", "user", "password")]);
605 let credentials = keyring
606 .fetch(DisplaySafeUrl::ref_cast(&url), Some("user"))
607 .await;
608 assert_eq!(
609 credentials,
610 Some(Credentials::basic(
611 Some("user".to_string()),
612 Some("password".to_string())
613 ))
614 );
615 }
616
617 #[tokio::test]
618 async fn fetch_http_scheme_host_no_cross_scheme() {
619 let url = Url::parse("https://127.0.0.1:8080/basic-auth/simple/anyio/").unwrap();
621 let keyring = KeyringProvider::dummy([("http://127.0.0.1:8080", "user", "password")]);
622 let credentials = keyring
623 .fetch(DisplaySafeUrl::ref_cast(&url), Some("user"))
624 .await;
625 assert_eq!(credentials, None);
626 }
627}