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::DisplaySafeUrl;
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 [`DisplaySafeUrl`] 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(
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        // Ensure we strip credentials from the URL before storing
94        let url = url.without_credentials();
95
96        // If there's no path, we'll perform a host-level login
97        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    /// Store credentials to the system keyring.
125    #[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    /// Remove credentials for the given [`DisplaySafeUrl`] and username from the keyring.
139    ///
140    /// Only the native keyring provider is supported at this time.
141    #[instrument(skip_all, fields(url = % url.to_string(), username))]
142    pub async fn remove(&self, url: &DisplaySafeUrl, username: &str) -> Result<(), Error> {
143        // Ensure we strip credentials from the URL before storing
144        let url = url.without_credentials();
145
146        // If there's no path, we'll perform a host-level login
147        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    /// Remove credentials from the system keyring for the given `service_name`/`username`
177    /// pair.
178    #[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    /// Fetch credentials for the given [`Url`] from the keyring.
192    ///
193    /// Returns [`None`] if no password was found for the username or if any errors
194    /// are encountered in the keyring backend.
195    #[instrument(skip_all, fields(url = % url.to_string(), username))]
196    pub async fn fetch(&self, url: &DisplaySafeUrl, username: Option<&str>) -> Option<Credentials> {
197        // Validate the request
198        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        // Check the full URL first
212        // <https://github.com/pypa/pip/blob/ae5fff36b0aad6e5e0037884927eaa29163c0611/src/pip/_internal/network/auth.py#L376C1-L379C14>
213        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        // And fallback to a check for the host
225        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            // For non-HTTPS URLs, `store` includes the scheme in the service name
242            // (e.g., `http://host:port`) to avoid leaking credentials across schemes.
243            // Try `scheme://host:port` as a fallback to match those entries.
244            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        // https://github.com/pypa/pip/blob/24.0/src/pip/_internal/network/auth.py#L136-L141
272        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            // If we're using `--mode creds`, we need to capture the output in order to avoid
285            // showing users an "unrecognized arguments: --mode" error; otherwise, we stream stderr
286            // so the user has visibility into keyring's behavior if it's doing something slow
287            .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            // If we captured stderr, display it in case it's helpful to the user
304            // TODO(zanieb): This was done when we added `--mode creds` support for parity with the
305            // existing behavior, but it might be a better UX to hide this on success? It also
306            // might be problematic that we're not streaming it. We could change this given some
307            // user feedback.
308            std::io::stderr().write_all(&output.stderr).ok();
309
310            // On success, parse the newline terminated credentials
311            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                // We're only expecting a password
317                let password = output.trim_end();
318                (username, password)
319            } else {
320                // We're expecting a username and password
321                let mut lines = output.lines();
322                let username = lines.next()?;
323                let Some(password) = lines.next() else {
324                    warn!(
325                        "Got username without password for `{service_name}` from `keyring` command"
326                    );
327                    return None;
328                };
329                (username, password)
330            };
331
332            if password.is_empty() {
333                // We allow this for backwards compatibility, but it might be better to return
334                // `None` instead if there's confusion from users — we haven't seen this in practice
335                // yet.
336                warn!("Got empty password for `{username}@{service_name}` from `keyring` command");
337            }
338
339            Some((username.to_string(), password.to_string()))
340        } else {
341            // On failure, no password was available
342            let stderr = std::str::from_utf8(&output.stderr).ok()?;
343            if stderr.contains("unrecognized arguments: --mode") {
344                // N.B. We do not show the `service_name` here because we'll show the warning twice
345                //      otherwise, once for the URL and once for the realm.
346                warn_user_once!(
347                    "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"
348                );
349            } else if username.is_none() {
350                // If we captured stderr, display it in case it's helpful to the user
351                std::io::stderr().write_all(&output.stderr).ok();
352            }
353            None
354        }
355    }
356
357    #[instrument(skip(self))]
358    async fn fetch_native(
359        &self,
360        service: &str,
361        username: Option<&str>,
362    ) -> Option<(String, String)> {
363        let prefixed_service = format!("{UV_SERVICE_PREFIX}{service}");
364        let username = username?;
365        let Ok(entry) = uv_keyring::Entry::new(&prefixed_service, username) else {
366            return None;
367        };
368        match entry.get_password().await {
369            Ok(password) => return Some((username.to_string(), password)),
370            Err(uv_keyring::Error::NoEntry) => {
371                debug!("No entry found in system keyring for {service}");
372            }
373            Err(err) => {
374                warn_user_once_with_chain!(
375                    anyhow::Error::from(err)
376                        .context(format!(
377                            "Unable to fetch credentials for {service} from system keyring"
378                        ))
379                        .as_ref()
380                );
381            }
382        }
383        None
384    }
385
386    #[cfg(test)]
387    fn fetch_dummy(
388        store: &Vec<(String, &'static str, &'static str)>,
389        service_name: &str,
390        username: Option<&str>,
391    ) -> Option<(String, String)> {
392        store.iter().find_map(|(service, user, password)| {
393            if service == service_name && username.is_none_or(|username| username == *user) {
394                Some(((*user).to_string(), (*password).to_string()))
395            } else {
396                None
397            }
398        })
399    }
400
401    /// Create a new provider with [`KeyringProviderBackend::Dummy`].
402    #[cfg(test)]
403    pub(crate) fn dummy<
404        S: Into<String>,
405        T: IntoIterator<Item = (S, &'static str, &'static str)>,
406    >(
407        iter: T,
408    ) -> Self {
409        Self {
410            backend: KeyringProviderBackend::Dummy(
411                iter.into_iter()
412                    .map(|(service, username, password)| (service.into(), username, password))
413                    .collect(),
414            ),
415        }
416    }
417
418    /// Create a new provider with no credentials available.
419    #[cfg(test)]
420    fn empty() -> Self {
421        Self {
422            backend: KeyringProviderBackend::Dummy(Vec::new()),
423        }
424    }
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430    use url::Url;
431
432    #[tokio::test]
433    #[cfg_attr(
434        debug_assertions,
435        should_panic(expected = "Should only use keyring for URLs with host")
436    )]
437    async fn fetch_url_no_host() {
438        let url = Url::parse("file:/etc/bin/").unwrap();
439        let keyring = KeyringProvider::empty();
440        // Panics due to debug assertion; returns `None` in production
441        assert_eq!(
442            keyring
443                .fetch(DisplaySafeUrl::ref_cast(&url), Some("user"))
444                .await,
445            None
446        );
447    }
448
449    #[tokio::test]
450    #[cfg_attr(
451        debug_assertions,
452        should_panic(expected = "Should only use keyring for URLs without a password")
453    )]
454    async fn fetch_url_with_password() {
455        let url = Url::parse("https://user:password@example.com").unwrap();
456        let keyring = KeyringProvider::empty();
457        // Panics due to debug assertion; returns `None` in production
458        assert_eq!(
459            keyring
460                .fetch(DisplaySafeUrl::ref_cast(&url), Some(url.username()))
461                .await,
462            None
463        );
464    }
465
466    #[tokio::test]
467    #[cfg_attr(
468        debug_assertions,
469        should_panic(expected = "Should only use keyring with a non-empty username")
470    )]
471    async fn fetch_url_with_empty_username() {
472        let url = Url::parse("https://example.com").unwrap();
473        let keyring = KeyringProvider::empty();
474        // Panics due to debug assertion; returns `None` in production
475        assert_eq!(
476            keyring
477                .fetch(DisplaySafeUrl::ref_cast(&url), Some(url.username()))
478                .await,
479            None
480        );
481    }
482
483    #[tokio::test]
484    async fn fetch_url_no_auth() {
485        let url = Url::parse("https://example.com").unwrap();
486        let url = DisplaySafeUrl::ref_cast(&url);
487        let keyring = KeyringProvider::empty();
488        let credentials = keyring.fetch(url, Some("user"));
489        assert!(credentials.await.is_none());
490    }
491
492    #[tokio::test]
493    async fn fetch_url() {
494        let url = Url::parse("https://example.com").unwrap();
495        let keyring = KeyringProvider::dummy([(url.host_str().unwrap(), "user", "password")]);
496        assert_eq!(
497            keyring
498                .fetch(DisplaySafeUrl::ref_cast(&url), Some("user"))
499                .await,
500            Some(Credentials::basic(
501                Some("user".to_string()),
502                Some("password".to_string())
503            ))
504        );
505        assert_eq!(
506            keyring
507                .fetch(
508                    DisplaySafeUrl::ref_cast(&url.join("test").unwrap()),
509                    Some("user")
510                )
511                .await,
512            Some(Credentials::basic(
513                Some("user".to_string()),
514                Some("password".to_string())
515            ))
516        );
517    }
518
519    #[tokio::test]
520    async fn fetch_url_no_match() {
521        let url = Url::parse("https://example.com").unwrap();
522        let keyring = KeyringProvider::dummy([("other.com", "user", "password")]);
523        let credentials = keyring
524            .fetch(DisplaySafeUrl::ref_cast(&url), Some("user"))
525            .await;
526        assert_eq!(credentials, None);
527    }
528
529    #[tokio::test]
530    async fn fetch_url_prefers_url_to_host() {
531        let url = Url::parse("https://example.com/").unwrap();
532        let keyring = KeyringProvider::dummy([
533            (url.join("foo").unwrap().as_str(), "user", "password"),
534            (url.host_str().unwrap(), "user", "other-password"),
535        ]);
536        assert_eq!(
537            keyring
538                .fetch(
539                    DisplaySafeUrl::ref_cast(&url.join("foo").unwrap()),
540                    Some("user")
541                )
542                .await,
543            Some(Credentials::basic(
544                Some("user".to_string()),
545                Some("password".to_string())
546            ))
547        );
548        assert_eq!(
549            keyring
550                .fetch(DisplaySafeUrl::ref_cast(&url), Some("user"))
551                .await,
552            Some(Credentials::basic(
553                Some("user".to_string()),
554                Some("other-password".to_string())
555            ))
556        );
557        assert_eq!(
558            keyring
559                .fetch(
560                    DisplaySafeUrl::ref_cast(&url.join("bar").unwrap()),
561                    Some("user")
562                )
563                .await,
564            Some(Credentials::basic(
565                Some("user".to_string()),
566                Some("other-password".to_string())
567            ))
568        );
569    }
570
571    #[tokio::test]
572    async fn fetch_url_username() {
573        let url = Url::parse("https://example.com").unwrap();
574        let keyring = KeyringProvider::dummy([(url.host_str().unwrap(), "user", "password")]);
575        let credentials = keyring
576            .fetch(DisplaySafeUrl::ref_cast(&url), Some("user"))
577            .await;
578        assert_eq!(
579            credentials,
580            Some(Credentials::basic(
581                Some("user".to_string()),
582                Some("password".to_string())
583            ))
584        );
585    }
586
587    #[tokio::test]
588    async fn fetch_url_no_username() {
589        let url = Url::parse("https://example.com").unwrap();
590        let keyring = KeyringProvider::dummy([(url.host_str().unwrap(), "user", "password")]);
591        let credentials = keyring.fetch(DisplaySafeUrl::ref_cast(&url), None).await;
592        assert_eq!(
593            credentials,
594            Some(Credentials::basic(
595                Some("user".to_string()),
596                Some("password".to_string())
597            ))
598        );
599    }
600
601    #[tokio::test]
602    async fn fetch_url_username_no_match() {
603        let url = Url::parse("https://example.com").unwrap();
604        let keyring = KeyringProvider::dummy([(url.host_str().unwrap(), "foo", "password")]);
605        let credentials = keyring
606            .fetch(DisplaySafeUrl::ref_cast(&url), Some("bar"))
607            .await;
608        assert_eq!(credentials, None);
609
610        // Still fails if we have `foo` in the URL itself
611        let url = Url::parse("https://foo@example.com").unwrap();
612        let credentials = keyring
613            .fetch(DisplaySafeUrl::ref_cast(&url), Some("bar"))
614            .await;
615        assert_eq!(credentials, None);
616    }
617
618    #[tokio::test]
619    async fn fetch_http_scheme_host_fallback() {
620        // When credentials are stored with scheme included (e.g., `http://host:port`),
621        // the fetch should find them via the `scheme://host:port` fallback.
622        let url = Url::parse("http://127.0.0.1:8080/basic-auth/simple/anyio/").unwrap();
623        let keyring = KeyringProvider::dummy([("http://127.0.0.1:8080", "user", "password")]);
624        let credentials = keyring
625            .fetch(DisplaySafeUrl::ref_cast(&url), Some("user"))
626            .await;
627        assert_eq!(
628            credentials,
629            Some(Credentials::basic(
630                Some("user".to_string()),
631                Some("password".to_string())
632            ))
633        );
634    }
635
636    #[tokio::test]
637    async fn fetch_http_scheme_host_no_cross_scheme() {
638        // Credentials stored under `http://` should not be returned for `https://` requests.
639        let url = Url::parse("https://127.0.0.1:8080/basic-auth/simple/anyio/").unwrap();
640        let keyring = KeyringProvider::dummy([("http://127.0.0.1:8080", "user", "password")]);
641        let credentials = keyring
642            .fetch(DisplaySafeUrl::ref_cast(&url), Some("user"))
643            .await;
644        assert_eq!(credentials, None);
645    }
646}