Skip to main content

fizzy_sdk/
cache.rs

1//! The `ETag` response cache: a trait, an in-memory implementation and a file-backed one.
2
3use std::collections::HashMap;
4use std::fs;
5use std::path::{Path, PathBuf};
6use std::sync::Mutex;
7
8use bytes::Bytes;
9use sha2::{Digest, Sha256};
10
11/// A response the cache holds for a URL: the validator Fizzy sent with it, and the body it
12/// validates.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct CachedResponse {
15    /// The `ETag` the server sent.
16    pub etag: String,
17    /// The body it validates.
18    pub body: Bytes,
19}
20
21/// Stores JSON responses by `ETag` so a repeated read can be answered from a 304.
22pub trait ResponseCache: Send + Sync {
23    /// What is held for the key.
24    fn get(&self, key: &str) -> Option<CachedResponse>;
25    /// Holds a response under the key.
26    fn set(&self, key: &str, response: CachedResponse);
27    /// Forgets the key.
28    fn invalidate(&self, key: &str);
29    /// Forgets everything.
30    fn clear(&self);
31}
32
33/// Keeps cached responses for the life of the process.
34#[derive(Debug, Default)]
35pub struct InMemoryCache {
36    entries: Mutex<HashMap<String, CachedResponse>>,
37}
38
39impl InMemoryCache {
40    /// An empty cache.
41    pub fn new() -> InMemoryCache {
42        InMemoryCache::default()
43    }
44}
45
46impl ResponseCache for InMemoryCache {
47    fn get(&self, key: &str) -> Option<CachedResponse> {
48        self.entries
49            .lock()
50            .unwrap_or_else(std::sync::PoisonError::into_inner)
51            .get(key)
52            .cloned()
53    }
54
55    fn set(&self, key: &str, response: CachedResponse) {
56        self.entries
57            .lock()
58            .unwrap_or_else(std::sync::PoisonError::into_inner)
59            .insert(key.to_string(), response);
60    }
61
62    fn invalidate(&self, key: &str) {
63        self.entries
64            .lock()
65            .unwrap_or_else(std::sync::PoisonError::into_inner)
66            .remove(key);
67    }
68
69    fn clear(&self) {
70        self.entries
71            .lock()
72            .unwrap_or_else(std::sync::PoisonError::into_inner)
73            .clear();
74    }
75}
76
77/// Keeps cached responses on disk, the same layout the Go SDK uses: `etags.json` maps
78/// keys to validators and `responses/<key>.body` holds the bodies. Anything that goes
79/// wrong on disk is treated as a miss.
80#[derive(Debug)]
81pub struct FileCache {
82    directory: PathBuf,
83    lock: Mutex<()>,
84}
85
86impl FileCache {
87    /// A cache over a directory, created on first write.
88    pub fn new(directory: impl Into<PathBuf>) -> FileCache {
89        FileCache {
90            directory: directory.into(),
91            lock: Mutex::new(()),
92        }
93    }
94
95    fn etags(&self) -> HashMap<String, String> {
96        fs::read(self.directory.join("etags.json"))
97            .ok()
98            .and_then(|bytes| serde_json::from_slice(&bytes).ok())
99            .unwrap_or_default()
100    }
101
102    fn write_etags(&self, etags: &HashMap<String, String>) {
103        if let Ok(json) = serde_json::to_vec_pretty(etags) {
104            write_private(&self.directory.join("etags.json"), &json);
105        }
106    }
107
108    fn body_path(&self, key: &str) -> PathBuf {
109        self.directory.join("responses").join(format!("{key}.body"))
110    }
111}
112
113impl ResponseCache for FileCache {
114    fn get(&self, key: &str) -> Option<CachedResponse> {
115        let _guard = self
116            .lock
117            .lock()
118            .unwrap_or_else(std::sync::PoisonError::into_inner);
119        let etag = self.etags().get(key).cloned()?;
120        let body = fs::read(self.body_path(key)).ok()?;
121        Some(CachedResponse {
122            etag,
123            body: Bytes::from(body),
124        })
125    }
126
127    fn set(&self, key: &str, response: CachedResponse) {
128        let _guard = self
129            .lock
130            .lock()
131            .unwrap_or_else(std::sync::PoisonError::into_inner);
132        write_private(&self.body_path(key), &response.body);
133        let mut etags = self.etags();
134        etags.insert(key.to_string(), response.etag);
135        self.write_etags(&etags);
136    }
137
138    fn invalidate(&self, key: &str) {
139        let _guard = self
140            .lock
141            .lock()
142            .unwrap_or_else(std::sync::PoisonError::into_inner);
143        let _ = fs::remove_file(self.body_path(key));
144        let mut etags = self.etags();
145        etags.remove(key);
146        self.write_etags(&etags);
147    }
148
149    /// Throws away what the cache put in the directory and nothing else. The directory is
150    /// shared with the Fizzy CLI, which keeps its credentials and its own state alongside, so
151    /// only `responses/` and `etags.json` go.
152    fn clear(&self) {
153        let _guard = self
154            .lock
155            .lock()
156            .unwrap_or_else(std::sync::PoisonError::into_inner);
157        let _ = fs::remove_dir_all(self.directory.join("responses"));
158        let _ = fs::remove_file(self.directory.join("etags.json"));
159    }
160}
161
162fn write_private(path: &Path, bytes: &[u8]) {
163    let Some(parent) = path.parent() else { return };
164    if fs::create_dir_all(parent).is_err() {
165        return;
166    }
167    let temporary = path.with_extension("tmp");
168    if fs::write(&temporary, bytes).is_ok() {
169        restrict_permissions(parent, &temporary);
170        let _ = fs::rename(&temporary, path);
171    }
172}
173
174#[cfg(unix)]
175fn restrict_permissions(directory: &Path, file: &Path) {
176    use std::os::unix::fs::PermissionsExt;
177    let _ = fs::set_permissions(directory, fs::Permissions::from_mode(0o700));
178    let _ = fs::set_permissions(file, fs::Permissions::from_mode(0o600));
179}
180
181#[cfg(not(unix))]
182fn restrict_permissions(_directory: &Path, _file: &Path) {}
183
184/// The key a URL is cached under. The credentials are folded in so one person's cached
185/// reads are never answered to another.
186pub fn cache_key(url: &str, credential: &str) -> String {
187    let mut credential_hash = String::new();
188    if !credential.is_empty() {
189        credential_hash = hex(&Sha256::digest(credential.as_bytes())[..8]);
190    }
191    hex(&Sha256::digest(
192        format!("{url}:{credential_hash}").as_bytes(),
193    ))
194}
195
196fn hex(bytes: &[u8]) -> String {
197    use std::fmt::Write;
198    bytes.iter().fold(String::new(), |mut out, byte| {
199        let _ = write!(out, "{byte:02x}");
200        out
201    })
202}