Skip to main content

uv_auth/
store.rs

1use std::ops::Deref;
2use std::path::{Path, PathBuf};
3
4use fs_err as fs;
5use rustc_hash::FxHashMap;
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8use uv_fs::{LockedFile, LockedFileError, LockedFileMode};
9use uv_preview::{Preview, PreviewFeature};
10use uv_redacted::DisplaySafeUrl;
11
12use uv_state::{StateBucket, StateStore};
13use uv_static::EnvVars;
14
15use crate::credentials::{Password, Token, Username};
16use crate::index::is_path_prefix;
17use crate::realm::Realm;
18use crate::service::Service;
19use crate::{Credentials, KeyringProvider};
20
21/// The storage backend to use in `uv auth` commands.
22#[derive(Debug)]
23pub enum AuthBackend {
24    // TODO(zanieb): Right now, we're using a keyring provider for the system store but that's just
25    // where the native implementation is living at the moment. We should consider refactoring these
26    // into a shared API in the future.
27    System(KeyringProvider),
28    TextStore(TextCredentialStore, LockedFile),
29}
30
31impl AuthBackend {
32    pub async fn from_settings(preview: Preview) -> Result<Self, TomlCredentialError> {
33        // If preview is enabled, we'll use the system-native store
34        if preview.is_enabled(PreviewFeature::NativeAuth) {
35            return Ok(Self::System(KeyringProvider::native()));
36        }
37
38        // Otherwise, we'll use the plaintext credential store
39        let path = TextCredentialStore::default_file()?;
40        match TextCredentialStore::read(&path).await {
41            Ok((store, lock)) => Ok(Self::TextStore(store, lock)),
42            Err(err)
43                if err
44                    .as_io_error()
45                    .is_some_and(|err| err.kind() == std::io::ErrorKind::NotFound) =>
46            {
47                Ok(Self::TextStore(
48                    TextCredentialStore::default(),
49                    TextCredentialStore::lock(&path).await?,
50                ))
51            }
52            Err(err) => Err(err),
53        }
54    }
55}
56
57/// Authentication scheme to use.
58#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "lowercase")]
60pub enum AuthScheme {
61    /// HTTP Basic Authentication
62    ///
63    /// Uses a username and password.
64    #[default]
65    Basic,
66    /// Bearer token authentication.
67    ///
68    /// Uses a token provided as `Bearer <token>` in the `Authorization` header.
69    Bearer,
70}
71
72/// Errors that can occur when working with TOML credential storage.
73#[derive(Debug, Error)]
74pub enum TomlCredentialError {
75    #[error(transparent)]
76    Io(#[from] std::io::Error),
77    #[error(transparent)]
78    LockedFile(#[from] LockedFileError),
79    #[error("Failed to parse TOML credential file: {0}")]
80    ParseError(#[from] toml::de::Error),
81    #[error("Failed to serialize credentials to TOML")]
82    SerializeError(#[from] toml::ser::Error),
83    #[error(transparent)]
84    BasicAuthError(#[from] BasicAuthError),
85    #[error(transparent)]
86    BearerAuthError(#[from] BearerAuthError),
87    #[error("Failed to determine credentials directory")]
88    CredentialsDirError,
89    #[error("Token is not valid unicode")]
90    TokenNotUnicode(#[from] std::string::FromUtf8Error),
91}
92
93impl TomlCredentialError {
94    pub(crate) fn as_io_error(&self) -> Option<&std::io::Error> {
95        match self {
96            Self::Io(err) => Some(err),
97            Self::LockedFile(err) => err.as_io_error(),
98            Self::ParseError(_)
99            | Self::SerializeError(_)
100            | Self::BasicAuthError(_)
101            | Self::BearerAuthError(_)
102            | Self::CredentialsDirError
103            | Self::TokenNotUnicode(_) => None,
104        }
105    }
106}
107
108#[derive(Debug, Error)]
109pub enum BasicAuthError {
110    #[error("`username` is required with `scheme = basic`")]
111    MissingUsername,
112    #[error("`token` cannot be provided with `scheme = basic`")]
113    UnexpectedToken,
114}
115
116#[derive(Debug, Error)]
117pub enum BearerAuthError {
118    #[error("`token` is required with `scheme = bearer`")]
119    MissingToken,
120    #[error("`username` cannot be provided with `scheme = bearer`")]
121    UnexpectedUsername,
122    #[error("`password` cannot be provided with `scheme = bearer`")]
123    UnexpectedPassword,
124}
125
126#[derive(Debug, Error, PartialEq)]
127pub enum LookupError {
128    #[error("Multiple credentials found for URL '{0}', specify which username to use")]
129    AmbiguousUsername(DisplaySafeUrl),
130}
131
132/// A single credential entry in a TOML credentials file.
133#[derive(Debug, Clone, Serialize, Deserialize)]
134#[serde(try_from = "TomlCredentialWire", into = "TomlCredentialWire")]
135struct TomlCredential {
136    /// The service URL for this credential.
137    service: Service,
138    /// The credentials for this entry.
139    credentials: Credentials,
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize)]
143struct TomlCredentialWire {
144    /// The service URL for this credential.
145    service: Service,
146    /// The username to use. Only allowed with [`AuthScheme::Basic`].
147    username: Username,
148    /// The authentication scheme.
149    #[serde(default)]
150    scheme: AuthScheme,
151    /// The password to use. Only allowed with [`AuthScheme::Basic`].
152    password: Option<Password>,
153    /// The token to use. Only allowed with [`AuthScheme::Bearer`].
154    token: Option<String>,
155}
156
157impl From<TomlCredential> for TomlCredentialWire {
158    fn from(value: TomlCredential) -> Self {
159        match value.credentials {
160            Credentials::Basic { username, password } => Self {
161                service: value.service,
162                username,
163                scheme: AuthScheme::Basic,
164                password,
165                token: None,
166            },
167            Credentials::Bearer { token } => Self {
168                service: value.service,
169                username: Username::new(None),
170                scheme: AuthScheme::Bearer,
171                password: None,
172                token: Some(String::from_utf8(token.into_bytes()).expect("Token is valid UTF-8")),
173            },
174        }
175    }
176}
177
178impl TryFrom<TomlCredentialWire> for TomlCredential {
179    type Error = TomlCredentialError;
180
181    fn try_from(value: TomlCredentialWire) -> Result<Self, Self::Error> {
182        match value.scheme {
183            AuthScheme::Basic => {
184                if value.username.as_deref().is_none() {
185                    return Err(TomlCredentialError::BasicAuthError(
186                        BasicAuthError::MissingUsername,
187                    ));
188                }
189                if value.token.is_some() {
190                    return Err(TomlCredentialError::BasicAuthError(
191                        BasicAuthError::UnexpectedToken,
192                    ));
193                }
194                let credentials = Credentials::Basic {
195                    username: value.username,
196                    password: value.password,
197                };
198                Ok(Self {
199                    service: value.service,
200                    credentials,
201                })
202            }
203            AuthScheme::Bearer => {
204                if value.username.is_some() {
205                    return Err(TomlCredentialError::BearerAuthError(
206                        BearerAuthError::UnexpectedUsername,
207                    ));
208                }
209                if value.password.is_some() {
210                    return Err(TomlCredentialError::BearerAuthError(
211                        BearerAuthError::UnexpectedPassword,
212                    ));
213                }
214                if value.token.is_none() {
215                    return Err(TomlCredentialError::BearerAuthError(
216                        BearerAuthError::MissingToken,
217                    ));
218                }
219                let credentials = Credentials::Bearer {
220                    token: Token::new(value.token.unwrap().into_bytes()),
221                };
222                Ok(Self {
223                    service: value.service,
224                    credentials,
225                })
226            }
227        }
228    }
229}
230
231#[derive(Debug, Clone, Serialize, Deserialize, Default)]
232struct TomlCredentials {
233    /// Array of credential entries.
234    #[serde(rename = "credential")]
235    credentials: Vec<TomlCredential>,
236}
237
238/// A credential store with a plain text storage backend.
239#[derive(Debug, Default)]
240pub struct TextCredentialStore {
241    credentials: FxHashMap<(Service, Username), Credentials>,
242}
243
244impl TextCredentialStore {
245    /// Return the directory for storing credentials.
246    pub fn directory_path() -> Result<PathBuf, TomlCredentialError> {
247        if let Some(dir) = std::env::var_os(EnvVars::UV_CREDENTIALS_DIR)
248            .filter(|s| !s.is_empty())
249            .map(PathBuf::from)
250        {
251            return Ok(dir);
252        }
253
254        Ok(StateStore::from_settings(None)?.bucket(StateBucket::Credentials))
255    }
256
257    /// Return the standard file path for storing credentials.
258    pub fn default_file() -> Result<PathBuf, TomlCredentialError> {
259        let dir = Self::directory_path()?;
260        Ok(dir.join("credentials.toml"))
261    }
262
263    /// Acquire a lock on the credentials file at the given path.
264    async fn lock(path: &Path) -> Result<LockedFile, TomlCredentialError> {
265        if let Some(parent) = path.parent() {
266            fs::create_dir_all(parent)?;
267        }
268        let lock = path.with_added_extension("lock");
269        Ok(LockedFile::acquire(lock, LockedFileMode::Exclusive, "credentials store").await?)
270    }
271
272    /// Read credentials from a file.
273    fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, TomlCredentialError> {
274        let content = fs::read_to_string(path)?;
275        let credentials: TomlCredentials = toml::from_str(&content)?;
276
277        let credentials: FxHashMap<(Service, Username), Credentials> = credentials
278            .credentials
279            .into_iter()
280            .map(|credential| {
281                let username = match &credential.credentials {
282                    Credentials::Basic { username, .. } => username.clone(),
283                    Credentials::Bearer { .. } => Username::none(),
284                };
285                (
286                    (credential.service.clone(), username),
287                    credential.credentials,
288                )
289            })
290            .collect();
291
292        Ok(Self { credentials })
293    }
294
295    /// Read credentials from a file.
296    ///
297    /// Returns [`TextCredentialStore`] and a [`LockedFile`] to hold if mutating the store.
298    ///
299    /// If the store will not be written to following the read, the lock can be dropped.
300    pub(crate) async fn read<P: AsRef<Path>>(
301        path: P,
302    ) -> Result<(Self, LockedFile), TomlCredentialError> {
303        let lock = Self::lock(path.as_ref()).await?;
304        let store = Self::from_file(path)?;
305        Ok((store, lock))
306    }
307
308    /// Persist credentials to a file.
309    ///
310    /// Requires a [`LockedFile`] from [`TextCredentialStore::lock`] or
311    /// [`TextCredentialStore::read`] to ensure exclusive access.
312    pub fn write<P: AsRef<Path>>(
313        self,
314        path: P,
315        _lock: LockedFile,
316    ) -> Result<(), TomlCredentialError> {
317        let credentials = self
318            .credentials
319            .into_iter()
320            .map(|((service, _username), credentials)| TomlCredential {
321                service,
322                credentials,
323            })
324            .collect::<Vec<_>>();
325
326        let toml_creds = TomlCredentials { credentials };
327        let content = toml::to_string_pretty(&toml_creds)?;
328        fs::create_dir_all(
329            path.as_ref()
330                .parent()
331                .ok_or(TomlCredentialError::CredentialsDirError)?,
332        )?;
333
334        // TODO(zanieb): We should use an atomic write here
335        fs::write(path, content)?;
336        Ok(())
337    }
338
339    /// Get credentials for a given URL and username.
340    ///
341    /// The most specific URL prefix match in the same [`Realm`] is returned, if any.
342    pub fn get_credentials(
343        &self,
344        url: &DisplaySafeUrl,
345        username: Option<&str>,
346    ) -> Result<Option<&Credentials>, LookupError> {
347        let request_realm = Realm::from(url);
348
349        // Perform an exact lookup first
350        // TODO(zanieb): Consider adding `DisplaySafeUrlRef` so we can avoid this clone
351        // TODO(zanieb): We could also return early here if we can't normalize to a `Service`
352        if let Ok(url_service) = Service::try_from(url.clone()) {
353            if let Some(credential) = self.credentials.get(&(
354                url_service.clone(),
355                Username::from(username.map(str::to_string)),
356            )) {
357                return Ok(Some(credential));
358            }
359        }
360
361        // If that fails, iterate through to find a prefix match
362        let mut best: Option<(usize, &Service, &Credentials)> = None;
363
364        for ((service, stored_username), credential) in &self.credentials {
365            let service_realm = Realm::from(service.url().deref());
366
367            // Only consider services in the same realm
368            if service_realm != request_realm {
369                continue;
370            }
371
372            // Service path must be a prefix of the request path.
373            if !is_path_prefix(service.url().path(), url.path()) {
374                continue;
375            }
376
377            // If a username is provided, it must match
378            if let Some(request_username) = username {
379                if Some(request_username) != stored_username.as_deref() {
380                    continue;
381                }
382            }
383
384            // Update our best matching credential based on prefix length
385            let specificity = service.url().path().len();
386            if best.is_none_or(|(best_specificity, _, _)| specificity > best_specificity) {
387                best = Some((specificity, service, credential));
388            } else if best.is_some_and(|(best_specificity, _, _)| specificity == best_specificity) {
389                return Err(LookupError::AmbiguousUsername(url.clone()));
390            }
391        }
392
393        // Return the most specific match
394        if let Some((_, _, credential)) = best {
395            return Ok(Some(credential));
396        }
397
398        Ok(None)
399    }
400
401    /// Store credentials for a given service.
402    pub fn insert(&mut self, service: Service, credentials: Credentials) -> Option<Credentials> {
403        let username = match &credentials {
404            Credentials::Basic { username, .. } => username.clone(),
405            Credentials::Bearer { .. } => Username::none(),
406        };
407        self.credentials.insert((service, username), credentials)
408    }
409
410    /// Remove credentials for a given service.
411    pub fn remove(&mut self, service: &Service, username: Username) -> Option<Credentials> {
412        // Remove the specific credential for this service and username
413        self.credentials.remove(&(service.clone(), username))
414    }
415}
416
417#[cfg(test)]
418mod tests {
419    use std::io::Write;
420    use std::str::FromStr;
421
422    use tempfile::NamedTempFile;
423
424    use super::*;
425
426    #[test]
427    fn test_toml_serialization() {
428        let credentials = TomlCredentials {
429            credentials: vec![
430                TomlCredential {
431                    service: Service::from_str("https://example.com").unwrap(),
432                    credentials: Credentials::Basic {
433                        username: Username::new(Some("user1".to_string())),
434                        password: Some(Password::new("pass1".to_string())),
435                    },
436                },
437                TomlCredential {
438                    service: Service::from_str("https://test.org").unwrap(),
439                    credentials: Credentials::Basic {
440                        username: Username::new(Some("user2".to_string())),
441                        password: Some(Password::new("pass2".to_string())),
442                    },
443                },
444            ],
445        };
446
447        let toml_str = toml::to_string_pretty(&credentials).unwrap();
448        let parsed: TomlCredentials = toml::from_str(&toml_str).unwrap();
449
450        assert_eq!(parsed.credentials.len(), 2);
451        assert_eq!(
452            parsed.credentials[0].service.to_string(),
453            "https://example.com/"
454        );
455        assert_eq!(
456            parsed.credentials[1].service.to_string(),
457            "https://test.org/"
458        );
459    }
460
461    #[test]
462    fn test_credential_store_operations() {
463        let mut store = TextCredentialStore::default();
464        let credentials = Credentials::basic(Some("user".to_string()), Some("pass".to_string()));
465
466        let service = Service::from_str("https://example.com").unwrap();
467        store.insert(service.clone(), credentials.clone());
468        let url = DisplaySafeUrl::parse("https://example.com/").unwrap();
469        assert!(store.get_credentials(&url, None).unwrap().is_some());
470
471        let url = DisplaySafeUrl::parse("https://example.com/path").unwrap();
472        let retrieved = store.get_credentials(&url, None).unwrap().unwrap();
473        assert_eq!(retrieved.username(), Some("user"));
474        assert_eq!(retrieved.password(), Some("pass"));
475
476        assert!(
477            store
478                .remove(&service, Username::from(Some("user".to_string())))
479                .is_some()
480        );
481        let url = DisplaySafeUrl::parse("https://example.com/").unwrap();
482        assert!(store.get_credentials(&url, None).unwrap().is_none());
483    }
484
485    #[tokio::test]
486    async fn test_file_operations() {
487        let mut temp_file = NamedTempFile::new().unwrap();
488        writeln!(
489            temp_file,
490            r#"
491[[credential]]
492service = "https://example.com"
493username = "testuser"
494scheme = "basic"
495password = "testpass"
496
497[[credential]]
498service = "https://test.org"
499username = "user2"
500password = "pass2"
501"#
502        )
503        .unwrap();
504
505        let store = TextCredentialStore::from_file(temp_file.path()).unwrap();
506
507        let url = DisplaySafeUrl::parse("https://example.com/").unwrap();
508        assert!(store.get_credentials(&url, None).unwrap().is_some());
509        let url = DisplaySafeUrl::parse("https://test.org/").unwrap();
510        assert!(store.get_credentials(&url, None).unwrap().is_some());
511
512        let url = DisplaySafeUrl::parse("https://example.com").unwrap();
513        let cred = store.get_credentials(&url, None).unwrap().unwrap();
514        assert_eq!(cred.username(), Some("testuser"));
515        assert_eq!(cred.password(), Some("testpass"));
516
517        // Test saving
518        let temp_output = NamedTempFile::new().unwrap();
519        store
520            .write(
521                temp_output.path(),
522                TextCredentialStore::lock(temp_file.path()).await.unwrap(),
523            )
524            .unwrap();
525
526        let content = fs::read_to_string(temp_output.path()).unwrap();
527        assert!(content.contains("example.com"));
528        assert!(content.contains("testuser"));
529    }
530
531    #[test]
532    fn test_prefix_matching() {
533        let mut store = TextCredentialStore::default();
534        let credentials = Credentials::basic(Some("user".to_string()), Some("pass".to_string()));
535
536        // Store credentials for a specific path prefix
537        let service = Service::from_str("https://example.com/api").unwrap();
538        store.insert(service.clone(), credentials.clone());
539
540        // Should match URLs that are prefixes of the stored service
541        let matching_urls = [
542            "https://example.com/api",
543            "https://example.com/api/v1",
544            "https://example.com/api/v1/users",
545        ];
546
547        for url_str in matching_urls {
548            let url = DisplaySafeUrl::parse(url_str).unwrap();
549            let cred = store.get_credentials(&url, None).unwrap();
550            assert!(cred.is_some(), "Failed to match URL with prefix: {url_str}");
551        }
552
553        // Should NOT match URLs that are not prefixes
554        let non_matching_urls = [
555            "https://example.com/different",
556            "https://example.com/ap", // Not a complete path segment match
557            "https://example.com/apiary", // Not a complete path segment match
558            "https://example.com/api-v2", // Not a complete path segment match
559            "https://example.com",    // Shorter than the stored prefix
560        ];
561
562        for url_str in non_matching_urls {
563            let url = DisplaySafeUrl::parse(url_str).unwrap();
564            let cred = store.get_credentials(&url, None).unwrap();
565            assert!(cred.is_none(), "Should not match non-prefix URL: {url_str}");
566        }
567    }
568
569    #[test]
570    fn test_realm_based_matching() {
571        let mut store = TextCredentialStore::default();
572        let credentials = Credentials::basic(Some("user".to_string()), Some("pass".to_string()));
573
574        // Store by full URL (realm)
575        let service = Service::from_str("https://example.com").unwrap();
576        store.insert(service.clone(), credentials.clone());
577
578        // Should match URLs in the same realm
579        let matching_urls = [
580            "https://example.com",
581            "https://example.com/path",
582            "https://example.com/different/path",
583            "https://example.com:443/path", // Default HTTPS port
584        ];
585
586        for url_str in matching_urls {
587            let url = DisplaySafeUrl::parse(url_str).unwrap();
588            let cred = store.get_credentials(&url, None).unwrap();
589            assert!(
590                cred.is_some(),
591                "Failed to match URL in same realm: {url_str}"
592            );
593        }
594
595        // Should NOT match URLs in different realms
596        let non_matching_urls = [
597            "http://example.com",       // Different scheme
598            "https://different.com",    // Different host
599            "https://example.com:8080", // Different port
600        ];
601
602        for url_str in non_matching_urls {
603            let url = DisplaySafeUrl::parse(url_str).unwrap();
604            let cred = store.get_credentials(&url, None).unwrap();
605            assert!(
606                cred.is_none(),
607                "Should not match URL in different realm: {url_str}"
608            );
609        }
610    }
611
612    #[test]
613    fn test_most_specific_prefix_matching() {
614        let mut store = TextCredentialStore::default();
615        let general_cred =
616            Credentials::basic(Some("general".to_string()), Some("pass1".to_string()));
617        let specific_cred =
618            Credentials::basic(Some("specific".to_string()), Some("pass2".to_string()));
619
620        // Store credentials with different prefix lengths
621        let general_service = Service::from_str("https://example.com/api").unwrap();
622        let specific_service = Service::from_str("https://example.com/api/v1").unwrap();
623        store.insert(general_service.clone(), general_cred);
624        store.insert(specific_service.clone(), specific_cred);
625
626        // Should match the most specific prefix
627        let url = DisplaySafeUrl::parse("https://example.com/api/v1/users").unwrap();
628        let cred = store.get_credentials(&url, None).unwrap().unwrap();
629        assert_eq!(cred.username(), Some("specific"));
630
631        // Should match the general prefix for non-specific paths
632        let url = DisplaySafeUrl::parse("https://example.com/api/v2").unwrap();
633        let cred = store.get_credentials(&url, None).unwrap().unwrap();
634        assert_eq!(cred.username(), Some("general"));
635    }
636
637    #[test]
638    fn test_username_exact_url_match() {
639        let mut store = TextCredentialStore::default();
640        let url = DisplaySafeUrl::parse("https://example.com").unwrap();
641        let service = Service::from_str("https://example.com").unwrap();
642        let user1_creds = Credentials::basic(Some("user1".to_string()), Some("pass1".to_string()));
643        store.insert(service.clone(), user1_creds.clone());
644
645        // Should return credentials when username matches
646        let result = store.get_credentials(&url, Some("user1")).unwrap();
647        assert!(result.is_some());
648        assert_eq!(result.unwrap().username(), Some("user1"));
649        assert_eq!(result.unwrap().password(), Some("pass1"));
650
651        // Should not return credentials when username doesn't match
652        let result = store.get_credentials(&url, Some("user2")).unwrap();
653        assert!(result.is_none());
654
655        // Should return credentials when no username is specified
656        let result = store.get_credentials(&url, None).unwrap();
657        assert!(result.is_some());
658        assert_eq!(result.unwrap().username(), Some("user1"));
659    }
660
661    #[test]
662    fn test_username_prefix_url_match() {
663        let mut store = TextCredentialStore::default();
664
665        // Add credentials with different usernames for overlapping URL prefixes
666        let general_service = Service::from_str("https://example.com/api").unwrap();
667        let specific_service = Service::from_str("https://example.com/api/v1").unwrap();
668
669        let general_creds = Credentials::basic(
670            Some("general_user".to_string()),
671            Some("general_pass".to_string()),
672        );
673        let specific_creds = Credentials::basic(
674            Some("specific_user".to_string()),
675            Some("specific_pass".to_string()),
676        );
677
678        store.insert(general_service, general_creds);
679        store.insert(specific_service, specific_creds);
680
681        let url = DisplaySafeUrl::parse("https://example.com/api/v1/users").unwrap();
682
683        // Should match specific credentials when username matches
684        let result = store.get_credentials(&url, Some("specific_user")).unwrap();
685        assert!(result.is_some());
686        assert_eq!(result.unwrap().username(), Some("specific_user"));
687
688        // Should match the general credentials when requesting general_user (falls back to less specific prefix)
689        let result = store.get_credentials(&url, Some("general_user")).unwrap();
690        assert!(
691            result.is_some(),
692            "Should match general_user from less specific prefix"
693        );
694        assert_eq!(result.unwrap().username(), Some("general_user"));
695
696        // Should match most specific when no username specified
697        let result = store.get_credentials(&url, None).unwrap();
698        assert!(result.is_some());
699        assert_eq!(result.unwrap().username(), Some("specific_user"));
700    }
701
702    #[test]
703    fn test_ambiguous_username_error() {
704        let mut store = TextCredentialStore::default();
705
706        // Add two credentials for the same service with different usernames
707        let service = Service::from_str("https://example.com/api").unwrap();
708        let user1_creds = Credentials::basic(Some("user1".to_string()), Some("pass1".to_string()));
709        let user2_creds = Credentials::basic(Some("user2".to_string()), Some("pass2".to_string()));
710
711        store.insert(service.clone(), user1_creds);
712        store.insert(service.clone(), user2_creds);
713
714        let url = DisplaySafeUrl::parse("https://example.com/api/v1").unwrap();
715
716        // When no username is specified, should return an error because there are multiple matches with same specificity
717        let result = store.get_credentials(&url, None);
718        assert!(result.is_err());
719        assert_eq!(result, Err(LookupError::AmbiguousUsername(url.clone())));
720
721        // When a specific username is provided, should return the correct credentials
722        let result = store.get_credentials(&url, Some("user1")).unwrap();
723        assert!(result.is_some());
724        assert_eq!(result.unwrap().username(), Some("user1"));
725
726        let result = store.get_credentials(&url, Some("user2")).unwrap();
727        assert!(result.is_some());
728        assert_eq!(result.unwrap().username(), Some("user2"));
729    }
730}