Skip to main content

starfield_datastore/
credential.rs

1use crate::{DatastoreError, Result};
2use std::{collections::HashMap, time::SystemTime};
3use zeroize::Zeroize;
4
5#[derive(Clone)]
6pub struct Secret(String);
7impl Secret {
8    pub fn new(value: String) -> Self {
9        Self(value)
10    }
11    pub fn expose(&self) -> &str {
12        &self.0
13    }
14}
15impl Drop for Secret {
16    fn drop(&mut self) {
17        self.0.zeroize();
18    }
19}
20impl std::fmt::Debug for Secret {
21    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22        f.write_str("Secret([redacted])")
23    }
24}
25
26#[derive(Clone)]
27pub enum Credential {
28    Basic {
29        user: String,
30        secret: Secret,
31    },
32    Bearer {
33        secret: Secret,
34        expires_at: Option<SystemTime>,
35    },
36}
37
38pub trait CredentialProvider: Send + Sync {
39    fn credential_for(&self, host: &str) -> Result<Option<Credential>>;
40    fn identity_for(&self, host: &str) -> Option<String>;
41}
42
43pub struct StaticProvider(pub HashMap<String, Credential>);
44impl CredentialProvider for StaticProvider {
45    fn credential_for(&self, host: &str) -> Result<Option<Credential>> {
46        Ok(self.0.get(host).cloned())
47    }
48    fn identity_for(&self, host: &str) -> Option<String> {
49        self.0.contains_key(host).then(|| format!("static:{host}"))
50    }
51}
52
53pub struct ChainProvider(pub Vec<Box<dyn CredentialProvider>>);
54impl CredentialProvider for ChainProvider {
55    fn credential_for(&self, host: &str) -> Result<Option<Credential>> {
56        for provider in &self.0 {
57            if let Some(value) = provider.credential_for(host)? {
58                return Ok(Some(value));
59            }
60        }
61        Ok(None)
62    }
63    fn identity_for(&self, host: &str) -> Option<String> {
64        self.0
65            .iter()
66            .find_map(|provider| provider.identity_for(host))
67    }
68}
69
70pub struct EnvProvider;
71impl EnvProvider {
72    fn variable(host: &str) -> String {
73        format!(
74            "STARFIELD_TOKEN_{}",
75            host.chars()
76                .map(|c| if c.is_ascii_alphanumeric() {
77                    c.to_ascii_uppercase()
78                } else {
79                    '_'
80                })
81                .collect::<String>()
82        )
83    }
84}
85impl CredentialProvider for EnvProvider {
86    fn credential_for(&self, host: &str) -> Result<Option<Credential>> {
87        match std::env::var(Self::variable(host)) {
88            Ok(token) if !token.is_empty() => Ok(Some(Credential::Bearer {
89                secret: Secret::new(token),
90                expires_at: None,
91            })),
92            Ok(_) | Err(std::env::VarError::NotPresent) => Ok(None),
93            Err(_) => Err(DatastoreError::Config(
94                "credential environment variable is not UTF-8".into(),
95            )),
96        }
97    }
98    fn identity_for(&self, host: &str) -> Option<String> {
99        self.credential_for(host)
100            .ok()
101            .flatten()
102            .map(|_| format!("env:{host}"))
103    }
104}
105
106/// Reads machine-specific entries in ~/.netrc. A default entry is deliberately
107/// ignored so credentials cannot be sent to an arbitrary redirect destination.
108pub struct NetrcProvider;
109impl CredentialProvider for NetrcProvider {
110    fn credential_for(&self, host: &str) -> Result<Option<Credential>> {
111        let Some(home) = std::env::var_os("HOME") else {
112            return Ok(None);
113        };
114        let path = std::path::PathBuf::from(home).join(".netrc");
115        let text = match std::fs::read_to_string(&path) {
116            Ok(text) => zeroize::Zeroizing::new(text),
117            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
118            Err(e) => return Err(e.into()),
119        };
120        #[cfg(unix)]
121        {
122            use std::os::unix::fs::PermissionsExt;
123            if std::fs::metadata(&path)?.permissions().mode() & 0o077 != 0 {
124                return Err(DatastoreError::Config(
125                    ".netrc must not be accessible to group or other users (chmod 600)".into(),
126                ));
127            }
128        }
129        parse_netrc(&text, host)
130    }
131    fn identity_for(&self, host: &str) -> Option<String> {
132        self.credential_for(host)
133            .ok()
134            .flatten()
135            .map(|_| format!("netrc:{host}"))
136    }
137}
138
139fn parse_netrc(text: &str, host: &str) -> Result<Option<Credential>> {
140    let mut tokens = Vec::new();
141    let mut token = String::new();
142    let mut quoted = false;
143    let mut escaped = false;
144    let mut comment = false;
145    for c in text.chars() {
146        if comment {
147            if c == '\n' {
148                comment = false;
149            } else {
150                continue;
151            }
152        }
153        if escaped {
154            token.push(c);
155            escaped = false;
156            continue;
157        }
158        if c == '\\' {
159            escaped = true;
160            continue;
161        }
162        if c == '"' {
163            quoted = !quoted;
164            continue;
165        }
166        if c == '#' && !quoted {
167            comment = true;
168        }
169        if (c.is_whitespace() || comment) && !quoted {
170            if !token.is_empty() {
171                tokens.push(zeroize::Zeroizing::new(std::mem::take(&mut token)));
172            }
173        } else {
174            token.push(c);
175        }
176    }
177    if quoted || escaped {
178        token.zeroize();
179        return Err(DatastoreError::Config("invalid .netrc quoting".into()));
180    }
181    if !token.is_empty() {
182        tokens.push(zeroize::Zeroizing::new(token));
183    }
184    let mut i = 0;
185    let mut machine = None;
186    let mut user = None;
187    let mut password = None;
188    while i < tokens.len() {
189        let field = tokens[i].as_str();
190        if field == "machine" || field == "default" {
191            if machine.as_deref() == Some(host) {
192                return netrc_credential(user, password);
193            }
194            machine = None;
195            user = None;
196            password = None;
197            if field == "machine" {
198                i += 1;
199                machine = tokens.get(i).map(|s| s.to_string());
200            }
201        } else if field == "login" || field == "password" || field == "account" {
202            i += 1;
203            let value = tokens
204                .get(i)
205                .ok_or_else(|| DatastoreError::Config("incomplete .netrc entry".into()))?;
206            if field == "login" {
207                user = Some(value.to_string());
208            }
209            if field == "password" {
210                password = Some(Secret::new(value.to_string()));
211            }
212        } else {
213            return Err(DatastoreError::Config(
214                "unsupported or malformed .netrc field".into(),
215            ));
216        }
217        i += 1;
218    }
219    if machine.as_deref() == Some(host) {
220        netrc_credential(user, password)
221    } else {
222        Ok(None)
223    }
224}
225
226fn netrc_credential(user: Option<String>, password: Option<Secret>) -> Result<Option<Credential>> {
227    match (user, password) {
228        (Some(user), Some(secret)) => Ok(Some(Credential::Basic { user, secret })),
229        _ => Err(DatastoreError::Config(
230            ".netrc machine needs login and password".into(),
231        )),
232    }
233}
234
235/// `item` is an op://vault/item reference; each host names a bearer-token field.
236#[cfg(feature = "onepassword")]
237pub struct OnePasswordProvider {
238    pub item: String,
239}
240#[cfg(feature = "onepassword")]
241impl CredentialProvider for OnePasswordProvider {
242    fn credential_for(&self, host: &str) -> Result<Option<Credential>> {
243        if !self.item.starts_with("op://")
244            || host.is_empty()
245            || !host
246                .bytes()
247                .all(|b| b.is_ascii_alphanumeric() || b".-:".contains(&b))
248        {
249            return Err(DatastoreError::Config(
250                "invalid 1Password reference or host".into(),
251            ));
252        }
253        let output = std::process::Command::new("op")
254            .args([
255                "read",
256                &format!("{}/{host}", self.item.trim_end_matches('/')),
257            ])
258            .output()?;
259        let stdout = zeroize::Zeroizing::new(output.stdout);
260        let _stderr = zeroize::Zeroizing::new(output.stderr);
261        if !output.status.success() {
262            return Err(DatastoreError::Config("1Password read failed".into()));
263        }
264        let token = std::str::from_utf8(&stdout)
265            .map_err(|_| DatastoreError::Config("1Password field is not UTF-8".into()))?
266            .trim_end_matches(['\r', '\n']);
267        if token.is_empty() {
268            return Ok(None);
269        }
270        Ok(Some(Credential::Bearer {
271            secret: Secret::new(token.to_owned()),
272            expires_at: None,
273        }))
274    }
275    fn identity_for(&self, host: &str) -> Option<String> {
276        Some(format!("onepassword:{host}"))
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283    #[test]
284    fn secrets_and_identities_are_redacted() {
285        let secret = Secret::new("sensitive".into());
286        assert_eq!(format!("{secret:?}"), "Secret([redacted])");
287        let provider = StaticProvider(HashMap::from([(
288            "archive.test".into(),
289            Credential::Basic {
290                user: "person".into(),
291                secret,
292            },
293        )]));
294        assert_eq!(
295            provider.identity_for("archive.test").as_deref(),
296            Some("static:archive.test")
297        );
298        assert!(provider.credential_for("elsewhere.test").unwrap().is_none());
299    }
300    #[test]
301    fn netrc_is_host_specific_and_supports_quoted_values() {
302        let text = "# comment\nmachine urs.test login person password \"a b\\\"c\"\nmachine other.test login x password y\n";
303        let Some(Credential::Basic { user, secret }) = parse_netrc(text, "urs.test").unwrap()
304        else {
305            panic!("missing credential");
306        };
307        assert_eq!(user, "person");
308        assert_eq!(secret.expose(), "a b\"c");
309        assert!(parse_netrc(text, "unknown.test").unwrap().is_none());
310        assert!(
311            parse_netrc("default login user password secret", "unknown.test")
312                .unwrap()
313                .is_none()
314        );
315    }
316    #[test]
317    fn chain_returns_first_match_and_host_env_name_is_stable() {
318        assert_eq!(
319            EnvProvider::variable("urs.earthdata.nasa.gov"),
320            "STARFIELD_TOKEN_URS_EARTHDATA_NASA_GOV"
321        );
322        let chain = ChainProvider(vec![
323            Box::new(StaticProvider(HashMap::new())),
324            Box::new(StaticProvider(HashMap::from([(
325                "x".into(),
326                Credential::Bearer {
327                    secret: Secret::new("token".into()),
328                    expires_at: None,
329                },
330            )]))),
331        ]);
332        assert!(chain.credential_for("x").unwrap().is_some());
333        assert_eq!(chain.identity_for("x").as_deref(), Some("static:x"));
334    }
335}