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;
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!(
375                    "Unable to fetch credentials for {service} from system keyring: {err}"
376                );
377            }
378        }
379        None
380    }
381
382    #[cfg(test)]
383    fn fetch_dummy(
384        store: &Vec<(String, &'static str, &'static str)>,
385        service_name: &str,
386        username: Option<&str>,
387    ) -> Option<(String, String)> {
388        store.iter().find_map(|(service, user, password)| {
389            if service == service_name && username.is_none_or(|username| username == *user) {
390                Some(((*user).to_string(), (*password).to_string()))
391            } else {
392                None
393            }
394        })
395    }
396
397    /// Create a new provider with [`KeyringProviderBackend::Dummy`].
398    #[cfg(test)]
399    pub(crate) fn dummy<
400        S: Into<String>,
401        T: IntoIterator<Item = (S, &'static str, &'static str)>,
402    >(
403        iter: T,
404    ) -> Self {
405        Self {
406            backend: KeyringProviderBackend::Dummy(
407                iter.into_iter()
408                    .map(|(service, username, password)| (service.into(), username, password))
409                    .collect(),
410            ),
411        }
412    }
413
414    /// Create a new provider with no credentials available.
415    #[cfg(test)]
416    fn empty() -> Self {
417        Self {
418            backend: KeyringProviderBackend::Dummy(Vec::new()),
419        }
420    }
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426    use url::Url;
427
428    #[tokio::test]
429    #[cfg_attr(
430        debug_assertions,
431        should_panic(expected = "Should only use keyring for URLs with host")
432    )]
433    async fn fetch_url_no_host() {
434        let url = Url::parse("file:/etc/bin/").unwrap();
435        let keyring = KeyringProvider::empty();
436        // Panics due to debug assertion; returns `None` in production
437        assert_eq!(
438            keyring
439                .fetch(DisplaySafeUrl::ref_cast(&url), Some("user"))
440                .await,
441            None
442        );
443    }
444
445    #[tokio::test]
446    #[cfg_attr(
447        debug_assertions,
448        should_panic(expected = "Should only use keyring for URLs without a password")
449    )]
450    async fn fetch_url_with_password() {
451        let url = Url::parse("https://user:password@example.com").unwrap();
452        let keyring = KeyringProvider::empty();
453        // Panics due to debug assertion; returns `None` in production
454        assert_eq!(
455            keyring
456                .fetch(DisplaySafeUrl::ref_cast(&url), Some(url.username()))
457                .await,
458            None
459        );
460    }
461
462    #[tokio::test]
463    #[cfg_attr(
464        debug_assertions,
465        should_panic(expected = "Should only use keyring with a non-empty username")
466    )]
467    async fn fetch_url_with_empty_username() {
468        let url = Url::parse("https://example.com").unwrap();
469        let keyring = KeyringProvider::empty();
470        // Panics due to debug assertion; returns `None` in production
471        assert_eq!(
472            keyring
473                .fetch(DisplaySafeUrl::ref_cast(&url), Some(url.username()))
474                .await,
475            None
476        );
477    }
478
479    #[tokio::test]
480    async fn fetch_url_no_auth() {
481        let url = Url::parse("https://example.com").unwrap();
482        let url = DisplaySafeUrl::ref_cast(&url);
483        let keyring = KeyringProvider::empty();
484        let credentials = keyring.fetch(url, Some("user"));
485        assert!(credentials.await.is_none());
486    }
487
488    #[tokio::test]
489    async fn fetch_url() {
490        let url = Url::parse("https://example.com").unwrap();
491        let keyring = KeyringProvider::dummy([(url.host_str().unwrap(), "user", "password")]);
492        assert_eq!(
493            keyring
494                .fetch(DisplaySafeUrl::ref_cast(&url), Some("user"))
495                .await,
496            Some(Credentials::basic(
497                Some("user".to_string()),
498                Some("password".to_string())
499            ))
500        );
501        assert_eq!(
502            keyring
503                .fetch(
504                    DisplaySafeUrl::ref_cast(&url.join("test").unwrap()),
505                    Some("user")
506                )
507                .await,
508            Some(Credentials::basic(
509                Some("user".to_string()),
510                Some("password".to_string())
511            ))
512        );
513    }
514
515    #[tokio::test]
516    async fn fetch_url_no_match() {
517        let url = Url::parse("https://example.com").unwrap();
518        let keyring = KeyringProvider::dummy([("other.com", "user", "password")]);
519        let credentials = keyring
520            .fetch(DisplaySafeUrl::ref_cast(&url), Some("user"))
521            .await;
522        assert_eq!(credentials, None);
523    }
524
525    #[tokio::test]
526    async fn fetch_url_prefers_url_to_host() {
527        let url = Url::parse("https://example.com/").unwrap();
528        let keyring = KeyringProvider::dummy([
529            (url.join("foo").unwrap().as_str(), "user", "password"),
530            (url.host_str().unwrap(), "user", "other-password"),
531        ]);
532        assert_eq!(
533            keyring
534                .fetch(
535                    DisplaySafeUrl::ref_cast(&url.join("foo").unwrap()),
536                    Some("user")
537                )
538                .await,
539            Some(Credentials::basic(
540                Some("user".to_string()),
541                Some("password".to_string())
542            ))
543        );
544        assert_eq!(
545            keyring
546                .fetch(DisplaySafeUrl::ref_cast(&url), Some("user"))
547                .await,
548            Some(Credentials::basic(
549                Some("user".to_string()),
550                Some("other-password".to_string())
551            ))
552        );
553        assert_eq!(
554            keyring
555                .fetch(
556                    DisplaySafeUrl::ref_cast(&url.join("bar").unwrap()),
557                    Some("user")
558                )
559                .await,
560            Some(Credentials::basic(
561                Some("user".to_string()),
562                Some("other-password".to_string())
563            ))
564        );
565    }
566
567    #[tokio::test]
568    async fn fetch_url_username() {
569        let url = Url::parse("https://example.com").unwrap();
570        let keyring = KeyringProvider::dummy([(url.host_str().unwrap(), "user", "password")]);
571        let credentials = keyring
572            .fetch(DisplaySafeUrl::ref_cast(&url), Some("user"))
573            .await;
574        assert_eq!(
575            credentials,
576            Some(Credentials::basic(
577                Some("user".to_string()),
578                Some("password".to_string())
579            ))
580        );
581    }
582
583    #[tokio::test]
584    async fn fetch_url_no_username() {
585        let url = Url::parse("https://example.com").unwrap();
586        let keyring = KeyringProvider::dummy([(url.host_str().unwrap(), "user", "password")]);
587        let credentials = keyring.fetch(DisplaySafeUrl::ref_cast(&url), None).await;
588        assert_eq!(
589            credentials,
590            Some(Credentials::basic(
591                Some("user".to_string()),
592                Some("password".to_string())
593            ))
594        );
595    }
596
597    #[tokio::test]
598    async fn fetch_url_username_no_match() {
599        let url = Url::parse("https://example.com").unwrap();
600        let keyring = KeyringProvider::dummy([(url.host_str().unwrap(), "foo", "password")]);
601        let credentials = keyring
602            .fetch(DisplaySafeUrl::ref_cast(&url), Some("bar"))
603            .await;
604        assert_eq!(credentials, None);
605
606        // Still fails if we have `foo` in the URL itself
607        let url = Url::parse("https://foo@example.com").unwrap();
608        let credentials = keyring
609            .fetch(DisplaySafeUrl::ref_cast(&url), Some("bar"))
610            .await;
611        assert_eq!(credentials, None);
612    }
613
614    #[tokio::test]
615    async fn fetch_http_scheme_host_fallback() {
616        // When credentials are stored with scheme included (e.g., `http://host:port`),
617        // the fetch should find them via the `scheme://host:port` fallback.
618        let url = Url::parse("http://127.0.0.1:8080/basic-auth/simple/anyio/").unwrap();
619        let keyring = KeyringProvider::dummy([("http://127.0.0.1:8080", "user", "password")]);
620        let credentials = keyring
621            .fetch(DisplaySafeUrl::ref_cast(&url), Some("user"))
622            .await;
623        assert_eq!(
624            credentials,
625            Some(Credentials::basic(
626                Some("user".to_string()),
627                Some("password".to_string())
628            ))
629        );
630    }
631
632    #[tokio::test]
633    async fn fetch_http_scheme_host_no_cross_scheme() {
634        // Credentials stored under `http://` should not be returned for `https://` requests.
635        let url = Url::parse("https://127.0.0.1:8080/basic-auth/simple/anyio/").unwrap();
636        let keyring = KeyringProvider::dummy([("http://127.0.0.1:8080", "user", "password")]);
637        let credentials = keyring
638            .fetch(DisplaySafeUrl::ref_cast(&url), Some("user"))
639            .await;
640        assert_eq!(credentials, None);
641    }
642}