Skip to main content

starfield_datastore/
credential_config.rs

1//! Named credential entries from the config file (spec §11).
2//!
3//! Each entry names the hosts it serves and a typed source. Known types are
4//! validated when the file is loaded. An entry of a type this build does not
5//! recognise is kept verbatim so the file round-trips, contributes no
6//! credential and no identity, and is reported — by name and type, never by
7//! content — so the operator can warn at startup. No known entry carries a
8//! literal secret: passwords and tokens are named by environment variable,
9//! `.netrc`, or a 1Password reference, and every secret is read at request
10//! time so rotation needs no restart.
11
12use 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/// One credential entry: which hosts it serves and where the secret comes
20/// from.
21#[derive(Debug, Clone, PartialEq)]
22pub struct CredentialConfig {
23    pub name: String,
24    pub hosts: Vec<String>,
25    pub source: CredentialSource,
26}
27
28/// Where an entry's secret comes from. Every known variant names a secret
29/// indirectly; the file never holds one.
30#[derive(Clone, PartialEq)]
31pub enum CredentialSource {
32    /// Bearer token read from an environment variable at request time.
33    Env { variable: String },
34    /// HTTP Basic with the password read from an environment variable.
35    Basic {
36        username: String,
37        password_env: String,
38    },
39    /// `~/.netrc`, looked up by the requested host.
40    Netrc,
41    /// `op read <reference>` at request time (feature `onepassword`).
42    OnePassword { reference: String },
43    /// A type this build does not know. Preserved exactly, including its
44    /// `type`, so the file round-trips. `configured_provider` skips it and
45    /// reports it for a startup warning; it grants no credential and no
46    /// identity, and its content is never inspected or printed.
47    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    /// The `type` string as written, or `"<missing>"`.
77    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
137/// Two-stage decode: the `type` string decides the shape; a known type is
138/// held to its exact field set, anything else is kept verbatim.
139fn 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
195/// TOML datetimes cannot survive a trip through `serde_json::Value`: they
196/// arrive as a privately tagged map and would be re-emitted as a table.
197fn 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/// An entry `configured_provider` left out because this build does not
259/// know its type. Print it as a warning at startup; it names no secret.
260#[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
276/// Everything about a list of entries that can be checked without touching
277/// a secret: non-empty unique names, well-formed hosts, required fields.
278/// Hosts may repeat across entries here, because two service profiles may
279/// legitimately reach one archive with different credentials; a clash is
280/// an error only within one active selection (`configured_provider`).
281pub 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
332/// Build the lazy provider for one active selection of entries. Validates
333/// them, rejects a host claimed twice within the selection, and leaves out
334/// entries of unrecognised type, returning those so the caller can warn.
335pub 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        // An unknown type may use any field names, including ones that
569        // would be literal secrets for a known type; it is never inspected.
570        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        // Two profiles may reach one archive with different credentials.
629        assert!(validate_entries(&[entry("a", &["h"]), entry("b", &["H"])]).is_ok());
630        // …but not within one active selection.
631        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}