Skip to main content

dig_urn_resolver/
cache.rs

1//! Response caching — an ADDITIVE layer in front of `resolve` that NEVER weakens
2//! fail-closed. URNs are content-addressed → immutable → cacheable.
3//!
4//! # What is safe to cache
5//!
6//! * **Only VERIFIED `Success` bytes.** An `IntegrityFailure` / `Unreachable` /
7//!   `NotFound` / any error outcome is NEVER cached (a cached `Unreachable` would
8//!   block recovery when the network returns; caching a failure is simply wrong).
9//! * **Keyed by the content-addressed identity** `storeId:root:resourceKey:salt`
10//!   with the CONCRETE resolved root — never the raw request URN. A root-pinned URN
11//!   is immutable; a rootless URN is cached under the root the resolve actually
12//!   produced (from the node's `X-Dig-Root`), so it can't go stale when the store
13//!   advances.
14//!
15//! # Two tiers, two trust levels
16//!
17//! * **Memory (LRU, bounded):** process-trusted — it only ever holds what THIS
18//!   process already verified THIS run, so a memory hit may skip re-verification.
19//! * **Disk (optional, native):** UNTRUSTED storage. It caches the *verifiable
20//!   artifacts* (ciphertext + inclusion proof + chunk lengths), NOT plaintext, so a
21//!   disk hit is RE-VERIFIED against the URN's chain-anchored root before use (see
22//!   [`DiskArtifacts`]). A tampered on-disk file therefore FAILS verification →
23//!   `IntegrityFailure`, and its bytes are never served. Filenames are the SHA-256
24//!   of the identity (content-addressed, no path-traversal from the URN).
25
26use crate::resolver::ResolvedData;
27use std::cell::RefCell;
28use std::collections::HashMap;
29
30/// The content-addressed cache identity: `storeId:root:resourceKey:salt`. `root` MUST
31/// be the CONCRETE resolved root (pinned root, or the node's `X-Dig-Root`).
32pub fn content_id(store_id: &str, root: &str, resource_key: &str, salt: Option<&str>) -> String {
33    format!("{store_id}:{root}:{resource_key}:{}", salt.unwrap_or(""))
34}
35
36/// The verifiable artifacts of an rpc-path fetch — enough to RE-VERIFY the bytes
37/// from scratch against the URN's root. Persisted to the disk cache so a disk hit is
38/// re-verified (never trusted blindly).
39#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
40pub struct DiskArtifacts {
41    /// The served ciphertext (raw bytes).
42    pub ciphertext: Vec<u8>,
43    /// The base64 merkle inclusion proof.
44    pub proof_b64: String,
45    /// Per-chunk ciphertext byte lengths (empty ⇒ single chunk).
46    pub chunk_lens: Vec<u32>,
47}
48
49// ---------------------------------------------------------------------------
50// In-memory LRU (process-trusted)
51// ---------------------------------------------------------------------------
52
53/// A bounded in-memory LRU of verified plaintext, keyed by [`content_id`]. Bounded
54/// by BOTH an entry count and a total-byte budget (this ends up in a wallet — no
55/// unbounded growth). Process-trusted: a hit returns without re-verification.
56pub struct MemoryCache {
57    max_entries: usize,
58    max_bytes: usize,
59    inner: RefCell<Inner>,
60}
61
62struct Inner {
63    tick: u64,
64    bytes: usize,
65    map: HashMap<String, Entry>,
66}
67
68struct Entry {
69    last_used: u64,
70    data: ResolvedData,
71}
72
73impl MemoryCache {
74    /// A cache bounded to `max_entries` entries and `max_bytes` total bytes.
75    pub fn new(max_entries: usize, max_bytes: usize) -> Self {
76        MemoryCache {
77            max_entries,
78            max_bytes,
79            inner: RefCell::new(Inner {
80                tick: 0,
81                bytes: 0,
82                map: HashMap::new(),
83            }),
84        }
85    }
86
87    /// A verified hit (bumps recency), or `None`.
88    pub fn get(&self, id: &str) -> Option<ResolvedData> {
89        let mut inner = self.inner.borrow_mut();
90        inner.tick += 1;
91        let tick = inner.tick;
92        let entry = inner.map.get_mut(id)?;
93        entry.last_used = tick;
94        Some(entry.data.clone())
95    }
96
97    /// Insert verified plaintext, evicting the least-recently-used entries until both
98    /// bounds hold. An entry larger than the whole byte budget is simply not cached.
99    pub fn put(&self, id: String, data: ResolvedData) {
100        let size = data.bytes.len();
101        if self.max_entries == 0 || size > self.max_bytes {
102            return;
103        }
104        let mut inner = self.inner.borrow_mut();
105        inner.tick += 1;
106        let tick = inner.tick;
107        if let Some(prev) = inner.map.insert(
108            id,
109            Entry {
110                last_used: tick,
111                data,
112            },
113        ) {
114            inner.bytes -= prev.data.bytes.len();
115        }
116        inner.bytes += size;
117        self.evict(&mut inner);
118    }
119
120    /// Evict LRU entries until within both bounds.
121    fn evict(&self, inner: &mut Inner) {
122        while inner.map.len() > self.max_entries || inner.bytes > self.max_bytes {
123            let Some(victim) = inner
124                .map
125                .iter()
126                .min_by_key(|(_, e)| e.last_used)
127                .map(|(k, _)| k.clone())
128            else {
129                break;
130            };
131            if let Some(removed) = inner.map.remove(&victim) {
132                inner.bytes -= removed.data.bytes.len();
133            }
134        }
135    }
136}
137
138/// The default memory-cache bounds: 256 entries or 32 MiB, whichever binds first.
139pub const DEFAULT_MEMORY_ENTRIES: usize = 256;
140/// See [`DEFAULT_MEMORY_ENTRIES`].
141pub const DEFAULT_MEMORY_BYTES: usize = 32 * 1024 * 1024;
142
143// ---------------------------------------------------------------------------
144// Disk cache (UNTRUSTED storage — always re-verified on read)
145//
146// Two backends behind ONE interface (`new`/`get`/`put`/`remove`): `std::fs` for the
147// native build, and Node's `fs` (via the injected `node_fs` seam) for the wasm build
148// running under Node. In the browser the wasm backend is inert — see `node_fs`.
149// ---------------------------------------------------------------------------
150
151#[cfg(feature = "native")]
152pub use disk::DiskCache;
153
154#[cfg(all(feature = "wasm", not(feature = "native")))]
155pub use disk_wasm::DiskCache;
156
157#[cfg(feature = "native")]
158mod disk {
159    use super::DiskArtifacts;
160    use digstore_core::hash::sha256;
161    use std::path::PathBuf;
162
163    /// A content-addressed disk cache of [`DiskArtifacts`]. UNTRUSTED: every read is
164    /// re-verified by the caller against the URN's root before use.
165    pub struct DiskCache {
166        dir: PathBuf,
167    }
168
169    impl DiskCache {
170        /// Open (creating the directory) a disk cache rooted at `dir`.
171        pub fn new(dir: impl Into<PathBuf>) -> Self {
172            let dir = dir.into();
173            let _ = std::fs::create_dir_all(&dir);
174            DiskCache { dir }
175        }
176
177        /// The content-addressed file path for an identity — `SHA-256(id)` hex, so a
178        /// malicious URN can never traverse out of the cache directory.
179        fn path(&self, id: &str) -> PathBuf {
180            self.dir
181                .join(format!("{}.json", sha256(id.as_bytes()).to_hex()))
182        }
183
184        /// Load the stored artifacts for `id`, or `None` on miss / unreadable /
185        /// malformed (a corrupt envelope is a miss, not a crash).
186        pub fn get(&self, id: &str) -> Option<DiskArtifacts> {
187            let raw = std::fs::read(self.path(id)).ok()?;
188            serde_json::from_slice(&raw).ok()
189        }
190
191        /// Persist the verifiable artifacts for `id` (best-effort; ignore I/O errors).
192        pub fn put(&self, id: &str, artifacts: &DiskArtifacts) {
193            if let Ok(bytes) = serde_json::to_vec(artifacts) {
194                let _ = std::fs::write(self.path(id), bytes);
195            }
196        }
197
198        /// Remove a (failed-verification / stale) entry, best-effort.
199        pub fn remove(&self, id: &str) {
200            let _ = std::fs::remove_file(self.path(id));
201        }
202    }
203}
204
205#[cfg(all(feature = "wasm", not(feature = "native")))]
206mod disk_wasm {
207    use super::DiskArtifacts;
208    use crate::node_fs;
209    use digstore_core::hash::sha256;
210
211    /// A content-addressed disk cache backed by Node's `fs` (the wasm build). Mirrors
212    /// the native [`super::disk::DiskCache`] byte-for-byte on disk (a `SHA-256(id).json`
213    /// envelope of [`DiskArtifacts`]), so the two backends are interchangeable and a
214    /// cache written by one is readable by the other. UNTRUSTED: every read is
215    /// re-verified by the caller against the URN's root. In the browser, `node_fs` is
216    /// inert, so `get` always misses and `put`/`remove` are no-ops.
217    pub struct DiskCache {
218        dir: String,
219    }
220
221    impl DiskCache {
222        /// Open (creating the directory under Node) a disk cache rooted at `dir`.
223        pub fn new(dir: impl AsRef<str>) -> Self {
224            let dir = dir.as_ref().trim_end_matches('/').to_string();
225            node_fs::mkdir_all(&dir);
226            DiskCache { dir }
227        }
228
229        /// The content-addressed file path for an identity — `SHA-256(id)` hex, so a
230        /// malicious URN can never traverse out of the cache directory.
231        fn path(&self, id: &str) -> String {
232            format!("{}/{}.json", self.dir, sha256(id.as_bytes()).to_hex())
233        }
234
235        /// Load the stored artifacts for `id`, or `None` on miss / unreadable /
236        /// malformed (a corrupt envelope is a miss, not a crash).
237        pub fn get(&self, id: &str) -> Option<DiskArtifacts> {
238            let raw = node_fs::read_file(&self.path(id))?;
239            serde_json::from_slice(&raw).ok()
240        }
241
242        /// Persist the verifiable artifacts for `id` (best-effort; ignore I/O errors).
243        pub fn put(&self, id: &str, artifacts: &DiskArtifacts) {
244            if let Ok(bytes) = serde_json::to_vec(artifacts) {
245                node_fs::write_file(&self.path(id), &bytes);
246            }
247        }
248
249        /// Remove a (failed-verification / stale) entry, best-effort.
250        pub fn remove(&self, id: &str) {
251            node_fs::remove_file(&self.path(id));
252        }
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259    use crate::resolver::ResolvedData;
260
261    fn data(n: usize) -> ResolvedData {
262        ResolvedData::new(vec![0u8; n], "image/png".into())
263    }
264
265    #[test]
266    fn content_id_is_stable_and_distinguishes_salt_and_root() {
267        assert_eq!(
268            content_id("s", "r", "a.png", None),
269            content_id("s", "r", "a.png", None)
270        );
271        assert_ne!(
272            content_id("s", "r1", "a.png", None),
273            content_id("s", "r2", "a.png", None)
274        );
275        assert_ne!(
276            content_id("s", "r", "a.png", Some("aa")),
277            content_id("s", "r", "a.png", None)
278        );
279    }
280
281    #[test]
282    fn memory_cache_hits_and_misses() {
283        let c = MemoryCache::new(8, 1 << 20);
284        assert!(c.get("k").is_none());
285        c.put("k".into(), data(10));
286        assert_eq!(c.get("k").unwrap().bytes.len(), 10);
287    }
288
289    #[test]
290    fn memory_cache_evicts_lru_at_entry_cap() {
291        let c = MemoryCache::new(2, 1 << 20);
292        c.put("a".into(), data(1));
293        c.put("b".into(), data(1));
294        let _ = c.get("a"); // 'a' now most-recently-used, 'b' is LRU
295        c.put("c".into(), data(1)); // evicts 'b'
296        assert!(c.get("a").is_some());
297        assert!(c.get("c").is_some());
298        assert!(c.get("b").is_none(), "LRU entry evicted");
299    }
300
301    #[test]
302    fn memory_cache_evicts_at_byte_cap() {
303        let c = MemoryCache::new(100, 100);
304        c.put("a".into(), data(60));
305        c.put("b".into(), data(60)); // total 120 > 100 → evict 'a'
306        assert!(c.get("a").is_none());
307        assert!(c.get("b").is_some());
308        // An oversized entry is simply not cached.
309        c.put("big".into(), data(200));
310        assert!(c.get("big").is_none());
311    }
312}