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}