1use crate::config::AuthRef;
19use crate::error::CoreError;
20
21#[derive(Clone)]
25pub struct Secret(String);
26
27impl std::fmt::Debug for Secret {
28 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29 f.write_str("Secret(***)")
30 }
31}
32
33impl std::fmt::Display for Secret {
34 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35 f.write_str("***")
36 }
37}
38
39impl Secret {
40 pub fn new(value: impl Into<String>) -> Self {
41 Self(value.into())
42 }
43
44 pub fn expose(&self) -> &str {
47 &self.0
48 }
49}
50
51#[derive(Debug, Clone)]
54pub enum Credential {
55 Token(Secret),
57 Basic(Secret, Secret),
59}
60
61pub trait SecretStore: Send + Sync {
64 fn resolve(&self, profile: &str, auth: &AuthRef) -> Result<Option<Credential>, CoreError>;
65}
66
67fn env_var(name: &str) -> Option<String> {
68 std::env::var(name).ok().filter(|value| !value.is_empty())
69}
70
71pub struct EnvStore;
75
76impl SecretStore for EnvStore {
77 fn resolve(&self, profile: &str, auth: &AuthRef) -> Result<Option<Credential>, CoreError> {
78 let specific = format!("IGNITION_TOKEN_{}", profile_env_suffix(profile));
79 if let Some(token) = env_var(&specific) {
80 return Ok(Some(Credential::Token(Secret::new(token))));
81 }
82 if let AuthRef::TokenEnv { token_env } = auth
83 && let Some(token) = env_var(token_env)
84 {
85 return Ok(Some(Credential::Token(Secret::new(token))));
86 }
87 if let Some(token) = env_var("IGNITION_TOKEN") {
88 return Ok(Some(Credential::Token(Secret::new(token))));
89 }
90 Ok(None)
91 }
92}
93
94pub(crate) fn profile_env_suffix(profile: &str) -> String {
99 profile
100 .chars()
101 .map(|c| {
102 if c.is_ascii_alphanumeric() {
103 c.to_ascii_uppercase()
104 } else {
105 '_'
106 }
107 })
108 .collect()
109}
110
111pub struct BasicEnvStore;
115
116impl SecretStore for BasicEnvStore {
117 fn resolve(&self, _profile: &str, _auth: &AuthRef) -> Result<Option<Credential>, CoreError> {
118 match (env_var("IGNITION_USER"), env_var("IGNITION_PASSWORD")) {
119 (Some(user), Some(password)) => Ok(Some(Credential::Basic(
120 Secret::new(user),
121 Secret::new(password),
122 ))),
123 _ => Ok(None),
124 }
125 }
126}
127
128pub struct KeyringStore;
138
139fn keyring_entry(profile: &str) -> Result<keyring::Entry, keyring::Error> {
141 keyring::Entry::new("ignition-cli", &format!("profile:{profile}"))
142}
143
144impl SecretStore for KeyringStore {
145 fn resolve(&self, profile: &str, _auth: &AuthRef) -> Result<Option<Credential>, CoreError> {
146 let entry = match keyring_entry(profile) {
147 Ok(entry) => entry,
148 Err(err) => {
149 tracing::debug!(error = %err, profile, "keyring unavailable; skipping");
150 return Ok(None);
151 }
152 };
153 match entry.get_password() {
154 Ok(password) => Ok(Some(Credential::Token(Secret::new(password)))),
155 Err(keyring::Error::NoEntry) => Ok(None),
156 Err(err) => {
157 tracing::warn!(error = %err, profile, "keyring entry unreadable");
158 Err(CoreError::SecretUnavailable {
159 profile: profile.to_string(),
160 })
161 }
162 }
163 }
164}
165
166impl KeyringStore {
167 pub fn set(&self, profile: &str, secret: &Secret) -> Result<(), CoreError> {
171 let entry = keyring_entry(profile).map_err(|_| CoreError::SecretUnavailable {
172 profile: profile.to_string(),
173 })?;
174 entry
175 .set_password(secret.expose())
176 .map_err(|_| CoreError::SecretUnavailable {
177 profile: profile.to_string(),
178 })
179 }
180
181 pub fn delete(&self, profile: &str) -> Result<(), CoreError> {
184 let entry = keyring_entry(profile).map_err(|_| CoreError::SecretUnavailable {
185 profile: profile.to_string(),
186 })?;
187 match entry.delete_credential() {
188 Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
189 Err(_) => Err(CoreError::SecretUnavailable {
190 profile: profile.to_string(),
191 }),
192 }
193 }
194}
195
196pub fn resolve_secret(
200 profile: &str,
201 auth: &AuthRef,
202 stores: &[Box<dyn SecretStore>],
203) -> Result<Credential, CoreError> {
204 for store in stores {
205 match store.resolve(profile, auth)? {
206 Some(credential) => return Ok(credential),
207 None => continue,
208 }
209 }
210 Err(CoreError::SecretUnavailable {
211 profile: profile.to_string(),
212 })
213}
214
215#[cfg(test)]
216mod tests {
217 use super::{
218 BasicEnvStore, Credential, EnvStore, KeyringStore, Secret, SecretStore, resolve_secret,
219 };
220 use crate::config::AuthRef;
221 use crate::config::ENV_LOCK;
222 use crate::error::CoreError;
223
224 struct FixedStore(Result<Option<Credential>, ()>);
226 impl SecretStore for FixedStore {
227 fn resolve(
228 &self,
229 _profile: &str,
230 _auth: &AuthRef,
231 ) -> Result<Option<Credential>, CoreError> {
232 self.0.clone().map_err(|()| CoreError::SecretUnavailable {
233 profile: "fixed".into(),
234 })
235 }
236 }
237
238 #[test]
240 fn secret_renders_redacted() {
241 let secret = Secret::new("CANARY-t0k3n");
242 assert_eq!(format!("{secret:?}"), "Secret(***)");
243 assert_eq!(format!("{secret}"), "***");
244 assert_eq!(
245 secret.expose(),
246 "CANARY-t0k3n",
247 "expose is the only read path"
248 );
249 }
250
251 #[test]
254 fn env_store_profile_specific_token_wins() {
255 let _lock = ENV_LOCK.lock().expect("env lock");
256 unsafe {
258 std::env::set_var("IGNITION_TOKEN_DEV", "specific");
259 std::env::set_var("IGNITION_TOKEN", "generic");
260 }
261 let auth = AuthRef::TokenEnv {
262 token_env: "MY_TOKEN".into(),
263 };
264 let credential = EnvStore
265 .resolve("dev", &auth)
266 .expect("resolve")
267 .expect("some");
268 let Credential::Token(token) = credential else {
269 panic!("expected token credential");
270 };
271 assert_eq!(token.expose(), "specific");
272 unsafe {
274 std::env::remove_var("IGNITION_TOKEN_DEV");
275 std::env::remove_var("IGNITION_TOKEN");
276 }
277 }
278
279 #[test]
282 fn env_store_token_env_ref_and_suffix_mapping() {
283 let _lock = ENV_LOCK.lock().expect("env lock");
284 unsafe {
286 std::env::set_var("MY_TOKEN", "from-ref");
287 std::env::set_var("IGNITION_TOKEN", "generic");
288 std::env::set_var("IGNITION_TOKEN_MY_RIG", "rig-specific");
289 }
290
291 let auth = AuthRef::TokenEnv {
292 token_env: "MY_TOKEN".into(),
293 };
294 let credential = EnvStore
295 .resolve("dev", &auth)
296 .expect("resolve")
297 .expect("some");
298 let Credential::Token(token) = credential else {
299 panic!("expected token credential");
300 };
301 assert_eq!(token.expose(), "from-ref", "token_env ref beats generic");
302
303 let credential = EnvStore
304 .resolve("my-rig", &auth)
305 .expect("resolve")
306 .expect("some");
307 let Credential::Token(token) = credential else {
308 panic!("expected token credential");
309 };
310 assert_eq!(
311 token.expose(),
312 "rig-specific",
313 "hyphen maps to _ then uppercases"
314 );
315
316 unsafe {
318 std::env::remove_var("MY_TOKEN");
319 std::env::remove_var("IGNITION_TOKEN");
320 std::env::remove_var("IGNITION_TOKEN_MY_RIG");
321 }
322 }
323
324 #[test]
327 fn basic_env_store_requires_both_vars() {
328 let _lock = ENV_LOCK.lock().expect("env lock");
329 unsafe {
331 std::env::set_var("IGNITION_USER", "admin");
332 std::env::remove_var("IGNITION_PASSWORD");
333 }
334 assert!(
335 BasicEnvStore
336 .resolve("dev", &AuthRef::default())
337 .expect("resolve")
338 .is_none()
339 );
340
341 unsafe {
343 std::env::set_var("IGNITION_PASSWORD", "pw");
344 }
345 let credential = BasicEnvStore
346 .resolve("dev", &AuthRef::default())
347 .expect("resolve")
348 .expect("some with both vars");
349 let Credential::Basic(user, password) = credential else {
350 panic!("expected basic credential");
351 };
352 assert_eq!(user.expose(), "admin");
353 assert_eq!(password.expose(), "pw");
354
355 unsafe {
357 std::env::remove_var("IGNITION_USER");
358 std::env::remove_var("IGNITION_PASSWORD");
359 }
360 }
361
362 #[test]
365 fn resolve_secret_chain_order_first_some_wins_and_exhaustion() {
366 let _lock = ENV_LOCK.lock().expect("env lock");
367 unsafe {
369 std::env::set_var("IGNITION_TOKEN", "env-token");
370 std::env::set_var("IGNITION_USER", "admin");
371 std::env::set_var("IGNITION_PASSWORD", "pw");
372 }
373 let auth = AuthRef::default();
374
375 let keyring_like = FixedStore(Ok(Some(Credential::Token(Secret::new("keyring-token")))));
378 let chain: Vec<Box<dyn SecretStore>> = vec![
379 Box::new(EnvStore),
380 Box::new(keyring_like),
381 Box::new(BasicEnvStore),
382 ];
383 let credential = resolve_secret("dev", &auth, &chain).expect("resolve");
384 let Credential::Token(token) = credential else {
385 panic!("expected token credential");
386 };
387 assert_eq!(token.expose(), "env-token");
388
389 let keyring_like = FixedStore(Ok(Some(Credential::Token(Secret::new("keyring-token")))));
391 let chain: Vec<Box<dyn SecretStore>> = vec![
392 Box::new(EnvStore),
393 Box::new(keyring_like),
394 Box::new(BasicEnvStore),
395 ];
396 unsafe { std::env::remove_var("IGNITION_TOKEN") };
398 let credential = resolve_secret("dev", &auth, &chain).expect("resolve");
399 let Credential::Token(token) = credential else {
400 panic!("expected token credential");
401 };
402 assert_eq!(token.expose(), "keyring-token", "keyring beats basic env");
403
404 let chain: Vec<Box<dyn SecretStore>> = vec![Box::new(EnvStore), Box::new(BasicEnvStore)];
406 let credential = resolve_secret("dev", &auth, &chain).expect("resolve");
407 let Credential::Basic(user, _) = credential else {
408 panic!("expected basic credential");
409 };
410 assert_eq!(user.expose(), "admin");
411
412 unsafe {
415 std::env::remove_var("IGNITION_USER");
416 std::env::remove_var("IGNITION_PASSWORD");
417 }
418 let err = resolve_secret("dev", &auth, &[]).expect_err("empty chain exhausts");
419 assert!(matches!(err, CoreError::SecretUnavailable { .. }));
420 assert_eq!(err.exit_code(), 3);
421 assert!(
422 err.hint().expect("hint").contains("IGNITION_TOKEN"),
423 "hint names the env path: {}",
424 err.hint().unwrap(),
425 );
426 }
427
428 #[test]
433 fn keyring_store_is_constructible_without_side_effects() {
434 let _store = KeyringStore;
435 }
436}