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 std::env::set_var("RUSTANT_TEST_SECRET_REF", "env-secret-value");
261 let sr = SecretRef::env("RUSTANT_TEST_SECRET_REF");
262 let store = InMemoryCredentialStore::new();
263 let resolved = SecretResolver::resolve(&sr, &store).unwrap();
264 assert_eq!(resolved, "env-secret-value");
265 std::env::remove_var("RUSTANT_TEST_SECRET_REF");
266 }
267
268 #[test]
269 fn test_resolve_inline() {
270 let sr = SecretRef::inline("plaintext-token");
271 let store = InMemoryCredentialStore::new();
272 let resolved = SecretResolver::resolve(&sr, &store).unwrap();
273 assert_eq!(resolved, "plaintext-token");
274 }
275
276 #[test]
277 fn test_resolve_empty_errors() {
278 let sr = SecretRef::default();
279 let store = InMemoryCredentialStore::new();
280 assert!(SecretResolver::resolve(&sr, &store).is_err());
281 }
282
283 #[test]
284 fn test_resolve_keychain_not_found() {
285 let sr = SecretRef::keychain("nonexistent");
286 let store = InMemoryCredentialStore::new();
287 let result = SecretResolver::resolve(&sr, &store);
288 assert!(result.is_err());
289 assert!(matches!(
290 result.unwrap_err(),
291 SecretResolveError::KeychainError { .. }
292 ));
293 }
294
295 #[test]
296 fn test_resolve_env_missing() {
297 std::env::remove_var("RUSTANT_ABSOLUTELY_MISSING_VAR");
298 let sr = SecretRef::env("RUSTANT_ABSOLUTELY_MISSING_VAR");
299 let store = InMemoryCredentialStore::new();
300 let result = SecretResolver::resolve(&sr, &store);
301 assert!(result.is_err());
302 assert!(matches!(
303 result.unwrap_err(),
304 SecretResolveError::EnvVarMissing { .. }
305 ));
306 }
307
308 #[test]
309 fn test_serde_transparent() {
310 let sr = SecretRef::keychain("test:account");
311 let json = serde_json::to_string(&sr).unwrap();
312 assert_eq!(json, "\"keychain:test:account\"");
313 let parsed: SecretRef = serde_json::from_str(&json).unwrap();
314 assert_eq!(parsed.as_str(), "keychain:test:account");
315 }
316
317 #[test]
318 fn test_migrate_channel_secrets() {
319 let store = InMemoryCredentialStore::new();
320 let result = migrate_channel_secrets(
321 Some("xoxb-plaintext-token"),
322 None,
323 Some("env:TELEGRAM_TOKEN"), Some("my-email-password"),
325 None,
326 None,
327 &store,
328 );
329 assert_eq!(result.migrated, 2); assert_eq!(result.already_secure, 1); assert!(result.errors.is_empty());
332
333 assert_eq!(
335 store.get_key("channel:slack:bot_token").unwrap(),
336 "xoxb-plaintext-token"
337 );
338 assert_eq!(
339 store.get_key("channel:email:password").unwrap(),
340 "my-email-password"
341 );
342 }
343}