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, with_added_extension};
9use uv_preview::{Preview, PreviewFeatures};
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 fn from_settings(preview: Preview) -> Result<Self, TomlCredentialError> {
32 if preview.is_enabled(PreviewFeatures::NATIVE_AUTH) {
34 return Ok(Self::System(KeyringProvider::native()));
35 }
36
37 let path = TextCredentialStore::default_file()?;
39 match TextCredentialStore::read(&path) {
40 Ok((store, lock)) => Ok(Self::TextStore(store, lock)),
41 Err(TomlCredentialError::Io(err)) if err.kind() == std::io::ErrorKind::NotFound => {
42 Ok(Self::TextStore(
43 TextCredentialStore::default(),
44 TextCredentialStore::lock(&path)?,
45 ))
46 }
47 Err(err) => Err(err),
48 }
49 }
50}
51
52#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(rename_all = "lowercase")]
55pub enum AuthScheme {
56 #[default]
60 Basic,
61 Bearer,
65}
66
67#[derive(Debug, Error)]
69pub enum TomlCredentialError {
70 #[error(transparent)]
71 Io(#[from] std::io::Error),
72 #[error("Failed to parse TOML credential file: {0}")]
73 ParseError(#[from] toml::de::Error),
74 #[error("Failed to serialize credentials to TOML")]
75 SerializeError(#[from] toml::ser::Error),
76 #[error(transparent)]
77 BasicAuthError(#[from] BasicAuthError),
78 #[error(transparent)]
79 BearerAuthError(#[from] BearerAuthError),
80 #[error("Failed to determine credentials directory")]
81 CredentialsDirError,
82 #[error("Token is not valid unicode")]
83 TokenNotUnicode(#[from] std::string::FromUtf8Error),
84}
85
86#[derive(Debug, Error)]
87pub enum BasicAuthError {
88 #[error("`username` is required with `scheme = basic`")]
89 MissingUsername,
90 #[error("`token` cannot be provided with `scheme = basic`")]
91 UnexpectedToken,
92}
93
94#[derive(Debug, Error)]
95pub enum BearerAuthError {
96 #[error("`token` is required with `scheme = bearer`")]
97 MissingToken,
98 #[error("`username` cannot be provided with `scheme = bearer`")]
99 UnexpectedUsername,
100 #[error("`password` cannot be provided with `scheme = bearer`")]
101 UnexpectedPassword,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
106#[serde(try_from = "TomlCredentialWire", into = "TomlCredentialWire")]
107struct TomlCredential {
108 service: Service,
110 credentials: Credentials,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
115struct TomlCredentialWire {
116 service: Service,
118 username: Username,
120 #[serde(default)]
122 scheme: AuthScheme,
123 password: Option<Password>,
125 token: Option<String>,
127}
128
129impl From<TomlCredential> for TomlCredentialWire {
130 fn from(value: TomlCredential) -> Self {
131 match value.credentials {
132 Credentials::Basic { username, password } => Self {
133 service: value.service,
134 username,
135 scheme: AuthScheme::Basic,
136 password,
137 token: None,
138 },
139 Credentials::Bearer { token } => Self {
140 service: value.service,
141 username: Username::new(None),
142 scheme: AuthScheme::Bearer,
143 password: None,
144 token: Some(String::from_utf8(token.into_bytes()).expect("Token is valid UTF-8")),
145 },
146 }
147 }
148}
149
150impl TryFrom<TomlCredentialWire> for TomlCredential {
151 type Error = TomlCredentialError;
152
153 fn try_from(value: TomlCredentialWire) -> Result<Self, Self::Error> {
154 match value.scheme {
155 AuthScheme::Basic => {
156 if value.username.as_deref().is_none() {
157 return Err(TomlCredentialError::BasicAuthError(
158 BasicAuthError::MissingUsername,
159 ));
160 }
161 if value.token.is_some() {
162 return Err(TomlCredentialError::BasicAuthError(
163 BasicAuthError::UnexpectedToken,
164 ));
165 }
166 let credentials = Credentials::Basic {
167 username: value.username,
168 password: value.password,
169 };
170 Ok(Self {
171 service: value.service,
172 credentials,
173 })
174 }
175 AuthScheme::Bearer => {
176 if value.username.is_some() {
177 return Err(TomlCredentialError::BearerAuthError(
178 BearerAuthError::UnexpectedUsername,
179 ));
180 }
181 if value.password.is_some() {
182 return Err(TomlCredentialError::BearerAuthError(
183 BearerAuthError::UnexpectedPassword,
184 ));
185 }
186 if value.token.is_none() {
187 return Err(TomlCredentialError::BearerAuthError(
188 BearerAuthError::MissingToken,
189 ));
190 }
191 let credentials = Credentials::Bearer {
192 token: Token::new(value.token.unwrap().into_bytes()),
193 };
194 Ok(Self {
195 service: value.service,
196 credentials,
197 })
198 }
199 }
200 }
201}
202
203#[derive(Debug, Clone, Serialize, Deserialize, Default)]
204struct TomlCredentials {
205 #[serde(rename = "credential")]
207 credentials: Vec<TomlCredential>,
208}
209
210#[derive(Debug, Default)]
212pub struct TextCredentialStore {
213 credentials: FxHashMap<(Service, Username), Credentials>,
214}
215
216impl TextCredentialStore {
217 pub fn directory_path() -> Result<PathBuf, TomlCredentialError> {
219 if let Some(dir) = std::env::var_os(EnvVars::UV_CREDENTIALS_DIR)
220 .filter(|s| !s.is_empty())
221 .map(PathBuf::from)
222 {
223 return Ok(dir);
224 }
225
226 Ok(StateStore::from_settings(None)?.bucket(StateBucket::Credentials))
227 }
228
229 pub fn default_file() -> Result<PathBuf, TomlCredentialError> {
231 let dir = Self::directory_path()?;
232 Ok(dir.join("credentials.toml"))
233 }
234
235 pub fn lock(path: &Path) -> Result<LockedFile, TomlCredentialError> {
237 if let Some(parent) = path.parent() {
238 fs::create_dir_all(parent)?;
239 }
240 let lock = with_added_extension(path, ".lock");
241 Ok(LockedFile::acquire_blocking(lock, "credentials store")?)
242 }
243
244 fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, TomlCredentialError> {
246 let content = fs::read_to_string(path)?;
247 let credentials: TomlCredentials = toml::from_str(&content)?;
248
249 let credentials: FxHashMap<(Service, Username), Credentials> = credentials
250 .credentials
251 .into_iter()
252 .map(|credential| {
253 let username = match &credential.credentials {
254 Credentials::Basic { username, .. } => username.clone(),
255 Credentials::Bearer { .. } => Username::none(),
256 };
257 (
258 (credential.service.clone(), username),
259 credential.credentials,
260 )
261 })
262 .collect();
263
264 Ok(Self { credentials })
265 }
266
267 pub fn read<P: AsRef<Path>>(path: P) -> Result<(Self, LockedFile), TomlCredentialError> {
273 let lock = Self::lock(path.as_ref())?;
274 let store = Self::from_file(path)?;
275 Ok((store, lock))
276 }
277
278 pub fn write<P: AsRef<Path>>(
283 self,
284 path: P,
285 _lock: LockedFile,
286 ) -> Result<(), TomlCredentialError> {
287 let credentials = self
288 .credentials
289 .into_iter()
290 .map(|((service, _username), credentials)| TomlCredential {
291 service,
292 credentials,
293 })
294 .collect::<Vec<_>>();
295
296 let toml_creds = TomlCredentials { credentials };
297 let content = toml::to_string_pretty(&toml_creds)?;
298 fs::create_dir_all(
299 path.as_ref()
300 .parent()
301 .ok_or(TomlCredentialError::CredentialsDirError)?,
302 )?;
303
304 fs::write(path, content)?;
306 Ok(())
307 }
308
309 pub fn get_credentials(
313 &self,
314 url: &DisplaySafeUrl,
315 username: Option<&str>,
316 ) -> Option<&Credentials> {
317 let request_realm = Realm::from(url);
318
319 if let Ok(url_service) = Service::try_from(url.clone()) {
323 if let Some(credential) = self.credentials.get(&(
324 url_service.clone(),
325 Username::from(username.map(str::to_string)),
326 )) {
327 return Some(credential);
328 }
329 }
330
331 let mut best: Option<(usize, &Service, &Credentials)> = None;
333
334 for ((service, stored_username), credential) in &self.credentials {
335 let service_realm = Realm::from(service.url().deref());
336
337 if service_realm != request_realm {
339 continue;
340 }
341
342 if !url.path().starts_with(service.url().path()) {
344 continue;
345 }
346
347 if let Some(request_username) = username {
349 if Some(request_username) != stored_username.as_deref() {
350 continue;
351 }
352 }
353
354 let specificity = service.url().path().len();
356 if best.is_none_or(|(best_specificity, _, _)| specificity > best_specificity) {
357 best = Some((specificity, service, credential));
358 }
359 }
360
361 if let Some((_, _, credential)) = best {
363 return Some(credential);
364 }
365
366 None
367 }
368
369 pub fn insert(&mut self, service: Service, credentials: Credentials) -> Option<Credentials> {
371 let username = match &credentials {
372 Credentials::Basic { username, .. } => username.clone(),
373 Credentials::Bearer { .. } => Username::none(),
374 };
375 self.credentials.insert((service, username), credentials)
376 }
377
378 pub fn remove(&mut self, service: &Service, username: Username) -> Option<Credentials> {
380 self.credentials.remove(&(service.clone(), username))
382 }
383}
384
385#[cfg(test)]
386mod tests {
387 use std::io::Write;
388 use std::str::FromStr;
389
390 use tempfile::NamedTempFile;
391
392 use super::*;
393
394 #[test]
395 fn test_toml_serialization() {
396 let credentials = TomlCredentials {
397 credentials: vec![
398 TomlCredential {
399 service: Service::from_str("https://example.com").unwrap(),
400 credentials: Credentials::Basic {
401 username: Username::new(Some("user1".to_string())),
402 password: Some(Password::new("pass1".to_string())),
403 },
404 },
405 TomlCredential {
406 service: Service::from_str("https://test.org").unwrap(),
407 credentials: Credentials::Basic {
408 username: Username::new(Some("user2".to_string())),
409 password: Some(Password::new("pass2".to_string())),
410 },
411 },
412 ],
413 };
414
415 let toml_str = toml::to_string_pretty(&credentials).unwrap();
416 let parsed: TomlCredentials = toml::from_str(&toml_str).unwrap();
417
418 assert_eq!(parsed.credentials.len(), 2);
419 assert_eq!(
420 parsed.credentials[0].service.to_string(),
421 "https://example.com/"
422 );
423 assert_eq!(
424 parsed.credentials[1].service.to_string(),
425 "https://test.org/"
426 );
427 }
428
429 #[test]
430 fn test_credential_store_operations() {
431 let mut store = TextCredentialStore::default();
432 let credentials = Credentials::basic(Some("user".to_string()), Some("pass".to_string()));
433
434 let service = Service::from_str("https://example.com").unwrap();
435 store.insert(service.clone(), credentials.clone());
436 let url = DisplaySafeUrl::parse("https://example.com/").unwrap();
437 assert!(store.get_credentials(&url, None).is_some());
438
439 let url = DisplaySafeUrl::parse("https://example.com/path").unwrap();
440 let retrieved = store.get_credentials(&url, None).unwrap();
441 assert_eq!(retrieved.username(), Some("user"));
442 assert_eq!(retrieved.password(), Some("pass"));
443
444 assert!(
445 store
446 .remove(&service, Username::from(Some("user".to_string())))
447 .is_some()
448 );
449 let url = DisplaySafeUrl::parse("https://example.com/").unwrap();
450 assert!(store.get_credentials(&url, None).is_none());
451 }
452
453 #[test]
454 fn test_file_operations() {
455 let mut temp_file = NamedTempFile::new().unwrap();
456 writeln!(
457 temp_file,
458 r#"
459[[credential]]
460service = "https://example.com"
461username = "testuser"
462scheme = "basic"
463password = "testpass"
464
465[[credential]]
466service = "https://test.org"
467username = "user2"
468password = "pass2"
469"#
470 )
471 .unwrap();
472
473 let store = TextCredentialStore::from_file(temp_file.path()).unwrap();
474
475 let url = DisplaySafeUrl::parse("https://example.com/").unwrap();
476 assert!(store.get_credentials(&url, None).is_some());
477 let url = DisplaySafeUrl::parse("https://test.org/").unwrap();
478 assert!(store.get_credentials(&url, None).is_some());
479
480 let url = DisplaySafeUrl::parse("https://example.com").unwrap();
481 let cred = store.get_credentials(&url, None).unwrap();
482 assert_eq!(cred.username(), Some("testuser"));
483 assert_eq!(cred.password(), Some("testpass"));
484
485 let temp_output = NamedTempFile::new().unwrap();
487 store
488 .write(
489 temp_output.path(),
490 TextCredentialStore::lock(temp_file.path()).unwrap(),
491 )
492 .unwrap();
493
494 let content = fs::read_to_string(temp_output.path()).unwrap();
495 assert!(content.contains("example.com"));
496 assert!(content.contains("testuser"));
497 }
498
499 #[test]
500 fn test_prefix_matching() {
501 let mut store = TextCredentialStore::default();
502 let credentials = Credentials::basic(Some("user".to_string()), Some("pass".to_string()));
503
504 let service = Service::from_str("https://example.com/api").unwrap();
506 store.insert(service.clone(), credentials.clone());
507
508 let matching_urls = [
510 "https://example.com/api",
511 "https://example.com/api/v1",
512 "https://example.com/api/v1/users",
513 ];
514
515 for url_str in matching_urls {
516 let url = DisplaySafeUrl::parse(url_str).unwrap();
517 let cred = store.get_credentials(&url, None);
518 assert!(cred.is_some(), "Failed to match URL with prefix: {url_str}");
519 }
520
521 let non_matching_urls = [
523 "https://example.com/different",
524 "https://example.com/ap", "https://example.com", ];
527
528 for url_str in non_matching_urls {
529 let url = DisplaySafeUrl::parse(url_str).unwrap();
530 let cred = store.get_credentials(&url, None);
531 assert!(cred.is_none(), "Should not match non-prefix URL: {url_str}");
532 }
533 }
534
535 #[test]
536 fn test_realm_based_matching() {
537 let mut store = TextCredentialStore::default();
538 let credentials = Credentials::basic(Some("user".to_string()), Some("pass".to_string()));
539
540 let service = Service::from_str("https://example.com").unwrap();
542 store.insert(service.clone(), credentials.clone());
543
544 let matching_urls = [
546 "https://example.com",
547 "https://example.com/path",
548 "https://example.com/different/path",
549 "https://example.com:443/path", ];
551
552 for url_str in matching_urls {
553 let url = DisplaySafeUrl::parse(url_str).unwrap();
554 let cred = store.get_credentials(&url, None);
555 assert!(
556 cred.is_some(),
557 "Failed to match URL in same realm: {url_str}"
558 );
559 }
560
561 let non_matching_urls = [
563 "http://example.com", "https://different.com", "https://example.com:8080", ];
567
568 for url_str in non_matching_urls {
569 let url = DisplaySafeUrl::parse(url_str).unwrap();
570 let cred = store.get_credentials(&url, None);
571 assert!(
572 cred.is_none(),
573 "Should not match URL in different realm: {url_str}"
574 );
575 }
576 }
577
578 #[test]
579 fn test_most_specific_prefix_matching() {
580 let mut store = TextCredentialStore::default();
581 let general_cred =
582 Credentials::basic(Some("general".to_string()), Some("pass1".to_string()));
583 let specific_cred =
584 Credentials::basic(Some("specific".to_string()), Some("pass2".to_string()));
585
586 let general_service = Service::from_str("https://example.com/api").unwrap();
588 let specific_service = Service::from_str("https://example.com/api/v1").unwrap();
589 store.insert(general_service.clone(), general_cred);
590 store.insert(specific_service.clone(), specific_cred);
591
592 let url = DisplaySafeUrl::parse("https://example.com/api/v1/users").unwrap();
594 let cred = store.get_credentials(&url, None).unwrap();
595 assert_eq!(cred.username(), Some("specific"));
596
597 let url = DisplaySafeUrl::parse("https://example.com/api/v2").unwrap();
599 let cred = store.get_credentials(&url, None).unwrap();
600 assert_eq!(cred.username(), Some("general"));
601 }
602
603 #[test]
604 fn test_username_exact_url_match() {
605 let mut store = TextCredentialStore::default();
606 let url = DisplaySafeUrl::parse("https://example.com").unwrap();
607 let service = Service::from_str("https://example.com").unwrap();
608 let user1_creds = Credentials::basic(Some("user1".to_string()), Some("pass1".to_string()));
609 store.insert(service.clone(), user1_creds.clone());
610
611 let result = store.get_credentials(&url, Some("user1"));
613 assert!(result.is_some());
614 assert_eq!(result.unwrap().username(), Some("user1"));
615 assert_eq!(result.unwrap().password(), Some("pass1"));
616
617 let result = store.get_credentials(&url, Some("user2"));
619 assert!(result.is_none());
620
621 let result = store.get_credentials(&url, None);
623 assert!(result.is_some());
624 assert_eq!(result.unwrap().username(), Some("user1"));
625 }
626
627 #[test]
628 fn test_username_prefix_url_match() {
629 let mut store = TextCredentialStore::default();
630
631 let general_service = Service::from_str("https://example.com/api").unwrap();
633 let specific_service = Service::from_str("https://example.com/api/v1").unwrap();
634
635 let general_creds = Credentials::basic(
636 Some("general_user".to_string()),
637 Some("general_pass".to_string()),
638 );
639 let specific_creds = Credentials::basic(
640 Some("specific_user".to_string()),
641 Some("specific_pass".to_string()),
642 );
643
644 store.insert(general_service, general_creds);
645 store.insert(specific_service, specific_creds);
646
647 let url = DisplaySafeUrl::parse("https://example.com/api/v1/users").unwrap();
648
649 let result = store.get_credentials(&url, Some("specific_user"));
651 assert!(result.is_some());
652 assert_eq!(result.unwrap().username(), Some("specific_user"));
653
654 let result = store.get_credentials(&url, Some("general_user"));
656 assert!(
657 result.is_some(),
658 "Should match general_user from less specific prefix"
659 );
660 assert_eq!(result.unwrap().username(), Some("general_user"));
661
662 let result = store.get_credentials(&url, None);
664 assert!(result.is_some());
665 assert_eq!(result.unwrap().username(), Some("specific_user"));
666 }
667}