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 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 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 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 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 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 fs::write(path, content)?;
334 Ok(())
335 }
336
337 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 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 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 if service_realm != request_realm {
367 continue;
368 }
369
370 if !is_path_prefix(service.url().path(), url.path()) {
372 continue;
373 }
374
375 if let Some(request_username) = username {
377 if Some(request_username) != stored_username.as_deref() {
378 continue;
379 }
380 }
381
382 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 if let Some((_, _, credential)) = best {
393 return Ok(Some(credential));
394 }
395
396 Ok(None)
397 }
398
399 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 pub fn remove(&mut self, service: &Service, username: Username) -> Option<Credentials> {
410 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 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 let service = Service::from_str("https://example.com/api").unwrap();
536 store.insert(service.clone(), credentials.clone());
537
538 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 let non_matching_urls = [
553 "https://example.com/different",
554 "https://example.com/ap", "https://example.com/apiary", "https://example.com/api-v2", "https://example.com", ];
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 let service = Service::from_str("https://example.com").unwrap();
574 store.insert(service.clone(), credentials.clone());
575
576 let matching_urls = [
578 "https://example.com",
579 "https://example.com/path",
580 "https://example.com/different/path",
581 "https://example.com:443/path", ];
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 let non_matching_urls = [
595 "http://example.com", "https://different.com", "https://example.com:8080", ];
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 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 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 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 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 let result = store.get_credentials(&url, Some("user2")).unwrap();
651 assert!(result.is_none());
652
653 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 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 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 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 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 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 let result = store.get_credentials(&url, None);
716 assert!(result.is_err());
717 assert_eq!(result, Err(LookupError::AmbiguousUsername(url.clone())));
718
719 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}