1use 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 crate::utilities::subprocess::{run_command_blocking, RunCommandOptions};
108
109 let whoami = run_command_blocking("whoami", &[] as &[&str], RunCommandOptions::default())
110 .map_err(|error| anyhow!("Could not identify the current Windows user: {error}"))?;
111 let principal = whoami.stdout.trim().to_owned();
112 if principal.is_empty() {
113 return Err(anyhow!("Could not identify the current Windows user"));
114 }
115 let permission = if directory { "(OI)(CI)F" } else { "F" };
116 let target = path.to_string_lossy().into_owned();
117 let grant = format!("{principal}:{permission}");
118 run_command_blocking(
119 "icacls",
120 &[
121 target.as_str(),
122 "/inheritance:r",
123 "/grant:r",
124 grant.as_str(),
125 "/q",
126 ],
127 RunCommandOptions::default(),
128 )
129 .map_err(|error| {
130 anyhow!(
131 "Could not protect cookie cache {} with a Windows ACL: {error}",
132 path.display()
133 )
134 })?;
135 Ok(())
136}
137
138#[cfg(all(not(unix), not(windows)))]
139fn restrict_owner_only(_path: &Path, _directory: bool) -> Result<()> {
140 Err(anyhow!(
141 "owner-only cookie caching is unsupported on this platform"
142 ))
143}
144
145fn read_fresh_json(path: &Path, ttl_seconds: f64) -> Option<Value> {
146 let value = serde_json::from_str::<Value>(&fs::read_to_string(path).ok()?).ok()?;
147 let saved_at = value.get("savedAt")?.as_f64()?;
148 let age = now_seconds() - saved_at;
149 (age >= 0.0 && age <= ttl_seconds).then_some(value)
150}
151
152fn temporary_path(path: &Path) -> PathBuf {
153 let unique = SystemTime::now()
154 .duration_since(UNIX_EPOCH)
155 .map(|duration| duration.as_nanos())
156 .unwrap_or_default();
157 path.with_file_name(format!(
158 "{}.{}.{unique}.tmp",
159 path.file_name()
160 .and_then(|name| name.to_str())
161 .unwrap_or("cache"),
162 std::process::id()
163 ))
164}
165
166fn owner_only_file(path: &Path) -> Result<File> {
167 let mut options = OpenOptions::new();
168 options.write(true).create_new(true);
169 #[cfg(unix)]
170 {
171 use std::os::unix::fs::OpenOptionsExt;
172 options.mode(0o600);
173 }
174 options
175 .open(path)
176 .with_context(|| format!("Could not create owner-only cache file {}", path.display()))
177}
178
179fn write_owner_only_json(path: &Path, value: &Value) -> Result<()> {
180 let temporary = temporary_path(path);
181 let mut file = owner_only_file(&temporary)?;
182 let result = (|| -> Result<()> {
183 serde_json::to_writer(&mut file, value)?;
184 file.write_all(b"\n")?;
185 file.sync_all()?;
186 drop(file);
187 #[cfg(windows)]
188 if path.exists() {
189 fs::remove_file(path)?;
190 }
191 fs::rename(&temporary, path)?;
192 restrict_owner_only(path, false)?;
193 Ok(())
194 })();
195 if result.is_err() {
196 let _ = fs::remove_file(&temporary);
197 }
198 result.with_context(|| format!("Could not write cookie cache {}", path.display()))
199}
200
201pub(crate) fn read_cookie_result_cache(
202 cache: &NormalizedCookieCache,
203 identity: &str,
204 refresh: bool,
205) -> Option<Vec<Value>> {
206 if !cache.enabled || refresh {
207 return None;
208 }
209 let value = read_fresh_json(&cache_path(cache, "cookies", identity), cache.ttl_seconds)?;
210 if value.get("kind").and_then(Value::as_str) != Some("cookies") {
211 return None;
212 }
213 value.get("cookies")?.as_array().cloned()
214}
215
216pub(crate) fn write_cookie_result_cache(
217 cache: &NormalizedCookieCache,
218 identity: &str,
219 cookies: &[Value],
220) -> Result<()> {
221 if !cache.enabled {
222 return Ok(());
223 }
224 ensure_cache_directory(&cache.directory)?;
225 write_owner_only_json(
226 &cache_path(cache, "cookies", identity),
227 &json!({
228 "version": 1,
229 "kind": "cookies",
230 "savedAt": now_seconds(),
231 "cookies": cookies,
232 }),
233 )
234}
235
236enum LockResult {
237 Acquired(File),
238 Cached(Value),
239}
240
241fn remove_stale_lock(lock_path: &Path) {
242 let stale = fs::metadata(lock_path)
243 .and_then(|metadata| metadata.modified())
244 .ok()
245 .and_then(|modified| SystemTime::now().duration_since(modified).ok())
246 .is_some_and(|age| age.as_secs_f64() > LOCK_STALE_SECONDS);
247 if stale {
248 let _ = fs::remove_file(lock_path);
249 }
250}
251
252fn acquire_lock_or_cached(
253 lock_path: &Path,
254 cached_path: &Path,
255 ttl_seconds: f64,
256 refresh: bool,
257 initial_saved_at: Option<f64>,
258) -> Result<LockResult> {
259 let started = Instant::now();
260 while started.elapsed().as_secs_f64() <= LOCK_WAIT_SECONDS {
261 match owner_only_file(lock_path) {
262 Ok(file) => return Ok(LockResult::Acquired(file)),
263 Err(error)
264 if error
265 .downcast_ref::<std::io::Error>()
266 .is_some_and(|error| error.kind() == ErrorKind::AlreadyExists) =>
267 {
268 if let Some(value) = read_fresh_json(cached_path, ttl_seconds) {
269 let saved_at = value.get("savedAt").and_then(Value::as_f64);
270 if value.get("kind").and_then(Value::as_str) == Some("derived-key")
271 && (!refresh || saved_at != initial_saved_at)
272 {
273 return Ok(LockResult::Cached(value));
274 }
275 }
276 remove_stale_lock(lock_path);
277 thread::sleep(Duration::from_millis(50));
278 }
279 Err(error) => return Err(error),
280 }
281 }
282 Err(anyhow!(
283 "timed out waiting for another cookie credential reader"
284 ))
285}
286
287fn decode_cached_credential(value: &Value) -> Result<CachedCredential> {
288 let key = BASE64
289 .decode(
290 value
291 .get("key")
292 .and_then(Value::as_str)
293 .ok_or_else(|| anyhow!("derived-key cache has no key"))?,
294 )
295 .context("derived-key cache contains invalid base64")?;
296 let saved_at = value
297 .get("savedAt")
298 .and_then(Value::as_f64)
299 .ok_or_else(|| anyhow!("derived-key cache has no savedAt timestamp"))?;
300 Ok(CachedCredential { key, saved_at })
301}
302
303pub(crate) fn get_cached_credential<F>(
304 cache: &NormalizedCookieCache,
305 identity: &str,
306 refresh: bool,
307 metadata: Map<String, Value>,
308 create: F,
309) -> Result<Vec<u8>>
310where
311 F: FnOnce() -> Result<Vec<u8>>,
312{
313 let memory_identity = format!("{}:{identity}", cache.directory.display());
314 if !refresh {
315 let mut memory = memory_cache()
316 .lock()
317 .map_err(|_| anyhow!("cookie credential memory cache is poisoned"))?;
318 if let Some(cached) = memory.get(&memory_identity) {
319 let age = now_seconds() - cached.saved_at;
320 if age >= 0.0 && age <= cache.ttl_seconds {
321 return Ok(cached.key.clone());
322 }
323 }
324 memory.remove(&memory_identity);
325 }
326 let credential = load_or_create_credential(cache, identity, refresh, metadata, create)?;
327 memory_cache()
328 .lock()
329 .map_err(|_| anyhow!("cookie credential memory cache is poisoned"))?
330 .insert(memory_identity, credential.clone());
331 Ok(credential.key)
332}
333
334fn load_or_create_credential<F>(
335 cache: &NormalizedCookieCache,
336 identity: &str,
337 refresh: bool,
338 metadata: Map<String, Value>,
339 create: F,
340) -> Result<CachedCredential>
341where
342 F: FnOnce() -> Result<Vec<u8>>,
343{
344 if !cache.enabled {
345 return Ok(CachedCredential {
346 key: create()?,
347 saved_at: now_seconds(),
348 });
349 }
350 ensure_cache_directory(&cache.directory)?;
351 let cached_path = cache_path(cache, "credential", identity);
352 let lock_path = PathBuf::from(format!("{}.lock", cached_path.display()));
353 let initial = read_fresh_json(&cached_path, cache.ttl_seconds);
354 if !refresh {
355 if let Some(value) = initial
356 .as_ref()
357 .filter(|value| value.get("kind").and_then(Value::as_str) == Some("derived-key"))
358 {
359 return decode_cached_credential(value);
360 }
361 }
362 let initial_saved_at = initial
363 .as_ref()
364 .and_then(|value| value.get("savedAt"))
365 .and_then(Value::as_f64);
366 match acquire_lock_or_cached(
367 &lock_path,
368 &cached_path,
369 cache.ttl_seconds,
370 refresh,
371 initial_saved_at,
372 )? {
373 LockResult::Cached(value) => decode_cached_credential(&value),
374 LockResult::Acquired(lock) => {
375 drop(lock);
376 let result = (|| -> Result<CachedCredential> {
377 if let Some(value) = read_fresh_json(&cached_path, cache.ttl_seconds) {
378 let saved_at = value.get("savedAt").and_then(Value::as_f64);
379 if value.get("kind").and_then(Value::as_str) == Some("derived-key")
380 && (!refresh || saved_at != initial_saved_at)
381 {
382 return decode_cached_credential(&value);
383 }
384 }
385 let key = create()?;
386 let saved_at = now_seconds();
387 let mut value = metadata;
388 value.insert("version".into(), json!(1));
389 value.insert("kind".into(), json!("derived-key"));
390 value.insert("savedAt".into(), json!(saved_at));
391 value.insert("key".into(), json!(BASE64.encode(&key)));
392 write_owner_only_json(&cached_path, &Value::Object(value))?;
393 Ok(CachedCredential { key, saved_at })
394 })();
395 let _ = fs::remove_file(lock_path);
396 result
397 }
398 }
399}
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404
405 #[test]
406 fn credential_cache_is_owner_only_and_reused_after_memory_reset() -> Result<()> {
407 #[cfg(unix)]
408 use std::os::unix::fs::PermissionsExt;
409
410 let directory = std::env::temp_dir().join(format!(
411 "browser-commander-cache-test-{}",
412 std::process::id()
413 ));
414 let _ = fs::remove_dir_all(&directory);
415 let cache = NormalizedCookieCache {
416 enabled: true,
417 directory: directory.clone(),
418 ttl_seconds: 60.0,
419 };
420 let calls = std::sync::atomic::AtomicUsize::new(0);
421 let create = || {
422 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
423 Ok(vec![7_u8; 16])
424 };
425 let metadata = Map::new();
426 get_cached_credential(
427 &cache,
428 "chrome:linux:safe-storage",
429 false,
430 metadata.clone(),
431 create,
432 )?;
433 clear_browser_cookie_memory_cache();
434 get_cached_credential(&cache, "chrome:linux:safe-storage", false, metadata, create)?;
435 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
436 let cached = fs::read_dir(&directory)?
437 .flatten()
438 .find(|entry| {
439 entry
440 .file_name()
441 .to_string_lossy()
442 .starts_with("credential-")
443 })
444 .unwrap();
445 #[cfg(unix)]
446 assert_eq!(cached.metadata()?.permissions().mode() & 0o777, 0o600);
447 #[cfg(windows)]
448 {
449 use crate::utilities::subprocess::{run_command_blocking, RunCommandOptions};
450
451 let target = cached.path().to_string_lossy().into_owned();
452 let acl = run_command_blocking("icacls", &[target], RunCommandOptions::default())?;
453 let principal =
454 run_command_blocking("whoami", &[] as &[&str], RunCommandOptions::default())?;
455 let acl = acl.stdout.to_lowercase();
456 let principal = principal.stdout.trim().to_lowercase();
457 assert!(acl.contains(&principal));
458 assert!(!acl.contains("(i)"));
459 }
460 fs::remove_dir_all(directory)?;
461 Ok(())
462 }
463
464 #[test]
465 fn in_process_credential_cache_expires_after_ttl() -> Result<()> {
466 let directory = std::env::temp_dir().join(format!(
467 "browser-commander-memory-ttl-test-{}",
468 std::process::id()
469 ));
470 let cache = NormalizedCookieCache {
471 enabled: false,
472 directory,
473 ttl_seconds: 0.0,
474 };
475 let calls = std::sync::atomic::AtomicUsize::new(0);
476 let create = || {
477 let call = calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1;
478 Ok(vec![call as u8; 16])
479 };
480
481 clear_browser_cookie_memory_cache();
482 assert_eq!(
483 get_cached_credential(&cache, "chrome:linux:ttl-test", false, Map::new(), create)?[0],
484 1
485 );
486 thread::sleep(Duration::from_millis(5));
487 assert_eq!(
488 get_cached_credential(&cache, "chrome:linux:ttl-test", false, Map::new(), create)?[0],
489 2
490 );
491 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 2);
492 Ok(())
493 }
494}