Skip to main content

hey_sdk/
cache.rs

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