1use crate::{Credential, CredentialProvider, DatastoreError, NetrcProvider, Result, Secret};
13use serde::de::Error as _;
14use serde::{Deserialize, Deserializer, Serialize, Serializer};
15use serde_json::{Map, Value};
16use std::collections::HashMap;
17use std::sync::Arc;
18
19#[derive(Debug, Clone, PartialEq)]
22pub struct CredentialConfig {
23 pub name: String,
24 pub hosts: Vec<String>,
25 pub source: CredentialSource,
26}
27
28#[derive(Clone, PartialEq)]
31pub enum CredentialSource {
32 Env { variable: String },
34 Basic {
36 username: String,
37 password_env: String,
38 },
39 Netrc,
41 OnePassword { reference: String },
43 Unrecognized(Value),
48}
49
50const TYPE: &str = "type";
51const LITERAL_SECRET_KEYS: [&str; 4] = ["password", "token", "secret", "api_key"];
52
53impl std::fmt::Debug for CredentialSource {
54 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55 match self {
56 Self::Env { variable } => f.debug_struct("Env").field("variable", variable).finish(),
57 Self::Basic {
58 username,
59 password_env,
60 } => f
61 .debug_struct("Basic")
62 .field("username", username)
63 .field("password_env", password_env)
64 .finish(),
65 Self::Netrc => f.write_str("Netrc"),
66 Self::OnePassword { reference } => f
67 .debug_struct("OnePassword")
68 .field("reference", reference)
69 .finish(),
70 Self::Unrecognized(raw) => write!(f, "Unrecognized(type = {:?})", kind_of(raw)),
71 }
72 }
73}
74
75impl CredentialSource {
76 pub fn kind(&self) -> &str {
78 match self {
79 Self::Env { .. } => "env",
80 Self::Basic { .. } => "basic",
81 Self::Netrc => "netrc",
82 Self::OnePassword { .. } => "onepassword",
83 Self::Unrecognized(raw) => kind_of(raw),
84 }
85 }
86}
87
88fn kind_of(raw: &Value) -> &str {
89 raw.get(TYPE).and_then(Value::as_str).unwrap_or("<missing>")
90}
91
92#[derive(Deserialize)]
93struct Envelope {
94 name: String,
95 #[serde(default)]
96 hosts: Vec<String>,
97 #[serde(flatten)]
98 source: Map<String, Value>,
99}
100
101#[derive(Deserialize)]
102#[serde(deny_unknown_fields)]
103struct EnvFields {
104 variable: String,
105}
106
107#[derive(Deserialize)]
108#[serde(deny_unknown_fields)]
109struct BasicFields {
110 username: String,
111 password_env: String,
112}
113
114#[derive(Deserialize)]
115#[serde(deny_unknown_fields)]
116struct NetrcFields {}
117
118#[derive(Deserialize)]
119#[serde(deny_unknown_fields)]
120struct OnePasswordFields {
121 reference: String,
122}
123
124impl<'de> Deserialize<'de> for CredentialConfig {
125 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
126 let envelope = Envelope::deserialize(deserializer)?;
127 let name = envelope.name;
128 let source = decode_source(&name, envelope.source).map_err(D::Error::custom)?;
129 Ok(Self {
130 name,
131 hosts: envelope.hosts,
132 source,
133 })
134 }
135}
136
137fn decode_source(
140 name: &str,
141 raw: Map<String, Value>,
142) -> std::result::Result<CredentialSource, String> {
143 let kind = match raw.get(TYPE) {
144 Some(Value::String(kind)) => kind.clone(),
145 Some(_) => return Err(format!("credential \"{name}\": `type` must be a string")),
146 None => return Err(format!("credential \"{name}\" has no `type`")),
147 };
148 if !matches!(kind.as_str(), "env" | "basic" | "netrc" | "onepassword") {
149 if contains_toml_datetime(&Value::Object(raw.clone())) {
150 return Err(format!(
151 "credential \"{name}\" (type {kind}): datetime values are not supported in credential entries"
152 ));
153 }
154 return Ok(CredentialSource::Unrecognized(Value::Object(raw)));
155 }
156 for key in LITERAL_SECRET_KEYS {
157 if raw.contains_key(key) {
158 return Err(format!(
159 "credential \"{name}\" carries a literal `{key}`; name an environment variable with `{key}_env` or use netrc/onepassword instead"
160 ));
161 }
162 }
163 let mut fields = raw.clone();
164 fields.remove(TYPE);
165 let fields = Value::Object(fields);
166 let malformed = |e: serde_json::Error| format!("credential \"{name}\" (type {kind}): {e}");
167 Ok(match kind.as_str() {
168 "env" => {
169 let EnvFields { variable } = serde_json::from_value(fields).map_err(malformed)?;
170 CredentialSource::Env { variable }
171 }
172 "basic" => {
173 let BasicFields {
174 username,
175 password_env,
176 } = serde_json::from_value(fields).map_err(malformed)?;
177 CredentialSource::Basic {
178 username,
179 password_env,
180 }
181 }
182 "netrc" => {
183 let NetrcFields {} = serde_json::from_value(fields).map_err(malformed)?;
184 CredentialSource::Netrc
185 }
186 "onepassword" => {
187 let OnePasswordFields { reference } =
188 serde_json::from_value(fields).map_err(malformed)?;
189 CredentialSource::OnePassword { reference }
190 }
191 _ => unreachable!("unknown kinds return above"),
192 })
193}
194
195fn contains_toml_datetime(value: &Value) -> bool {
198 match value {
199 Value::Object(map) => {
200 map.keys().any(|k| k.starts_with("$__toml_private_"))
201 || map.values().any(contains_toml_datetime)
202 }
203 Value::Array(items) => items.iter().any(contains_toml_datetime),
204 _ => false,
205 }
206}
207
208impl Serialize for CredentialConfig {
209 fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
210 let mut map = Map::new();
211 map.insert("name".into(), Value::String(self.name.clone()));
212 map.insert(
213 "hosts".into(),
214 Value::Array(self.hosts.iter().cloned().map(Value::String).collect()),
215 );
216 match &self.source {
217 CredentialSource::Env { variable } => {
218 map.insert(TYPE.into(), "env".into());
219 map.insert("variable".into(), variable.clone().into());
220 }
221 CredentialSource::Basic {
222 username,
223 password_env,
224 } => {
225 map.insert(TYPE.into(), "basic".into());
226 map.insert("username".into(), username.clone().into());
227 map.insert("password_env".into(), password_env.clone().into());
228 }
229 CredentialSource::Netrc => {
230 map.insert(TYPE.into(), "netrc".into());
231 }
232 CredentialSource::OnePassword { reference } => {
233 map.insert(TYPE.into(), "onepassword".into());
234 map.insert("reference".into(), reference.clone().into());
235 }
236 CredentialSource::Unrecognized(raw) => {
237 let Value::Object(fields) = raw else {
238 return Err(serde::ser::Error::custom(format!(
239 "credential \"{}\": unrecognised source is not a table",
240 self.name
241 )));
242 };
243 for (key, value) in fields {
244 if key == "name" || key == "hosts" {
245 return Err(serde::ser::Error::custom(format!(
246 "credential \"{}\": unrecognised source must not carry a `{key}` key",
247 self.name
248 )));
249 }
250 map.insert(key.clone(), value.clone());
251 }
252 }
253 }
254 map.serialize(serializer)
255 }
256}
257
258#[derive(Debug, Clone, PartialEq, Eq)]
261pub struct SkippedCredential {
262 pub name: String,
263 pub kind: String,
264}
265
266impl std::fmt::Display for SkippedCredential {
267 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
268 write!(
269 f,
270 "credential \"{}\" has unrecognised type \"{}\" and is skipped; its hosts get no credential from this entry",
271 self.name, self.kind
272 )
273 }
274}
275
276pub fn validate_entries(entries: &[CredentialConfig]) -> Result<()> {
282 let mut names = std::collections::HashSet::new();
283 for entry in entries {
284 let invalid = |detail: String| {
285 DatastoreError::Config(format!("credential \"{}\": {detail}", entry.name))
286 };
287 if entry.name.trim().is_empty() {
288 return Err(DatastoreError::Config(
289 "a credential entry has an empty name".into(),
290 ));
291 }
292 if !names.insert(entry.name.as_str()) {
293 return Err(invalid("name is used twice".into()));
294 }
295 if entry.hosts.is_empty() {
296 return Err(invalid("lists no hosts".into()));
297 }
298 for host in &entry.hosts {
299 if !is_bare_host(host) {
300 return Err(invalid(format!(
301 "host {host:?} must be a bare hostname: no port, path, scheme or wildcard"
302 )));
303 }
304 }
305 match &entry.source {
306 CredentialSource::Env { variable } if variable.trim().is_empty() => {
307 return Err(invalid("`variable` is empty".into()));
308 }
309 CredentialSource::Basic {
310 username,
311 password_env,
312 } if username.is_empty() || password_env.trim().is_empty() => {
313 return Err(invalid("`username` and `password_env` are required".into()));
314 }
315 CredentialSource::OnePassword { reference } if !reference.starts_with("op://") => {
316 return Err(invalid("`reference` must start with op://".into()));
317 }
318 _ => {}
319 }
320 }
321 Ok(())
322}
323
324fn is_bare_host(host: &str) -> bool {
325 let host = host.trim();
326 !host.is_empty()
327 && host
328 .bytes()
329 .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-')
330}
331
332pub fn configured_provider(
336 entries: Vec<CredentialConfig>,
337) -> Result<(Box<dyn CredentialProvider>, Vec<SkippedCredential>)> {
338 validate_entries(&entries)?;
339 let mut by_host: HashMap<String, Arc<CredentialConfig>> = HashMap::new();
340 let mut skipped = Vec::new();
341 for mut entry in entries {
342 if let CredentialSource::Unrecognized(raw) = &entry.source {
343 skipped.push(SkippedCredential {
344 name: entry.name.clone(),
345 kind: kind_of(raw).to_string(),
346 });
347 continue;
348 }
349 for host in &mut entry.hosts {
350 *host = host.trim().to_ascii_lowercase();
351 }
352 let entry = Arc::new(entry);
353 for host in &entry.hosts {
354 if let Some(other) = by_host.insert(host.clone(), entry.clone()) {
355 return Err(DatastoreError::Config(format!(
356 "host {host} is claimed by both credential \"{}\" and \"{}\" in the same selection",
357 other.name, entry.name
358 )));
359 }
360 }
361 }
362 Ok((Box::new(ConfigProvider { by_host }), skipped))
363}
364
365struct ConfigProvider {
366 by_host: HashMap<String, Arc<CredentialConfig>>,
367}
368
369impl ConfigProvider {
370 fn entry(&self, host: &str) -> Option<&CredentialConfig> {
371 self.by_host
372 .get(&host.to_ascii_lowercase())
373 .map(Arc::as_ref)
374 }
375}
376
377impl CredentialProvider for ConfigProvider {
378 fn credential_for(&self, host: &str) -> Result<Option<Credential>> {
379 let Some(entry) = self.entry(host) else {
380 return Ok(None);
381 };
382 let unset = |variable: &str| {
383 DatastoreError::Config(format!(
384 "credential \"{}\" for {host} needs environment variable {variable}, which is not set",
385 entry.name
386 ))
387 };
388 match &entry.source {
389 CredentialSource::Env { variable } => Ok(Some(Credential::Bearer {
390 secret: Secret::new(read_env(variable).ok_or_else(|| unset(variable))?),
391 expires_at: None,
392 })),
393 CredentialSource::Basic {
394 username,
395 password_env,
396 } => Ok(Some(Credential::Basic {
397 user: username.clone(),
398 secret: Secret::new(read_env(password_env).ok_or_else(|| unset(password_env))?),
399 })),
400 CredentialSource::Netrc => NetrcProvider.credential_for(host),
401 CredentialSource::OnePassword { reference } => one_password(&entry.name, reference),
402 CredentialSource::Unrecognized(_) => Ok(None),
403 }
404 }
405
406 fn identity_for(&self, host: &str) -> Option<String> {
407 self.entry(host)
408 .map(|entry| format!("config:{}", entry.name))
409 }
410}
411
412fn read_env(variable: &str) -> Option<String> {
413 std::env::var(variable).ok().filter(|v| !v.is_empty())
414}
415
416#[cfg(feature = "onepassword")]
417fn one_password(name: &str, reference: &str) -> Result<Option<Credential>> {
418 use zeroize::Zeroizing;
419 let output = std::process::Command::new("op")
420 .args(["read", reference])
421 .output()?;
422 let stdout = Zeroizing::new(output.stdout);
423 let _stderr = Zeroizing::new(output.stderr);
424 if !output.status.success() {
425 return Err(DatastoreError::Config(format!(
426 "credential \"{name}\": `op read` failed for its reference"
427 )));
428 }
429 let token = std::str::from_utf8(&stdout)
430 .map_err(|_| {
431 DatastoreError::Config(format!(
432 "credential \"{name}\": 1Password field is not UTF-8"
433 ))
434 })?
435 .trim_end_matches(['\r', '\n']);
436 if token.is_empty() {
437 return Ok(None);
438 }
439 Ok(Some(Credential::Bearer {
440 secret: Secret::new(token.to_owned()),
441 expires_at: None,
442 }))
443}
444
445#[cfg(not(feature = "onepassword"))]
446fn one_password(name: &str, _reference: &str) -> Result<Option<Credential>> {
447 Err(DatastoreError::Config(format!(
448 "credential \"{name}\" uses 1Password, but this build has no `onepassword` feature"
449 )))
450}
451
452#[cfg(test)]
453mod tests {
454 use super::*;
455
456 #[derive(Debug, Serialize, Deserialize)]
457 struct Doc {
458 credential: Vec<CredentialConfig>,
459 }
460
461 const FILE: &str = r#"
462[[credential]]
463name = "earthdata"
464hosts = ["urs.earthdata.nasa.gov", "LADSWEB.modaps.eosdis.nasa.gov"]
465type = "netrc"
466
467[[credential]]
468name = "mast"
469hosts = ["archive.stsci.edu"]
470type = "env"
471variable = "MAST_TOKEN"
472
473[[credential]]
474name = "usgs"
475hosts = ["astrogeology.usgs.gov"]
476type = "basic"
477username = "person"
478password_env = "USGS_PW"
479
480[[credential]]
481name = "vault"
482hosts = ["data.example.org"]
483type = "onepassword"
484reference = "op://infra/example/token"
485
486[[credential]]
487name = "future"
488hosts = ["oauth.example.org"]
489type = "oauth2-device"
490client_id = "abc"
491scopes = ["read", "write"]
492[credential.extra]
493nested = 1
494"#;
495
496 fn load() -> Vec<CredentialConfig> {
497 toml::from_str::<Doc>(FILE).unwrap().credential
498 }
499
500 #[test]
501 fn known_types_decode_and_unknown_types_round_trip_verbatim() {
502 let entries = load();
503 assert_eq!(entries[0].source, CredentialSource::Netrc);
504 assert_eq!(
505 entries[1].source,
506 CredentialSource::Env {
507 variable: "MAST_TOKEN".into()
508 }
509 );
510 assert_eq!(
511 entries[2].source,
512 CredentialSource::Basic {
513 username: "person".into(),
514 password_env: "USGS_PW".into()
515 }
516 );
517 assert_eq!(
518 entries[3].source,
519 CredentialSource::OnePassword {
520 reference: "op://infra/example/token".into()
521 }
522 );
523 let CredentialSource::Unrecognized(raw) = &entries[4].source else {
524 panic!("unknown type must be preserved");
525 };
526 assert_eq!(raw["type"], "oauth2-device");
527 assert_eq!(raw["client_id"], "abc");
528 assert_eq!(raw["scopes"][1], "write");
529 assert_eq!(raw["extra"]["nested"], 1);
530
531 let text = toml::to_string(&Doc {
532 credential: entries.clone(),
533 })
534 .unwrap();
535 assert!(text.contains("type = \"oauth2-device\""), "{text}");
536 assert!(text.contains("client_id = \"abc\""), "{text}");
537 let again = toml::from_str::<Doc>(&text).unwrap().credential;
538 assert_eq!(again, entries, "round trip is lossless");
539 }
540
541 #[test]
542 fn malformed_known_types_and_literal_secrets_fail_at_load() {
543 let bad = |body: &str| {
544 toml::from_str::<Doc>(&format!(
545 "[[credential]]\nname = \"x\"\nhosts = [\"h\"]\n{body}\n"
546 ))
547 .unwrap_err()
548 .to_string()
549 };
550 assert!(bad("type = \"env\"").contains("missing field `variable`"));
551 assert!(
552 bad("type = \"env\"\nvariable = \"V\"\nextra = 1").contains("unknown field `extra`")
553 );
554 assert!(bad("type = \"netrc\"\nlogin = \"u\"").contains("unknown field `login`"));
555 assert!(
556 bad("type = \"basic\"\nusername = \"u\"\npassword = \"hunter2\"")
557 .contains("password_env")
558 );
559 let err = bad("type = \"env\"\nvariable = \"V\"\ntoken = \"sup3r\"");
560 assert!(err.contains("token_env"), "{err}");
561 assert!(
562 !err.contains("sup3r"),
563 "a literal secret is never echoed: {err}"
564 );
565 let err = bad("type = \"future\"\nissued = 2026-09-10T00:00:00Z");
566 assert!(err.contains("datetime"), "{err}");
567
568 let doc = toml::from_str::<Doc>(
571 "[[credential]]\nname = \"x\"\nhosts = [\"h\"]\ntype = \"future\"\ntoken = \"opaque\"\n",
572 )
573 .unwrap();
574 assert!(matches!(
575 doc.credential[0].source,
576 CredentialSource::Unrecognized(_)
577 ));
578 let (provider, skipped) = configured_provider(doc.credential).unwrap();
579 assert_eq!(skipped[0].kind, "future");
580 assert!(provider.credential_for("h").unwrap().is_none());
581
582 let clash = Doc {
583 credential: vec![CredentialConfig {
584 name: "x".into(),
585 hosts: vec!["h".into()],
586 source: CredentialSource::Unrecognized(serde_json::json!({
587 "type": "future", "hosts": ["evil"]
588 })),
589 }],
590 };
591 let err = toml::to_string(&clash).unwrap_err().to_string();
592 assert!(err.contains("`hosts`"), "{err}");
593 assert!(bad("variable = \"V\"").contains("has no `type`"));
594 assert!(bad("type = 3").contains("must be a string"));
595 }
596
597 #[test]
598 fn validation_rejects_bad_names_hosts_and_fields_but_allows_shared_hosts() {
599 let entry = |name: &str, hosts: &[&str]| CredentialConfig {
600 name: name.into(),
601 hosts: hosts.iter().map(|h| h.to_string()).collect(),
602 source: CredentialSource::Netrc,
603 };
604 let err =
605 |entries: Vec<CredentialConfig>| validate_entries(&entries).err().unwrap().to_string();
606 assert!(err(vec![entry("a", &["h"]), entry("a", &["i"])]).contains("used twice"));
607 assert!(err(vec![entry("a", &[])]).contains("lists no hosts"));
608 assert!(err(vec![entry("a", &["https://h"])]).contains("bare hostname"));
609 assert!(err(vec![entry("a", &["h:8080"])]).contains("bare hostname"));
610 assert!(err(vec![entry("a", &["*.h"])]).contains("bare hostname"));
611 assert!(err(vec![entry("", &["h"])]).contains("empty name"));
612 assert!(err(vec![CredentialConfig {
613 name: "v".into(),
614 hosts: vec!["h".into()],
615 source: CredentialSource::OnePassword {
616 reference: "infra/x".into()
617 },
618 }])
619 .contains("op://"));
620 assert!(err(vec![CredentialConfig {
621 name: "e".into(),
622 hosts: vec!["h".into()],
623 source: CredentialSource::Env {
624 variable: " ".into()
625 },
626 }])
627 .contains("`variable` is empty"));
628 assert!(validate_entries(&[entry("a", &["h"]), entry("b", &["H"])]).is_ok());
630 let clash = configured_provider(vec![entry("a", &["h"]), entry("b", &["H"])])
632 .err()
633 .unwrap()
634 .to_string();
635 assert!(
636 clash.contains("claimed by both") && clash.contains("same selection"),
637 "{clash}"
638 );
639 assert!(configured_provider(vec![entry("a", &["h"]), entry("b", &["i"])]).is_ok());
640 }
641
642 #[test]
643 fn unknown_entries_are_skipped_with_a_safe_warning_and_grant_nothing() {
644 let (provider, skipped) = configured_provider(load()).unwrap();
645 assert_eq!(
646 skipped,
647 vec![SkippedCredential {
648 name: "future".into(),
649 kind: "oauth2-device".into()
650 }]
651 );
652 let warning = skipped[0].to_string();
653 assert!(
654 warning.contains("\"future\"") && warning.contains("oauth2-device"),
655 "{warning}"
656 );
657 assert!(
658 !warning.contains("client_id") && !warning.contains("abc"),
659 "no payload: {warning}"
660 );
661 assert!(provider
662 .credential_for("oauth.example.org")
663 .unwrap()
664 .is_none());
665 assert!(provider.identity_for("oauth.example.org").is_none());
666 assert!(
667 validate_entries(&load()).is_ok(),
668 "validation itself never warns or fails on unknown types"
669 );
670 }
671
672 #[test]
673 fn known_entries_resolve_lazily_by_exact_host() {
674 std::env::set_var("CREDENTIAL_CONFIG_TEST_MAST", "mast-token");
675 let mut entries = load();
676 entries[1].source = CredentialSource::Env {
677 variable: "CREDENTIAL_CONFIG_TEST_MAST".into(),
678 };
679 let (provider, _) = configured_provider(entries).unwrap();
680 let Some(Credential::Bearer { secret, .. }) =
681 provider.credential_for("archive.stsci.edu").unwrap()
682 else {
683 panic!("env entry resolves when its host is requested");
684 };
685 assert_eq!(secret.expose(), "mast-token");
686 assert_eq!(
687 provider.identity_for("ARCHIVE.stsci.edu").as_deref(),
688 Some("config:mast")
689 );
690 assert!(provider.credential_for("nasa.gov").unwrap().is_none());
691 assert!(provider
692 .credential_for("sub.archive.stsci.edu")
693 .unwrap()
694 .is_none());
695 assert!(provider.identity_for("nasa.gov").is_none());
696 assert_eq!(
697 provider
698 .identity_for("ladsweb.modaps.eosdis.nasa.gov")
699 .as_deref(),
700 Some("config:earthdata")
701 );
702 let (provider, _) = configured_provider(vec![CredentialConfig {
703 name: "usgs".into(),
704 hosts: vec!["astrogeology.usgs.gov".into()],
705 source: CredentialSource::Basic {
706 username: "person".into(),
707 password_env: "CREDENTIAL_CONFIG_TEST_USGS_PW".into(),
708 },
709 }])
710 .unwrap();
711 std::env::remove_var("CREDENTIAL_CONFIG_TEST_USGS_PW");
712 let err = provider
713 .credential_for("astrogeology.usgs.gov")
714 .err()
715 .unwrap()
716 .to_string();
717 assert!(
718 err.contains("CREDENTIAL_CONFIG_TEST_USGS_PW") && err.contains("\"usgs\""),
719 "{err}"
720 );
721 std::env::set_var("CREDENTIAL_CONFIG_TEST_USGS_PW", "pw");
722 let Some(Credential::Basic { user, secret }) =
723 provider.credential_for("astrogeology.usgs.gov").unwrap()
724 else {
725 panic!("basic entry resolves once the variable is set");
726 };
727 assert_eq!((user.as_str(), secret.expose()), ("person", "pw"));
728 std::env::remove_var("CREDENTIAL_CONFIG_TEST_USGS_PW");
729 std::env::remove_var("CREDENTIAL_CONFIG_TEST_MAST");
730 }
731
732 #[test]
733 fn debug_never_prints_an_unknown_payload() {
734 let entries = load();
735 let text = format!("{:?}", entries[4]);
736 assert!(
737 text.contains("Unrecognized(type = \"oauth2-device\")"),
738 "{text}"
739 );
740 assert!(!text.contains("client_id"), "{text}");
741 assert_eq!(entries[4].source.kind(), "oauth2-device");
742 assert_eq!(entries[1].source.kind(), "env");
743 }
744}