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#[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")]
59pub enum 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 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#[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(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 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 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 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 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 fs::write(path, content)?;
333 Ok(())
334 }
335
336 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 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 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 if service_realm != request_realm {
366 continue;
367 }
368
369 if !url.path().starts_with(service.url().path()) {
371 continue;
372 }
373
374 if let Some(request_username) = username {
376 if Some(request_username) != stored_username.as_deref() {
377 continue;
378 }
379 }
380
381 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 if let Some((_, _, credential)) = best {
392 return Ok(Some(credential));
393 }
394
395 Ok(None)
396 }
397
398 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 pub fn remove(&mut self, service: &Service, username: Username) -> Option<Credentials> {
409 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 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 let service = Service::from_str("https://example.com/api").unwrap();
535 store.insert(service.clone(), credentials.clone());
536
537 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 let non_matching_urls = [
552 "https://example.com/different",
553 "https://example.com/ap", "https://example.com", ];
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 let service = Service::from_str("https://example.com").unwrap();
571 store.insert(service.clone(), credentials.clone());
572
573 let matching_urls = [
575 "https://example.com",
576 "https://example.com/path",
577 "https://example.com/different/path",
578 "https://example.com:443/path", ];
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 let non_matching_urls = [
592 "http://example.com", "https://different.com", "https://example.com:8080", ];
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 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 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 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 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 let result = store.get_credentials(&url, Some("user2")).unwrap();
648 assert!(result.is_none());
649
650 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 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 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 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 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 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 let result = store.get_credentials(&url, None);
713 assert!(result.is_err());
714 assert_eq!(result, Err(LookupError::AmbiguousUsername(url.clone())));
715
716 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}