1use 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
30pub const DEFAULT_TTL_SECONDS: u64 = 604_800;
32
33#[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#[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#[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
150pub struct MemoryEntry {
154 bytes: Vec<u8>,
155 meta: EntryMeta,
156}
157
158pub 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 pub fn default_filesystem() -> Self {
178 Self::filesystem(default_store_path())
179 }
180
181 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 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 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 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 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
542fn 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
559pub 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
576pub 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 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 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 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 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 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 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 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}