bw/tools/
send_access_token_cache.rs1use std::{collections::HashMap, path::Path};
19
20use serde::{Deserialize, Serialize};
21
22use crate::platform::appdata_dir;
23
24const EXPIRY_SLACK_MS: i64 = 5_000;
28
29#[derive(Debug, Default, Serialize, Deserialize)]
30struct CacheFile {
31 #[serde(default)]
32 entries: HashMap<String, CachedToken>,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
36struct CachedToken {
37 token: String,
38 expires_at: i64,
39}
40
41fn cache_key(resolved_host: &str, send_id: &str) -> String {
42 format!("{resolved_host}|{send_id}")
43}
44
45fn cache_path() -> Option<std::path::PathBuf> {
46 appdata_dir()
47 .ok()
48 .map(|dir| dir.join("send_access_tokens.json"))
49}
50
51fn read_cache_from(path: &Path) -> CacheFile {
52 match std::fs::read(path) {
53 Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_default(),
54 Err(_) => CacheFile::default(),
55 }
56}
57
58fn write_cache_to(path: &Path, cache: &CacheFile) {
59 let Ok(json) = serde_json::to_vec(cache) else {
60 return;
61 };
62 if let Some(parent) = path.parent() {
63 let _ = std::fs::create_dir_all(parent);
64 }
65 let _ = super::file_output::write_file_private(path, &json);
66}
67
68pub(crate) fn get(resolved_host: &str, send_id: &str) -> Option<String> {
70 let path = cache_path()?;
71 let cache = read_cache_from(&path);
72 let entry = cache.entries.get(&cache_key(resolved_host, send_id))?;
73 let now = chrono::Utc::now().timestamp_millis();
74 (entry.expires_at - EXPIRY_SLACK_MS > now).then(|| entry.token.clone())
75}
76
77pub(crate) fn set(resolved_host: &str, send_id: &str, token: &str, expires_at: i64) {
80 let Some(path) = cache_path() else {
81 return;
82 };
83 let mut cache = read_cache_from(&path);
84 cache.entries.insert(
85 cache_key(resolved_host, send_id),
86 CachedToken {
87 token: token.to_string(),
88 expires_at,
89 },
90 );
91 write_cache_to(&path, &cache);
92}
93
94pub(crate) fn evict(resolved_host: &str, send_id: &str) {
97 let Some(path) = cache_path() else {
98 return;
99 };
100 let mut cache = read_cache_from(&path);
101 if cache
102 .entries
103 .remove(&cache_key(resolved_host, send_id))
104 .is_some()
105 {
106 write_cache_to(&path, &cache);
107 }
108}
109
110#[cfg(test)]
111mod tests {
112 use std::path::PathBuf;
113
114 use super::*;
115
116 fn tempdir() -> PathBuf {
117 let dir = std::env::temp_dir().join(format!(
118 "bw-send-access-token-cache-test-{}-{}",
119 std::process::id(),
120 uuid::Uuid::new_v4(),
121 ));
122 std::fs::create_dir_all(&dir).expect("tempdir");
123 dir
124 }
125
126 fn cache_file_path() -> PathBuf {
127 tempdir().join("send_access_tokens.json")
128 }
129
130 fn now_ms() -> i64 {
131 chrono::Utc::now().timestamp_millis()
132 }
133
134 #[test]
135 fn get_returns_none_when_file_is_missing() {
136 let path = cache_file_path();
137 let cache = read_cache_from(&path);
138 assert!(cache.entries.is_empty());
139 }
140
141 #[test]
142 fn set_then_get_round_trips_a_token() {
143 let path = cache_file_path();
144 let mut cache = read_cache_from(&path);
145 cache.entries.insert(
146 cache_key("https://api.example.com", "send-1"),
147 CachedToken {
148 token: "tok".to_string(),
149 expires_at: now_ms() + 60_000,
150 },
151 );
152 write_cache_to(&path, &cache);
153
154 let reloaded = read_cache_from(&path);
155 let entry = reloaded
156 .entries
157 .get(&cache_key("https://api.example.com", "send-1"))
158 .unwrap();
159 assert_eq!(entry.token, "tok");
160 }
161
162 #[test]
163 fn different_hosts_do_not_collide_for_the_same_send_id() {
164 let path = cache_file_path();
165 let mut cache = read_cache_from(&path);
166 cache.entries.insert(
167 cache_key("https://real.example.com", "send-1"),
168 CachedToken {
169 token: "real-token".to_string(),
170 expires_at: now_ms() + 60_000,
171 },
172 );
173 write_cache_to(&path, &cache);
174
175 let reloaded = read_cache_from(&path);
176 assert!(
177 !reloaded
178 .entries
179 .contains_key(&cache_key("https://attacker.example.com", "send-1")),
180 "a token cached under one host must not be visible under a different host, \
181 even for the same send id"
182 );
183 }
184
185 #[test]
186 fn expired_entries_are_not_returned() {
187 let path = cache_file_path();
188 let mut cache = read_cache_from(&path);
189 cache.entries.insert(
190 cache_key("https://api.example.com", "send-1"),
191 CachedToken {
192 token: "tok".to_string(),
193 expires_at: now_ms() - 1,
194 },
195 );
196 write_cache_to(&path, &cache);
197
198 let reloaded = read_cache_from(&path);
199 let entry = reloaded
200 .entries
201 .get(&cache_key("https://api.example.com", "send-1"))
202 .unwrap();
203 assert!(entry.expires_at - EXPIRY_SLACK_MS <= now_ms());
204 }
205
206 #[test]
207 fn a_corrupt_file_is_treated_as_empty_rather_than_an_error() {
208 let path = cache_file_path();
209 std::fs::write(&path, b"not json").unwrap();
210 let cache = read_cache_from(&path);
211 assert!(cache.entries.is_empty());
212 }
213
214 #[test]
215 fn evict_removes_only_the_matching_entry() {
216 let path = cache_file_path();
217 let mut cache = read_cache_from(&path);
218 cache.entries.insert(
219 cache_key("https://api.example.com", "send-1"),
220 CachedToken {
221 token: "tok-1".to_string(),
222 expires_at: now_ms() + 60_000,
223 },
224 );
225 cache.entries.insert(
226 cache_key("https://api.example.com", "send-2"),
227 CachedToken {
228 token: "tok-2".to_string(),
229 expires_at: now_ms() + 60_000,
230 },
231 );
232 write_cache_to(&path, &cache);
233
234 cache
235 .entries
236 .remove(&cache_key("https://api.example.com", "send-1"));
237 write_cache_to(&path, &cache);
238
239 let reloaded = read_cache_from(&path);
240 assert!(
241 !reloaded
242 .entries
243 .contains_key(&cache_key("https://api.example.com", "send-1"))
244 );
245 assert!(
246 reloaded
247 .entries
248 .contains_key(&cache_key("https://api.example.com", "send-2"))
249 );
250 }
251
252 #[cfg(unix)]
253 #[test]
254 fn cache_file_is_written_owner_only() {
255 use std::os::unix::fs::PermissionsExt as _;
256
257 let path = cache_file_path();
258 write_cache_to(&path, &CacheFile::default());
259
260 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
261 assert_eq!(mode, 0o600, "send access token cache must be owner-only");
262 }
263}