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