starfield_datastore/
credential.rs1use 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
106pub 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#[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}