Skip to main content

ironflow_store/memory/
secret_store.rs

1//! [`SecretStore`] trait implementation for [`InMemoryStore`].
2
3#[cfg(feature = "secret-store")]
4use std::collections::hash_map::Entry;
5
6#[cfg(feature = "secret-store")]
7use chrono::Utc;
8#[cfg(feature = "secret-store")]
9use uuid::Uuid;
10
11#[cfg(feature = "secret-store")]
12use crate::crypto::{decrypt, encrypt};
13use crate::entities::{Page, Secret, SecretMetadata};
14use crate::error::StoreError;
15use crate::secret_store::SecretStore;
16use crate::store::StoreFuture;
17
18use super::InMemoryStore;
19
20impl SecretStore for InMemoryStore {
21    fn get_secret(&self, key: &str) -> StoreFuture<'_, Option<Secret>> {
22        let key = key.to_string();
23        Box::pin(async move {
24            #[cfg(feature = "secret-store")]
25            {
26                let master_key = self
27                    .master_key
28                    .as_ref()
29                    .ok_or_else(|| StoreError::Crypto("no master key configured".to_string()))?;
30
31                let state = self.state.read().await;
32                let Some(encrypted) = state.secrets.get(&key) else {
33                    return Ok(None);
34                };
35
36                let plaintext = decrypt(master_key, &encrypted.encrypted_value, &encrypted.nonce)
37                    .map_err(|e| StoreError::Crypto(e.to_string()))?;
38
39                let value = String::from_utf8(plaintext)
40                    .map_err(|e| StoreError::Crypto(format!("invalid UTF-8: {e}")))?;
41
42                Ok(Some(Secret {
43                    id: encrypted.id,
44                    key: encrypted.key.clone(),
45                    value,
46                    created_at: encrypted.created_at,
47                    updated_at: encrypted.updated_at,
48                }))
49            }
50            #[cfg(not(feature = "secret-store"))]
51            {
52                let _ = key;
53                Err(StoreError::Crypto(
54                    "secret-store feature not enabled".to_string(),
55                ))
56            }
57        })
58    }
59
60    fn set_secret(&self, key: &str, value: &str) -> StoreFuture<'_, Secret> {
61        let key = key.to_string();
62        let value = value.to_string();
63        Box::pin(async move {
64            #[cfg(feature = "secret-store")]
65            {
66                let master_key = self
67                    .master_key
68                    .as_ref()
69                    .ok_or_else(|| StoreError::Crypto("no master key configured".to_string()))?;
70
71                let (encrypted_value, nonce) = encrypt(master_key, value.as_bytes())
72                    .map_err(|e| StoreError::Crypto(e.to_string()))?;
73
74                let now = Utc::now();
75                let mut state = self.state.write().await;
76
77                let entry = state.secrets.entry(key.clone());
78                let encrypted = match entry {
79                    Entry::Occupied(mut occ) => {
80                        let existing = occ.get_mut();
81                        existing.encrypted_value = encrypted_value;
82                        existing.nonce = nonce;
83                        existing.updated_at = now;
84                        existing.clone()
85                    }
86                    Entry::Vacant(vac) => {
87                        let new = super::EncryptedSecret {
88                            id: Uuid::now_v7(),
89                            key: key.clone(),
90                            encrypted_value,
91                            nonce,
92                            created_at: now,
93                            updated_at: now,
94                        };
95                        vac.insert(new.clone());
96                        new
97                    }
98                };
99
100                Ok(Secret {
101                    id: encrypted.id,
102                    key: encrypted.key,
103                    value,
104                    created_at: encrypted.created_at,
105                    updated_at: encrypted.updated_at,
106                })
107            }
108            #[cfg(not(feature = "secret-store"))]
109            {
110                let _ = (key, value);
111                Err(StoreError::Crypto(
112                    "secret-store feature not enabled".to_string(),
113                ))
114            }
115        })
116    }
117
118    fn delete_secret(&self, key: &str) -> StoreFuture<'_, bool> {
119        let key = key.to_string();
120        Box::pin(async move {
121            let mut state = self.state.write().await;
122            Ok(state.secrets.remove(&key).is_some())
123        })
124    }
125
126    fn list_secret_keys(&self, prefix: &str) -> StoreFuture<'_, Vec<String>> {
127        let prefix = prefix.to_string();
128        Box::pin(async move {
129            let state = self.state.read().await;
130            let keys: Vec<String> = state
131                .secrets
132                .keys()
133                .filter(|k| k.starts_with(&prefix))
134                .cloned()
135                .collect();
136            Ok(keys)
137        })
138    }
139
140    fn list_secrets(
141        &self,
142        prefix: &str,
143        page: u32,
144        per_page: u32,
145    ) -> StoreFuture<'_, Page<SecretMetadata>> {
146        let prefix = prefix.to_string();
147        Box::pin(async move {
148            let state = self.state.read().await;
149            let mut metadata: Vec<SecretMetadata> = state
150                .secrets
151                .values()
152                .filter(|s| s.key.starts_with(&prefix))
153                .map(|s| SecretMetadata {
154                    id: s.id,
155                    key: s.key.clone(),
156                    created_at: s.created_at,
157                    updated_at: s.updated_at,
158                })
159                .collect();
160
161            metadata.sort_by(|a, b| a.key.cmp(&b.key));
162
163            let total = metadata.len() as u64;
164            let offset = ((page.saturating_sub(1)) as usize) * (per_page as usize);
165            let items: Vec<SecretMetadata> = metadata
166                .into_iter()
167                .skip(offset)
168                .take(per_page as usize)
169                .collect();
170
171            Ok(Page {
172                items,
173                total,
174                page,
175                per_page,
176            })
177        })
178    }
179}
180
181#[cfg(all(test, feature = "secret-store"))]
182mod tests {
183    use crate::crypto::MasterKey;
184    use crate::memory::InMemoryStore;
185    use crate::secret_store::SecretStore;
186
187    fn test_store() -> InMemoryStore {
188        let key = MasterKey::from_bytes(&[42u8; 32]).unwrap();
189        let mut store = InMemoryStore::new();
190        store.set_master_key(key);
191        store
192    }
193
194    #[tokio::test]
195    async fn set_and_get_secret() {
196        let store = test_store();
197        let secret = store.set_secret("my/key", "my-value").await.unwrap();
198        assert_eq!(secret.key, "my/key");
199        assert_eq!(secret.value, "my-value");
200
201        let fetched = store.get_secret("my/key").await.unwrap().unwrap();
202        assert_eq!(fetched.value, "my-value");
203        assert_eq!(fetched.id, secret.id);
204    }
205
206    #[tokio::test]
207    async fn get_missing_secret_returns_none() {
208        let store = test_store();
209        let result = store.get_secret("does/not/exist").await.unwrap();
210        assert!(result.is_none());
211    }
212
213    #[tokio::test]
214    async fn set_secret_updates_existing() {
215        let store = test_store();
216        let first = store.set_secret("token", "v1").await.unwrap();
217        let second = store.set_secret("token", "v2").await.unwrap();
218
219        assert_eq!(first.id, second.id);
220        assert_eq!(second.value, "v2");
221
222        let fetched = store.get_secret("token").await.unwrap().unwrap();
223        assert_eq!(fetched.value, "v2");
224    }
225
226    #[tokio::test]
227    async fn delete_existing_secret() {
228        let store = test_store();
229        store.set_secret("to-delete", "val").await.unwrap();
230
231        let deleted = store.delete_secret("to-delete").await.unwrap();
232        assert!(deleted);
233
234        let fetched = store.get_secret("to-delete").await.unwrap();
235        assert!(fetched.is_none());
236    }
237
238    #[tokio::test]
239    async fn delete_missing_secret_returns_false() {
240        let store = test_store();
241        let deleted = store.delete_secret("nope").await.unwrap();
242        assert!(!deleted);
243    }
244
245    #[tokio::test]
246    async fn list_keys_with_prefix() {
247        let store = test_store();
248        store.set_secret("wf/inbox/token_a", "a").await.unwrap();
249        store.set_secret("wf/inbox/token_b", "b").await.unwrap();
250        store.set_secret("wf/veille/token_c", "c").await.unwrap();
251
252        let mut keys = store.list_secret_keys("wf/inbox/").await.unwrap();
253        keys.sort();
254        assert_eq!(keys, vec!["wf/inbox/token_a", "wf/inbox/token_b"]);
255    }
256
257    #[tokio::test]
258    async fn list_keys_empty_prefix_returns_all() {
259        let store = test_store();
260        store.set_secret("a", "1").await.unwrap();
261        store.set_secret("b", "2").await.unwrap();
262
263        let keys = store.list_secret_keys("").await.unwrap();
264        assert_eq!(keys.len(), 2);
265    }
266
267    #[tokio::test]
268    async fn operations_without_master_key_fail() {
269        let store = InMemoryStore::new();
270        let err = store.get_secret("key").await.unwrap_err();
271        assert!(err.to_string().contains("no master key"));
272
273        let err = store.set_secret("key", "val").await.unwrap_err();
274        assert!(err.to_string().contains("no master key"));
275    }
276
277    #[tokio::test]
278    async fn secret_value_is_encrypted_at_rest() {
279        let store = test_store();
280        store
281            .set_secret("sensitive", "plaintext-value")
282            .await
283            .unwrap();
284
285        let state = store.state.read().await;
286        let encrypted = state.secrets.get("sensitive").unwrap();
287        let as_str = String::from_utf8(encrypted.encrypted_value.clone());
288        assert!(
289            as_str.is_err() || as_str.unwrap() != "plaintext-value",
290            "value must be encrypted at rest"
291        );
292    }
293
294    #[tokio::test]
295    async fn set_secret_with_empty_value() {
296        let store = test_store();
297        let secret = store.set_secret("empty", "").await.unwrap();
298        assert_eq!(secret.value, "");
299
300        let fetched = store.get_secret("empty").await.unwrap().unwrap();
301        assert_eq!(fetched.value, "");
302    }
303
304    #[tokio::test]
305    async fn list_secrets_paginated() {
306        let store = test_store();
307        store.set_secret("a/1", "v").await.unwrap();
308        store.set_secret("a/2", "v").await.unwrap();
309        store.set_secret("a/3", "v").await.unwrap();
310        store.set_secret("b/1", "v").await.unwrap();
311
312        let page = store.list_secrets("a/", 1, 2).await.unwrap();
313        assert_eq!(page.total, 3);
314        assert_eq!(page.items.len(), 2);
315        assert_eq!(page.items[0].key, "a/1");
316        assert_eq!(page.items[1].key, "a/2");
317
318        let page2 = store.list_secrets("a/", 2, 2).await.unwrap();
319        assert_eq!(page2.items.len(), 1);
320        assert_eq!(page2.items[0].key, "a/3");
321    }
322}