Skip to main content

uv_auth/
keyring.rs

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
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(&'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    /// 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 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    /// Create a new [`KeyringProvider::Native`].
62    pub(crate) fn native() -> Self {
63        Self {
64            backend: KeyringProviderBackend::Native,
65        }
66    }
67
68    /// Create a new [`KeyringProvider::Subprocess`].
69    pub fn subprocess() -> Self {
70        Self {
71            backend: KeyringProviderBackend::Subprocess,
72        }
73    }
74
75    /// Store credentials for the given [`SafeUrl`] to the keyring.
76    ///
77    /// Only the native keyring provider is supported at this time.
78    #[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        // Ensure we strip credentials from the URL before storing
90        let url = url.without_credentials();
91
92        // If there's no path, we'll perform a host-level login
93        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    /// Store credentials to the system keyring.
121    #[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    /// Remove credentials for the given [`SafeUrl`] and username from the keyring.
135    ///
136    /// Only the native keyring provider is supported at this time.
137    #[instrument(skip_all, fields(url = % url.to_string(), username))]
138    pub async fn remove(&self, url: &SafeUrl, 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.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    /// 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 [`SafeUrl`] 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: &SafeUrl, 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.is_none_or(|username| !username.is_empty()),
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            // For non-HTTPS URLs, `store` includes the scheme in the service name
238            // (e.g., `http://host:port`) to avoid leaking credentials across schemes.
239            // Try `scheme://host:port` as a fallback to match those entries.
240            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        // https://github.com/pypa/pip/blob/24.0/src/pip/_internal/network/auth.py#L136-L141
268        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            // If we're using `--mode creds`, we need to capture the output in order to avoid
281            // showing users an "unrecognized arguments: --mode" error; otherwise, we stream stderr
282            // so the user has visibility into keyring's behavior if it's doing something slow
283            .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            // If we captured stderr, display it in case it's helpful to the user
300            // TODO(zanieb): This was done when we added `--mode creds` support for parity with the
301            // existing behavior, but it might be a better UX to hide this on success? It also
302            // might be problematic that we're not streaming it. We could change this given some
303            // user feedback.
304            std::io::stderr().write_all(&output.stderr).ok();
305
306            // On success, parse the newline terminated credentials
307            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                // `keyring get` prints the password with a trailing newline.
313                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                // We're expecting a username and password
320                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                // We allow this for backwards compatibility, but it might be better to return
333                // `None` instead if there's confusion from users — we haven't seen this in practice
334                // yet.
335                warn!("Got empty password for `{username}@{service_name}` from `keyring` command");
336            }
337
338            Some((username.to_string(), password.to_string()))
339        } else {
340            // On failure, no password was available
341            let stderr = std::str::from_utf8(&output.stderr).ok()?;
342            if stderr.contains("unrecognized arguments: --mode") {
343                // N.B. We do not show the `service_name` here because we'll show the warning twice
344                //      otherwise, once for the URL and once for the realm.
345                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                // If we captured stderr, display it in case it's helpful to the user
350                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    /// Create a new provider with [`KeyringProviderBackend::Dummy`].
401    #[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    /// Create a new provider with no credentials available.
418    #[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        // Panics due to debug assertion; returns `None` in production
439        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        // Panics due to debug assertion; returns `None` in production
451        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        // Panics due to debug assertion; returns `None` in production
463        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        // Still fails if we have `foo` in the URL itself
570        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        // When credentials are stored with scheme included (e.g., `http://host:port`),
578        // the fetch should find them via the `scheme://host:port` fallback.
579        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        // Credentials stored under `http://` should not be returned for `https://` requests.
594        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}