1use 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#[derive(Debug, Clone)]
21pub struct Cache {
22 dir: PathBuf,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct CacheEntry {
28 pub url: String,
30 pub fetched_at_s: u64,
32 #[serde(skip)]
34 pub body: Vec<u8>,
35}
36
37impl Cache {
38 pub fn new(dir: impl Into<PathBuf>) -> Self {
40 Self { dir: dir.into() }
41 }
42
43 pub fn dir(&self) -> &Path {
45 &self.dir
46 }
47
48 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 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 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 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
124fn 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 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
169fn 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 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 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 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 #[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 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 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 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}