1use std::collections::HashMap;
17use std::path::PathBuf;
18use std::sync::Mutex;
19use std::time::{SystemTime, UNIX_EPOCH};
20
21use serde::{Deserialize, Serialize};
22use sha2::{Digest, Sha256};
23
24use crate::errors::TokenFoldError;
25use crate::transforms::redaction;
26
27pub const DEFAULT_TTL_SECONDS: u64 = 604_800;
29
30#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct RetrievalMarker {
34 pub hash: String,
35 pub alg: &'static str,
36 pub namespace: String,
37 pub bytes: usize,
38 pub ttl_seconds: Option<u64>,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
44pub enum RetrievalOutcome {
45 Found(Vec<u8>),
46 Missing,
47 Expired,
48}
49
50#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
51pub struct GcOutcome {
52 pub expired_removed: usize,
53 pub evicted_removed: usize,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
57struct EntryMeta {
58 stored_at_unix: u64,
59 ttl_seconds: Option<u64>,
60 bytes: usize,
61}
62
63pub struct MemoryEntry {
67 bytes: Vec<u8>,
68 meta: EntryMeta,
69}
70
71pub enum RetrievalStore {
75 Memory(Mutex<HashMap<(String, String), MemoryEntry>>),
76 Filesystem { root: PathBuf },
77}
78
79impl RetrievalStore {
80 pub fn memory() -> Self {
81 RetrievalStore::Memory(Mutex::new(HashMap::new()))
82 }
83
84 pub fn filesystem(root: impl Into<PathBuf>) -> Self {
85 RetrievalStore::Filesystem { root: root.into() }
86 }
87
88 pub fn default_filesystem() -> Self {
91 Self::filesystem(default_store_path())
92 }
93
94 pub fn open(
98 backend: &str,
99 hash_algorithm: &str,
100 store_path_override: Option<PathBuf>,
101 ) -> Result<Self, TokenFoldError> {
102 if hash_algorithm != "sha256" {
103 return Err(TokenFoldError::ConfigError(format!(
104 "retrieval hash_algorithm {hash_algorithm:?} is not implemented yet in v0.2; only \"sha256\" is supported"
105 )));
106 }
107 match backend {
108 "memory" => Ok(Self::memory()),
109 "filesystem" => Ok(Self::filesystem(
110 store_path_override.unwrap_or_else(default_store_path),
111 )),
112 "sqlite" => Err(TokenFoldError::ConfigError(
113 "retrieval backend \"sqlite\" is not implemented yet in v0.2; use \"memory\" or \"filesystem\"".to_string(),
114 )),
115 other => Err(TokenFoldError::ConfigError(format!(
116 "unknown retrieval backend {other:?}; expected \"memory\" or \"filesystem\" (\"sqlite\" is a documented v0.2 scope cut)"
117 ))),
118 }
119 }
120
121 pub fn store(
126 &self,
127 bytes: &[u8],
128 namespace: &str,
129 ttl_seconds: Option<u64>,
130 ) -> Result<RetrievalMarker, TokenFoldError> {
131 if redaction::contains_secret(bytes) {
132 return Err(TokenFoldError::SafetyViolation(
133 "refusing to persist bytes that match a secret-redaction pattern".to_string(),
134 ));
135 }
136 if !is_safe_path_component(namespace) {
137 return Err(TokenFoldError::InvalidInput(format!(
138 "invalid retrieval namespace: {namespace:?}"
139 )));
140 }
141
142 let hash = hex_sha256(bytes);
143 let meta = EntryMeta {
144 stored_at_unix: now_unix(),
145 ttl_seconds,
146 bytes: bytes.len(),
147 };
148
149 match self {
150 RetrievalStore::Memory(map) => {
151 let mut guard = map.lock().unwrap_or_else(|e| e.into_inner());
152 guard.insert(
153 (namespace.to_string(), hash.clone()),
154 MemoryEntry {
155 bytes: bytes.to_vec(),
156 meta,
157 },
158 );
159 }
160 RetrievalStore::Filesystem { root } => {
161 let dir = root.join(namespace);
162 std::fs::create_dir_all(&dir)?;
163 std::fs::write(dir.join(format!("{hash}.bin")), bytes)?;
164 let meta_json = serde_json::to_vec_pretty(&meta).map_err(|e| {
165 TokenFoldError::InternalError(format!(
166 "failed to encode retrieval metadata: {e}"
167 ))
168 })?;
169 std::fs::write(dir.join(format!("{hash}.meta.json")), meta_json)?;
170 }
171 }
172
173 Ok(RetrievalMarker {
174 hash,
175 alg: "sha256",
176 namespace: namespace.to_string(),
177 bytes: bytes.len(),
178 ttl_seconds,
179 })
180 }
181
182 pub fn retrieve(&self, hash: &str, namespace: &str) -> RetrievalOutcome {
185 if !is_safe_path_component(namespace) || !is_safe_path_component(hash) {
186 return RetrievalOutcome::Missing;
187 }
188
189 match self {
190 RetrievalStore::Memory(map) => {
191 let guard = map.lock().unwrap_or_else(|e| e.into_inner());
192 match guard.get(&(namespace.to_string(), hash.to_string())) {
193 None => RetrievalOutcome::Missing,
194 Some(entry) if is_expired(&entry.meta) => RetrievalOutcome::Expired,
195 Some(entry) => RetrievalOutcome::Found(entry.bytes.clone()),
196 }
197 }
198 RetrievalStore::Filesystem { root } => {
199 let dir = root.join(namespace);
200 let meta_path = dir.join(format!("{hash}.meta.json"));
201 let data_path = dir.join(format!("{hash}.bin"));
202 let Ok(meta_bytes) = std::fs::read(&meta_path) else {
203 return RetrievalOutcome::Missing;
204 };
205 let Ok(meta) = serde_json::from_slice::<EntryMeta>(&meta_bytes) else {
206 return RetrievalOutcome::Missing;
207 };
208 if is_expired(&meta) {
209 return RetrievalOutcome::Expired;
210 }
211 match std::fs::read(&data_path) {
212 Ok(bytes) => RetrievalOutcome::Found(bytes),
213 Err(_) => RetrievalOutcome::Missing,
214 }
215 }
216 }
217 }
218
219 pub fn gc(&self, max_store_bytes: Option<u64>) -> Result<GcOutcome, TokenFoldError> {
223 match self {
224 RetrievalStore::Memory(map) => {
225 let mut guard = map.lock().unwrap_or_else(|e| e.into_inner());
226 let mut outcome = GcOutcome::default();
227
228 let expired: Vec<_> = guard
229 .iter()
230 .filter(|(_, entry)| is_expired(&entry.meta))
231 .map(|(key, _)| key.clone())
232 .collect();
233 for key in expired {
234 guard.remove(&key);
235 outcome.expired_removed += 1;
236 }
237
238 if let Some(cap) = max_store_bytes {
239 let mut total: u64 = guard.values().map(|e| e.meta.bytes as u64).sum();
240 if total > cap {
241 let mut remaining: Vec<_> = guard
242 .iter()
243 .map(|(key, e)| {
244 (key.clone(), e.meta.stored_at_unix, e.meta.bytes as u64)
245 })
246 .collect();
247 remaining.sort_by_key(|(_, stored_at, _)| *stored_at);
248 for (key, _, bytes) in remaining {
249 if total <= cap {
250 break;
251 }
252 guard.remove(&key);
253 total = total.saturating_sub(bytes);
254 outcome.evicted_removed += 1;
255 }
256 }
257 }
258 Ok(outcome)
259 }
260 RetrievalStore::Filesystem { root } => {
261 let mut outcome = GcOutcome::default();
262 if !root.is_dir() {
263 return Ok(outcome);
264 }
265
266 let mut live: Vec<(PathBuf, PathBuf, EntryMeta)> = Vec::new();
267 for ns_entry in std::fs::read_dir(root)? {
268 let ns_entry = ns_entry?;
269 if !ns_entry.file_type()?.is_dir() {
270 continue;
271 }
272 let ns_dir = ns_entry.path();
273 for file_entry in std::fs::read_dir(&ns_dir)? {
274 let file_entry = file_entry?;
275 let meta_path = file_entry.path();
276 let Some(name) = meta_path.file_name().and_then(|n| n.to_str()) else {
277 continue;
278 };
279 let Some(hash) = name.strip_suffix(".meta.json") else {
280 continue;
281 };
282 let Ok(meta_bytes) = std::fs::read(&meta_path) else {
283 continue;
284 };
285 let Ok(meta) = serde_json::from_slice::<EntryMeta>(&meta_bytes) else {
286 continue;
287 };
288 let data_path = ns_dir.join(format!("{hash}.bin"));
289 if is_expired(&meta) {
290 std::fs::remove_file(&meta_path).ok();
291 std::fs::remove_file(&data_path).ok();
292 outcome.expired_removed += 1;
293 continue;
294 }
295 live.push((meta_path, data_path, meta));
296 }
297 }
298
299 if let Some(cap) = max_store_bytes {
300 let mut total: u64 = live.iter().map(|(_, _, m)| m.bytes as u64).sum();
301 if total > cap {
302 live.sort_by_key(|(_, _, m)| m.stored_at_unix);
303 for (meta_path, data_path, meta) in live {
304 if total <= cap {
305 break;
306 }
307 std::fs::remove_file(&meta_path).ok();
308 std::fs::remove_file(&data_path).ok();
309 total = total.saturating_sub(meta.bytes as u64);
310 outcome.evicted_removed += 1;
311 }
312 }
313 }
314 Ok(outcome)
315 }
316 }
317 }
318}
319
320fn is_expired(meta: &EntryMeta) -> bool {
321 match meta.ttl_seconds {
322 None => false,
323 Some(ttl) => now_unix().saturating_sub(meta.stored_at_unix) >= ttl,
324 }
325}
326
327fn is_safe_path_component(value: &str) -> bool {
330 !value.is_empty()
331 && !value.contains('/')
332 && !value.contains('\\')
333 && value != "."
334 && value != ".."
335}
336
337fn now_unix() -> u64 {
338 SystemTime::now()
339 .duration_since(UNIX_EPOCH)
340 .map(|d| d.as_secs())
341 .unwrap_or(0)
342}
343
344pub fn hex_sha256(bytes: &[u8]) -> String {
346 let digest = Sha256::digest(bytes);
347 let mut hex = String::with_capacity(digest.len() * 2);
348 for byte in digest {
349 use std::fmt::Write;
350 let _ = write!(hex, "{byte:02x}");
351 }
352 hex
353}
354
355fn home_dir() -> Option<PathBuf> {
356 std::env::var_os("HOME")
357 .or_else(|| std::env::var_os("USERPROFILE"))
358 .map(PathBuf::from)
359}
360
361pub fn default_store_path() -> PathBuf {
366 if let Some(dir) = std::env::var_os("XDG_DATA_HOME") {
367 return PathBuf::from(dir).join("tokenfold").join("retrieve");
368 }
369 let home = home_dir().unwrap_or_else(|| PathBuf::from("."));
370 home.join(".local")
371 .join("share")
372 .join("tokenfold")
373 .join("retrieve")
374}
375
376#[cfg(test)]
377mod tests {
378 use super::*;
379 use std::sync::atomic::{AtomicU64, Ordering};
380
381 fn temp_root(tag: &str) -> PathBuf {
382 static COUNTER: AtomicU64 = AtomicU64::new(0);
383 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
384 std::env::temp_dir().join(format!(
385 "tokenfold_retrieval_store_test_{tag}_{}_{n}",
386 std::process::id()
387 ))
388 }
389
390 #[test]
391 fn hex_sha256_matches_known_test_vector() {
392 assert_eq!(
394 hex_sha256(b""),
395 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
396 );
397 }
398
399 #[test]
400 fn stores_by_content_hash_and_namespace_independently() {
401 let store = RetrievalStore::memory();
402 let marker_a = store.store(b"hello world", "project-a", None).unwrap();
403 let marker_b = store.store(b"hello world", "project-b", None).unwrap();
404 assert_eq!(marker_a.hash, marker_b.hash, "same bytes hash identically");
405
406 assert_eq!(
407 store.retrieve(&marker_a.hash, "project-a"),
408 RetrievalOutcome::Found(b"hello world".to_vec())
409 );
410 assert_eq!(
412 store.retrieve(&marker_a.hash, "project-nope"),
413 RetrievalOutcome::Missing
414 );
415 assert_eq!(
416 store.retrieve(&marker_b.hash, "project-b"),
417 RetrievalOutcome::Found(b"hello world".to_vec())
418 );
419 }
420
421 #[test]
422 fn memory_retrieve_restores_exact_bytes_including_non_utf8() {
423 let store = RetrievalStore::memory();
424 let original: Vec<u8> = vec![0, 159, 146, 150, 1, 2, 3, 255, 0, 254];
425 let marker = store.store(&original, "default", None).unwrap();
426 match store.retrieve(&marker.hash, "default") {
427 RetrievalOutcome::Found(bytes) => assert_eq!(bytes, original),
428 other => panic!("expected Found, got {other:?}"),
429 }
430 }
431
432 #[test]
433 fn filesystem_retrieve_restores_exact_bytes() {
434 let root = temp_root("roundtrip");
435 let store = RetrievalStore::filesystem(&root);
436 let original = b"the quick brown fox jumps over the lazy dog";
437 let marker = store.store(original, "default", None).unwrap();
438
439 match store.retrieve(&marker.hash, "default") {
440 RetrievalOutcome::Found(bytes) => assert_eq!(bytes, original),
441 other => panic!("expected Found, got {other:?}"),
442 }
443
444 std::fs::remove_dir_all(&root).ok();
445 }
446
447 #[test]
448 fn missing_hash_returns_missing_with_no_partial_output() {
449 let store = RetrievalStore::memory();
450 store.store(b"stored content", "default", None).unwrap();
451 assert_eq!(
452 store.retrieve(
453 "0000000000000000000000000000000000000000000000000000000000000000",
454 "default"
455 ),
456 RetrievalOutcome::Missing
457 );
458 }
459
460 #[test]
461 fn expired_entry_returns_expired_with_no_partial_output() {
462 let store = RetrievalStore::memory();
463 let marker = store
465 .store(b"will expire immediately", "default", Some(0))
466 .unwrap();
467 assert_eq!(
468 store.retrieve(&marker.hash, "default"),
469 RetrievalOutcome::Expired
470 );
471 }
472
473 #[test]
474 fn none_ttl_never_expires() {
475 let store = RetrievalStore::memory();
476 let marker = store.store(b"never expires", "default", None).unwrap();
477 assert_eq!(
478 store.retrieve(&marker.hash, "default"),
479 RetrievalOutcome::Found(b"never expires".to_vec())
480 );
481 }
482
483 #[test]
484 fn gc_removes_only_expired_entries() {
485 let store = RetrievalStore::memory();
486 let expired = store.store(b"expired entry", "default", Some(0)).unwrap();
487 let alive = store.store(b"alive entry", "default", None).unwrap();
488
489 let outcome = store.gc(None).unwrap();
490 assert_eq!(outcome.expired_removed, 1);
491 assert_eq!(outcome.evicted_removed, 0);
492 assert_eq!(
493 store.retrieve(&expired.hash, "default"),
494 RetrievalOutcome::Missing
495 );
496 assert_eq!(
497 store.retrieve(&alive.hash, "default"),
498 RetrievalOutcome::Found(b"alive entry".to_vec())
499 );
500 }
501
502 #[test]
503 fn gc_evicts_oldest_entries_first_when_over_size_cap() {
504 let store = RetrievalStore::memory();
505 store.store(b"aaaaaaaaaa", "default", None).unwrap();
510 store.store(b"bbbbbbbbbb", "default", None).unwrap();
511 store.store(b"cccccccccc", "default", None).unwrap();
512
513 let outcome = store.gc(Some(15)).unwrap();
514 assert!(
515 outcome.evicted_removed >= 1,
516 "at least one entry must be evicted over cap"
517 );
518 assert_eq!(outcome.expired_removed, 0);
519 }
520
521 #[test]
522 fn filesystem_gc_evicts_oldest_stored_at_first() {
523 let root = temp_root("gc_order");
524 let store = RetrievalStore::filesystem(&root);
525 let old = store.store(b"oldest-entry-here", "default", None).unwrap();
526 std::thread::sleep(std::time::Duration::from_millis(1100));
527 let newer = store.store(b"newest-entry", "default", None).unwrap();
528
529 let meta_path = root.join("default").join(format!("{}.meta.json", old.hash));
532 let mut meta: serde_json::Value =
533 serde_json::from_slice(&std::fs::read(&meta_path).unwrap()).unwrap();
534 meta["stored_at_unix"] = serde_json::json!(1);
535 std::fs::write(&meta_path, serde_json::to_vec(&meta).unwrap()).unwrap();
536
537 let outcome = store.gc(Some(newer.bytes as u64)).unwrap();
538 assert_eq!(outcome.evicted_removed, 1);
539 assert_eq!(
540 store.retrieve(&old.hash, "default"),
541 RetrievalOutcome::Missing,
542 "the older entry must be the one evicted"
543 );
544 assert!(matches!(
545 store.retrieve(&newer.hash, "default"),
546 RetrievalOutcome::Found(_)
547 ));
548
549 std::fs::remove_dir_all(&root).ok();
550 }
551
552 #[test]
553 fn store_refuses_bytes_containing_a_known_secret_pattern() {
554 let store = RetrievalStore::memory();
555 let err = store
556 .store(b"AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE", "default", None)
557 .unwrap_err();
558 assert!(matches!(err, TokenFoldError::SafetyViolation(_)));
559 }
560
561 #[test]
562 fn filesystem_backend_also_refuses_secret_bearing_bytes() {
563 let root = temp_root("secret_gate");
564 let store = RetrievalStore::filesystem(&root);
565 let err = store
566 .store(
567 b"Authorization: Bearer abcDEF123.token-value",
568 "default",
569 None,
570 )
571 .unwrap_err();
572 assert!(matches!(err, TokenFoldError::SafetyViolation(_)));
573 assert!(!root.join("default").exists());
575 std::fs::remove_dir_all(&root).ok();
576 }
577
578 #[test]
579 fn open_rejects_sqlite_backend_as_a_clear_config_error() {
580 let err = RetrievalStore::open("sqlite", "sha256", None)
583 .err()
584 .unwrap();
585 assert!(matches!(err, TokenFoldError::ConfigError(_)));
586 }
587
588 #[test]
589 fn open_rejects_blake3_hash_algorithm_as_a_clear_config_error() {
590 let err = RetrievalStore::open("filesystem", "blake3", None)
591 .err()
592 .unwrap();
593 assert!(matches!(err, TokenFoldError::ConfigError(_)));
594 }
595
596 #[test]
597 fn open_accepts_memory_and_filesystem_with_sha256() {
598 assert!(RetrievalStore::open("memory", "sha256", None).is_ok());
599 assert!(RetrievalStore::open("filesystem", "sha256", Some(temp_root("open_ok"))).is_ok());
600 }
601
602 #[test]
603 fn unsafe_namespace_is_rejected_by_store_and_missing_from_retrieve() {
604 let store = RetrievalStore::memory();
605 assert!(store.store(b"data", "../escape", None).is_err());
606 assert_eq!(
607 store.retrieve("deadbeef", "../escape"),
608 RetrievalOutcome::Missing
609 );
610 }
611}