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::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")]
59pub enum 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 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(DisplaySafeUrl),
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(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    pub 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 async fn read<P: AsRef<Path>>(path: P) -> Result<(Self, LockedFile), TomlCredentialError> {
300        let lock = Self::lock(path.as_ref()).await?;
301        let store = Self::from_file(path)?;
302        Ok((store, lock))
303    }
304
305    /// Persist credentials to a file.
306    ///
307    /// Requires a [`LockedFile`] from [`TextCredentialStore::lock`] or
308    /// [`TextCredentialStore::read`] to ensure exclusive access.
309    pub fn write<P: AsRef<Path>>(
310        self,
311        path: P,
312        _lock: LockedFile,
313    ) -> Result<(), TomlCredentialError> {
314        let credentials = self
315            .credentials
316            .into_iter()
317            .map(|((service, _username), credentials)| TomlCredential {
318                service,
319                credentials,
320            })
321            .collect::<Vec<_>>();
322
323        let toml_creds = TomlCredentials { credentials };
324        let content = toml::to_string_pretty(&toml_creds)?;
325        fs::create_dir_all(
326            path.as_ref()
327                .parent()
328                .ok_or(TomlCredentialError::CredentialsDirError)?,
329        )?;
330
331        // TODO(zanieb): We should use an atomic write here
332        fs::write(path, content)?;
333        Ok(())
334    }
335
336    /// Get credentials for a given URL and username.
337    ///
338    /// The most specific URL prefix match in the same [`Realm`] is returned, if any.
339    pub fn get_credentials(
340        &self,
341        url: &DisplaySafeUrl,
342        username: Option<&str>,
343    ) -> Result<Option<&Credentials>, LookupError> {
344        let request_realm = Realm::from(url);
345
346        // Perform an exact lookup first
347        // TODO(zanieb): Consider adding `DisplaySafeUrlRef` so we can avoid this clone
348        // TODO(zanieb): We could also return early here if we can't normalize to a `Service`
349        if let Ok(url_service) = Service::try_from(url.clone()) {
350            if let Some(credential) = self.credentials.get(&(
351                url_service.clone(),
352                Username::from(username.map(str::to_string)),
353            )) {
354                return Ok(Some(credential));
355            }
356        }
357
358        // If that fails, iterate through to find a prefix match
359        let mut best: Option<(usize, &Service, &Credentials)> = None;
360
361        for ((service, stored_username), credential) in &self.credentials {
362            let service_realm = Realm::from(service.url().deref());
363
364            // Only consider services in the same realm
365            if service_realm != request_realm {
366                continue;
367            }
368
369            // Service path must be a prefix of request path
370            if !url.path().starts_with(service.url().path()) {
371                continue;
372            }
373
374            // If a username is provided, it must match
375            if let Some(request_username) = username {
376                if Some(request_username) != stored_username.as_deref() {
377                    continue;
378                }
379            }
380
381            // Update our best matching credential based on prefix length
382            let specificity = service.url().path().len();
383            if best.is_none_or(|(best_specificity, _, _)| specificity > best_specificity) {
384                best = Some((specificity, service, credential));
385            } else if best.is_some_and(|(best_specificity, _, _)| specificity == best_specificity) {
386                return Err(LookupError::AmbiguousUsername(url.clone()));
387            }
388        }
389
390        // Return the most specific match
391        if let Some((_, _, credential)) = best {
392            return Ok(Some(credential));
393        }
394
395        Ok(None)
396    }
397
398    /// Store credentials for a given service.
399    pub fn insert(&mut self, service: Service, credentials: Credentials) -> Option<Credentials> {
400        let username = match &credentials {
401            Credentials::Basic { username, .. } => username.clone(),
402            Credentials::Bearer { .. } => Username::none(),
403        };
404        self.credentials.insert((service, username), credentials)
405    }
406
407    /// Remove credentials for a given service.
408    pub fn remove(&mut self, service: &Service, username: Username) -> Option<Credentials> {
409        // Remove the specific credential for this service and username
410        self.credentials.remove(&(service.clone(), username))
411    }
412}
413
414#[cfg(test)]
415mod tests {
416    use std::io::Write;
417    use std::str::FromStr;
418
419    use tempfile::NamedTempFile;
420
421    use super::*;
422
423    #[test]
424    fn test_toml_serialization() {
425        let credentials = TomlCredentials {
426            credentials: vec![
427                TomlCredential {
428                    service: Service::from_str("https://example.com").unwrap(),
429                    credentials: Credentials::Basic {
430                        username: Username::new(Some("user1".to_string())),
431                        password: Some(Password::new("pass1".to_string())),
432                    },
433                },
434                TomlCredential {
435                    service: Service::from_str("https://test.org").unwrap(),
436                    credentials: Credentials::Basic {
437                        username: Username::new(Some("user2".to_string())),
438                        password: Some(Password::new("pass2".to_string())),
439                    },
440                },
441            ],
442        };
443
444        let toml_str = toml::to_string_pretty(&credentials).unwrap();
445        let parsed: TomlCredentials = toml::from_str(&toml_str).unwrap();
446
447        assert_eq!(parsed.credentials.len(), 2);
448        assert_eq!(
449            parsed.credentials[0].service.to_string(),
450            "https://example.com/"
451        );
452        assert_eq!(
453            parsed.credentials[1].service.to_string(),
454            "https://test.org/"
455        );
456    }
457
458    #[test]
459    fn test_credential_store_operations() {
460        let mut store = TextCredentialStore::default();
461        let credentials = Credentials::basic(Some("user".to_string()), Some("pass".to_string()));
462
463        let service = Service::from_str("https://example.com").unwrap();
464        store.insert(service.clone(), credentials.clone());
465        let url = DisplaySafeUrl::parse("https://example.com/").unwrap();
466        assert!(store.get_credentials(&url, None).unwrap().is_some());
467
468        let url = DisplaySafeUrl::parse("https://example.com/path").unwrap();
469        let retrieved = store.get_credentials(&url, None).unwrap().unwrap();
470        assert_eq!(retrieved.username(), Some("user"));
471        assert_eq!(retrieved.password(), Some("pass"));
472
473        assert!(
474            store
475                .remove(&service, Username::from(Some("user".to_string())))
476                .is_some()
477        );
478        let url = DisplaySafeUrl::parse("https://example.com/").unwrap();
479        assert!(store.get_credentials(&url, None).unwrap().is_none());
480    }
481
482    #[tokio::test]
483    async fn test_file_operations() {
484        let mut temp_file = NamedTempFile::new().unwrap();
485        writeln!(
486            temp_file,
487            r#"
488[[credential]]
489service = "https://example.com"
490username = "testuser"
491scheme = "basic"
492password = "testpass"
493
494[[credential]]
495service = "https://test.org"
496username = "user2"
497password = "pass2"
498"#
499        )
500        .unwrap();
501
502        let store = TextCredentialStore::from_file(temp_file.path()).unwrap();
503
504        let url = DisplaySafeUrl::parse("https://example.com/").unwrap();
505        assert!(store.get_credentials(&url, None).unwrap().is_some());
506        let url = DisplaySafeUrl::parse("https://test.org/").unwrap();
507        assert!(store.get_credentials(&url, None).unwrap().is_some());
508
509        let url = DisplaySafeUrl::parse("https://example.com").unwrap();
510        let cred = store.get_credentials(&url, None).unwrap().unwrap();
511        assert_eq!(cred.username(), Some("testuser"));
512        assert_eq!(cred.password(), Some("testpass"));
513
514        // Test saving
515        let temp_output = NamedTempFile::new().unwrap();
516        store
517            .write(
518                temp_output.path(),
519                TextCredentialStore::lock(temp_file.path()).await.unwrap(),
520            )
521            .unwrap();
522
523        let content = fs::read_to_string(temp_output.path()).unwrap();
524        assert!(content.contains("example.com"));
525        assert!(content.contains("testuser"));
526    }
527
528    #[test]
529    fn test_prefix_matching() {
530        let mut store = TextCredentialStore::default();
531        let credentials = Credentials::basic(Some("user".to_string()), Some("pass".to_string()));
532
533        // Store credentials for a specific path prefix
534        let service = Service::from_str("https://example.com/api").unwrap();
535        store.insert(service.clone(), credentials.clone());
536
537        // Should match URLs that are prefixes of the stored service
538        let matching_urls = [
539            "https://example.com/api",
540            "https://example.com/api/v1",
541            "https://example.com/api/v1/users",
542        ];
543
544        for url_str in matching_urls {
545            let url = DisplaySafeUrl::parse(url_str).unwrap();
546            let cred = store.get_credentials(&url, None).unwrap();
547            assert!(cred.is_some(), "Failed to match URL with prefix: {url_str}");
548        }
549
550        // Should NOT match URLs that are not prefixes
551        let non_matching_urls = [
552            "https://example.com/different",
553            "https://example.com/ap", // Not a complete path segment match
554            "https://example.com",    // Shorter than the stored prefix
555        ];
556
557        for url_str in non_matching_urls {
558            let url = DisplaySafeUrl::parse(url_str).unwrap();
559            let cred = store.get_credentials(&url, None).unwrap();
560            assert!(cred.is_none(), "Should not match non-prefix URL: {url_str}");
561        }
562    }
563
564    #[test]
565    fn test_realm_based_matching() {
566        let mut store = TextCredentialStore::default();
567        let credentials = Credentials::basic(Some("user".to_string()), Some("pass".to_string()));
568
569        // Store by full URL (realm)
570        let service = Service::from_str("https://example.com").unwrap();
571        store.insert(service.clone(), credentials.clone());
572
573        // Should match URLs in the same realm
574        let matching_urls = [
575            "https://example.com",
576            "https://example.com/path",
577            "https://example.com/different/path",
578            "https://example.com:443/path", // Default HTTPS port
579        ];
580
581        for url_str in matching_urls {
582            let url = DisplaySafeUrl::parse(url_str).unwrap();
583            let cred = store.get_credentials(&url, None).unwrap();
584            assert!(
585                cred.is_some(),
586                "Failed to match URL in same realm: {url_str}"
587            );
588        }
589
590        // Should NOT match URLs in different realms
591        let non_matching_urls = [
592            "http://example.com",       // Different scheme
593            "https://different.com",    // Different host
594            "https://example.com:8080", // Different port
595        ];
596
597        for url_str in non_matching_urls {
598            let url = DisplaySafeUrl::parse(url_str).unwrap();
599            let cred = store.get_credentials(&url, None).unwrap();
600            assert!(
601                cred.is_none(),
602                "Should not match URL in different realm: {url_str}"
603            );
604        }
605    }
606
607    #[test]
608    fn test_most_specific_prefix_matching() {
609        let mut store = TextCredentialStore::default();
610        let general_cred =
611            Credentials::basic(Some("general".to_string()), Some("pass1".to_string()));
612        let specific_cred =
613            Credentials::basic(Some("specific".to_string()), Some("pass2".to_string()));
614
615        // Store credentials with different prefix lengths
616        let general_service = Service::from_str("https://example.com/api").unwrap();
617        let specific_service = Service::from_str("https://example.com/api/v1").unwrap();
618        store.insert(general_service.clone(), general_cred);
619        store.insert(specific_service.clone(), specific_cred);
620
621        // Should match the most specific prefix
622        let url = DisplaySafeUrl::parse("https://example.com/api/v1/users").unwrap();
623        let cred = store.get_credentials(&url, None).unwrap().unwrap();
624        assert_eq!(cred.username(), Some("specific"));
625
626        // Should match the general prefix for non-specific paths
627        let url = DisplaySafeUrl::parse("https://example.com/api/v2").unwrap();
628        let cred = store.get_credentials(&url, None).unwrap().unwrap();
629        assert_eq!(cred.username(), Some("general"));
630    }
631
632    #[test]
633    fn test_username_exact_url_match() {
634        let mut store = TextCredentialStore::default();
635        let url = DisplaySafeUrl::parse("https://example.com").unwrap();
636        let service = Service::from_str("https://example.com").unwrap();
637        let user1_creds = Credentials::basic(Some("user1".to_string()), Some("pass1".to_string()));
638        store.insert(service.clone(), user1_creds.clone());
639
640        // Should return credentials when username matches
641        let result = store.get_credentials(&url, Some("user1")).unwrap();
642        assert!(result.is_some());
643        assert_eq!(result.unwrap().username(), Some("user1"));
644        assert_eq!(result.unwrap().password(), Some("pass1"));
645
646        // Should not return credentials when username doesn't match
647        let result = store.get_credentials(&url, Some("user2")).unwrap();
648        assert!(result.is_none());
649
650        // Should return credentials when no username is specified
651        let result = store.get_credentials(&url, None).unwrap();
652        assert!(result.is_some());
653        assert_eq!(result.unwrap().username(), Some("user1"));
654    }
655
656    #[test]
657    fn test_username_prefix_url_match() {
658        let mut store = TextCredentialStore::default();
659
660        // Add credentials with different usernames for overlapping URL prefixes
661        let general_service = Service::from_str("https://example.com/api").unwrap();
662        let specific_service = Service::from_str("https://example.com/api/v1").unwrap();
663
664        let general_creds = Credentials::basic(
665            Some("general_user".to_string()),
666            Some("general_pass".to_string()),
667        );
668        let specific_creds = Credentials::basic(
669            Some("specific_user".to_string()),
670            Some("specific_pass".to_string()),
671        );
672
673        store.insert(general_service, general_creds);
674        store.insert(specific_service, specific_creds);
675
676        let url = DisplaySafeUrl::parse("https://example.com/api/v1/users").unwrap();
677
678        // Should match specific credentials when username matches
679        let result = store.get_credentials(&url, Some("specific_user")).unwrap();
680        assert!(result.is_some());
681        assert_eq!(result.unwrap().username(), Some("specific_user"));
682
683        // Should match the general credentials when requesting general_user (falls back to less specific prefix)
684        let result = store.get_credentials(&url, Some("general_user")).unwrap();
685        assert!(
686            result.is_some(),
687            "Should match general_user from less specific prefix"
688        );
689        assert_eq!(result.unwrap().username(), Some("general_user"));
690
691        // Should match most specific when no username specified
692        let result = store.get_credentials(&url, None).unwrap();
693        assert!(result.is_some());
694        assert_eq!(result.unwrap().username(), Some("specific_user"));
695    }
696
697    #[test]
698    fn test_ambiguous_username_error() {
699        let mut store = TextCredentialStore::default();
700
701        // Add two credentials for the same service with different usernames
702        let service = Service::from_str("https://example.com/api").unwrap();
703        let user1_creds = Credentials::basic(Some("user1".to_string()), Some("pass1".to_string()));
704        let user2_creds = Credentials::basic(Some("user2".to_string()), Some("pass2".to_string()));
705
706        store.insert(service.clone(), user1_creds);
707        store.insert(service.clone(), user2_creds);
708
709        let url = DisplaySafeUrl::parse("https://example.com/api/v1").unwrap();
710
711        // When no username is specified, should return an error because there are multiple matches with same specificity
712        let result = store.get_credentials(&url, None);
713        assert!(result.is_err());
714        assert_eq!(result, Err(LookupError::AmbiguousUsername(url.clone())));
715
716        // When a specific username is provided, should return the correct credentials
717        let result = store.get_credentials(&url, Some("user1")).unwrap();
718        assert!(result.is_some());
719        assert_eq!(result.unwrap().username(), Some("user1"));
720
721        let result = store.get_credentials(&url, Some("user2")).unwrap();
722        assert!(result.is_some());
723        assert_eq!(result.unwrap().username(), Some("user2"));
724    }
725}