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#[derive(Debug)]
22pub enum AuthBackend {
23 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(PreviewFeature::NativeAuth) {
34 return Ok(Self::System(KeyringProvider::native()));
35 }
36
37 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#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(rename_all = "lowercase")]
59enum AuthScheme {
60 #[default]
64 Basic,
65 Bearer,
69}
70
71#[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#[derive(Debug, Clone, Serialize, Deserialize)]
133#[serde(try_from = "TomlCredentialWire", into = "TomlCredentialWire")]
134struct TomlCredential {
135 service: Service,
137 credentials: Credentials,
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize)]
142struct TomlCredentialWire {
143 service: Service,
145 username: Username,
147 #[serde(default)]
149 scheme: AuthScheme,
150 password: Option<Password>,
152 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 #[serde(default, rename = "credential")]
234 credentials: Vec<TomlCredential>,
235}
236
237#[derive(Debug, Default)]
239pub struct TextCredentialStore {
240 credentials: FxHashMap<(Service, Username), Credentials>,
241}
242
243impl TextCredentialStore {
244 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 pub fn default_file() -> Result<PathBuf, TomlCredentialError> {
258 let dir = Self::directory_path()?;
259 Ok(dir.join("credentials.toml"))
260 }
261
262 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 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 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 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 fs::write(path, content)?;
335 Ok(())
336 }
337
338 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 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 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 if service_realm != request_realm {
368 continue;
369 }
370
371 if !is_path_prefix(service.url().path(), url.path()) {
373 continue;
374 }
375
376 if let Some(request_username) = username {
378 if Some(request_username) != stored_username.as_deref() {
379 continue;
380 }
381 }
382
383 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 if let Some((_, _, credential)) = best {
394 return Ok(Some(credential));
395 }
396
397 Ok(None)
398 }
399
400 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 pub fn remove(&mut self, service: Service, username: Username) -> Option<Credentials> {
411 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 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 let service = Service::from_str("https://example.com/api").unwrap();
537 store.insert(service.clone(), credentials.clone());
538
539 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 let non_matching_urls = [
554 "https://example.com/different",
555 "https://example.com/ap", "https://example.com/apiary", "https://example.com/api-v2", "https://example.com", ];
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 let service = Service::from_str("https://example.com").unwrap();
575 store.insert(service.clone(), credentials.clone());
576
577 let matching_urls = [
579 "https://example.com",
580 "https://example.com/path",
581 "https://example.com/different/path",
582 "https://example.com:443/path", ];
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 let non_matching_urls = [
596 "http://example.com", "https://different.com", "https://example.com:8080", ];
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 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 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 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 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 let result = store.get_credentials(&url, Some("user2")).unwrap();
652 assert!(result.is_none());
653
654 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 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 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 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 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 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 let result = store.get_credentials(&url, None);
717 assert!(result.is_err());
718 assert_eq!(result, Err(LookupError::AmbiguousUsername(url.clone())));
719
720 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}