Skip to main content

browser_commander/browser/
browser_cookie_cache.rs

1//! Owner-only cookie and derived-key cache with cross-process locking.
2
3use 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
62/// Clear only the current process's derived-key cache.
63pub 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}