Skip to main content

hpr_net/
cache.rs

1//! The on-disk cache: one body file and one metadata file per URL.
2
3use std::ffi::OsString;
4use std::fs;
5use std::io::ErrorKind;
6use std::path::{Path, PathBuf};
7use std::sync::atomic::{AtomicU64, Ordering};
8
9use serde::{Deserialize, Serialize};
10
11use crate::NetError;
12
13/// A directory of cached responses, keyed by URL.
14///
15/// Each URL is stored as `<key>.bin` (the bytes) and `<key>.json` (a [`CacheEntry`] without the
16/// bytes), where `<key>` is the 64-bit FNV-1a hash of the URL in hex. The metadata records the URL
17/// itself, so two URLs whose hashes collide read as a miss rather than as each other's data. Each
18/// file is written whole to a temporary name and renamed, the metadata last. Nothing locks the
19/// cache: two writers of one URL may leave one's body beside the other's fetch time.
20#[derive(Debug, Clone)]
21pub struct Cache {
22    dir: PathBuf,
23}
24
25/// What the cache knows about one URL.
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct CacheEntry {
28    /// The URL the bytes came from.
29    pub url: String,
30    /// When they were fetched, in seconds since the Unix epoch.
31    pub fetched_at_s: u64,
32    /// The response body.
33    #[serde(skip)]
34    pub body: Vec<u8>,
35}
36
37impl Cache {
38    /// A cache in `dir`, created on the first write.
39    pub fn new(dir: impl Into<PathBuf>) -> Self {
40        Self { dir: dir.into() }
41    }
42
43    /// The directory the cache lives in.
44    pub fn dir(&self) -> &Path {
45        &self.dir
46    }
47
48    /// The standard place for hpr's cache on this platform, or `None` if the environment names no
49    /// home folder.
50    ///
51    /// `HPR_CACHE_DIR`, when set and not empty, wins on every platform; it is used as given, so a
52    /// relative path is taken from the current directory. Otherwise:
53    ///
54    /// | platform | folder |
55    /// |---|---|
56    /// | macOS | `$HOME/Library/Caches/hpr-sim` |
57    /// | Windows | `%LOCALAPPDATA%\hpr-sim\cache` |
58    /// | Linux and other Unix | `$XDG_CACHE_HOME/hpr-sim`, or `$HOME/.cache/hpr-sim` |
59    ///
60    /// `XDG_CACHE_HOME` counts only when it is an absolute path, as the XDG Base Directory
61    /// Specification says. The folder is not created until the first write.
62    pub fn platform_dir() -> Option<PathBuf> {
63        platform_dir_from(std::env::consts::OS, |name| std::env::var_os(name))
64    }
65
66    fn paths(&self, url: &str) -> (PathBuf, PathBuf) {
67        let key = format!("{:016x}", fnv1a64(url.as_bytes()));
68        (
69            self.dir.join(format!("{key}.json")),
70            self.dir.join(format!("{key}.bin")),
71        )
72    }
73
74    /// The cached entry for `url`, or `None` if there is none.
75    ///
76    /// # Errors
77    /// [`NetError::Cache`] if a file exists but cannot be read; [`NetError::CorruptEntry`] if the
78    /// metadata does not parse.
79    pub fn get(&self, url: &str) -> Result<Option<CacheEntry>, NetError> {
80        let (meta_path, body_path) = self.paths(url);
81        let Some(meta) = read_optional(&meta_path)? else {
82            return Ok(None);
83        };
84        let mut entry: CacheEntry =
85            serde_json::from_slice(&meta).map_err(|e| NetError::CorruptEntry {
86                path: meta_path.clone(),
87                reason: e.to_string(),
88            })?;
89        if entry.url != url {
90            // A hash collision: this slot holds another URL.
91            return Ok(None);
92        }
93        let Some(body) = read_optional(&body_path)? else {
94            return Ok(None);
95        };
96        entry.body = body;
97        Ok(Some(entry))
98    }
99
100    /// Stores `body` as `url`'s response, fetched at `fetched_at_s`.
101    ///
102    /// # Errors
103    /// [`NetError::Cache`] if the directory or a file cannot be written.
104    pub fn put(&self, url: &str, body: &[u8], fetched_at_s: u64) -> Result<(), NetError> {
105        fs::create_dir_all(&self.dir).map_err(|source| NetError::Cache {
106            path: self.dir.clone(),
107            source,
108        })?;
109        let (meta_path, body_path) = self.paths(url);
110        let meta = CacheEntry {
111            url: url.to_owned(),
112            fetched_at_s,
113            body: Vec::new(),
114        };
115        let meta = serde_json::to_vec(&meta).map_err(|e| NetError::CorruptEntry {
116            path: meta_path.clone(),
117            reason: e.to_string(),
118        })?;
119        write_atomic(&body_path, body)?;
120        write_atomic(&meta_path, &meta)
121    }
122}
123
124/// [`Cache::platform_dir`] for operating system `os` (as [`std::env::consts::OS`] names it), with
125/// `var` reading the environment.
126fn platform_dir_from(os: &str, var: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
127    let set = |name: &str| {
128        var(name)
129            .filter(|value| !value.is_empty())
130            .map(PathBuf::from)
131    };
132    if let Some(dir) = set("HPR_CACHE_DIR") {
133        return Some(dir);
134    }
135    match os {
136        "macos" => set("HOME").map(|home| home.join("Library").join("Caches").join("hpr-sim")),
137        "windows" => set("LOCALAPPDATA").map(|local| local.join("hpr-sim").join("cache")),
138        _ => set("XDG_CACHE_HOME")
139            .filter(|dir| dir.is_absolute())
140            .or_else(|| set("HOME").map(|home| home.join(".cache")))
141            .map(|cache| cache.join("hpr-sim")),
142    }
143}
144
145fn read_optional(path: &Path) -> Result<Option<Vec<u8>>, NetError> {
146    match fs::read(path) {
147        Ok(bytes) => Ok(Some(bytes)),
148        Err(e) if e.kind() == ErrorKind::NotFound => Ok(None),
149        Err(source) => Err(NetError::Cache {
150            path: path.to_owned(),
151            source,
152        }),
153    }
154}
155
156fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), NetError> {
157    // A name no other process or call is writing, so concurrent writers never share a file.
158    static NEXT: AtomicU64 = AtomicU64::new(0);
159    let n = NEXT.fetch_add(1, Ordering::Relaxed);
160    let tmp = path.with_extension(format!("{}.{n}.tmp", std::process::id()));
161    let err = |source| NetError::Cache {
162        path: path.to_owned(),
163        source,
164    };
165    fs::write(&tmp, bytes).map_err(err)?;
166    fs::rename(&tmp, path).map_err(err)
167}
168
169/// The 64-bit FNV-1a hash (Fowler, Noll and Vo), stable across platforms and releases.
170fn fnv1a64(bytes: &[u8]) -> u64 {
171    bytes.iter().fold(0xcbf2_9ce4_8422_2325, |h, &b| {
172        (h ^ u64::from(b)).wrapping_mul(0x0000_0100_0000_01b3)
173    })
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    #[test]
181    fn fnv1a64_matches_published_vectors() {
182        // Test vectors from the FNV reference page (Noll, "FNV Hash").
183        assert_eq!(fnv1a64(b""), 0xcbf2_9ce4_8422_2325);
184        assert_eq!(fnv1a64(b"a"), 0xaf63_dc4c_8601_ec8c);
185        assert_eq!(fnv1a64(b"foobar"), 0x8594_4171_f739_67e8);
186    }
187
188    #[test]
189    fn a_stored_body_reads_back() {
190        let dir = tempfile::tempdir().unwrap();
191        let cache = Cache::new(dir.path().join("sub"));
192        assert_eq!(cache.get("https://example.test/a").unwrap(), None);
193        cache.put("https://example.test/a", b"abc", 42).unwrap();
194        let entry = cache.get("https://example.test/a").unwrap().unwrap();
195        assert_eq!(entry.body, b"abc");
196        assert_eq!(entry.fetched_at_s, 42);
197        assert_eq!(cache.get("https://example.test/b").unwrap(), None);
198    }
199
200    #[test]
201    fn another_urls_entry_in_the_slot_reads_as_a_miss() {
202        let dir = tempfile::tempdir().unwrap();
203        let cache = Cache::new(dir.path());
204        cache.put("https://example.test/a", b"abc", 1).unwrap();
205        // Forge a collision: move a's files into b's slot.
206        let (meta_a, body_a) = cache.paths("https://example.test/a");
207        let (meta_b, body_b) = cache.paths("https://example.test/b");
208        fs::rename(meta_a, meta_b).unwrap();
209        fs::rename(body_a, body_b).unwrap();
210        assert_eq!(cache.get("https://example.test/b").unwrap(), None);
211    }
212
213    /// `platform_dir_from` with the environment given as pairs.
214    fn dir_with(os: &str, vars: &[(&str, &str)]) -> Option<PathBuf> {
215        platform_dir_from(os, |name| {
216            vars.iter()
217                .find(|(key, _)| *key == name)
218                .map(|(_, value)| OsString::from(value))
219        })
220    }
221
222    #[test]
223    fn platform_dirs_follow_each_systems_convention() {
224        let home = [("HOME", "/home/u")];
225        assert_eq!(
226            dir_with("macos", &[("HOME", "/Users/u")]),
227            Some(PathBuf::from("/Users/u/Library/Caches/hpr-sim"))
228        );
229        assert_eq!(
230            dir_with("windows", &[("LOCALAPPDATA", "C:/Users/u/AppData/Local")]),
231            Some(PathBuf::from("C:/Users/u/AppData/Local/hpr-sim/cache"))
232        );
233        assert_eq!(
234            dir_with("linux", &home),
235            Some(PathBuf::from("/home/u/.cache/hpr-sim"))
236        );
237        // Whether a path is absolute is the host's rule, and `/var/cache/u` has no drive letter
238        // for Windows; the XDG branch only ever runs on Unix hosts.
239        #[cfg(unix)]
240        assert_eq!(
241            dir_with(
242                "freebsd",
243                &[("HOME", "/home/u"), ("XDG_CACHE_HOME", "/var/cache/u")]
244            ),
245            Some(PathBuf::from("/var/cache/u/hpr-sim"))
246        );
247    }
248
249    #[test]
250    fn platform_dir_skips_what_the_conventions_skip() {
251        // A relative or empty XDG_CACHE_HOME is ignored, per the XDG Base Directory Specification.
252        for xdg in ["relative/cache", ""] {
253            assert_eq!(
254                dir_with("linux", &[("HOME", "/home/u"), ("XDG_CACHE_HOME", xdg)]),
255                Some(PathBuf::from("/home/u/.cache/hpr-sim"))
256            );
257        }
258        // Windows reads LOCALAPPDATA, never HOME; nothing named, no folder.
259        assert_eq!(dir_with("windows", &[("HOME", "/home/u")]), None);
260        assert_eq!(dir_with("macos", &[("HOME", "")]), None);
261        assert_eq!(dir_with("linux", &[]), None);
262    }
263
264    #[test]
265    fn hpr_cache_dir_overrides_every_platform() {
266        for os in ["macos", "windows", "linux"] {
267            let vars = [
268                ("HPR_CACHE_DIR", "/tmp/hpr"),
269                ("HOME", "/home/u"),
270                ("LOCALAPPDATA", "C:/L"),
271                ("XDG_CACHE_HOME", "/x"),
272            ];
273            assert_eq!(dir_with(os, &vars), Some(PathBuf::from("/tmp/hpr")));
274        }
275        // An empty override is no override.
276        assert_eq!(
277            dir_with("linux", &[("HPR_CACHE_DIR", ""), ("HOME", "/home/u")]),
278            Some(PathBuf::from("/home/u/.cache/hpr-sim"))
279        );
280    }
281
282    #[test]
283    fn corrupt_metadata_is_an_error_not_a_miss() {
284        let dir = tempfile::tempdir().unwrap();
285        let cache = Cache::new(dir.path());
286        cache.put("u", b"x", 1).unwrap();
287        fs::write(cache.paths("u").0, b"{not json").unwrap();
288        assert!(matches!(cache.get("u"), Err(NetError::CorruptEntry { .. })));
289    }
290}