Skip to main content

storage/
digest_cache.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! Content-addressed digest storage: the same bytes
7//! are digested once for their whole life.
8//!
9//! The address is the content hash, so a second put of the same content
10//! is not an update — there is nothing it could change. That is what
11//! makes the cache safe without a version field, a timestamp, or an
12//! eviction policy: entries never go stale, they only stop being
13//! wanted, and `invalidate` is the explicit way to say so.
14//!
15//! Writes go through tmp-then-rename, the same discipline as the CAS: a
16//! crash leaves a partial file in `tmp/`, never a half-written entry at
17//! its final name.
18//!
19//! **Whether an entry is there has one spelling: [`Vfs::exists`].**
20//! Asking by reading the whole file would answer "not cached" for an
21//! entry this process may not open and pay a full read to learn a
22//! yes/no. Reading is for `get`, which wants the bytes (`crates/storage/spec/DigestCache.lean`
23//! §8-11).
24
25use std::path::{Path, PathBuf};
26
27use kernel::{B3Hash, Payload};
28use serde_json::{Map, Value};
29
30use crate::error::{StorageError, io_err};
31use crate::real_fs::RealFs;
32use crate::vfs::Vfs;
33
34pub struct DigestCache {
35    dir: PathBuf,
36    fs: RealFs,
37}
38
39impl DigestCache {
40    pub fn open(dir: &Path) -> Result<DigestCache, StorageError> {
41        let mut fs = RealFs::new();
42        fs.create_dir_all(dir)
43            .map_err(io_err("create digest cache dir", dir))?;
44        let tmp = dir.join("tmp");
45        fs.create_dir_all(&tmp)
46            .map_err(io_err("create digest cache tmp", &tmp))?;
47        // Sweep crash residue: rename is atomic, so anything still in
48        // tmp is a half-written entry from a previous process (the same
49        // discipline as the CAS, not a second one).
50        let leftovers = fs.list(&tmp).map_err(io_err("list digest tmp", &tmp))?;
51        for leftover in leftovers {
52            fs.remove_file(&leftover)
53                .map_err(io_err("sweep digest tmp", &leftover))?;
54        }
55        Ok(DigestCache {
56            dir: dir.to_path_buf(),
57            fs,
58        })
59    }
60
61    fn entry_path(&self, content: &B3Hash) -> PathBuf {
62        self.dir.join(format!("{content}.json"))
63    }
64
65    /// Stores a digest for content that has none. Content already
66    /// digested is left exactly as it was — same address, same bytes,
67    /// nothing to decide.
68    pub fn put(&mut self, content: &B3Hash, tree_json: &[u8]) -> Result<(), StorageError> {
69        let final_path = self.entry_path(content);
70        if self.fs.exists(&final_path) {
71            return Ok(());
72        }
73        let tmp_path = self.dir.join("tmp").join(format!("{content}.part"));
74        if self.fs.exists(&tmp_path) {
75            self.fs
76                .truncate(&tmp_path, 0)
77                .map_err(io_err("reset digest tmp", &tmp_path))?;
78        }
79        self.fs
80            .append(&tmp_path, tree_json)
81            .map_err(io_err("write digest entry", &tmp_path))?;
82        self.fs
83            .sync_data(&tmp_path)
84            .map_err(io_err("sync digest entry", &tmp_path))?;
85        self.fs
86            .rename(&tmp_path, &final_path)
87            .map_err(io_err("publish digest entry", &final_path))?;
88        self.fs
89            .sync_dir(&self.dir)
90            .map_err(io_err("sync digest cache dir", &self.dir))?;
91        Ok(())
92    }
93
94    pub fn get(&self, content: &B3Hash) -> Result<Option<Vec<u8>>, StorageError> {
95        match self.fs.read(&self.entry_path(content)) {
96            Ok(bytes) => Ok(Some(bytes)),
97            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
98            Err(err) => Err(io_err("read digest entry", &self.entry_path(content))(err)),
99        }
100    }
101
102    /// Drops an entry and returns the `digest_invalidated` payload. The
103    /// next digest of this content re-runs; invalidating something that
104    /// was never cached is not an error, because the end state is the
105    /// one the caller asked for.
106    pub fn invalidate(&mut self, content: &B3Hash, reason: &str) -> Result<Payload, StorageError> {
107        let path = self.entry_path(content);
108        let existed = self.fs.exists(&path);
109        if existed {
110            self.fs
111                .remove_file(&path)
112                .map_err(io_err("remove digest entry", &path))?;
113        }
114        let mut map = Map::new();
115        map.insert("content".to_owned(), Value::String(content.to_string()));
116        map.insert("reason".to_owned(), Value::String(reason.to_owned()));
117        map.insert("existed".to_owned(), Value::Bool(existed));
118        Payload::new(map).map_err(|source| StorageError::Draft { source })
119    }
120}
121
122#[cfg(test)]
123#[allow(
124    clippy::unwrap_used,
125    clippy::expect_used,
126    clippy::panic,
127    clippy::indexing_slicing,
128    reason = "test code"
129)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn the_same_content_digests_once_for_life() {
135        let tmp = tempfile::tempdir().unwrap();
136        let mut cache = DigestCache::open(tmp.path()).unwrap();
137        let content = B3Hash::digest(b"a long document");
138        cache.put(&content, br#"{"tree":"first"}"#).unwrap();
139        // A second put of the same content changes nothing: the address
140        // is the content, so there is no newer version of it.
141        cache.put(&content, br#"{"tree":"second"}"#).unwrap();
142        assert_eq!(
143            cache.get(&content).unwrap().as_deref(),
144            Some(&br#"{"tree":"first"}"#[..])
145        );
146    }
147
148    #[test]
149    fn a_miss_is_absence_not_failure() {
150        let tmp = tempfile::tempdir().unwrap();
151        let cache = DigestCache::open(tmp.path()).unwrap();
152        let never = B3Hash::digest(b"never stored");
153        assert_eq!(cache.get(&never).unwrap(), None);
154    }
155
156    #[test]
157    fn invalidation_removes_the_entry_and_says_whether_it_was_there() {
158        let tmp = tempfile::tempdir().unwrap();
159        let mut cache = DigestCache::open(tmp.path()).unwrap();
160        let content = B3Hash::digest(b"doc");
161        cache.put(&content, b"{}").unwrap();
162        let payload = cache.invalidate(&content, "source edited").unwrap();
163        let value = serde_json::to_value(&payload).unwrap();
164        assert_eq!(value["existed"], true);
165        assert_eq!(value["reason"], "source edited");
166        assert_eq!(cache.get(&content).unwrap(), None);
167        // Invalidating an absent entry reaches the same end state.
168        let payload = cache.invalidate(&content, "again").unwrap();
169        assert_eq!(serde_json::to_value(&payload).unwrap()["existed"], false);
170    }
171
172    #[test]
173    fn a_crash_between_write_and_publish_leaves_nothing_at_the_final_name() {
174        let tmp = tempfile::tempdir().unwrap();
175        let mut cache = DigestCache::open(tmp.path()).unwrap();
176        let content = B3Hash::digest(b"doc");
177        // Simulate the torn write: a leftover part file, no final entry.
178        std::fs::write(
179            tmp.path().join("tmp").join(format!("{content}.part")),
180            b"half",
181        )
182        .unwrap();
183        assert_eq!(
184            cache.get(&content).unwrap(),
185            None,
186            "no half entry is visible"
187        );
188        // Reopening sweeps the debris; either way the next put must
189        // publish exactly what it was given, never debris plus content.
190        cache.put(&content, b"{\"tree\":\"ok\"}").unwrap();
191        assert_eq!(
192            cache.get(&content).unwrap().as_deref(),
193            Some(&b"{\"tree\":\"ok\"}"[..])
194        );
195    }
196}