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(KeyringProviderBackend),
28
29    #[error("The '{0}' keyring provider does not support removing credentials")]
30    RemoveUnsupported(KeyringProviderBackend),
31}
32
33#[derive(Debug, Clone)]
34pub enum 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 std::fmt::Display for KeyringProviderBackend {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        match self {
46            Self::Native => write!(f, "native"),
47            Self::Subprocess => write!(f, "subprocess"),
48            #[cfg(test)]
49            Self::Dummy(_) => write!(f, "dummy"),
50        }
51    }
52}
53
54impl KeyringProvider {
55    /// Create a new [`KeyringProvider::Native`].
56    pub fn native() -> Self {
57        Self {
58            backend: KeyringProviderBackend::Native,
59        }
60    }
61
62    /// Create a new [`KeyringProvider::Subprocess`].
63    pub fn subprocess() -> Self {
64        Self {
65            backend: KeyringProviderBackend::Subprocess,
66        }
67    }
68
69    /// Store credentials for the given [`DisplaySafeUrl`] to the keyring.
70    ///
71    /// Only [`KeyringProviderBackend::Native`] is supported at this time.
72    #[instrument(skip_all, fields(url = % url.to_string(), username))]
73    pub async fn store(
74        &self,
75        url: &DisplaySafeUrl,
76        credentials: &Credentials,
77    ) -> Result<bool, Error> {
78        let Some(username) = credentials.username() else {
79            trace!("Unable to store credentials in keyring for {url} due to missing username");
80            return Ok(false);
81        };
82        let Some(password) = credentials.password() else {
83            trace!("Unable to store credentials in keyring for {url} due to missing password");
84            return Ok(false);
85        };
86
87        // Ensure we strip credentials from the URL before storing
88        let url = url.without_credentials();
89
90        // If there's no path, we'll perform a host-level login
91        let target = if let Some(host) = url.host_str().filter(|_| !url.path().is_empty()) {
92            let mut target = String::new();
93            if url.scheme() != "https" {
94                target.push_str(url.scheme());
95                target.push_str("://");
96            }
97            target.push_str(host);
98            if let Some(port) = url.port() {
99                target.push(':');
100                target.push_str(&port.to_string());
101            }
102            target
103        } else {
104            url.to_string()
105        };
106
107        match &self.backend {
108            KeyringProviderBackend::Native => {
109                self.store_native(&target, username, password).await?;
110                Ok(true)
111            }
112            KeyringProviderBackend::Subprocess => {
113                Err(Error::StoreUnsupported(self.backend.clone()))
114            }
115            #[cfg(test)]
116            KeyringProviderBackend::Dummy(_) => Err(Error::StoreUnsupported(self.backend.clone())),
117        }
118    }
119
120    /// Store credentials to the system keyring.
121    #[instrument(skip(self))]
122    async fn store_native(
123        &self,
124        service: &str,
125        username: &str,
126        password: &str,
127    ) -> Result<(), Error> {
128        let prefixed_service = format!("{UV_SERVICE_PREFIX}{service}");
129        let entry = uv_keyring::Entry::new(&prefixed_service, username)?;
130        entry.set_password(password).await?;
131        Ok(())
132    }
133
134    /// Remove credentials for the given [`DisplaySafeUrl`] and username from the keyring.
135    ///
136    /// Only [`KeyringProviderBackend::Native`] is supported at this time.
137    #[instrument(skip_all, fields(url = % url.to_string(), username))]
138    pub async fn remove(&self, url: &DisplaySafeUrl, username: &str) -> Result<(), Error> {
139        // Ensure we strip credentials from the URL before storing
140        let url = url.without_credentials();
141
142        // If there's no path, we'll perform a host-level login
143        let target = if let Some(host) = url.host_str().filter(|_| !url.path().is_empty()) {
144            let mut target = String::new();
145            if url.scheme() != "https" {
146                target.push_str(url.scheme());
147                target.push_str("://");
148            }
149            target.push_str(host);
150            if let Some(port) = url.port() {
151                target.push(':');
152                target.push_str(&port.to_string());
153            }
154            target
155        } else {
156            url.to_string()
157        };
158
159        match &self.backend {
160            KeyringProviderBackend::Native => {
161                self.remove_native(&target, username).await?;
162                Ok(())
163            }
164            KeyringProviderBackend::Subprocess => {
165                Err(Error::RemoveUnsupported(self.backend.clone()))
166            }
167            #[cfg(test)]
168            KeyringProviderBackend::Dummy(_) => Err(Error::RemoveUnsupported(self.backend.clone())),
169        }
170    }
171
172    /// Remove credentials from the system keyring for the given `service_name`/`username`
173    /// pair.
174    #[instrument(skip(self))]
175    async fn remove_native(
176        &self,
177        service_name: &str,
178        username: &str,
179    ) -> Result<(), uv_keyring::Error> {
180        let prefixed_service = format!("{UV_SERVICE_PREFIX}{service_name}");
181        let entry = uv_keyring::Entry::new(&prefixed_service, username)?;
182        entry.delete_credential().await?;
183        trace!("Removed credentials for {username}@{service_name} from system keyring");
184        Ok(())
185    }
186
187    /// Fetch credentials for the given [`Url`] from the keyring.
188    ///
189    /// Returns [`None`] if no password was found for the username or if any errors
190    /// are encountered in the keyring backend.
191    #[instrument(skip_all, fields(url = % url.to_string(), username))]
192    pub async fn fetch(&self, url: &DisplaySafeUrl, username: Option<&str>) -> Option<Credentials> {
193        // Validate the request
194        debug_assert!(
195            url.host_str().is_some(),
196            "Should only use keyring for URLs with host"
197        );
198        debug_assert!(
199            url.password().is_none(),
200            "Should only use keyring for URLs without a password"
201        );
202        debug_assert!(
203            !username.map(str::is_empty).unwrap_or(false),
204            "Should only use keyring with a non-empty username"
205        );
206
207        // Check the full URL first
208        // <https://github.com/pypa/pip/blob/ae5fff36b0aad6e5e0037884927eaa29163c0611/src/pip/_internal/network/auth.py#L376C1-L379C14>
209        trace!("Checking keyring for URL {url}");
210        let mut credentials = match self.backend {
211            KeyringProviderBackend::Native => self.fetch_native(url.as_str(), username).await,
212            KeyringProviderBackend::Subprocess => {
213                self.fetch_subprocess(url.as_str(), username).await
214            }
215            #[cfg(test)]
216            KeyringProviderBackend::Dummy(ref store) => {
217                Self::fetch_dummy(store, url.as_str(), username)
218            }
219        };
220        // And fallback to a check for the host
221        if credentials.is_none() {
222            let host = if let Some(port) = url.port() {
223                format!("{}:{}", url.host_str()?, port)
224            } else {
225                url.host_str()?.to_string()
226            };
227            trace!("Checking keyring for host {host}");
228            credentials = match self.backend {
229                KeyringProviderBackend::Native => self.fetch_native(&host, username).await,
230                KeyringProviderBackend::Subprocess => self.fetch_subprocess(&host, username).await,
231                #[cfg(test)]
232                KeyringProviderBackend::Dummy(ref store) => {
233                    Self::fetch_dummy(store, &host, username)
234                }
235            };
236
237            // For non-HTTPS URLs, `store` includes the scheme in the service name
238            // (e.g., `http://host:port`) to avoid leaking credentials across schemes.
239            // Try `scheme://host:port` as a fallback to match those entries.
240            if credentials.is_none() && url.scheme() != "https" {
241                let scheme_host = format!("{}://{host}", url.scheme());
242                trace!("Checking keyring for scheme+host {scheme_host}");
243                credentials = match self.backend {
244                    KeyringProviderBackend::Native => {
245                        self.fetch_native(&scheme_host, username).await
246                    }
247                    KeyringProviderBackend::Subprocess => {
248                        self.fetch_subprocess(&scheme_host, username).await
249                    }
250                    #[cfg(test)]
251                    KeyringProviderBackend::Dummy(ref store) => {
252                        Self::fetch_dummy(store, &scheme_host, username)
253                    }
254                };
255            }
256        }
257
258        credentials.map(|(username, password)| Credentials::basic(Some(username), Some(password)))
259    }
260
261    #[instrument(skip(self))]
262    async fn fetch_subprocess(
263        &self,
264        service_name: &str,
265        username: Option<&str>,
266    ) -> Option<(String, String)> {
267        // https://github.com/pypa/pip/blob/24.0/src/pip/_internal/network/auth.py#L136-L141
268        let mut command = Command::new("keyring");
269        command.arg("get").arg(service_name);
270
271        if let Some(username) = username {
272            command.arg(username);
273        } else {
274            command.arg("--mode").arg("creds");
275        }
276
277        let child = command
278            .stdin(Stdio::null())
279            .stdout(Stdio::piped())
280            // If we're using `--mode creds`, we need to capture the output in order to avoid
281            // showing users an "unrecognized arguments: --mode" error; otherwise, we stream stderr
282            // so the user has visibility into keyring's behavior if it's doing something slow
283            .stderr(if username.is_some() {
284                Stdio::inherit()
285            } else {
286                Stdio::piped()
287            })
288            .spawn()
289            .inspect_err(|err| warn!("Failure running `keyring` command: {err}"))
290            .ok()?;
291
292        let output = child
293            .wait_with_output()
294            .await
295            .inspect_err(|err| warn!("Failed to wait for `keyring` output: {err}"))
296            .ok()?;
297
298        if output.status.success() {
299            // If we captured stderr, display it in case it's helpful to the user
300            // TODO(zanieb): This was done when we added `--mode creds` support for parity with the
301            // existing behavior, but it might be a better UX to hide this on success? It also
302            // might be problematic that we're not streaming it. We could change this given some
303            // user feedback.
304            std::io::stderr().write_all(&output.stderr).ok();
305
306            // On success, parse the newline terminated credentials
307            let output = String::from_utf8(output.stdout)
308                .inspect_err(|err| warn!("Failed to parse response from `keyring` command: {err}"))
309                .ok()?;
310
311            let (username, password) = if let Some(username) = username {
312                // We're only expecting a password
313                let password = output.trim_end();
314                (username, password)
315            } else {
316                // We're expecting a username and password
317                let mut lines = output.lines();
318                let username = lines.next()?;
319                let Some(password) = lines.next() else {
320                    warn!(
321                        "Got username without password for `{service_name}` from `keyring` command"
322                    );
323                    return None;
324                };
325                (username, password)
326            };
327
328            if password.is_empty() {
329                // We allow this for backwards compatibility, but it might be better to return
330                // `None` instead if there's confusion from users — we haven't seen this in practice
331                // yet.
332                warn!("Got empty password for `{username}@{service_name}` from `keyring` command");
333            }
334
335            Some((username.to_string(), password.to_string()))
336        } else {
337            // On failure, no password was available
338            let stderr = std::str::from_utf8(&output.stderr).ok()?;
339            if stderr.contains("unrecognized arguments: --mode") {
340                // N.B. We do not show the `service_name` here because we'll show the warning twice
341                //      otherwise, once for the URL and once for the realm.
342                warn_user_once!(
343                    "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"
344                );
345            } else if username.is_none() {
346                // If we captured stderr, display it in case it's helpful to the user
347                std::io::stderr().write_all(&output.stderr).ok();
348            }
349            None
350        }
351    }
352
353    #[instrument(skip(self))]
354    async fn fetch_native(
355        &self,
356        service: &str,
357        username: Option<&str>,
358    ) -> Option<(String, String)> {
359        let prefixed_service = format!("{UV_SERVICE_PREFIX}{service}");
360        let username = username?;
361        let Ok(entry) = uv_keyring::Entry::new(&prefixed_service, username) else {
362            return None;
363        };
364        match entry.get_password().await {
365            Ok(password) => return Some((username.to_string(), password)),
366            Err(uv_keyring::Error::NoEntry) => {
367                debug!("No entry found in system keyring for {service}");
368            }
369            Err(err) => {
370                warn_user_once!(
371                    "Unable to fetch credentials for {service} from system keyring: {err}"
372                );
373            }
374        }
375        None
376    }
377
378    #[cfg(test)]
379    fn fetch_dummy(
380        store: &Vec<(String, &'static str, &'static str)>,
381        service_name: &str,
382        username: Option<&str>,
383    ) -> Option<(String, String)> {
384        store.iter().find_map(|(service, user, password)| {
385            if service == service_name && username.is_none_or(|username| username == *user) {
386                Some(((*user).to_string(), (*password).to_string()))
387            } else {
388                None
389            }
390        })
391    }
392
393    /// Create a new provider with [`KeyringProviderBackend::Dummy`].
394    #[cfg(test)]
395    pub fn dummy<S: Into<String>, T: IntoIterator<Item = (S, &'static str, &'static str)>>(
396        iter: T,
397    ) -> Self {
398        Self {
399            backend: KeyringProviderBackend::Dummy(
400                iter.into_iter()
401                    .map(|(service, username, password)| (service.into(), username, password))
402                    .collect(),
403            ),
404        }
405    }
406
407    /// Create a new provider with no credentials available.
408    #[cfg(test)]
409    pub fn empty() -> Self {
410        Self {
411            backend: KeyringProviderBackend::Dummy(Vec::new()),
412        }
413    }
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419    use futures::FutureExt;
420    use url::Url;
421
422    #[tokio::test]
423    async fn fetch_url_no_host() {
424        let url = Url::parse("file:/etc/bin/").unwrap();
425        let keyring = KeyringProvider::empty();
426        // Panics due to debug assertion; returns `None` in production
427        let fetch = keyring.fetch(DisplaySafeUrl::ref_cast(&url), Some("user"));
428        if cfg!(debug_assertions) {
429            let result = std::panic::AssertUnwindSafe(fetch).catch_unwind().await;
430            assert!(result.is_err());
431        } else {
432            assert_eq!(fetch.await, None);
433        }
434    }
435
436    #[tokio::test]
437    async fn fetch_url_with_password() {
438        let url = Url::parse("https://user:password@example.com").unwrap();
439        let keyring = KeyringProvider::empty();
440        // Panics due to debug assertion; returns `None` in production
441        let fetch = keyring.fetch(DisplaySafeUrl::ref_cast(&url), Some(url.username()));
442        if cfg!(debug_assertions) {
443            let result = std::panic::AssertUnwindSafe(fetch).catch_unwind().await;
444            assert!(result.is_err());
445        } else {
446            assert_eq!(fetch.await, None);
447        }
448    }
449
450    #[tokio::test]
451    async fn fetch_url_with_empty_username() {
452        let url = Url::parse("https://example.com").unwrap();
453        let keyring = KeyringProvider::empty();
454        // Panics due to debug assertion; returns `None` in production
455        let fetch = keyring.fetch(DisplaySafeUrl::ref_cast(&url), Some(url.username()));
456        if cfg!(debug_assertions) {
457            let result = std::panic::AssertUnwindSafe(fetch).catch_unwind().await;
458            assert!(result.is_err());
459        } else {
460            assert_eq!(fetch.await, None);
461        }
462    }
463
464    #[tokio::test]
465    async fn fetch_url_no_auth() {
466        let url = Url::parse("https://example.com").unwrap();
467        let url = DisplaySafeUrl::ref_cast(&url);
468        let keyring = KeyringProvider::empty();
469        let credentials = keyring.fetch(url, Some("user"));
470        assert!(credentials.await.is_none());
471    }
472
473    #[tokio::test]
474    async fn fetch_url() {
475        let url = Url::parse("https://example.com").unwrap();
476        let keyring = KeyringProvider::dummy([(url.host_str().unwrap(), "user", "password")]);
477        assert_eq!(
478            keyring
479                .fetch(DisplaySafeUrl::ref_cast(&url), Some("user"))
480                .await,
481            Some(Credentials::basic(
482                Some("user".to_string()),
483                Some("password".to_string())
484            ))
485        );
486        assert_eq!(
487            keyring
488                .fetch(
489                    DisplaySafeUrl::ref_cast(&url.join("test").unwrap()),
490                    Some("user")
491                )
492                .await,
493            Some(Credentials::basic(
494                Some("user".to_string()),
495                Some("password".to_string())
496            ))
497        );
498    }
499
500    #[tokio::test]
501    async fn fetch_url_no_match() {
502        let url = Url::parse("https://example.com").unwrap();
503        let keyring = KeyringProvider::dummy([("other.com", "user", "password")]);
504        let credentials = keyring
505            .fetch(DisplaySafeUrl::ref_cast(&url), Some("user"))
506            .await;
507        assert_eq!(credentials, None);
508    }
509
510    #[tokio::test]
511    async fn fetch_url_prefers_url_to_host() {
512        let url = Url::parse("https://example.com/").unwrap();
513        let keyring = KeyringProvider::dummy([
514            (url.join("foo").unwrap().as_str(), "user", "password"),
515            (url.host_str().unwrap(), "user", "other-password"),
516        ]);
517        assert_eq!(
518            keyring
519                .fetch(
520                    DisplaySafeUrl::ref_cast(&url.join("foo").unwrap()),
521                    Some("user")
522                )
523                .await,
524            Some(Credentials::basic(
525                Some("user".to_string()),
526                Some("password".to_string())
527            ))
528        );
529        assert_eq!(
530            keyring
531                .fetch(DisplaySafeUrl::ref_cast(&url), Some("user"))
532                .await,
533            Some(Credentials::basic(
534                Some("user".to_string()),
535                Some("other-password".to_string())
536            ))
537        );
538        assert_eq!(
539            keyring
540                .fetch(
541                    DisplaySafeUrl::ref_cast(&url.join("bar").unwrap()),
542                    Some("user")
543                )
544                .await,
545            Some(Credentials::basic(
546                Some("user".to_string()),
547                Some("other-password".to_string())
548            ))
549        );
550    }
551
552    #[tokio::test]
553    async fn fetch_url_username() {
554        let url = Url::parse("https://example.com").unwrap();
555        let keyring = KeyringProvider::dummy([(url.host_str().unwrap(), "user", "password")]);
556        let credentials = keyring
557            .fetch(DisplaySafeUrl::ref_cast(&url), Some("user"))
558            .await;
559        assert_eq!(
560            credentials,
561            Some(Credentials::basic(
562                Some("user".to_string()),
563                Some("password".to_string())
564            ))
565        );
566    }
567
568    #[tokio::test]
569    async fn fetch_url_no_username() {
570        let url = Url::parse("https://example.com").unwrap();
571        let keyring = KeyringProvider::dummy([(url.host_str().unwrap(), "user", "password")]);
572        let credentials = keyring.fetch(DisplaySafeUrl::ref_cast(&url), None).await;
573        assert_eq!(
574            credentials,
575            Some(Credentials::basic(
576                Some("user".to_string()),
577                Some("password".to_string())
578            ))
579        );
580    }
581
582    #[tokio::test]
583    async fn fetch_url_username_no_match() {
584        let url = Url::parse("https://example.com").unwrap();
585        let keyring = KeyringProvider::dummy([(url.host_str().unwrap(), "foo", "password")]);
586        let credentials = keyring
587            .fetch(DisplaySafeUrl::ref_cast(&url), Some("bar"))
588            .await;
589        assert_eq!(credentials, None);
590
591        // Still fails if we have `foo` in the URL itself
592        let url = Url::parse("https://foo@example.com").unwrap();
593        let credentials = keyring
594            .fetch(DisplaySafeUrl::ref_cast(&url), Some("bar"))
595            .await;
596        assert_eq!(credentials, None);
597    }
598
599    #[tokio::test]
600    async fn fetch_http_scheme_host_fallback() {
601        // When credentials are stored with scheme included (e.g., `http://host:port`),
602        // the fetch should find them via the `scheme://host:port` fallback.
603        let url = Url::parse("http://127.0.0.1:8080/basic-auth/simple/anyio/").unwrap();
604        let keyring = KeyringProvider::dummy([("http://127.0.0.1:8080", "user", "password")]);
605        let credentials = keyring
606            .fetch(DisplaySafeUrl::ref_cast(&url), Some("user"))
607            .await;
608        assert_eq!(
609            credentials,
610            Some(Credentials::basic(
611                Some("user".to_string()),
612                Some("password".to_string())
613            ))
614        );
615    }
616
617    #[tokio::test]
618    async fn fetch_http_scheme_host_no_cross_scheme() {
619        // Credentials stored under `http://` should not be returned for `https://` requests.
620        let url = Url::parse("https://127.0.0.1:8080/basic-auth/simple/anyio/").unwrap();
621        let keyring = KeyringProvider::dummy([("http://127.0.0.1:8080", "user", "password")]);
622        let credentials = keyring
623            .fetch(DisplaySafeUrl::ref_cast(&url), Some("user"))
624            .await;
625        assert_eq!(credentials, None);
626    }
627}