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