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#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct CachedResponse {
13 pub etag: String,
15 pub body: Bytes,
17}
18
19pub trait ResponseCache: Send + Sync {
21 fn get(&self, key: &str) -> Option<CachedResponse>;
23 fn set(&self, key: &str, response: CachedResponse);
25 fn invalidate(&self, key: &str);
27 fn clear(&self);
29}
30
31#[derive(Debug, Default)]
33pub struct InMemoryCache {
34 entries: Mutex<HashMap<String, CachedResponse>>,
35}
36
37impl InMemoryCache {
38 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#[derive(Debug)]
79pub struct FileCache {
80 directory: PathBuf,
81 lock: Mutex<()>,
82}
83
84impl FileCache {
85 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 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
170pub 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}