browser_commander/browser/
browser_cookie_cache.rs1use std::collections::HashMap;
4use std::fs::{self, File, OpenOptions};
5use std::io::{ErrorKind, Write};
6use std::path::{Path, PathBuf};
7use std::sync::{Mutex, OnceLock};
8use std::thread;
9use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
10
11use anyhow::{anyhow, Context, Result};
12use base64::engine::general_purpose::STANDARD as BASE64;
13use base64::Engine;
14use serde_json::{json, Map, Value};
15use sha2::{Digest, Sha256};
16
17const DEFAULT_TTL_MINUTES: f64 = 60.0;
18const LOCK_STALE_SECONDS: f64 = 30.0;
19const LOCK_WAIT_SECONDS: f64 = 30.0;
20
21static CREDENTIAL_MEMORY_CACHE: OnceLock<Mutex<HashMap<String, CachedCredential>>> =
22 OnceLock::new();
23
24#[derive(Debug, Clone)]
25struct CachedCredential {
26 key: Vec<u8>,
27 saved_at: f64,
28}
29
30#[derive(Debug, Clone)]
31pub(crate) struct NormalizedCookieCache {
32 pub enabled: bool,
33 pub directory: PathBuf,
34 pub ttl_seconds: f64,
35}
36
37pub(crate) fn normalize_cookie_cache(
38 enabled: bool,
39 directory: Option<&Path>,
40 home_dir: &Path,
41 ttl_minutes: Option<f64>,
42) -> Result<NormalizedCookieCache> {
43 let ttl_minutes = ttl_minutes.unwrap_or(DEFAULT_TTL_MINUTES);
44 if !ttl_minutes.is_finite() || ttl_minutes < 0.0 {
45 return Err(anyhow!(
46 "cookie cache ttl_minutes must be a non-negative number"
47 ));
48 }
49 Ok(NormalizedCookieCache {
50 enabled,
51 directory: directory
52 .map(Path::to_path_buf)
53 .unwrap_or_else(|| home_dir.join(".browser-commander/cookie-cache")),
54 ttl_seconds: ttl_minutes * 60.0,
55 })
56}
57
58fn memory_cache() -> &'static Mutex<HashMap<String, CachedCredential>> {
59 CREDENTIAL_MEMORY_CACHE.get_or_init(|| Mutex::new(HashMap::new()))
60}
61
62pub fn clear_browser_cookie_memory_cache() {
64 if let Ok(mut cache) = memory_cache().lock() {
65 cache.clear();
66 }
67}
68
69fn now_seconds() -> f64 {
70 SystemTime::now()
71 .duration_since(UNIX_EPOCH)
72 .map(|duration| duration.as_secs_f64())
73 .unwrap_or_default()
74}
75
76fn hash(identity: &str) -> String {
77 Sha256::digest(identity.as_bytes())
78 .iter()
79 .map(|byte| format!("{byte:02x}"))
80 .collect()
81}
82
83fn cache_path(cache: &NormalizedCookieCache, kind: &str, identity: &str) -> PathBuf {
84 cache
85 .directory
86 .join(format!("{kind}-{}.json", hash(identity)))
87}
88
89fn ensure_cache_directory(directory: &Path) -> Result<()> {
90 fs::create_dir_all(directory)
91 .with_context(|| format!("Could not create cookie cache {}", directory.display()))?;
92 restrict_owner_only(directory, true)?;
93 Ok(())
94}
95
96#[cfg(unix)]
97fn restrict_owner_only(path: &Path, directory: bool) -> Result<()> {
98 use std::os::unix::fs::PermissionsExt;
99
100 let mode = if directory { 0o700 } else { 0o600 };
101 fs::set_permissions(path, fs::Permissions::from_mode(mode))
102 .with_context(|| format!("Could not protect cookie cache {}", path.display()))
103}
104
105#[cfg(windows)]
106fn restrict_owner_only(path: &Path, directory: bool) -> Result<()> {
107 use std::process::Command;
108
109 let whoami = Command::new("whoami")
110 .output()
111 .context("Could not identify the current Windows user")?;
112 if !whoami.status.success() {
113 return Err(anyhow!("Could not identify the current Windows user"));
114 }
115 let principal = String::from_utf8(whoami.stdout)
116 .context("Windows user identity was not valid UTF-8")?
117 .trim()
118 .to_owned();
119 if principal.is_empty() {
120 return Err(anyhow!("Could not identify the current Windows user"));
121 }
122 let permission = if directory { "(OI)(CI)F" } else { "F" };
123 let status = Command::new("icacls")
124 .arg(path)
125 .args(["/inheritance:r", "/grant:r"])
126 .arg(format!("{principal}:{permission}"))
127 .arg("/q")
128 .status()
129 .with_context(|| format!("Could not protect cookie cache {}", path.display()))?;
130 if !status.success() {
131 return Err(anyhow!(
132 "Could not protect cookie cache {} with a Windows ACL",
133 path.display()
134 ));
135 }
136 Ok(())
137}
138
139#[cfg(all(not(unix), not(windows)))]
140fn restrict_owner_only(_path: &Path, _directory: bool) -> Result<()> {
141 Err(anyhow!(
142 "owner-only cookie caching is unsupported on this platform"
143 ))
144}
145
146fn read_fresh_json(path: &Path, ttl_seconds: f64) -> Option<Value> {
147 let value = serde_json::from_str::<Value>(&fs::read_to_string(path).ok()?).ok()?;
148 let saved_at = value.get("savedAt")?.as_f64()?;
149 let age = now_seconds() - saved_at;
150 (age >= 0.0 && age <= ttl_seconds).then_some(value)
151}
152
153fn temporary_path(path: &Path) -> PathBuf {
154 let unique = SystemTime::now()
155 .duration_since(UNIX_EPOCH)
156 .map(|duration| duration.as_nanos())
157 .unwrap_or_default();
158 path.with_file_name(format!(
159 "{}.{}.{unique}.tmp",
160 path.file_name()
161 .and_then(|name| name.to_str())
162 .unwrap_or("cache"),
163 std::process::id()
164 ))
165}
166
167fn owner_only_file(path: &Path) -> Result<File> {
168 let mut options = OpenOptions::new();
169 options.write(true).create_new(true);
170 #[cfg(unix)]
171 {
172 use std::os::unix::fs::OpenOptionsExt;
173 options.mode(0o600);
174 }
175 options
176 .open(path)
177 .with_context(|| format!("Could not create owner-only cache file {}", path.display()))
178}
179
180fn write_owner_only_json(path: &Path, value: &Value) -> Result<()> {
181 let temporary = temporary_path(path);
182 let mut file = owner_only_file(&temporary)?;
183 let result = (|| -> Result<()> {
184 serde_json::to_writer(&mut file, value)?;
185 file.write_all(b"\n")?;
186 file.sync_all()?;
187 drop(file);
188 #[cfg(windows)]
189 if path.exists() {
190 fs::remove_file(path)?;
191 }
192 fs::rename(&temporary, path)?;
193 restrict_owner_only(path, false)?;
194 Ok(())
195 })();
196 if result.is_err() {
197 let _ = fs::remove_file(&temporary);
198 }
199 result.with_context(|| format!("Could not write cookie cache {}", path.display()))
200}
201
202pub(crate) fn read_cookie_result_cache(
203 cache: &NormalizedCookieCache,
204 identity: &str,
205 refresh: bool,
206) -> Option<Vec<Value>> {
207 if !cache.enabled || refresh {
208 return None;
209 }
210 let value = read_fresh_json(&cache_path(cache, "cookies", identity), cache.ttl_seconds)?;
211 if value.get("kind").and_then(Value::as_str) != Some("cookies") {
212 return None;
213 }
214 value.get("cookies")?.as_array().cloned()
215}
216
217pub(crate) fn write_cookie_result_cache(
218 cache: &NormalizedCookieCache,
219 identity: &str,
220 cookies: &[Value],
221) -> Result<()> {
222 if !cache.enabled {
223 return Ok(());
224 }
225 ensure_cache_directory(&cache.directory)?;
226 write_owner_only_json(
227 &cache_path(cache, "cookies", identity),
228 &json!({
229 "version": 1,
230 "kind": "cookies",
231 "savedAt": now_seconds(),
232 "cookies": cookies,
233 }),
234 )
235}
236
237enum LockResult {
238 Acquired(File),
239 Cached(Value),
240}
241
242fn remove_stale_lock(lock_path: &Path) {
243 let stale = fs::metadata(lock_path)
244 .and_then(|metadata| metadata.modified())
245 .ok()
246 .and_then(|modified| SystemTime::now().duration_since(modified).ok())
247 .is_some_and(|age| age.as_secs_f64() > LOCK_STALE_SECONDS);
248 if stale {
249 let _ = fs::remove_file(lock_path);
250 }
251}
252
253fn acquire_lock_or_cached(
254 lock_path: &Path,
255 cached_path: &Path,
256 ttl_seconds: f64,
257 refresh: bool,
258 initial_saved_at: Option<f64>,
259) -> Result<LockResult> {
260 let started = Instant::now();
261 while started.elapsed().as_secs_f64() <= LOCK_WAIT_SECONDS {
262 match owner_only_file(lock_path) {
263 Ok(file) => return Ok(LockResult::Acquired(file)),
264 Err(error)
265 if error
266 .downcast_ref::<std::io::Error>()
267 .is_some_and(|error| error.kind() == ErrorKind::AlreadyExists) =>
268 {
269 if let Some(value) = read_fresh_json(cached_path, ttl_seconds) {
270 let saved_at = value.get("savedAt").and_then(Value::as_f64);
271 if value.get("kind").and_then(Value::as_str) == Some("derived-key")
272 && (!refresh || saved_at != initial_saved_at)
273 {
274 return Ok(LockResult::Cached(value));
275 }
276 }
277 remove_stale_lock(lock_path);
278 thread::sleep(Duration::from_millis(50));
279 }
280 Err(error) => return Err(error),
281 }
282 }
283 Err(anyhow!(
284 "timed out waiting for another cookie credential reader"
285 ))
286}
287
288fn decode_cached_credential(value: &Value) -> Result<CachedCredential> {
289 let key = BASE64
290 .decode(
291 value
292 .get("key")
293 .and_then(Value::as_str)
294 .ok_or_else(|| anyhow!("derived-key cache has no key"))?,
295 )
296 .context("derived-key cache contains invalid base64")?;
297 let saved_at = value
298 .get("savedAt")
299 .and_then(Value::as_f64)
300 .ok_or_else(|| anyhow!("derived-key cache has no savedAt timestamp"))?;
301 Ok(CachedCredential { key, saved_at })
302}
303
304pub(crate) fn get_cached_credential<F>(
305 cache: &NormalizedCookieCache,
306 identity: &str,
307 refresh: bool,
308 metadata: Map<String, Value>,
309 create: F,
310) -> Result<Vec<u8>>
311where
312 F: FnOnce() -> Result<Vec<u8>>,
313{
314 let memory_identity = format!("{}:{identity}", cache.directory.display());
315 if !refresh {
316 let mut memory = memory_cache()
317 .lock()
318 .map_err(|_| anyhow!("cookie credential memory cache is poisoned"))?;
319 if let Some(cached) = memory.get(&memory_identity) {
320 let age = now_seconds() - cached.saved_at;
321 if age >= 0.0 && age <= cache.ttl_seconds {
322 return Ok(cached.key.clone());
323 }
324 }
325 memory.remove(&memory_identity);
326 }
327 let credential = load_or_create_credential(cache, identity, refresh, metadata, create)?;
328 memory_cache()
329 .lock()
330 .map_err(|_| anyhow!("cookie credential memory cache is poisoned"))?
331 .insert(memory_identity, credential.clone());
332 Ok(credential.key)
333}
334
335fn load_or_create_credential<F>(
336 cache: &NormalizedCookieCache,
337 identity: &str,
338 refresh: bool,
339 metadata: Map<String, Value>,
340 create: F,
341) -> Result<CachedCredential>
342where
343 F: FnOnce() -> Result<Vec<u8>>,
344{
345 if !cache.enabled {
346 return Ok(CachedCredential {
347 key: create()?,
348 saved_at: now_seconds(),
349 });
350 }
351 ensure_cache_directory(&cache.directory)?;
352 let cached_path = cache_path(cache, "credential", identity);
353 let lock_path = PathBuf::from(format!("{}.lock", cached_path.display()));
354 let initial = read_fresh_json(&cached_path, cache.ttl_seconds);
355 if !refresh {
356 if let Some(value) = initial
357 .as_ref()
358 .filter(|value| value.get("kind").and_then(Value::as_str) == Some("derived-key"))
359 {
360 return decode_cached_credential(value);
361 }
362 }
363 let initial_saved_at = initial
364 .as_ref()
365 .and_then(|value| value.get("savedAt"))
366 .and_then(Value::as_f64);
367 match acquire_lock_or_cached(
368 &lock_path,
369 &cached_path,
370 cache.ttl_seconds,
371 refresh,
372 initial_saved_at,
373 )? {
374 LockResult::Cached(value) => decode_cached_credential(&value),
375 LockResult::Acquired(lock) => {
376 drop(lock);
377 let result = (|| -> Result<CachedCredential> {
378 if let Some(value) = read_fresh_json(&cached_path, cache.ttl_seconds) {
379 let saved_at = value.get("savedAt").and_then(Value::as_f64);
380 if value.get("kind").and_then(Value::as_str) == Some("derived-key")
381 && (!refresh || saved_at != initial_saved_at)
382 {
383 return decode_cached_credential(&value);
384 }
385 }
386 let key = create()?;
387 let saved_at = now_seconds();
388 let mut value = metadata;
389 value.insert("version".into(), json!(1));
390 value.insert("kind".into(), json!("derived-key"));
391 value.insert("savedAt".into(), json!(saved_at));
392 value.insert("key".into(), json!(BASE64.encode(&key)));
393 write_owner_only_json(&cached_path, &Value::Object(value))?;
394 Ok(CachedCredential { key, saved_at })
395 })();
396 let _ = fs::remove_file(lock_path);
397 result
398 }
399 }
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405
406 #[test]
407 fn credential_cache_is_owner_only_and_reused_after_memory_reset() -> Result<()> {
408 #[cfg(unix)]
409 use std::os::unix::fs::PermissionsExt;
410
411 let directory = std::env::temp_dir().join(format!(
412 "browser-commander-cache-test-{}",
413 std::process::id()
414 ));
415 let _ = fs::remove_dir_all(&directory);
416 let cache = NormalizedCookieCache {
417 enabled: true,
418 directory: directory.clone(),
419 ttl_seconds: 60.0,
420 };
421 let calls = std::sync::atomic::AtomicUsize::new(0);
422 let create = || {
423 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
424 Ok(vec![7_u8; 16])
425 };
426 let metadata = Map::new();
427 get_cached_credential(
428 &cache,
429 "chrome:linux:safe-storage",
430 false,
431 metadata.clone(),
432 create,
433 )?;
434 clear_browser_cookie_memory_cache();
435 get_cached_credential(&cache, "chrome:linux:safe-storage", false, metadata, create)?;
436 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
437 let cached = fs::read_dir(&directory)?
438 .flatten()
439 .find(|entry| {
440 entry
441 .file_name()
442 .to_string_lossy()
443 .starts_with("credential-")
444 })
445 .unwrap();
446 #[cfg(unix)]
447 assert_eq!(cached.metadata()?.permissions().mode() & 0o777, 0o600);
448 #[cfg(windows)]
449 {
450 use std::process::Command;
451
452 let acl = Command::new("icacls").arg(cached.path()).output()?;
453 let principal = Command::new("whoami").output()?;
454 let acl = String::from_utf8(acl.stdout)?.to_lowercase();
455 let principal = String::from_utf8(principal.stdout)?.trim().to_lowercase();
456 assert!(acl.contains(&principal));
457 assert!(!acl.contains("(i)"));
458 }
459 fs::remove_dir_all(directory)?;
460 Ok(())
461 }
462
463 #[test]
464 fn in_process_credential_cache_expires_after_ttl() -> Result<()> {
465 let directory = std::env::temp_dir().join(format!(
466 "browser-commander-memory-ttl-test-{}",
467 std::process::id()
468 ));
469 let cache = NormalizedCookieCache {
470 enabled: false,
471 directory,
472 ttl_seconds: 0.0,
473 };
474 let calls = std::sync::atomic::AtomicUsize::new(0);
475 let create = || {
476 let call = calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1;
477 Ok(vec![call as u8; 16])
478 };
479
480 clear_browser_cookie_memory_cache();
481 assert_eq!(
482 get_cached_credential(&cache, "chrome:linux:ttl-test", false, Map::new(), create)?[0],
483 1
484 );
485 thread::sleep(Duration::from_millis(5));
486 assert_eq!(
487 get_cached_credential(&cache, "chrome:linux:ttl-test", false, Map::new(), create)?[0],
488 2
489 );
490 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 2);
491 Ok(())
492 }
493}