Skip to main content

uv_auth/
store.rs

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