uv_auth/
keyring.rs

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
9/// Service name prefix for storing credentials in a keyring.
10static UV_SERVICE_PREFIX: &str = "uv:";
11
12/// A backend for retrieving credentials from a keyring.
13///
14/// See pip's implementation for reference
15/// <https://github.com/pypa/pip/blob/ae5fff36b0aad6e5e0037884927eaa29163c0611/src/pip/_internal/network/auth.py#L102>
16#[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    /// Use a native system keyring integration for credentials.
36    Native,
37    /// Use the external `keyring` command for credentials.
38    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    /// Create a new [`KeyringProvider::Native`].
56    pub fn native() -> Self {
57        Self {
58            backend: KeyringProviderBackend::Native,
59        }
60    }
61
62    /// Create a new [`KeyringProvider::Subprocess`].
63    pub fn subprocess() -> Self {
64        Self {
65            backend: KeyringProviderBackend::Subprocess,
66        }
67    }
68
69    /// Store credentials for the given [`DisplaySafeUrl`] to the keyring.
70    ///
71    /// Only [`KeyringProviderBackend::Native`] is supported at this time.
72    #[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        // Ensure we strip credentials from the URL before storing
88        let url = url.without_credentials();
89
90        // If there's no path, we'll perform a host-level login
91        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    /// Store credentials to the system keyring.
121    #[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    /// Remove credentials for the given [`DisplaySafeUrl`] and username from the keyring.
135    ///
136    /// Only [`KeyringProviderBackend::Native`] is supported at this time.
137    #[instrument(skip_all, fields(url = % url.to_string(), username))]
138    pub async fn remove(&self, url: &DisplaySafeUrl, username: &str) -> Result<(), Error> {
139        // Ensure we strip credentials from the URL before storing
140        let url = url.without_credentials();
141
142        // If there's no path, we'll perform a host-level login
143        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    /// Remove credentials from the system keyring for the given `service_name`/`username`
173    /// pair.
174    #[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    /// Fetch credentials for the given [`Url`] from the keyring.
188    ///
189    /// Returns [`None`] if no password was found for the username or if any errors
190    /// are encountered in the keyring backend.
191    #[instrument(skip_all, fields(url = % url.to_string(), username))]
192    pub async fn fetch(&self, url: &DisplaySafeUrl, username: Option<&str>) -> Option<Credentials> {
193        // Validate the request
194        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        // Check the full URL first
208        // <https://github.com/pypa/pip/blob/ae5fff36b0aad6e5e0037884927eaa29163c0611/src/pip/_internal/network/auth.py#L376C1-L379C14>
209        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        // And fallback to a check for the host
221        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        // https://github.com/pypa/pip/blob/24.0/src/pip/_internal/network/auth.py#L136-L141
248        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            // If we're using `--mode creds`, we need to capture the output in order to avoid
261            // showing users an "unrecognized arguments: --mode" error; otherwise, we stream stderr
262            // so the user has visibility into keyring's behavior if it's doing something slow
263            .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            // If we captured stderr, display it in case it's helpful to the user
280            // TODO(zanieb): This was done when we added `--mode creds` support for parity with the
281            // existing behavior, but it might be a better UX to hide this on success? It also
282            // might be problematic that we're not streaming it. We could change this given some
283            // user feedback.
284            std::io::stderr().write_all(&output.stderr).ok();
285
286            // On success, parse the newline terminated credentials
287            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                // We're only expecting a password
293                let password = output.trim_end();
294                (username, password)
295            } else {
296                // We're expecting a username and password
297                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                // We allow this for backwards compatibility, but it might be better to return
310                // `None` instead if there's confusion from users — we haven't seen this in practice
311                // yet.
312                warn!("Got empty password for `{username}@{service_name}` from `keyring` command");
313            }
314
315            Some((username.to_string(), password.to_string()))
316        } else {
317            // On failure, no password was available
318            let stderr = std::str::from_utf8(&output.stderr).ok()?;
319            if stderr.contains("unrecognized arguments: --mode") {
320                // N.B. We do not show the `service_name` here because we'll show the warning twice
321                //      otherwise, once for the URL and once for the realm.
322                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                // If we captured stderr, display it in case it's helpful to the user
327                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    /// Create a new provider with [`KeyringProviderBackend::Dummy`].
374    #[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    /// Create a new provider with no credentials available.
388    #[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        // Panics due to debug assertion; returns `None` in production
407        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        // Panics due to debug assertion; returns `None` in production
421        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        // Panics due to debug assertion; returns `None` in production
435        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        // Still fails if we have `foo` in the URL itself
572        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}