Skip to main content

tokenfold_core/
retrieval_store.rs

1//! Reversible evidence store and retrieval: content-addressed storage of pre-transform
2//! originals, plus the `[retrieval]` `tokenfold.toml` schema block that configures it.
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::fs::{File, OpenOptions};
18use std::io::Write;
19use std::path::{Path, PathBuf};
20use std::sync::Mutex;
21use std::sync::atomic::{AtomicU64, Ordering};
22use std::time::{SystemTime, UNIX_EPOCH};
23
24use serde::{Deserialize, Serialize};
25use sha2::{Digest, Sha256};
26
27use crate::errors::TokenFoldError;
28use crate::transforms::redaction;
29
30/// `tokenfold.toml`'s documented `[retrieval].ttl_seconds` default (7 days).
31pub const DEFAULT_TTL_SECONDS: u64 = 604_800;
32
33/// One retrieval marker's worth of metadata, in the documented marker grammar:
34/// `[tokenfold:retrieve hash=<hex> alg=sha256 namespace=<ns> bytes=<n> ttl=<seconds>]`.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct RetrievalMarker {
37    pub hash: String,
38    pub alg: &'static str,
39    pub namespace: String,
40    pub bytes: usize,
41    pub ttl_seconds: Option<u64>,
42}
43
44/// A validated reference to one stored original. Accepted inputs are a raw SHA-256 hash,
45/// the legacy `[tokenfold:retrieve ...]` marker, or the JSON `{"$tf_ref": ...}` marker
46/// emitted by lossy JSON pruning.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct RetrievalReference {
49    pub hash: String,
50    pub namespace: Option<String>,
51}
52
53pub fn parse_retrieval_reference(reference: &str) -> Result<RetrievalReference, TokenFoldError> {
54    let reference = reference.trim();
55    if reference.starts_with('{') {
56        let value: serde_json::Value = serde_json::from_str(reference).map_err(|error| {
57            TokenFoldError::InvalidInput(format!("invalid retrieval JSON marker: {error}"))
58        })?;
59        let marker = value.get("$tf_ref").unwrap_or(&value);
60        let hash = marker
61            .get("hash")
62            .and_then(serde_json::Value::as_str)
63            .ok_or_else(|| {
64                TokenFoldError::InvalidInput(
65                    "retrieval JSON marker has no string hash field".into(),
66                )
67            })?;
68        let alg = marker
69            .get("alg")
70            .and_then(serde_json::Value::as_str)
71            .unwrap_or("sha256");
72        let namespace = marker
73            .get("namespace")
74            .and_then(serde_json::Value::as_str)
75            .map(str::to_string);
76        return validate_reference(hash, alg, namespace);
77    }
78    if reference.contains("tokenfold:retrieve") {
79        let hash = extract_marker_field(reference, "hash").ok_or_else(|| {
80            TokenFoldError::InvalidInput("retrieval marker has no hash=<hex> field".into())
81        })?;
82        let alg = extract_marker_field(reference, "alg").unwrap_or_else(|| "sha256".into());
83        return validate_reference(&hash, &alg, extract_marker_field(reference, "namespace"));
84    }
85    validate_reference(reference, "sha256", None)
86}
87
88fn validate_reference(
89    hash: &str,
90    alg: &str,
91    namespace: Option<String>,
92) -> Result<RetrievalReference, TokenFoldError> {
93    if alg != "sha256" {
94        return Err(TokenFoldError::InvalidInput(format!(
95            "unsupported retrieval hash algorithm {alg:?}; expected \"sha256\""
96        )));
97    }
98    if hash.len() != 64 || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
99        return Err(TokenFoldError::InvalidInput(format!(
100            "{hash:?} is not a valid SHA-256 hex hash"
101        )));
102    }
103    if namespace
104        .as_deref()
105        .is_some_and(|value| !is_safe_path_component(value))
106    {
107        return Err(TokenFoldError::InvalidInput(format!(
108            "invalid retrieval namespace: {:?}",
109            namespace.as_deref().unwrap_or_default()
110        )));
111    }
112    Ok(RetrievalReference {
113        hash: hash.to_ascii_lowercase(),
114        namespace,
115    })
116}
117
118fn extract_marker_field(marker: &str, field: &str) -> Option<String> {
119    let needle = format!("{field}=");
120    let start = marker.find(&needle)? + needle.len();
121    let rest = &marker[start..];
122    let end = rest
123        .find(|c: char| c.is_whitespace() || c == ']')
124        .unwrap_or(rest.len());
125    Some(rest[..end].to_string())
126}
127
128/// Result of a retrieval lookup. Deliberately has no "partial" variant: a caller either gets
129/// the exact original bytes back, or an explicit reason it did not.
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub enum RetrievalOutcome {
132    Found(Vec<u8>),
133    Missing,
134    Expired,
135}
136
137#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
138pub struct GcOutcome {
139    pub expired_removed: usize,
140    pub evicted_removed: usize,
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize)]
144struct EntryMeta {
145    stored_at_unix: u64,
146    ttl_seconds: Option<u64>,
147    bytes: usize,
148}
149
150// `pub` only because it's the type of a field inside the public `RetrievalStore::Memory`
151// variant (tuple-variant fields of a `pub enum` are implicitly public); its own fields stay
152// private; nothing outside this module ever constructs or reads one directly.
153pub struct MemoryEntry {
154    bytes: Vec<u8>,
155    meta: EntryMeta,
156}
157
158/// A content-addressed, namespaced store for reversible originals. Backend-dispatching enum
159/// rather than a trait object: only two live variants exist in this pass, so a trait would add
160/// indirection without buying any real polymorphism.
161pub enum RetrievalStore {
162    Memory(Mutex<HashMap<(String, String), MemoryEntry>>),
163    Filesystem { root: PathBuf },
164}
165
166impl RetrievalStore {
167    pub fn memory() -> Self {
168        RetrievalStore::Memory(Mutex::new(HashMap::new()))
169    }
170
171    pub fn filesystem(root: impl Into<PathBuf>) -> Self {
172        RetrievalStore::Filesystem { root: root.into() }
173    }
174
175    /// The default persistent store used when nothing overrides it: a `filesystem` backend
176    /// rooted at [`default_store_path`].
177    pub fn default_filesystem() -> Self {
178        Self::filesystem(default_store_path())
179    }
180
181    /// Builds a store from `tokenfold.toml`'s `[retrieval]` schema values. `hash_algorithm` and
182    /// `backend` are validated here so that selecting an unimplemented option (`blake3`,
183    /// `sqlite`) fails clearly instead of silently behaving like `sha256`/`filesystem`.
184    pub fn open(
185        backend: &str,
186        hash_algorithm: &str,
187        store_path_override: Option<PathBuf>,
188    ) -> Result<Self, TokenFoldError> {
189        if hash_algorithm != "sha256" {
190            return Err(TokenFoldError::ConfigError(format!(
191                "retrieval hash_algorithm {hash_algorithm:?} is not implemented yet in v0.2; only \"sha256\" is supported"
192            )));
193        }
194        match backend {
195            "memory" => Ok(Self::memory()),
196            "filesystem" => Ok(Self::filesystem(
197                store_path_override.unwrap_or_else(default_store_path),
198            )),
199            "sqlite" => Err(TokenFoldError::ConfigError(
200                "retrieval backend \"sqlite\" is not implemented yet in v0.2; use \"memory\" or \"filesystem\"".to_string(),
201            )),
202            other => Err(TokenFoldError::ConfigError(format!(
203                "unknown retrieval backend {other:?}; expected \"memory\" or \"filesystem\" (\"sqlite\" is a documented v0.2 scope cut)"
204            ))),
205        }
206    }
207
208    /// Persists `bytes` under their SHA-256 hex hash, namespaced by `namespace`. Refuses to
209    /// store (and never partially stores) anything [`redaction::contains_secret`] flags — this
210    /// check runs unconditionally inside `store`, so no caller anywhere (pipeline, CLI, tests)
211    /// can reach a code path that stores secret-shaped bytes.
212    pub fn store(
213        &self,
214        bytes: &[u8],
215        namespace: &str,
216        ttl_seconds: Option<u64>,
217    ) -> Result<RetrievalMarker, TokenFoldError> {
218        self.store_batch(&[(bytes, namespace, ttl_seconds)])
219            .map(|mut markers| markers.remove(0))
220    }
221
222    /// Stores a set as one publication unit. On failure, no newly-created entry remains.
223    pub fn store_batch(
224        &self,
225        entries: &[(&[u8], &str, Option<u64>)],
226    ) -> Result<Vec<RetrievalMarker>, TokenFoldError> {
227        let prepared: Vec<_> = entries
228            .iter()
229            .map(|(bytes, namespace, ttl_seconds)| {
230                validate_store_input(bytes, namespace)?;
231                let hash = hex_sha256(bytes);
232                let meta = EntryMeta {
233                    stored_at_unix: now_unix(),
234                    ttl_seconds: *ttl_seconds,
235                    bytes: bytes.len(),
236                };
237                let marker = RetrievalMarker {
238                    hash,
239                    alg: "sha256",
240                    namespace: (*namespace).to_string(),
241                    bytes: bytes.len(),
242                    ttl_seconds: *ttl_seconds,
243                };
244                Ok((bytes.to_vec(), meta, marker))
245            })
246            .collect::<Result<_, TokenFoldError>>()?;
247
248        match self {
249            RetrievalStore::Memory(map) => {
250                let mut guard = map.lock().unwrap_or_else(|e| e.into_inner());
251                for (bytes, meta, marker) in &prepared {
252                    guard.insert(
253                        (marker.namespace.clone(), marker.hash.clone()),
254                        MemoryEntry {
255                            bytes: bytes.clone(),
256                            meta: meta.clone(),
257                        },
258                    );
259                }
260            }
261            RetrievalStore::Filesystem { root } => {
262                std::fs::create_dir_all(root)?;
263                let _lock = lock_store(root)?;
264                let mut created = Vec::new();
265                for (bytes, meta, marker) in &prepared {
266                    match store_filesystem_entry(root, bytes, meta, marker) {
267                        Ok(true) => created.push((marker.namespace.clone(), marker.hash.clone())),
268                        Ok(false) => {}
269                        Err(error) => {
270                            for (namespace, hash) in created {
271                                let dir = root.join(namespace);
272                                std::fs::remove_file(dir.join(format!("{hash}.meta.json"))).ok();
273                                std::fs::remove_file(dir.join(format!("{hash}.bin"))).ok();
274                            }
275                            return Err(error);
276                        }
277                    }
278                }
279            }
280        }
281
282        Ok(prepared.into_iter().map(|(_, _, marker)| marker).collect())
283    }
284
285    /// Looks up `hash` in `namespace`. Never returns a partial result: exactly one of
286    /// `Found`/`Missing`/`Expired`.
287    pub fn retrieve(&self, hash: &str, namespace: &str) -> RetrievalOutcome {
288        if !is_safe_path_component(namespace) || !is_safe_path_component(hash) {
289            return RetrievalOutcome::Missing;
290        }
291
292        match self {
293            RetrievalStore::Memory(map) => {
294                let guard = map.lock().unwrap_or_else(|e| e.into_inner());
295                match guard.get(&(namespace.to_string(), hash.to_string())) {
296                    None => RetrievalOutcome::Missing,
297                    Some(entry) if is_expired(&entry.meta) => RetrievalOutcome::Expired,
298                    Some(entry) => RetrievalOutcome::Found(entry.bytes.clone()),
299                }
300            }
301            RetrievalStore::Filesystem { root } => {
302                if !root.is_dir() {
303                    return RetrievalOutcome::Missing;
304                }
305                let Ok(_lock) = lock_store(root) else {
306                    return RetrievalOutcome::Missing;
307                };
308                let dir = root.join(namespace);
309                let meta_path = dir.join(format!("{hash}.meta.json"));
310                let data_path = dir.join(format!("{hash}.bin"));
311                let Ok(meta_bytes) = std::fs::read(&meta_path) else {
312                    return RetrievalOutcome::Missing;
313                };
314                let Ok(meta) = serde_json::from_slice::<EntryMeta>(&meta_bytes) else {
315                    return RetrievalOutcome::Missing;
316                };
317                if is_expired(&meta) {
318                    return RetrievalOutcome::Expired;
319                }
320                match std::fs::read(&data_path) {
321                    Ok(bytes) => RetrievalOutcome::Found(bytes),
322                    Err(_) => RetrievalOutcome::Missing,
323                }
324            }
325        }
326    }
327
328    /// Deletes entries whose `ttl_seconds` has elapsed (entries stored with `ttl_seconds:
329    /// None` never expire), then — if `max_store_bytes` is given and total remaining stored
330    /// bytes still exceed it — evicts the oldest-`stored_at` entries first until under the cap.
331    pub fn gc(&self, max_store_bytes: Option<u64>) -> Result<GcOutcome, TokenFoldError> {
332        match self {
333            RetrievalStore::Memory(map) => {
334                let mut guard = map.lock().unwrap_or_else(|e| e.into_inner());
335                let mut outcome = GcOutcome::default();
336
337                let expired: Vec<_> = guard
338                    .iter()
339                    .filter(|(_, entry)| is_expired(&entry.meta))
340                    .map(|(key, _)| key.clone())
341                    .collect();
342                for key in expired {
343                    guard.remove(&key);
344                    outcome.expired_removed += 1;
345                }
346
347                if let Some(cap) = max_store_bytes {
348                    let mut total: u64 = guard.values().map(|e| e.meta.bytes as u64).sum();
349                    if total > cap {
350                        let mut remaining: Vec<_> = guard
351                            .iter()
352                            .map(|(key, e)| {
353                                (key.clone(), e.meta.stored_at_unix, e.meta.bytes as u64)
354                            })
355                            .collect();
356                        remaining.sort_by_key(|(_, stored_at, _)| *stored_at);
357                        for (key, _, bytes) in remaining {
358                            if total <= cap {
359                                break;
360                            }
361                            guard.remove(&key);
362                            total = total.saturating_sub(bytes);
363                            outcome.evicted_removed += 1;
364                        }
365                    }
366                }
367                Ok(outcome)
368            }
369            RetrievalStore::Filesystem { root } => {
370                let mut outcome = GcOutcome::default();
371                if !root.is_dir() {
372                    return Ok(outcome);
373                }
374                let _lock = lock_store(root)?;
375
376                let mut live: Vec<(PathBuf, PathBuf, EntryMeta)> = Vec::new();
377                for ns_entry in std::fs::read_dir(root)? {
378                    let ns_entry = ns_entry?;
379                    if !ns_entry.file_type()?.is_dir() {
380                        continue;
381                    }
382                    let ns_dir = ns_entry.path();
383                    for file_entry in std::fs::read_dir(&ns_dir)? {
384                        let file_entry = file_entry?;
385                        let meta_path = file_entry.path();
386                        let Some(name) = meta_path.file_name().and_then(|n| n.to_str()) else {
387                            continue;
388                        };
389                        let Some(hash) = name.strip_suffix(".meta.json") else {
390                            continue;
391                        };
392                        let Ok(meta_bytes) = std::fs::read(&meta_path) else {
393                            continue;
394                        };
395                        let Ok(meta) = serde_json::from_slice::<EntryMeta>(&meta_bytes) else {
396                            continue;
397                        };
398                        let data_path = ns_dir.join(format!("{hash}.bin"));
399                        if is_expired(&meta) {
400                            std::fs::remove_file(&meta_path).ok();
401                            std::fs::remove_file(&data_path).ok();
402                            outcome.expired_removed += 1;
403                            continue;
404                        }
405                        live.push((meta_path, data_path, meta));
406                    }
407                }
408
409                if let Some(cap) = max_store_bytes {
410                    let mut total: u64 = live.iter().map(|(_, _, m)| m.bytes as u64).sum();
411                    if total > cap {
412                        live.sort_by_key(|(_, _, m)| m.stored_at_unix);
413                        for (meta_path, data_path, meta) in live {
414                            if total <= cap {
415                                break;
416                            }
417                            std::fs::remove_file(&meta_path).ok();
418                            std::fs::remove_file(&data_path).ok();
419                            total = total.saturating_sub(meta.bytes as u64);
420                            outcome.evicted_removed += 1;
421                        }
422                    }
423                }
424                Ok(outcome)
425            }
426        }
427    }
428}
429
430fn validate_store_input(bytes: &[u8], namespace: &str) -> Result<(), TokenFoldError> {
431    if redaction::contains_secret(bytes) {
432        return Err(TokenFoldError::SafetyViolation(
433            "refusing to persist bytes that match a secret-redaction pattern".to_string(),
434        ));
435    }
436    if !is_safe_path_component(namespace) {
437        return Err(TokenFoldError::InvalidInput(format!(
438            "invalid retrieval namespace: {namespace:?}"
439        )));
440    }
441    Ok(())
442}
443
444fn lock_store(root: &Path) -> Result<File, TokenFoldError> {
445    std::fs::create_dir_all(root)?;
446    let file = OpenOptions::new()
447        .read(true)
448        .write(true)
449        .create(true)
450        .truncate(false)
451        .open(root.join(".tokenfold.lock"))?;
452    file.lock()?;
453    Ok(file)
454}
455
456static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
457
458fn unique_sidecar(path: &Path, label: &str) -> PathBuf {
459    let id = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
460    let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("entry");
461    path.with_file_name(format!(".{name}.{}.{}.{label}", std::process::id(), id))
462}
463
464fn stage_file(path: &Path, bytes: &[u8]) -> Result<PathBuf, TokenFoldError> {
465    let temp = unique_sidecar(path, "tmp");
466    let mut file = OpenOptions::new()
467        .write(true)
468        .create_new(true)
469        .open(&temp)?;
470    if let Err(error) = file.write_all(bytes).and_then(|_| file.sync_all()) {
471        std::fs::remove_file(&temp).ok();
472        return Err(error.into());
473    }
474    Ok(temp)
475}
476
477fn publish_file(temp: &Path, destination: &Path) -> Result<(), TokenFoldError> {
478    let backup = unique_sidecar(destination, "bak");
479    let had_destination = destination.exists();
480    if had_destination {
481        std::fs::rename(destination, &backup)?;
482    }
483    if let Err(error) = std::fs::rename(temp, destination) {
484        if had_destination {
485            std::fs::rename(&backup, destination).ok();
486        }
487        std::fs::remove_file(temp).ok();
488        return Err(error.into());
489    }
490    if had_destination {
491        std::fs::remove_file(backup).ok();
492    }
493    Ok(())
494}
495
496fn store_filesystem_entry(
497    root: &Path,
498    bytes: &[u8],
499    meta: &EntryMeta,
500    marker: &RetrievalMarker,
501) -> Result<bool, TokenFoldError> {
502    let dir = root.join(&marker.namespace);
503    std::fs::create_dir_all(&dir)?;
504    let data_path = dir.join(format!("{}.bin", marker.hash));
505    let meta_path = dir.join(format!("{}.meta.json", marker.hash));
506    let existed = data_path.is_file() && meta_path.is_file();
507    if !existed {
508        std::fs::remove_file(&data_path).ok();
509        std::fs::remove_file(&meta_path).ok();
510    }
511    let meta_json = serde_json::to_vec_pretty(meta).map_err(|e| {
512        TokenFoldError::InternalError(format!("failed to encode retrieval metadata: {e}"))
513    })?;
514    let data_temp = stage_file(&data_path, bytes)?;
515    let meta_temp = match stage_file(&meta_path, &meta_json) {
516        Ok(path) => path,
517        Err(error) => {
518            std::fs::remove_file(data_temp).ok();
519            return Err(error);
520        }
521    };
522    if let Err(error) = publish_file(&data_temp, &data_path) {
523        std::fs::remove_file(meta_temp).ok();
524        return Err(error);
525    }
526    if let Err(error) = publish_file(&meta_temp, &meta_path) {
527        if !existed {
528            std::fs::remove_file(data_path).ok();
529        }
530        return Err(error);
531    }
532    Ok(!existed)
533}
534
535fn is_expired(meta: &EntryMeta) -> bool {
536    match meta.ttl_seconds {
537        None => false,
538        Some(ttl) => now_unix().saturating_sub(meta.stored_at_unix) >= ttl,
539    }
540}
541
542/// Rejects values that would let a namespace or hash escape the store root via path
543/// traversal (`..`, embedded separators) when used as a directory/file name component.
544fn is_safe_path_component(value: &str) -> bool {
545    !value.is_empty()
546        && !value.contains('/')
547        && !value.contains('\\')
548        && value != "."
549        && value != ".."
550}
551
552fn now_unix() -> u64 {
553    SystemTime::now()
554        .duration_since(UNIX_EPOCH)
555        .map(|d| d.as_secs())
556        .unwrap_or(0)
557}
558
559/// Lowercase hex SHA-256 of `bytes`.
560pub fn hex_sha256(bytes: &[u8]) -> String {
561    let digest = Sha256::digest(bytes);
562    let mut hex = String::with_capacity(digest.len() * 2);
563    for byte in digest {
564        use std::fmt::Write;
565        let _ = write!(hex, "{byte:02x}");
566    }
567    hex
568}
569
570fn home_dir() -> Option<PathBuf> {
571    std::env::var_os("HOME")
572        .or_else(|| std::env::var_os("USERPROFILE"))
573        .map(PathBuf::from)
574}
575
576/// `$XDG_DATA_HOME/tokenfold/retrieve`, falling back to `<home>/.local/share/tokenfold/retrieve`
577/// when `XDG_DATA_HOME` is unset — mirrors `tokenfold-cli::config`'s HOME/USERPROFILE fallback
578/// for `home_dir()`. Deliberately not a Windows-native path (e.g. `%LOCALAPPDATA%`): the rest
579/// of the codebase is XDG-everywhere by convention.
580pub fn default_store_path() -> PathBuf {
581    if let Some(dir) = std::env::var_os("XDG_DATA_HOME") {
582        return PathBuf::from(dir).join("tokenfold").join("retrieve");
583    }
584    let home = home_dir().unwrap_or_else(|| PathBuf::from("."));
585    home.join(".local")
586        .join("share")
587        .join("tokenfold")
588        .join("retrieve")
589}
590
591#[cfg(test)]
592mod tests {
593    use super::*;
594    use std::sync::atomic::{AtomicU64, Ordering};
595
596    fn temp_root(tag: &str) -> PathBuf {
597        static COUNTER: AtomicU64 = AtomicU64::new(0);
598        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
599        std::env::temp_dir().join(format!(
600            "tokenfold_retrieval_store_test_{tag}_{}_{n}",
601            std::process::id()
602        ))
603    }
604
605    #[test]
606    fn hex_sha256_matches_known_test_vector() {
607        // sha256("") is a widely published test vector.
608        assert_eq!(
609            hex_sha256(b""),
610            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
611        );
612    }
613
614    #[test]
615    fn stores_by_content_hash_and_namespace_independently() {
616        let store = RetrievalStore::memory();
617        let marker_a = store.store(b"hello world", "project-a", None).unwrap();
618        let marker_b = store.store(b"hello world", "project-b", None).unwrap();
619        assert_eq!(marker_a.hash, marker_b.hash, "same bytes hash identically");
620
621        assert_eq!(
622            store.retrieve(&marker_a.hash, "project-a"),
623            RetrievalOutcome::Found(b"hello world".to_vec())
624        );
625        // Same hash, wrong namespace: not found (namespaces are independent).
626        assert_eq!(
627            store.retrieve(&marker_a.hash, "project-nope"),
628            RetrievalOutcome::Missing
629        );
630        assert_eq!(
631            store.retrieve(&marker_b.hash, "project-b"),
632            RetrievalOutcome::Found(b"hello world".to_vec())
633        );
634    }
635
636    #[test]
637    fn memory_retrieve_restores_exact_bytes_including_non_utf8() {
638        let store = RetrievalStore::memory();
639        let original: Vec<u8> = vec![0, 159, 146, 150, 1, 2, 3, 255, 0, 254];
640        let marker = store.store(&original, "default", None).unwrap();
641        match store.retrieve(&marker.hash, "default") {
642            RetrievalOutcome::Found(bytes) => assert_eq!(bytes, original),
643            other => panic!("expected Found, got {other:?}"),
644        }
645    }
646
647    #[test]
648    fn filesystem_retrieve_restores_exact_bytes() {
649        let root = temp_root("roundtrip");
650        let store = RetrievalStore::filesystem(&root);
651        let original = b"the quick brown fox jumps over the lazy dog";
652        let marker = store.store(original, "default", None).unwrap();
653
654        match store.retrieve(&marker.hash, "default") {
655            RetrievalOutcome::Found(bytes) => assert_eq!(bytes, original),
656            other => panic!("expected Found, got {other:?}"),
657        }
658
659        std::fs::remove_dir_all(&root).ok();
660    }
661
662    #[test]
663    fn missing_hash_returns_missing_with_no_partial_output() {
664        let store = RetrievalStore::memory();
665        store.store(b"stored content", "default", None).unwrap();
666        assert_eq!(
667            store.retrieve(
668                "0000000000000000000000000000000000000000000000000000000000000000",
669                "default"
670            ),
671            RetrievalOutcome::Missing
672        );
673    }
674
675    #[test]
676    fn expired_entry_returns_expired_with_no_partial_output() {
677        let store = RetrievalStore::memory();
678        // ttl_seconds: Some(0) means "already elapsed" the instant it's stored.
679        let marker = store
680            .store(b"will expire immediately", "default", Some(0))
681            .unwrap();
682        assert_eq!(
683            store.retrieve(&marker.hash, "default"),
684            RetrievalOutcome::Expired
685        );
686    }
687
688    #[test]
689    fn none_ttl_never_expires() {
690        let store = RetrievalStore::memory();
691        let marker = store.store(b"never expires", "default", None).unwrap();
692        assert_eq!(
693            store.retrieve(&marker.hash, "default"),
694            RetrievalOutcome::Found(b"never expires".to_vec())
695        );
696    }
697
698    #[test]
699    fn gc_removes_only_expired_entries() {
700        let store = RetrievalStore::memory();
701        let expired = store.store(b"expired entry", "default", Some(0)).unwrap();
702        let alive = store.store(b"alive entry", "default", None).unwrap();
703
704        let outcome = store.gc(None).unwrap();
705        assert_eq!(outcome.expired_removed, 1);
706        assert_eq!(outcome.evicted_removed, 0);
707        assert_eq!(
708            store.retrieve(&expired.hash, "default"),
709            RetrievalOutcome::Missing
710        );
711        assert_eq!(
712            store.retrieve(&alive.hash, "default"),
713            RetrievalOutcome::Found(b"alive entry".to_vec())
714        );
715    }
716
717    #[test]
718    fn gc_evicts_oldest_entries_first_when_over_size_cap() {
719        let store = RetrievalStore::memory();
720        // Each entry is stored with a slightly later `stored_at_unix` via a manual meta
721        // override isn't available on the public API, so rely on filesystem gc's stable
722        // ordering test below for eviction-order coverage, and just prove the cap is enforced
723        // here (all entries share the same instant, so ties are broken by iteration order).
724        store.store(b"aaaaaaaaaa", "default", None).unwrap();
725        store.store(b"bbbbbbbbbb", "default", None).unwrap();
726        store.store(b"cccccccccc", "default", None).unwrap();
727
728        let outcome = store.gc(Some(15)).unwrap();
729        assert!(
730            outcome.evicted_removed >= 1,
731            "at least one entry must be evicted over cap"
732        );
733        assert_eq!(outcome.expired_removed, 0);
734    }
735
736    #[test]
737    fn filesystem_gc_evicts_oldest_stored_at_first() {
738        let root = temp_root("gc_order");
739        let store = RetrievalStore::filesystem(&root);
740        let old = store.store(b"oldest-entry-here", "default", None).unwrap();
741        std::thread::sleep(std::time::Duration::from_millis(1100));
742        let newer = store.store(b"newest-entry", "default", None).unwrap();
743
744        // Force the older entry's stored_at further into the past so ordering is unambiguous
745        // regardless of clock resolution, then cap tight enough to evict exactly one entry.
746        let meta_path = root.join("default").join(format!("{}.meta.json", old.hash));
747        let mut meta: serde_json::Value =
748            serde_json::from_slice(&std::fs::read(&meta_path).unwrap()).unwrap();
749        meta["stored_at_unix"] = serde_json::json!(1);
750        std::fs::write(&meta_path, serde_json::to_vec(&meta).unwrap()).unwrap();
751
752        let outcome = store.gc(Some(newer.bytes as u64)).unwrap();
753        assert_eq!(outcome.evicted_removed, 1);
754        assert_eq!(
755            store.retrieve(&old.hash, "default"),
756            RetrievalOutcome::Missing,
757            "the older entry must be the one evicted"
758        );
759        assert!(matches!(
760            store.retrieve(&newer.hash, "default"),
761            RetrievalOutcome::Found(_)
762        ));
763
764        std::fs::remove_dir_all(&root).ok();
765    }
766
767    #[test]
768    fn store_refuses_bytes_containing_a_known_secret_pattern() {
769        let store = RetrievalStore::memory();
770        let err = store
771            .store(b"AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE", "default", None)
772            .unwrap_err();
773        assert!(matches!(err, TokenFoldError::SafetyViolation(_)));
774    }
775
776    #[test]
777    fn filesystem_backend_also_refuses_secret_bearing_bytes() {
778        let root = temp_root("secret_gate");
779        let store = RetrievalStore::filesystem(&root);
780        let err = store
781            .store(
782                b"Authorization: Bearer abcDEF123.token-value",
783                "default",
784                None,
785            )
786            .unwrap_err();
787        assert!(matches!(err, TokenFoldError::SafetyViolation(_)));
788        // Nothing should have been written to disk.
789        assert!(!root.join("default").exists());
790        std::fs::remove_dir_all(&root).ok();
791    }
792
793    #[test]
794    fn filesystem_batch_failure_commits_no_new_entries() {
795        let root = temp_root("batch_rollback");
796        let store = RetrievalStore::filesystem(&root);
797        let entries = [
798            (b"safe first".as_slice(), "default", None),
799            (
800                b"Authorization: Bearer abcDEF123.token-value".as_slice(),
801                "default",
802                None,
803            ),
804        ];
805        assert!(store.store_batch(&entries).is_err());
806        assert_eq!(
807            store.retrieve(&hex_sha256(b"safe first"), "default"),
808            RetrievalOutcome::Missing
809        );
810        std::fs::remove_dir_all(&root).ok();
811    }
812
813    #[test]
814    fn filesystem_store_and_gc_do_not_expose_partial_entries() {
815        let root = temp_root("store_gc_race");
816        let store = std::sync::Arc::new(RetrievalStore::filesystem(&root));
817        let writer = {
818            let store = std::sync::Arc::clone(&store);
819            std::thread::spawn(move || {
820                for i in 0..50 {
821                    let bytes = format!("entry-{i}-{}", "x".repeat(256));
822                    let marker = store.store(bytes.as_bytes(), "default", None).unwrap();
823                    assert_eq!(
824                        store.retrieve(&marker.hash, "default"),
825                        RetrievalOutcome::Found(bytes.into_bytes())
826                    );
827                }
828            })
829        };
830        let collector = {
831            let store = std::sync::Arc::clone(&store);
832            std::thread::spawn(move || {
833                for _ in 0..50 {
834                    store.gc(None).unwrap();
835                }
836            })
837        };
838        writer.join().unwrap();
839        collector.join().unwrap();
840        std::fs::remove_dir_all(&root).ok();
841    }
842
843    #[test]
844    fn open_rejects_sqlite_backend_as_a_clear_config_error() {
845        // `RetrievalStore` isn't `Debug` (it holds a `Mutex`), so assert via `Result::err`
846        // rather than `unwrap_err`.
847        let err = RetrievalStore::open("sqlite", "sha256", None)
848            .err()
849            .unwrap();
850        assert!(matches!(err, TokenFoldError::ConfigError(_)));
851    }
852
853    #[test]
854    fn open_rejects_blake3_hash_algorithm_as_a_clear_config_error() {
855        let err = RetrievalStore::open("filesystem", "blake3", None)
856            .err()
857            .unwrap();
858        assert!(matches!(err, TokenFoldError::ConfigError(_)));
859    }
860
861    #[test]
862    fn open_accepts_memory_and_filesystem_with_sha256() {
863        assert!(RetrievalStore::open("memory", "sha256", None).is_ok());
864        assert!(RetrievalStore::open("filesystem", "sha256", Some(temp_root("open_ok"))).is_ok());
865    }
866
867    #[test]
868    fn unsafe_namespace_is_rejected_by_store_and_missing_from_retrieve() {
869        let store = RetrievalStore::memory();
870        assert!(store.store(b"data", "../escape", None).is_err());
871        assert_eq!(
872            store.retrieve("deadbeef", "../escape"),
873            RetrievalOutcome::Missing
874        );
875    }
876
877    #[test]
878    fn parses_all_supported_retrieval_reference_forms() {
879        let hash = "A".repeat(64);
880        let raw = parse_retrieval_reference(&hash).unwrap();
881        assert_eq!(raw.hash, "a".repeat(64));
882        assert_eq!(raw.namespace, None);
883
884        let legacy = parse_retrieval_reference(&format!(
885            "[tokenfold:retrieve hash={hash} alg=sha256 namespace=project bytes=1]"
886        ))
887        .unwrap();
888        assert_eq!(legacy.namespace.as_deref(), Some("project"));
889
890        let json = parse_retrieval_reference(&format!(
891            r#"{{"$tf_ref":{{"hash":"{hash}","alg":"sha256","namespace":"project"}}}}"#
892        ))
893        .unwrap();
894        assert_eq!(json, legacy);
895    }
896
897    #[test]
898    fn rejects_malformed_retrieval_references() {
899        assert!(parse_retrieval_reference("deadbeef").is_err());
900        assert!(
901            parse_retrieval_reference(&format!(
902                "[tokenfold:retrieve hash={} alg=blake3]",
903                "a".repeat(64)
904            ))
905            .is_err()
906        );
907        assert!(
908            parse_retrieval_reference(&format!(
909                r#"{{"$tf_ref":{{"hash":"{}","namespace":"../escape"}}}}"#,
910                "a".repeat(64)
911            ))
912            .is_err()
913        );
914    }
915}