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
238 credentials.map(|(username, password)| Credentials::basic(Some(username), Some(password)))
239 }
240
241 #[instrument(skip(self))]
242 async fn fetch_subprocess(
243 &self,
244 service_name: &str,
245 username: Option<&str>,
246 ) -> Option<(String, String)> {
247 let mut command = Command::new("keyring");
249 command.arg("get").arg(service_name);
250
251 if let Some(username) = username {
252 command.arg(username);
253 } else {
254 command.arg("--mode").arg("creds");
255 }
256
257 let child = command
258 .stdin(Stdio::null())
259 .stdout(Stdio::piped())
260 .stderr(if username.is_some() {
264 Stdio::inherit()
265 } else {
266 Stdio::piped()
267 })
268 .spawn()
269 .inspect_err(|err| warn!("Failure running `keyring` command: {err}"))
270 .ok()?;
271
272 let output = child
273 .wait_with_output()
274 .await
275 .inspect_err(|err| warn!("Failed to wait for `keyring` output: {err}"))
276 .ok()?;
277
278 if output.status.success() {
279 std::io::stderr().write_all(&output.stderr).ok();
285
286 let output = String::from_utf8(output.stdout)
288 .inspect_err(|err| warn!("Failed to parse response from `keyring` command: {err}"))
289 .ok()?;
290
291 let (username, password) = if let Some(username) = username {
292 let password = output.trim_end();
294 (username, password)
295 } else {
296 let mut lines = output.lines();
298 let username = lines.next()?;
299 let Some(password) = lines.next() else {
300 warn!(
301 "Got username without password for `{service_name}` from `keyring` command"
302 );
303 return None;
304 };
305 (username, password)
306 };
307
308 if password.is_empty() {
309 warn!("Got empty password for `{username}@{service_name}` from `keyring` command");
313 }
314
315 Some((username.to_string(), password.to_string()))
316 } else {
317 let stderr = std::str::from_utf8(&output.stderr).ok()?;
319 if stderr.contains("unrecognized arguments: --mode") {
320 warn_user_once!(
323 "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"
324 );
325 } else if username.is_none() {
326 std::io::stderr().write_all(&output.stderr).ok();
328 }
329 None
330 }
331 }
332
333 #[instrument(skip(self))]
334 async fn fetch_native(
335 &self,
336 service: &str,
337 username: Option<&str>,
338 ) -> Option<(String, String)> {
339 let prefixed_service = format!("{UV_SERVICE_PREFIX}{service}");
340 let username = username?;
341 let Ok(entry) = uv_keyring::Entry::new(&prefixed_service, username) else {
342 return None;
343 };
344 match entry.get_password().await {
345 Ok(password) => return Some((username.to_string(), password)),
346 Err(uv_keyring::Error::NoEntry) => {
347 debug!("No entry found in system keyring for {service}");
348 }
349 Err(err) => {
350 warn_user_once!(
351 "Unable to fetch credentials for {service} from system keyring: {err}"
352 );
353 }
354 }
355 None
356 }
357
358 #[cfg(test)]
359 fn fetch_dummy(
360 store: &Vec<(String, &'static str, &'static str)>,
361 service_name: &str,
362 username: Option<&str>,
363 ) -> Option<(String, String)> {
364 store.iter().find_map(|(service, user, password)| {
365 if service == service_name && username.is_none_or(|username| username == *user) {
366 Some(((*user).to_string(), (*password).to_string()))
367 } else {
368 None
369 }
370 })
371 }
372
373 #[cfg(test)]
375 pub fn dummy<S: Into<String>, T: IntoIterator<Item = (S, &'static str, &'static str)>>(
376 iter: T,
377 ) -> Self {
378 Self {
379 backend: KeyringProviderBackend::Dummy(
380 iter.into_iter()
381 .map(|(service, username, password)| (service.into(), username, password))
382 .collect(),
383 ),
384 }
385 }
386
387 #[cfg(test)]
389 pub fn empty() -> Self {
390 Self {
391 backend: KeyringProviderBackend::Dummy(Vec::new()),
392 }
393 }
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399 use futures::FutureExt;
400 use url::Url;
401
402 #[tokio::test]
403 async fn fetch_url_no_host() {
404 let url = Url::parse("file:/etc/bin/").unwrap();
405 let keyring = KeyringProvider::empty();
406 let fetch = keyring.fetch(DisplaySafeUrl::ref_cast(&url), Some("user"));
408 if cfg!(debug_assertions) {
409 let result = std::panic::AssertUnwindSafe(fetch).catch_unwind().await;
410 assert!(result.is_err());
411 } else {
412 assert_eq!(fetch.await, None);
413 }
414 }
415
416 #[tokio::test]
417 async fn fetch_url_with_password() {
418 let url = Url::parse("https://user:password@example.com").unwrap();
419 let keyring = KeyringProvider::empty();
420 let fetch = keyring.fetch(DisplaySafeUrl::ref_cast(&url), Some(url.username()));
422 if cfg!(debug_assertions) {
423 let result = std::panic::AssertUnwindSafe(fetch).catch_unwind().await;
424 assert!(result.is_err());
425 } else {
426 assert_eq!(fetch.await, None);
427 }
428 }
429
430 #[tokio::test]
431 async fn fetch_url_with_empty_username() {
432 let url = Url::parse("https://example.com").unwrap();
433 let keyring = KeyringProvider::empty();
434 let fetch = keyring.fetch(DisplaySafeUrl::ref_cast(&url), Some(url.username()));
436 if cfg!(debug_assertions) {
437 let result = std::panic::AssertUnwindSafe(fetch).catch_unwind().await;
438 assert!(result.is_err());
439 } else {
440 assert_eq!(fetch.await, None);
441 }
442 }
443
444 #[tokio::test]
445 async fn fetch_url_no_auth() {
446 let url = Url::parse("https://example.com").unwrap();
447 let url = DisplaySafeUrl::ref_cast(&url);
448 let keyring = KeyringProvider::empty();
449 let credentials = keyring.fetch(url, Some("user"));
450 assert!(credentials.await.is_none());
451 }
452
453 #[tokio::test]
454 async fn fetch_url() {
455 let url = Url::parse("https://example.com").unwrap();
456 let keyring = KeyringProvider::dummy([(url.host_str().unwrap(), "user", "password")]);
457 assert_eq!(
458 keyring
459 .fetch(DisplaySafeUrl::ref_cast(&url), Some("user"))
460 .await,
461 Some(Credentials::basic(
462 Some("user".to_string()),
463 Some("password".to_string())
464 ))
465 );
466 assert_eq!(
467 keyring
468 .fetch(
469 DisplaySafeUrl::ref_cast(&url.join("test").unwrap()),
470 Some("user")
471 )
472 .await,
473 Some(Credentials::basic(
474 Some("user".to_string()),
475 Some("password".to_string())
476 ))
477 );
478 }
479
480 #[tokio::test]
481 async fn fetch_url_no_match() {
482 let url = Url::parse("https://example.com").unwrap();
483 let keyring = KeyringProvider::dummy([("other.com", "user", "password")]);
484 let credentials = keyring
485 .fetch(DisplaySafeUrl::ref_cast(&url), Some("user"))
486 .await;
487 assert_eq!(credentials, None);
488 }
489
490 #[tokio::test]
491 async fn fetch_url_prefers_url_to_host() {
492 let url = Url::parse("https://example.com/").unwrap();
493 let keyring = KeyringProvider::dummy([
494 (url.join("foo").unwrap().as_str(), "user", "password"),
495 (url.host_str().unwrap(), "user", "other-password"),
496 ]);
497 assert_eq!(
498 keyring
499 .fetch(
500 DisplaySafeUrl::ref_cast(&url.join("foo").unwrap()),
501 Some("user")
502 )
503 .await,
504 Some(Credentials::basic(
505 Some("user".to_string()),
506 Some("password".to_string())
507 ))
508 );
509 assert_eq!(
510 keyring
511 .fetch(DisplaySafeUrl::ref_cast(&url), Some("user"))
512 .await,
513 Some(Credentials::basic(
514 Some("user".to_string()),
515 Some("other-password".to_string())
516 ))
517 );
518 assert_eq!(
519 keyring
520 .fetch(
521 DisplaySafeUrl::ref_cast(&url.join("bar").unwrap()),
522 Some("user")
523 )
524 .await,
525 Some(Credentials::basic(
526 Some("user".to_string()),
527 Some("other-password".to_string())
528 ))
529 );
530 }
531
532 #[tokio::test]
533 async fn fetch_url_username() {
534 let url = Url::parse("https://example.com").unwrap();
535 let keyring = KeyringProvider::dummy([(url.host_str().unwrap(), "user", "password")]);
536 let credentials = keyring
537 .fetch(DisplaySafeUrl::ref_cast(&url), Some("user"))
538 .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 = Url::parse("https://example.com").unwrap();
551 let keyring = KeyringProvider::dummy([(url.host_str().unwrap(), "user", "password")]);
552 let credentials = keyring.fetch(DisplaySafeUrl::ref_cast(&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 = Url::parse("https://example.com").unwrap();
565 let keyring = KeyringProvider::dummy([(url.host_str().unwrap(), "foo", "password")]);
566 let credentials = keyring
567 .fetch(DisplaySafeUrl::ref_cast(&url), Some("bar"))
568 .await;
569 assert_eq!(credentials, None);
570
571 let url = Url::parse("https://foo@example.com").unwrap();
573 let credentials = keyring
574 .fetch(DisplaySafeUrl::ref_cast(&url), Some("bar"))
575 .await;
576 assert_eq!(credentials, None);
577 }
578}