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#[derive(Debug)]
23pub enum AuthBackend {
24 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(PreviewFeature::NativeAuth) {
35 return Ok(Self::System(KeyringProvider::native()));
36 }
37
38 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#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "lowercase")]
60pub enum AuthScheme {
61 #[default]
65 Basic,
66 Bearer,
70}
71
72#[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(crate) 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#[derive(Debug, Clone, Serialize, Deserialize)]
134#[serde(try_from = "TomlCredentialWire", into = "TomlCredentialWire")]
135struct TomlCredential {
136 service: Service,
138 credentials: Credentials,
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize)]
143struct TomlCredentialWire {
144 service: Service,
146 username: Username,
148 #[serde(default)]
150 scheme: AuthScheme,
151 password: Option<Password>,
153 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 #[serde(rename = "credential")]
235 credentials: Vec<TomlCredential>,
236}
237
238#[derive(Debug, Default)]
240pub struct TextCredentialStore {
241 credentials: FxHashMap<(Service, Username), Credentials>,
242}
243
244impl TextCredentialStore {
245 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 pub fn default_file() -> Result<PathBuf, TomlCredentialError> {
259 let dir = Self::directory_path()?;
260 Ok(dir.join("credentials.toml"))
261 }
262
263 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 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 pub(crate) async fn read<P: AsRef<Path>>(
301 path: P,
302 ) -> Result<(Self, LockedFile), TomlCredentialError> {
303 let lock = Self::lock(path.as_ref()).await?;
304 let store = Self::from_file(path)?;
305 Ok((store, lock))
306 }
307
308 pub fn write<P: AsRef<Path>>(
313 self,
314 path: P,
315 _lock: LockedFile,
316 ) -> Result<(), TomlCredentialError> {
317 let credentials = self
318 .credentials
319 .into_iter()
320 .map(|((service, _username), credentials)| TomlCredential {
321 service,
322 credentials,
323 })
324 .collect::<Vec<_>>();
325
326 let toml_creds = TomlCredentials { credentials };
327 let content = toml::to_string_pretty(&toml_creds)?;
328 fs::create_dir_all(
329 path.as_ref()
330 .parent()
331 .ok_or(TomlCredentialError::CredentialsDirError)?,
332 )?;
333
334 fs::write(path, content)?;
336 Ok(())
337 }
338
339 pub fn get_credentials(
343 &self,
344 url: &DisplaySafeUrl,
345 username: Option<&str>,
346 ) -> Result<Option<&Credentials>, LookupError> {
347 let request_realm = Realm::from(url);
348
349 if let Ok(url_service) = Service::try_from(url.clone()) {
353 if let Some(credential) = self.credentials.get(&(
354 url_service.clone(),
355 Username::from(username.map(str::to_string)),
356 )) {
357 return Ok(Some(credential));
358 }
359 }
360
361 let mut best: Option<(usize, &Service, &Credentials)> = None;
363
364 for ((service, stored_username), credential) in &self.credentials {
365 let service_realm = Realm::from(service.url().deref());
366
367 if service_realm != request_realm {
369 continue;
370 }
371
372 if !is_path_prefix(service.url().path(), url.path()) {
374 continue;
375 }
376
377 if let Some(request_username) = username {
379 if Some(request_username) != stored_username.as_deref() {
380 continue;
381 }
382 }
383
384 let specificity = service.url().path().len();
386 if best.is_none_or(|(best_specificity, _, _)| specificity > best_specificity) {
387 best = Some((specificity, service, credential));
388 } else if best.is_some_and(|(best_specificity, _, _)| specificity == best_specificity) {
389 return Err(LookupError::AmbiguousUsername(url.clone()));
390 }
391 }
392
393 if let Some((_, _, credential)) = best {
395 return Ok(Some(credential));
396 }
397
398 Ok(None)
399 }
400
401 pub fn insert(&mut self, service: Service, credentials: Credentials) -> Option<Credentials> {
403 let username = match &credentials {
404 Credentials::Basic { username, .. } => username.clone(),
405 Credentials::Bearer { .. } => Username::none(),
406 };
407 self.credentials.insert((service, username), credentials)
408 }
409
410 pub fn remove(&mut self, service: &Service, username: Username) -> Option<Credentials> {
412 self.credentials.remove(&(service.clone(), username))
414 }
415}
416
417#[cfg(test)]
418mod tests {
419 use std::io::Write;
420 use std::str::FromStr;
421
422 use tempfile::NamedTempFile;
423
424 use super::*;
425
426 #[test]
427 fn test_toml_serialization() {
428 let credentials = TomlCredentials {
429 credentials: vec![
430 TomlCredential {
431 service: Service::from_str("https://example.com").unwrap(),
432 credentials: Credentials::Basic {
433 username: Username::new(Some("user1".to_string())),
434 password: Some(Password::new("pass1".to_string())),
435 },
436 },
437 TomlCredential {
438 service: Service::from_str("https://test.org").unwrap(),
439 credentials: Credentials::Basic {
440 username: Username::new(Some("user2".to_string())),
441 password: Some(Password::new("pass2".to_string())),
442 },
443 },
444 ],
445 };
446
447 let toml_str = toml::to_string_pretty(&credentials).unwrap();
448 let parsed: TomlCredentials = toml::from_str(&toml_str).unwrap();
449
450 assert_eq!(parsed.credentials.len(), 2);
451 assert_eq!(
452 parsed.credentials[0].service.to_string(),
453 "https://example.com/"
454 );
455 assert_eq!(
456 parsed.credentials[1].service.to_string(),
457 "https://test.org/"
458 );
459 }
460
461 #[test]
462 fn test_credential_store_operations() {
463 let mut store = TextCredentialStore::default();
464 let credentials = Credentials::basic(Some("user".to_string()), Some("pass".to_string()));
465
466 let service = Service::from_str("https://example.com").unwrap();
467 store.insert(service.clone(), credentials.clone());
468 let url = DisplaySafeUrl::parse("https://example.com/").unwrap();
469 assert!(store.get_credentials(&url, None).unwrap().is_some());
470
471 let url = DisplaySafeUrl::parse("https://example.com/path").unwrap();
472 let retrieved = store.get_credentials(&url, None).unwrap().unwrap();
473 assert_eq!(retrieved.username(), Some("user"));
474 assert_eq!(retrieved.password(), Some("pass"));
475
476 assert!(
477 store
478 .remove(&service, Username::from(Some("user".to_string())))
479 .is_some()
480 );
481 let url = DisplaySafeUrl::parse("https://example.com/").unwrap();
482 assert!(store.get_credentials(&url, None).unwrap().is_none());
483 }
484
485 #[tokio::test]
486 async fn test_file_operations() {
487 let mut temp_file = NamedTempFile::new().unwrap();
488 writeln!(
489 temp_file,
490 r#"
491[[credential]]
492service = "https://example.com"
493username = "testuser"
494scheme = "basic"
495password = "testpass"
496
497[[credential]]
498service = "https://test.org"
499username = "user2"
500password = "pass2"
501"#
502 )
503 .unwrap();
504
505 let store = TextCredentialStore::from_file(temp_file.path()).unwrap();
506
507 let url = DisplaySafeUrl::parse("https://example.com/").unwrap();
508 assert!(store.get_credentials(&url, None).unwrap().is_some());
509 let url = DisplaySafeUrl::parse("https://test.org/").unwrap();
510 assert!(store.get_credentials(&url, None).unwrap().is_some());
511
512 let url = DisplaySafeUrl::parse("https://example.com").unwrap();
513 let cred = store.get_credentials(&url, None).unwrap().unwrap();
514 assert_eq!(cred.username(), Some("testuser"));
515 assert_eq!(cred.password(), Some("testpass"));
516
517 let temp_output = NamedTempFile::new().unwrap();
519 store
520 .write(
521 temp_output.path(),
522 TextCredentialStore::lock(temp_file.path()).await.unwrap(),
523 )
524 .unwrap();
525
526 let content = fs::read_to_string(temp_output.path()).unwrap();
527 assert!(content.contains("example.com"));
528 assert!(content.contains("testuser"));
529 }
530
531 #[test]
532 fn test_prefix_matching() {
533 let mut store = TextCredentialStore::default();
534 let credentials = Credentials::basic(Some("user".to_string()), Some("pass".to_string()));
535
536 let service = Service::from_str("https://example.com/api").unwrap();
538 store.insert(service.clone(), credentials.clone());
539
540 let matching_urls = [
542 "https://example.com/api",
543 "https://example.com/api/v1",
544 "https://example.com/api/v1/users",
545 ];
546
547 for url_str in matching_urls {
548 let url = DisplaySafeUrl::parse(url_str).unwrap();
549 let cred = store.get_credentials(&url, None).unwrap();
550 assert!(cred.is_some(), "Failed to match URL with prefix: {url_str}");
551 }
552
553 let non_matching_urls = [
555 "https://example.com/different",
556 "https://example.com/ap", "https://example.com/apiary", "https://example.com/api-v2", "https://example.com", ];
561
562 for url_str in non_matching_urls {
563 let url = DisplaySafeUrl::parse(url_str).unwrap();
564 let cred = store.get_credentials(&url, None).unwrap();
565 assert!(cred.is_none(), "Should not match non-prefix URL: {url_str}");
566 }
567 }
568
569 #[test]
570 fn test_realm_based_matching() {
571 let mut store = TextCredentialStore::default();
572 let credentials = Credentials::basic(Some("user".to_string()), Some("pass".to_string()));
573
574 let service = Service::from_str("https://example.com").unwrap();
576 store.insert(service.clone(), credentials.clone());
577
578 let matching_urls = [
580 "https://example.com",
581 "https://example.com/path",
582 "https://example.com/different/path",
583 "https://example.com:443/path", ];
585
586 for url_str in matching_urls {
587 let url = DisplaySafeUrl::parse(url_str).unwrap();
588 let cred = store.get_credentials(&url, None).unwrap();
589 assert!(
590 cred.is_some(),
591 "Failed to match URL in same realm: {url_str}"
592 );
593 }
594
595 let non_matching_urls = [
597 "http://example.com", "https://different.com", "https://example.com:8080", ];
601
602 for url_str in non_matching_urls {
603 let url = DisplaySafeUrl::parse(url_str).unwrap();
604 let cred = store.get_credentials(&url, None).unwrap();
605 assert!(
606 cred.is_none(),
607 "Should not match URL in different realm: {url_str}"
608 );
609 }
610 }
611
612 #[test]
613 fn test_most_specific_prefix_matching() {
614 let mut store = TextCredentialStore::default();
615 let general_cred =
616 Credentials::basic(Some("general".to_string()), Some("pass1".to_string()));
617 let specific_cred =
618 Credentials::basic(Some("specific".to_string()), Some("pass2".to_string()));
619
620 let general_service = Service::from_str("https://example.com/api").unwrap();
622 let specific_service = Service::from_str("https://example.com/api/v1").unwrap();
623 store.insert(general_service.clone(), general_cred);
624 store.insert(specific_service.clone(), specific_cred);
625
626 let url = DisplaySafeUrl::parse("https://example.com/api/v1/users").unwrap();
628 let cred = store.get_credentials(&url, None).unwrap().unwrap();
629 assert_eq!(cred.username(), Some("specific"));
630
631 let url = DisplaySafeUrl::parse("https://example.com/api/v2").unwrap();
633 let cred = store.get_credentials(&url, None).unwrap().unwrap();
634 assert_eq!(cred.username(), Some("general"));
635 }
636
637 #[test]
638 fn test_username_exact_url_match() {
639 let mut store = TextCredentialStore::default();
640 let url = DisplaySafeUrl::parse("https://example.com").unwrap();
641 let service = Service::from_str("https://example.com").unwrap();
642 let user1_creds = Credentials::basic(Some("user1".to_string()), Some("pass1".to_string()));
643 store.insert(service.clone(), user1_creds.clone());
644
645 let result = store.get_credentials(&url, Some("user1")).unwrap();
647 assert!(result.is_some());
648 assert_eq!(result.unwrap().username(), Some("user1"));
649 assert_eq!(result.unwrap().password(), Some("pass1"));
650
651 let result = store.get_credentials(&url, Some("user2")).unwrap();
653 assert!(result.is_none());
654
655 let result = store.get_credentials(&url, None).unwrap();
657 assert!(result.is_some());
658 assert_eq!(result.unwrap().username(), Some("user1"));
659 }
660
661 #[test]
662 fn test_username_prefix_url_match() {
663 let mut store = TextCredentialStore::default();
664
665 let general_service = Service::from_str("https://example.com/api").unwrap();
667 let specific_service = Service::from_str("https://example.com/api/v1").unwrap();
668
669 let general_creds = Credentials::basic(
670 Some("general_user".to_string()),
671 Some("general_pass".to_string()),
672 );
673 let specific_creds = Credentials::basic(
674 Some("specific_user".to_string()),
675 Some("specific_pass".to_string()),
676 );
677
678 store.insert(general_service, general_creds);
679 store.insert(specific_service, specific_creds);
680
681 let url = DisplaySafeUrl::parse("https://example.com/api/v1/users").unwrap();
682
683 let result = store.get_credentials(&url, Some("specific_user")).unwrap();
685 assert!(result.is_some());
686 assert_eq!(result.unwrap().username(), Some("specific_user"));
687
688 let result = store.get_credentials(&url, Some("general_user")).unwrap();
690 assert!(
691 result.is_some(),
692 "Should match general_user from less specific prefix"
693 );
694 assert_eq!(result.unwrap().username(), Some("general_user"));
695
696 let result = store.get_credentials(&url, None).unwrap();
698 assert!(result.is_some());
699 assert_eq!(result.unwrap().username(), Some("specific_user"));
700 }
701
702 #[test]
703 fn test_ambiguous_username_error() {
704 let mut store = TextCredentialStore::default();
705
706 let service = Service::from_str("https://example.com/api").unwrap();
708 let user1_creds = Credentials::basic(Some("user1".to_string()), Some("pass1".to_string()));
709 let user2_creds = Credentials::basic(Some("user2".to_string()), Some("pass2".to_string()));
710
711 store.insert(service.clone(), user1_creds);
712 store.insert(service.clone(), user2_creds);
713
714 let url = DisplaySafeUrl::parse("https://example.com/api/v1").unwrap();
715
716 let result = store.get_credentials(&url, None);
718 assert!(result.is_err());
719 assert_eq!(result, Err(LookupError::AmbiguousUsername(url.clone())));
720
721 let result = store.get_credentials(&url, Some("user1")).unwrap();
723 assert!(result.is_some());
724 assert_eq!(result.unwrap().username(), Some("user1"));
725
726 let result = store.get_credentials(&url, Some("user2")).unwrap();
727 assert!(result.is_some());
728 assert_eq!(result.unwrap().username(), Some("user2"));
729 }
730}