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
94fn profile_env_suffix(profile: &str) -> String {
97 profile
98 .chars()
99 .map(|c| {
100 if c.is_ascii_alphanumeric() {
101 c.to_ascii_uppercase()
102 } else {
103 '_'
104 }
105 })
106 .collect()
107}
108
109pub struct BasicEnvStore;
113
114impl SecretStore for BasicEnvStore {
115 fn resolve(&self, _profile: &str, _auth: &AuthRef) -> Result<Option<Credential>, CoreError> {
116 match (env_var("IGNITION_USER"), env_var("IGNITION_PASSWORD")) {
117 (Some(user), Some(password)) => Ok(Some(Credential::Basic(
118 Secret::new(user),
119 Secret::new(password),
120 ))),
121 _ => Ok(None),
122 }
123 }
124}
125
126pub struct KeyringStore;
136
137fn keyring_entry(profile: &str) -> Result<keyring::Entry, keyring::Error> {
139 keyring::Entry::new("ignition-cli", &format!("profile:{profile}"))
140}
141
142impl SecretStore for KeyringStore {
143 fn resolve(&self, profile: &str, _auth: &AuthRef) -> Result<Option<Credential>, CoreError> {
144 let entry = match keyring_entry(profile) {
145 Ok(entry) => entry,
146 Err(err) => {
147 tracing::debug!(error = %err, profile, "keyring unavailable; skipping");
148 return Ok(None);
149 }
150 };
151 match entry.get_password() {
152 Ok(password) => Ok(Some(Credential::Token(Secret::new(password)))),
153 Err(keyring::Error::NoEntry) => Ok(None),
154 Err(err) => {
155 tracing::warn!(error = %err, profile, "keyring entry unreadable");
156 Err(CoreError::SecretUnavailable {
157 profile: profile.to_string(),
158 })
159 }
160 }
161 }
162}
163
164impl KeyringStore {
165 pub fn set(&self, profile: &str, secret: &Secret) -> Result<(), CoreError> {
169 let entry = keyring_entry(profile).map_err(|_| CoreError::SecretUnavailable {
170 profile: profile.to_string(),
171 })?;
172 entry
173 .set_password(secret.expose())
174 .map_err(|_| CoreError::SecretUnavailable {
175 profile: profile.to_string(),
176 })
177 }
178
179 pub fn delete(&self, profile: &str) -> Result<(), CoreError> {
182 let entry = keyring_entry(profile).map_err(|_| CoreError::SecretUnavailable {
183 profile: profile.to_string(),
184 })?;
185 match entry.delete_credential() {
186 Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
187 Err(_) => Err(CoreError::SecretUnavailable {
188 profile: profile.to_string(),
189 }),
190 }
191 }
192}
193
194pub fn resolve_secret(
198 profile: &str,
199 auth: &AuthRef,
200 stores: &[Box<dyn SecretStore>],
201) -> Result<Credential, CoreError> {
202 for store in stores {
203 match store.resolve(profile, auth)? {
204 Some(credential) => return Ok(credential),
205 None => continue,
206 }
207 }
208 Err(CoreError::SecretUnavailable {
209 profile: profile.to_string(),
210 })
211}
212
213#[cfg(test)]
214mod tests {
215 use super::{
216 BasicEnvStore, Credential, EnvStore, KeyringStore, Secret, SecretStore, resolve_secret,
217 };
218 use crate::config::AuthRef;
219 use crate::config::ENV_LOCK;
220 use crate::error::CoreError;
221
222 struct FixedStore(Result<Option<Credential>, ()>);
224 impl SecretStore for FixedStore {
225 fn resolve(
226 &self,
227 _profile: &str,
228 _auth: &AuthRef,
229 ) -> Result<Option<Credential>, CoreError> {
230 self.0.clone().map_err(|()| CoreError::SecretUnavailable {
231 profile: "fixed".into(),
232 })
233 }
234 }
235
236 #[test]
238 fn secret_renders_redacted() {
239 let secret = Secret::new("CANARY-t0k3n");
240 assert_eq!(format!("{secret:?}"), "Secret(***)");
241 assert_eq!(format!("{secret}"), "***");
242 assert_eq!(
243 secret.expose(),
244 "CANARY-t0k3n",
245 "expose is the only read path"
246 );
247 }
248
249 #[test]
252 fn env_store_profile_specific_token_wins() {
253 let _lock = ENV_LOCK.lock().expect("env lock");
254 unsafe {
256 std::env::set_var("IGNITION_TOKEN_DEV", "specific");
257 std::env::set_var("IGNITION_TOKEN", "generic");
258 }
259 let auth = AuthRef::TokenEnv {
260 token_env: "MY_TOKEN".into(),
261 };
262 let credential = EnvStore
263 .resolve("dev", &auth)
264 .expect("resolve")
265 .expect("some");
266 let Credential::Token(token) = credential else {
267 panic!("expected token credential");
268 };
269 assert_eq!(token.expose(), "specific");
270 unsafe {
272 std::env::remove_var("IGNITION_TOKEN_DEV");
273 std::env::remove_var("IGNITION_TOKEN");
274 }
275 }
276
277 #[test]
280 fn env_store_token_env_ref_and_suffix_mapping() {
281 let _lock = ENV_LOCK.lock().expect("env lock");
282 unsafe {
284 std::env::set_var("MY_TOKEN", "from-ref");
285 std::env::set_var("IGNITION_TOKEN", "generic");
286 std::env::set_var("IGNITION_TOKEN_MY_RIG", "rig-specific");
287 }
288
289 let auth = AuthRef::TokenEnv {
290 token_env: "MY_TOKEN".into(),
291 };
292 let credential = EnvStore
293 .resolve("dev", &auth)
294 .expect("resolve")
295 .expect("some");
296 let Credential::Token(token) = credential else {
297 panic!("expected token credential");
298 };
299 assert_eq!(token.expose(), "from-ref", "token_env ref beats generic");
300
301 let credential = EnvStore
302 .resolve("my-rig", &auth)
303 .expect("resolve")
304 .expect("some");
305 let Credential::Token(token) = credential else {
306 panic!("expected token credential");
307 };
308 assert_eq!(
309 token.expose(),
310 "rig-specific",
311 "hyphen maps to _ then uppercases"
312 );
313
314 unsafe {
316 std::env::remove_var("MY_TOKEN");
317 std::env::remove_var("IGNITION_TOKEN");
318 std::env::remove_var("IGNITION_TOKEN_MY_RIG");
319 }
320 }
321
322 #[test]
325 fn basic_env_store_requires_both_vars() {
326 let _lock = ENV_LOCK.lock().expect("env lock");
327 unsafe {
329 std::env::set_var("IGNITION_USER", "admin");
330 std::env::remove_var("IGNITION_PASSWORD");
331 }
332 assert!(
333 BasicEnvStore
334 .resolve("dev", &AuthRef::default())
335 .expect("resolve")
336 .is_none()
337 );
338
339 unsafe {
341 std::env::set_var("IGNITION_PASSWORD", "pw");
342 }
343 let credential = BasicEnvStore
344 .resolve("dev", &AuthRef::default())
345 .expect("resolve")
346 .expect("some with both vars");
347 let Credential::Basic(user, password) = credential else {
348 panic!("expected basic credential");
349 };
350 assert_eq!(user.expose(), "admin");
351 assert_eq!(password.expose(), "pw");
352
353 unsafe {
355 std::env::remove_var("IGNITION_USER");
356 std::env::remove_var("IGNITION_PASSWORD");
357 }
358 }
359
360 #[test]
363 fn resolve_secret_chain_order_first_some_wins_and_exhaustion() {
364 let _lock = ENV_LOCK.lock().expect("env lock");
365 unsafe {
367 std::env::set_var("IGNITION_TOKEN", "env-token");
368 std::env::set_var("IGNITION_USER", "admin");
369 std::env::set_var("IGNITION_PASSWORD", "pw");
370 }
371 let auth = AuthRef::default();
372
373 let keyring_like = FixedStore(Ok(Some(Credential::Token(Secret::new("keyring-token")))));
376 let chain: Vec<Box<dyn SecretStore>> = vec![
377 Box::new(EnvStore),
378 Box::new(keyring_like),
379 Box::new(BasicEnvStore),
380 ];
381 let credential = resolve_secret("dev", &auth, &chain).expect("resolve");
382 let Credential::Token(token) = credential else {
383 panic!("expected token credential");
384 };
385 assert_eq!(token.expose(), "env-token");
386
387 let keyring_like = FixedStore(Ok(Some(Credential::Token(Secret::new("keyring-token")))));
389 let chain: Vec<Box<dyn SecretStore>> = vec![
390 Box::new(EnvStore),
391 Box::new(keyring_like),
392 Box::new(BasicEnvStore),
393 ];
394 unsafe { std::env::remove_var("IGNITION_TOKEN") };
396 let credential = resolve_secret("dev", &auth, &chain).expect("resolve");
397 let Credential::Token(token) = credential else {
398 panic!("expected token credential");
399 };
400 assert_eq!(token.expose(), "keyring-token", "keyring beats basic env");
401
402 let chain: Vec<Box<dyn SecretStore>> = vec![Box::new(EnvStore), Box::new(BasicEnvStore)];
404 let credential = resolve_secret("dev", &auth, &chain).expect("resolve");
405 let Credential::Basic(user, _) = credential else {
406 panic!("expected basic credential");
407 };
408 assert_eq!(user.expose(), "admin");
409
410 unsafe {
413 std::env::remove_var("IGNITION_USER");
414 std::env::remove_var("IGNITION_PASSWORD");
415 }
416 let err = resolve_secret("dev", &auth, &[]).expect_err("empty chain exhausts");
417 assert!(matches!(err, CoreError::SecretUnavailable { .. }));
418 assert_eq!(err.exit_code(), 3);
419 assert!(
420 err.hint().expect("hint").contains("IGNITION_TOKEN"),
421 "hint names the env path: {}",
422 err.hint().unwrap(),
423 );
424 }
425
426 #[test]
431 fn keyring_store_is_constructible_without_side_effects() {
432 let _store = KeyringStore;
433 }
434}