Skip to main content

qualia_client_core/wellfair/
blob_store.rs

1//! Content-addressed blob store for WellFair payloads (master plan ยง5).
2//!
3//! Large or structured payloads that don't belong inline on the journal โ€” credential claim
4//! sets, clinical attachment bytes, letter scans โ€” are stored here as content-addressed blobs.
5//! Their identifier is the SHA-256 (hex) of the bytes, which is exactly the `blob_hash` the
6//! record envelope already carries, so a journal row's `blob_hash` is a direct handle to its
7//! blob. Writes are idempotent (same bytes โ†’ same file) and reads verify integrity.
8
9use 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
17/// Content-addressed store rooted under `{storage_root}/wellfair/blobs`.
18pub struct BlobStore {
19    dir: PathBuf,
20}
21
22fn sha256_hex(bytes: &[u8]) -> String {
23    hex::encode(Sha256::digest(bytes))
24}
25
26/// A content hash is a 64-char lowercase hex string; reject anything else so a hostile or
27/// malformed handle can never escape the blob directory (path-traversal defense).
28fn 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    /// Store bytes, returning their content hash. Idempotent: re-storing identical bytes is a
48    /// no-op that returns the same hash. Written via a temp file + rename so a reader never sees
49    /// a partial blob.
50    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    /// Read a blob by content hash, verifying integrity. Returns `Ok(None)` if absent, and an
71    /// error if the stored bytes no longer hash to the requested handle (corruption/tamper).
72    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        // Path-traversal attempt and wrong-length handles must not resolve.
134        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        // Overwrite the stored file with different bytes under the same name.
145        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}