rustant_core/
secret_ref.rs1use crate::credentials::{CredentialError, CredentialStore};
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, Default, Serialize, Deserialize)]
20#[serde(transparent)]
21pub struct SecretRef(String);
22
23impl SecretRef {
24 pub fn keychain(account: &str) -> Self {
26 Self(format!("keychain:{}", account))
27 }
28
29 pub fn env(var_name: &str) -> Self {
31 Self(format!("env:{}", var_name))
32 }
33
34 pub fn inline(value: &str) -> Self {
36 Self(value.to_string())
37 }
38
39 pub fn is_empty(&self) -> bool {
41 self.0.is_empty()
42 }
43
44 pub fn is_keychain(&self) -> bool {
46 self.0.starts_with("keychain:")
47 }
48
49 pub fn is_env(&self) -> bool {
51 self.0.starts_with("env:")
52 }
53
54 pub fn is_inline(&self) -> bool {
56 !self.0.is_empty() && !self.is_keychain() && !self.is_env()
57 }
58
59 pub fn as_str(&self) -> &str {
61 &self.0
62 }
63}
64
65impl From<String> for SecretRef {
66 fn from(s: String) -> Self {
67 Self(s)
68 }
69}
70
71impl From<&str> for SecretRef {
72 fn from(s: &str) -> Self {
73 Self(s.to_string())
74 }
75}
76
77pub struct SecretResolver;
79
80impl SecretResolver {
81 pub fn resolve(
88 secret_ref: &SecretRef,
89 store: &dyn CredentialStore,
90 ) -> Result<String, SecretResolveError> {
91 let raw = &secret_ref.0;
92 if raw.is_empty() {
93 return Err(SecretResolveError::Empty);
94 }
95
96 if let Some(account) = raw.strip_prefix("keychain:") {
97 store
98 .get_key(account)
99 .map_err(|e| SecretResolveError::KeychainError {
100 account: account.to_string(),
101 source: e,
102 })
103 } else if let Some(var) = raw.strip_prefix("env:") {
104 std::env::var(var).map_err(|_| SecretResolveError::EnvVarMissing {
105 var: var.to_string(),
106 })
107 } else {
108 tracing::warn!(
110 "Inline plaintext secret detected. Migrate to keychain with: rustant setup migrate-secrets"
111 );
112 Ok(raw.clone())
113 }
114 }
115}
116
117#[derive(Debug, thiserror::Error)]
119pub enum SecretResolveError {
120 #[error("Secret reference is empty")]
121 Empty,
122
123 #[error("Keychain lookup failed for '{account}': {source}")]
124 KeychainError {
125 account: String,
126 source: CredentialError,
127 },
128
129 #[error("Environment variable '{var}' not set")]
130 EnvVarMissing { var: String },
131}
132
133#[derive(Debug, Default)]
135pub struct MigrationResult {
136 pub migrated: usize,
138 pub already_secure: usize,
140 pub errors: Vec<String>,
142}
143
144impl std::fmt::Display for MigrationResult {
145 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146 write!(
147 f,
148 "Migration complete: {} migrated, {} already secure, {} errors",
149 self.migrated,
150 self.already_secure,
151 self.errors.len()
152 )
153 }
154}
155
156pub fn migrate_channel_secrets(
162 slack_token: Option<&str>,
163 discord_token: Option<&str>,
164 telegram_token: Option<&str>,
165 email_password: Option<&str>,
166 matrix_token: Option<&str>,
167 whatsapp_token: Option<&str>,
168 store: &dyn CredentialStore,
169) -> MigrationResult {
170 let mut result = MigrationResult::default();
171
172 let secrets = [
173 ("channel:slack:bot_token", slack_token),
174 ("channel:discord:bot_token", discord_token),
175 ("channel:telegram:bot_token", telegram_token),
176 ("channel:email:password", email_password),
177 ("channel:matrix:access_token", matrix_token),
178 ("channel:whatsapp:access_token", whatsapp_token),
179 ];
180
181 for (account, value) in &secrets {
182 match value {
183 Some(v) if !v.is_empty() && !v.starts_with("keychain:") && !v.starts_with("env:") => {
184 match store.store_key(account, v) {
185 Ok(()) => {
186 tracing::info!(account = account, "Migrated secret to keychain");
187 result.migrated += 1;
188 }
189 Err(e) => {
190 result
191 .errors
192 .push(format!("Failed to store {}: {}", account, e));
193 }
194 }
195 }
196 Some(v) if v.starts_with("keychain:") || v.starts_with("env:") => {
197 result.already_secure += 1;
198 }
199 _ => {
200 }
202 }
203 }
204
205 result
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211 use crate::credentials::InMemoryCredentialStore;
212
213 #[test]
214 fn test_secret_ref_keychain() {
215 let sr = SecretRef::keychain("channel:slack:bot_token");
216 assert!(sr.is_keychain());
217 assert!(!sr.is_env());
218 assert!(!sr.is_inline());
219 assert!(!sr.is_empty());
220 assert_eq!(sr.as_str(), "keychain:channel:slack:bot_token");
221 }
222
223 #[test]
224 fn test_secret_ref_env() {
225 let sr = SecretRef::env("SLACK_BOT_TOKEN");
226 assert!(sr.is_env());
227 assert!(!sr.is_keychain());
228 assert!(!sr.is_inline());
229 assert_eq!(sr.as_str(), "env:SLACK_BOT_TOKEN");
230 }
231
232 #[test]
233 fn test_secret_ref_inline() {
234 let sr = SecretRef::inline("xoxb-123-456");
235 assert!(sr.is_inline());
236 assert!(!sr.is_keychain());
237 assert!(!sr.is_env());
238 }
239
240 #[test]
241 fn test_secret_ref_empty() {
242 let sr = SecretRef::default();
243 assert!(sr.is_empty());
244 assert!(!sr.is_inline());
245 }
246
247 #[test]
248 fn test_resolve_keychain() {
249 let store = InMemoryCredentialStore::new();
250 store
251 .store_key("channel:slack:bot_token", "xoxb-secret")
252 .unwrap();
253 let sr = SecretRef::keychain("channel:slack:bot_token");
254 let resolved = SecretResolver::resolve(&sr, &store).unwrap();
255 assert_eq!(resolved, "xoxb-secret");
256 }
257
258 #[test]
259 fn test_resolve_env() {
260 unsafe { std::env::set_var("RUSTANT_TEST_SECRET_REF", "env-secret-value") };
262 let sr = SecretRef::env("RUSTANT_TEST_SECRET_REF");
263 let store = InMemoryCredentialStore::new();
264 let resolved = SecretResolver::resolve(&sr, &store).unwrap();
265 assert_eq!(resolved, "env-secret-value");
266 unsafe { std::env::remove_var("RUSTANT_TEST_SECRET_REF") };
268 }
269
270 #[test]
271 fn test_resolve_inline() {
272 let sr = SecretRef::inline("plaintext-token");
273 let store = InMemoryCredentialStore::new();
274 let resolved = SecretResolver::resolve(&sr, &store).unwrap();
275 assert_eq!(resolved, "plaintext-token");
276 }
277
278 #[test]
279 fn test_resolve_empty_errors() {
280 let sr = SecretRef::default();
281 let store = InMemoryCredentialStore::new();
282 assert!(SecretResolver::resolve(&sr, &store).is_err());
283 }
284
285 #[test]
286 fn test_resolve_keychain_not_found() {
287 let sr = SecretRef::keychain("nonexistent");
288 let store = InMemoryCredentialStore::new();
289 let result = SecretResolver::resolve(&sr, &store);
290 assert!(result.is_err());
291 assert!(matches!(
292 result.unwrap_err(),
293 SecretResolveError::KeychainError { .. }
294 ));
295 }
296
297 #[test]
298 fn test_resolve_env_missing() {
299 unsafe { std::env::remove_var("RUSTANT_ABSOLUTELY_MISSING_VAR") };
301 let sr = SecretRef::env("RUSTANT_ABSOLUTELY_MISSING_VAR");
302 let store = InMemoryCredentialStore::new();
303 let result = SecretResolver::resolve(&sr, &store);
304 assert!(result.is_err());
305 assert!(matches!(
306 result.unwrap_err(),
307 SecretResolveError::EnvVarMissing { .. }
308 ));
309 }
310
311 #[test]
312 fn test_serde_transparent() {
313 let sr = SecretRef::keychain("test:account");
314 let json = serde_json::to_string(&sr).unwrap();
315 assert_eq!(json, "\"keychain:test:account\"");
316 let parsed: SecretRef = serde_json::from_str(&json).unwrap();
317 assert_eq!(parsed.as_str(), "keychain:test:account");
318 }
319
320 #[test]
321 fn test_migrate_channel_secrets() {
322 let store = InMemoryCredentialStore::new();
323 let result = migrate_channel_secrets(
324 Some("xoxb-plaintext-token"),
325 None,
326 Some("env:TELEGRAM_TOKEN"), Some("my-email-password"),
328 None,
329 None,
330 &store,
331 );
332 assert_eq!(result.migrated, 2); assert_eq!(result.already_secure, 1); assert!(result.errors.is_empty());
335
336 assert_eq!(
338 store.get_key("channel:slack:bot_token").unwrap(),
339 "xoxb-plaintext-token"
340 );
341 assert_eq!(
342 store.get_key("channel:email:password").unwrap(),
343 "my-email-password"
344 );
345 }
346}