Skip to main content

tokenfold_core/
retrieval_store.rs

1//! F-045: reversible evidence store and retrieval (`roadmap.md` F-045, `interfaces.md`
2//! "Retrieval Marker Grammar" and the `[retrieval]` `tokenfold.toml` schema block).
3//!
4//! Granularity in this pass is whole-payload, not per-span: `pipeline.rs` stores the entire
5//! pre-transform input under its SHA-256 content hash when `CompressionPolicy.store_originals`
6//! is set and the payload contains no secret-shaped content. Per-span inline
7//! `[tokenfold:retrieve ...]` markers are an explicitly out-of-scope future enhancement (see
8//! the marker grammar's own fallback rule: "If a format cannot carry markers safely, markers
9//! live only in `CompressionReport.retrieval`").
10//!
11//! Hash algorithm is SHA-256 only in this pass; `blake3` is a documented, rejected scope cut
12//! (see [`RetrievalStore::open`]). Backends are `memory` (in-process, used in tests) and
13//! `filesystem` (the default persistent backend); `sqlite` is likewise a documented, rejected
14//! scope cut.
15
16use 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
27/// `tokenfold.toml`'s documented `[retrieval].ttl_seconds` default (7 days).
28pub const DEFAULT_TTL_SECONDS: u64 = 604_800;
29
30/// One retrieval marker's worth of metadata, per `interfaces.md`'s Retrieval Marker Grammar:
31/// `[tokenfold:retrieve hash=<hex> alg=sha256 namespace=<ns> bytes=<n> ttl=<seconds>]`.
32#[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/// Result of a retrieval lookup. Deliberately has no "partial" variant: a caller either gets
42/// the exact original bytes back, or an explicit reason it did not.
43#[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
63// `pub` only because it's the type of a field inside the public `RetrievalStore::Memory`
64// variant (tuple-variant fields of a `pub enum` are implicitly public); its own fields stay
65// private; nothing outside this module ever constructs or reads one directly.
66pub struct MemoryEntry {
67    bytes: Vec<u8>,
68    meta: EntryMeta,
69}
70
71/// A content-addressed, namespaced store for reversible originals. Backend-dispatching enum
72/// rather than a trait object: only two live variants exist in this pass, so a trait would add
73/// indirection without buying any real polymorphism.
74pub 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    /// The default persistent store used when nothing overrides it: a `filesystem` backend
89    /// rooted at [`default_store_path`].
90    pub fn default_filesystem() -> Self {
91        Self::filesystem(default_store_path())
92    }
93
94    /// Builds a store from `tokenfold.toml`'s `[retrieval]` schema values. `hash_algorithm` and
95    /// `backend` are validated here so that selecting an unimplemented option (`blake3`,
96    /// `sqlite`) fails clearly instead of silently behaving like `sha256`/`filesystem`.
97    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    /// Persists `bytes` under their SHA-256 hex hash, namespaced by `namespace`. Refuses to
122    /// store (and never partially stores) anything [`redaction::contains_secret`] flags — this
123    /// check runs unconditionally inside `store`, so no caller anywhere (pipeline, CLI, tests)
124    /// can reach a code path that stores secret-shaped bytes.
125    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    /// Looks up `hash` in `namespace`. Never returns a partial result: exactly one of
183    /// `Found`/`Missing`/`Expired`.
184    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    /// Deletes entries whose `ttl_seconds` has elapsed (entries stored with `ttl_seconds:
220    /// None` never expire), then — if `max_store_bytes` is given and total remaining stored
221    /// bytes still exceed it — evicts the oldest-`stored_at` entries first until under the cap.
222    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
327/// Rejects values that would let a namespace or hash escape the store root via path
328/// traversal (`..`, embedded separators) when used as a directory/file name component.
329fn 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
344/// Lowercase hex SHA-256 of `bytes`.
345pub 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
361/// `$XDG_DATA_HOME/tokenfold/retrieve`, falling back to `<home>/.local/share/tokenfold/retrieve`
362/// when `XDG_DATA_HOME` is unset — mirrors `tokenfold-cli::config`'s HOME/USERPROFILE fallback
363/// for `home_dir()`. Deliberately not a Windows-native path (e.g. `%LOCALAPPDATA%`): the rest
364/// of the codebase is XDG-everywhere by convention.
365pub 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        // sha256("") is a widely published test vector.
393        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        // Same hash, wrong namespace: not found (namespaces are independent).
411        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        // ttl_seconds: Some(0) means "already elapsed" the instant it's stored.
464        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        // Each entry is stored with a slightly later `stored_at_unix` via a manual meta
506        // override isn't available on the public API, so rely on filesystem gc's stable
507        // ordering test below for eviction-order coverage, and just prove the cap is enforced
508        // here (all entries share the same instant, so ties are broken by iteration order).
509        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        // Force the older entry's stored_at further into the past so ordering is unambiguous
530        // regardless of clock resolution, then cap tight enough to evict exactly one entry.
531        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        // Nothing should have been written to disk.
574        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        // `RetrievalStore` isn't `Debug` (it holds a `Mutex`), so assert via `Result::err`
581        // rather than `unwrap_err`.
582        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}