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