qualia_client_core/wellfair/
blob_store.rs1use std::fs::{self, OpenOptions};
10use std::io::Write;
11use std::path::{Path, PathBuf};
12
13use sha2::{Digest, Sha256};
14
15pub const BLOB_DIR: &str = "wellfair/blobs";
16
17pub struct BlobStore {
19 dir: PathBuf,
20}
21
22fn sha256_hex(bytes: &[u8]) -> String {
23 hex::encode(Sha256::digest(bytes))
24}
25
26fn is_valid_hash(hash_hex: &str) -> bool {
29 hash_hex.len() == 64 && hash_hex.bytes().all(|b| b.is_ascii_hexdigit())
30}
31
32impl BlobStore {
33 pub fn open(storage_root: impl AsRef<Path>) -> std::io::Result<Self> {
34 let dir = storage_root.as_ref().join(BLOB_DIR);
35 fs::create_dir_all(&dir)?;
36 Ok(Self { dir })
37 }
38
39 fn path_for(&self, hash_hex: &str) -> Option<PathBuf> {
40 if is_valid_hash(hash_hex) {
41 Some(self.dir.join(hash_hex))
42 } else {
43 None
44 }
45 }
46
47 pub fn put(&self, bytes: &[u8]) -> std::io::Result<String> {
51 let hash = sha256_hex(bytes);
52 let path = self.dir.join(&hash);
53 if path.exists() {
54 return Ok(hash);
55 }
56 let tmp = self.dir.join(format!("{hash}.tmp"));
57 {
58 let mut file = OpenOptions::new()
59 .create(true)
60 .write(true)
61 .truncate(true)
62 .open(&tmp)?;
63 file.write_all(bytes)?;
64 file.sync_all()?;
65 }
66 fs::rename(&tmp, &path)?;
67 Ok(hash)
68 }
69
70 pub fn get(&self, hash_hex: &str) -> std::io::Result<Option<Vec<u8>>> {
73 let Some(path) = self.path_for(hash_hex) else {
74 return Err(std::io::Error::new(
75 std::io::ErrorKind::InvalidInput,
76 "invalid blob hash",
77 ));
78 };
79 if !path.exists() {
80 return Ok(None);
81 }
82 let bytes = fs::read(&path)?;
83 if sha256_hex(&bytes) != hash_hex {
84 return Err(std::io::Error::new(
85 std::io::ErrorKind::InvalidData,
86 "blob content hash mismatch (corrupt or tampered)",
87 ));
88 }
89 Ok(Some(bytes))
90 }
91
92 pub fn exists(&self, hash_hex: &str) -> bool {
93 self.path_for(hash_hex).map(|p| p.exists()).unwrap_or(false)
94 }
95}
96
97#[cfg(test)]
98mod tests {
99 use super::*;
100
101 #[test]
102 fn put_returns_content_hash_and_round_trips() {
103 let dir = tempfile::tempdir().unwrap();
104 let store = BlobStore::open(dir.path()).unwrap();
105 let payload = b"{\"claims\":[[\"postcode\",\"3000\"]]}";
106 let hash = store.put(payload).unwrap();
107 assert_eq!(hash.len(), 64);
108 assert!(store.exists(&hash));
109 assert_eq!(store.get(&hash).unwrap().unwrap(), payload);
110 }
111
112 #[test]
113 fn put_is_idempotent() {
114 let dir = tempfile::tempdir().unwrap();
115 let store = BlobStore::open(dir.path()).unwrap();
116 let h1 = store.put(b"same bytes").unwrap();
117 let h2 = store.put(b"same bytes").unwrap();
118 assert_eq!(h1, h2);
119 }
120
121 #[test]
122 fn get_missing_is_none() {
123 let dir = tempfile::tempdir().unwrap();
124 let store = BlobStore::open(dir.path()).unwrap();
125 let absent = sha256_hex(b"never stored");
126 assert!(store.get(&absent).unwrap().is_none());
127 }
128
129 #[test]
130 fn invalid_hash_is_rejected() {
131 let dir = tempfile::tempdir().unwrap();
132 let store = BlobStore::open(dir.path()).unwrap();
133 assert!(store.get("../secret").is_err());
135 assert!(store.get("deadbeef").is_err());
136 assert!(!store.exists("../secret"));
137 }
138
139 #[test]
140 fn tampered_blob_fails_integrity() {
141 let dir = tempfile::tempdir().unwrap();
142 let store = BlobStore::open(dir.path()).unwrap();
143 let hash = store.put(b"original").unwrap();
144 fs::write(dir.path().join(BLOB_DIR).join(&hash), b"tampered").unwrap();
146 assert!(store.get(&hash).is_err());
147 }
148
149 #[test]
150 fn survives_reopen() {
151 let dir = tempfile::tempdir().unwrap();
152 let hash = {
153 let store = BlobStore::open(dir.path()).unwrap();
154 store.put(b"persisted payload").unwrap()
155 };
156 let reopened = BlobStore::open(dir.path()).unwrap();
157 assert_eq!(reopened.get(&hash).unwrap().unwrap(), b"persisted payload");
158 }
159}