pub mod digest; pub mod types; pub use digest::compute_digest; pub use types::{Cache, CacheLaboratory, CacheLabsWrapper, CacheToken, CacheUser}; use crate::connection::MDRSConnection; use anyhow::{anyhow, bail}; use std::collections::HashMap; use std::fs; #[cfg(unix)] use std::os::unix::fs::{MetadataExt, PermissionsExt}; use std::path::{Path, PathBuf}; use std::sync::{Arc, LazyLock, Mutex}; use std::time::UNIX_EPOCH; // --------------------------------------------------------------------------- // Per-remote async mutex map (in-process serialization) // --------------------------------------------------------------------------- static REMOTE_LOCKS: LazyLock>>>> = LazyLock::new(|| Mutex::new(HashMap::new())); #[derive(Clone, Debug, Eq, Hash, PartialEq)] struct CacheStoreKey { config_dir: PathBuf, remote: String, } #[derive(Clone, Debug, Eq, PartialEq)] struct CacheFileSnapshot { len: u64, modified_nanos: u128, #[cfg(unix)] inode: u64, } #[derive(Clone)] struct MemoryCacheEntry { snapshot: CacheFileSnapshot, cache: Cache, } static MEMORY_CACHE: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); fn get_remote_lock(remote: &str) -> Arc> { let mut map = REMOTE_LOCKS.lock().unwrap(); map.entry(remote.to_string()) .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))) .clone() } // --------------------------------------------------------------------------- // Cache file path helpers // --------------------------------------------------------------------------- fn cache_store_key(config_dir: &Path, remote: &str) -> CacheStoreKey { CacheStoreKey { config_dir: config_dir.to_path_buf(), remote: remote.to_string(), } } fn cache_dir_path(config_dir: &Path) -> PathBuf { config_dir.join("cache") } fn cache_file_path_in(config_dir: &Path, remote: &str) -> PathBuf { cache_dir_path(config_dir).join(format!("{}.json", remote)) } fn cache_file_path(remote: &str) -> PathBuf { cache_file_path_in(&crate::settings::SETTINGS.config_dirname, remote) } fn cache_snapshot(metadata: &fs::Metadata) -> CacheFileSnapshot { let modified_nanos = metadata .modified() .ok() .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) .map(|duration| duration.as_nanos()) .unwrap_or_default(); CacheFileSnapshot { len: metadata.len(), modified_nanos, #[cfg(unix)] inode: metadata.ino(), } } fn read_cache_snapshot(cache_path: &Path) -> Result { fs::metadata(cache_path).map(|metadata| cache_snapshot(&metadata)) } fn cached_entry(config_dir: &Path, remote: &str, snapshot: &CacheFileSnapshot) -> Option { let key = cache_store_key(config_dir, remote); let map = MEMORY_CACHE.lock().unwrap(); map.get(&key) .filter(|entry| entry.snapshot == *snapshot) .map(|entry| entry.cache.clone()) } fn update_cached_entry(config_dir: &Path, remote: &str, snapshot: CacheFileSnapshot, cache: Cache) { let key = cache_store_key(config_dir, remote); let mut map = MEMORY_CACHE.lock().unwrap(); map.insert(key, MemoryCacheEntry { snapshot, cache }); } fn invalidate_cached_entry(config_dir: &Path, remote: &str) { let key = cache_store_key(config_dir, remote); let mut map = MEMORY_CACHE.lock().unwrap(); map.remove(&key); } fn ensure_cache_dir(cache_dir: &Path) -> Result<(), anyhow::Error> { fs::create_dir_all(cache_dir)?; #[cfg(unix)] { let mut perms = fs::metadata(cache_dir)?.permissions(); perms.set_mode(0o700); fs::set_permissions(cache_dir, perms)?; } Ok(()) } fn write_cache_file(cache_path: &Path, cache: &Cache) -> Result<(), anyhow::Error> { let tmp_path = cache_path.with_extension("tmp"); fs::write(&tmp_path, serde_json::to_vec_pretty(cache)?)?; #[cfg(unix)] { let mut perms = fs::metadata(&tmp_path)?.permissions(); perms.set_mode(0o600); fs::set_permissions(&tmp_path, perms)?; } fs::rename(&tmp_path, cache_path)?; Ok(()) } fn parse_cache(remote: &str, data: &str) -> Result { let cache = serde_json::from_str::(data).map_err(|e| { anyhow!( "Cache for `{}` is invalid or outdated ({}). Run `mdrs login {}` to refresh it.", remote, e, remote ) })?; // The digest is what says the file is still the one this client wrote. The Python // client checks it on every read and the two share the file, so a cache one of them // would refuse must not be honoured by the other. let expected = compute_digest( cache.user.as_ref(), &cache.token.access, &cache.token.refresh, &cache.laboratories, ); if cache.digest != expected { bail!( "Cache for `{}` has been altered or was written by an incompatible version. \ Run `mdrs login {}` to refresh it.", remote, remote ); } Ok(cache) } fn load_cache_from_dir(remote: &str, config_dir: &Path) -> Result { let cache_path = cache_file_path_in(config_dir, remote); let snapshot = match read_cache_snapshot(&cache_path) { Ok(snapshot) => snapshot, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { invalidate_cached_entry(config_dir, remote); bail!( "Not logged in to `{}`. Run `mdrs login {}` first.", remote, remote ); } Err(e) => return Err(e.into()), }; if let Some(cache) = cached_entry(config_dir, remote, &snapshot) { return Ok(cache); } let data = match fs::read_to_string(&cache_path) { Ok(data) => data, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { invalidate_cached_entry(config_dir, remote); bail!( "Not logged in to `{}`. Run `mdrs login {}` first.", remote, remote ); } Err(e) => return Err(e.into()), }; let cache = parse_cache(remote, &data)?; update_cached_entry(config_dir, remote, snapshot, cache.clone()); Ok(cache) } fn load_cache_if_present_from_dir( remote: &str, config_dir: &Path, ) -> Result, anyhow::Error> { let cache_path = cache_file_path_in(config_dir, remote); let snapshot = match read_cache_snapshot(&cache_path) { Ok(snapshot) => snapshot, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { invalidate_cached_entry(config_dir, remote); return Ok(None); } Err(e) => return Err(e.into()), }; if let Some(cache) = cached_entry(config_dir, remote, &snapshot) { return Ok(Some(cache)); } let data = match fs::read_to_string(&cache_path) { Ok(data) => data, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { invalidate_cached_entry(config_dir, remote); return Ok(None); } Err(e) => return Err(e.into()), }; let cache = match parse_cache(remote, &data) { Ok(cache) => cache, Err(e) => { // Said out loud before the credentials go. The alternative is a command that // quietly carries on anonymously and fails later for a reason that looks // unrelated to the cache it just threw away. eprintln!("{e}"); remove_cache_in_dir(remote, config_dir)?; return Ok(None); } }; update_cached_entry(config_dir, remote, snapshot, cache.clone()); Ok(Some(cache)) } fn persist_cache_in_dir( remote: &str, config_dir: &Path, cache: &Cache, ) -> Result<(), anyhow::Error> { let cache_dir = cache_dir_path(config_dir); ensure_cache_dir(&cache_dir)?; let cache_path = cache_file_path_in(config_dir, remote); write_cache_file(&cache_path, cache)?; let snapshot = read_cache_snapshot(&cache_path)?; update_cached_entry(config_dir, remote, snapshot, cache.clone()); Ok(()) } fn remove_cache_in_dir(remote: &str, config_dir: &Path) -> Result<(), anyhow::Error> { let cache_path = cache_file_path_in(config_dir, remote); match fs::remove_file(&cache_path) { Ok(()) => {} Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) => return Err(e.into()), } invalidate_cached_entry(config_dir, remote); Ok(()) } // --------------------------------------------------------------------------- // Load cache (low-level, no token refresh) // --------------------------------------------------------------------------- /// Load token and laboratories from the login cache file (no token refresh check). pub fn load_cache(remote: &str) -> Result { load_cache_from_dir(remote, &crate::settings::SETTINGS.config_dirname) } /// Persist a cache entry and refresh the in-memory fast path. pub fn persist_cache(remote: &str, cache: &Cache) -> Result<(), anyhow::Error> { persist_cache_in_dir(remote, &crate::settings::SETTINGS.config_dirname, cache) } /// Remove a cache entry from disk and memory. pub fn remove_cache(remote: &str) -> Result<(), anyhow::Error> { remove_cache_in_dir(remote, &crate::settings::SETTINGS.config_dirname) } // --------------------------------------------------------------------------- // Token-aware cache load with refresh and locking // --------------------------------------------------------------------------- /// Load cache, check token expiry, and refresh the access token if needed. /// /// Locking strategy: /// - Per-remote `tokio::sync::Mutex` serializes concurrent async tasks within /// the same process. /// - `flock(LOCK_EX)` on a dedicated `cache/{remote}.lock` file serializes /// the entire read-check-refresh-write cycle across separate processes on /// the same host. pub async fn load_cache_with_token_refresh(remote: &str) -> Result { let lock = get_remote_lock(remote); let _guard = lock.lock().await; ensure_cache_dir(&cache_dir_path(&crate::settings::SETTINGS.config_dirname))?; let lock_path = cache_file_path(remote).with_extension("lock"); use fs2::FileExt; let lock_file = fs::OpenOptions::new() .write(true) .create(true) // Nothing is ever written into it: the file exists only to be flocked, and // truncating it would touch a file other processes are holding open. .truncate(false) .open(&lock_path)?; lock_file.lock_exclusive()?; // Re-read inside the lock: another process may have already refreshed the // token since we last checked. let result: Result = async { let mut cache = load_cache(remote)?; if crate::token::is_expired(&cache.token.refresh) { bail!( "Session for `{}` has expired. Please run `mdrs login {}` again.", remote, remote ); } if crate::token::is_refresh_required(&cache.token.access, &cache.token.refresh) { cache = refresh_and_persist(remote, &cache).await?; } Ok(cache) } .await; lock_file.unlock()?; result } async fn load_cache_with_token_refresh_optional_from_dir( remote: &str, config_dir: &Path, ) -> Result, anyhow::Error> { let lock = get_remote_lock(remote); let _guard = lock.lock().await; ensure_cache_dir(&cache_dir_path(config_dir))?; let lock_path = cache_file_path_in(config_dir, remote).with_extension("lock"); use fs2::FileExt; let lock_file = fs::OpenOptions::new() .write(true) .create(true) // Nothing is ever written into it: the file exists only to be flocked, and // truncating it would touch a file other processes are holding open. .truncate(false) .open(&lock_path)?; lock_file.lock_exclusive()?; let result: Result, anyhow::Error> = async { let Some(mut cache) = load_cache_if_present_from_dir(remote, config_dir)? else { return Ok(None); }; if crate::token::is_expired(&cache.token.refresh) { remove_cache_in_dir(remote, config_dir)?; return Ok(None); } if crate::token::is_refresh_required(&cache.token.access, &cache.token.refresh) { cache = refresh_and_persist_in_dir(remote, config_dir, &cache).await?; } Ok(Some(cache)) } .await; lock_file.unlock()?; result } /// Load cache when present and refresh its token if needed. /// /// Unlike `load_cache_with_token_refresh`, this returns `Ok(None)` when the user /// is effectively anonymous: no cache file exists, the cache is invalid, or the /// refresh token has already expired. This mirrors the Python client behavior /// used by read-only commands. pub async fn load_cache_with_token_refresh_optional( remote: &str, ) -> Result, anyhow::Error> { load_cache_with_token_refresh_optional_from_dir( remote, &crate::settings::SETTINGS.config_dirname, ) .await } /// Call the token-refresh endpoint and write the new access token back to the /// cache file. The caller must already hold the per-remote async mutex. async fn refresh_and_persist(remote: &str, cache: &Cache) -> Result { refresh_and_persist_in_dir(remote, &crate::settings::SETTINGS.config_dirname, cache).await } async fn refresh_and_persist_in_dir( remote: &str, config_dir: &Path, cache: &Cache, ) -> Result { let url = crate::commands::config::get_remote_url(remote)? .ok_or_else(|| anyhow!("Remote `{}` is not configured.", remote))?; let conn = MDRSConnection::new(&url); let refreshed = conn.token_refresh(&cache.token.refresh).await?; let mut updated_cache = cache.clone(); updated_cache.token.access = refreshed.access; if let Some(refresh) = refreshed.refresh { updated_cache.token.refresh = refresh; } updated_cache.digest = compute_digest( updated_cache.user.as_ref(), &updated_cache.token.access, &updated_cache.token.refresh, &updated_cache.laboratories, ); persist_cache_in_dir(remote, config_dir, &updated_cache)?; Ok(updated_cache) } // --------------------------------------------------------------------------- // Connection helpers // --------------------------------------------------------------------------- /// Create an authenticated `MDRSConnection` for the given remote label. pub fn create_authenticated_conn( remote: &str, cache: &Cache, ) -> Result { Ok(create_remote_conn(remote)?.with_token(cache.token.access.clone())) } /// Create an unauthenticated `MDRSConnection` for the given remote label. pub fn create_remote_conn(remote: &str) -> Result { let url = crate::commands::config::get_remote_url(remote)? .ok_or_else(|| anyhow!("Remote `{}` is not configured.", remote))?; Ok(MDRSConnection::new(&url).with_remote(remote)) } /// Create a connection for read-only commands, attaching a bearer token only /// when a valid login cache is available. pub async fn create_readonly_conn( remote: &str, ) -> Result<(MDRSConnection, Option), anyhow::Error> { let conn = create_remote_conn(remote)?; match load_cache_with_token_refresh_optional(remote).await? { Some(cache) => Ok((conn.with_token(cache.token.access.clone()), Some(cache))), None => Ok((conn, None)), } } #[cfg(test)] mod tests { use super::*; use tempfile::tempdir; fn sample_cache(username: &str) -> Cache { let mut cache = Cache { user: Some(CacheUser { id: 1, username: username.to_string(), laboratory_ids: vec![10, 20], is_reviewer: false, }), token: types::CacheToken { access: format!("access-{username}"), refresh: format!("refresh-{username}"), }, laboratories: CacheLabsWrapper { items: vec![CacheLaboratory { id: 10, name: "lab".to_string(), pi_name: String::new(), full_name: "Laboratory".to_string(), }], }, digest: String::new(), }; cache.digest = compute_digest( cache.user.as_ref(), &cache.token.access, &cache.token.refresh, &cache.laboratories, ); cache } fn remote_name(prefix: &str, config_dir: &Path) -> String { format!( "{prefix}-{}", config_dir .file_name() .unwrap_or_default() .to_string_lossy() .replace('.', "_") ) } #[cfg(unix)] #[test] fn load_cache_uses_memory_fast_path_when_snapshot_matches() { let dir = tempdir().unwrap(); let remote = remote_name("fast-path", dir.path()); let cache = sample_cache("alice"); persist_cache_in_dir(&remote, dir.path(), &cache).unwrap(); let first = load_cache_from_dir(&remote, dir.path()).unwrap(); assert_eq!(first.user.unwrap().username, "alice"); let cache_path = cache_file_path_in(dir.path(), &remote); let mut perms = fs::metadata(&cache_path).unwrap().permissions(); perms.set_mode(0o000); fs::set_permissions(&cache_path, perms).unwrap(); let second = load_cache_from_dir(&remote, dir.path()).unwrap(); assert_eq!(second.user.unwrap().username, "alice"); } #[test] fn load_cache_reloads_when_external_writer_changes_file() { let dir = tempdir().unwrap(); let remote = remote_name("reload", dir.path()); let original = sample_cache("alice"); let updated = sample_cache("bob"); persist_cache_in_dir(&remote, dir.path(), &original).unwrap(); let first = load_cache_from_dir(&remote, dir.path()).unwrap(); assert_eq!(first.user.unwrap().username, "alice"); let cache_dir = cache_dir_path(dir.path()); ensure_cache_dir(&cache_dir).unwrap(); let cache_path = cache_file_path_in(dir.path(), &remote); write_cache_file(&cache_path, &updated).unwrap(); let second = load_cache_from_dir(&remote, dir.path()).unwrap(); assert_eq!(second.user.unwrap().username, "bob"); } #[cfg(unix)] #[test] fn persist_cache_refreshes_memory_entry() { let dir = tempdir().unwrap(); let remote = remote_name("persist", dir.path()); let original = sample_cache("alice"); let updated = sample_cache("bob"); persist_cache_in_dir(&remote, dir.path(), &original).unwrap(); let _ = load_cache_from_dir(&remote, dir.path()).unwrap(); persist_cache_in_dir(&remote, dir.path(), &updated).unwrap(); let cache_path = cache_file_path_in(dir.path(), &remote); let mut perms = fs::metadata(&cache_path).unwrap().permissions(); perms.set_mode(0o000); fs::set_permissions(&cache_path, perms).unwrap(); let loaded = load_cache_from_dir(&remote, dir.path()).unwrap(); assert_eq!(loaded.user.unwrap().username, "bob"); } #[test] fn remove_cache_invalidates_memory_entry() { let dir = tempdir().unwrap(); let remote = remote_name("remove", dir.path()); let cache = sample_cache("alice"); persist_cache_in_dir(&remote, dir.path(), &cache).unwrap(); let _ = load_cache_from_dir(&remote, dir.path()).unwrap(); remove_cache_in_dir(&remote, dir.path()).unwrap(); let err = load_cache_from_dir(&remote, dir.path()).unwrap_err(); assert!( err.to_string() .contains(&format!("Not logged in to `{remote}`")) ); } fn make_jwt_with_exp(exp: i64) -> String { use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"none","typ":"JWT"}"#); let payload = URL_SAFE_NO_PAD.encode(format!(r#"{{"exp":{exp}}}"#)); format!("{header}.{payload}.") } #[test] fn load_cache_if_present_returns_none_when_cache_missing() { let dir = tempdir().unwrap(); let remote = remote_name("missing", dir.path()); let loaded = load_cache_if_present_from_dir(&remote, dir.path()).unwrap(); assert!(loaded.is_none()); } /// A cache whose contents no longer match its digest is not a session to act on. #[test] fn load_cache_refuses_a_tampered_cache() { let dir = tempdir().unwrap(); let remote = remote_name("tampered", dir.path()); let mut cache = sample_cache("alice"); persist_cache_in_dir(&remote, dir.path(), &cache).unwrap(); // Someone edits the stored token but leaves the digest as it was. cache.token.access = "someone-elses-access".to_string(); let cache_path = cache_file_path_in(dir.path(), &remote); fs::write(&cache_path, serde_json::to_vec_pretty(&cache).unwrap()).unwrap(); invalidate_cached_entry(dir.path(), &remote); let err = load_cache_from_dir(&remote, dir.path()).unwrap_err(); assert!(err.to_string().contains("has been altered")); } #[test] fn load_cache_if_present_clears_invalid_cache() { let dir = tempdir().unwrap(); let remote = remote_name("invalid", dir.path()); let cache_dir = cache_dir_path(dir.path()); ensure_cache_dir(&cache_dir).unwrap(); let cache_path = cache_file_path_in(dir.path(), &remote); fs::write(&cache_path, b"{invalid json").unwrap(); let loaded = load_cache_if_present_from_dir(&remote, dir.path()).unwrap(); assert!(loaded.is_none()); assert!(!cache_path.exists()); } #[tokio::test] async fn optional_cache_load_treats_expired_session_as_anonymous() { let dir = tempdir().unwrap(); let remote = remote_name("expired", dir.path()); let mut cache = sample_cache("alice"); cache.token.access = make_jwt_with_exp(0); cache.token.refresh = make_jwt_with_exp(0); // Re-stamped, so this exercises the expired session rather than the digest check. cache.digest = compute_digest( cache.user.as_ref(), &cache.token.access, &cache.token.refresh, &cache.laboratories, ); persist_cache_in_dir(&remote, dir.path(), &cache).unwrap(); let loaded = load_cache_with_token_refresh_optional_from_dir(&remote, dir.path()) .await .unwrap(); assert!(loaded.is_none()); assert!(!cache_file_path_in(dir.path(), &remote).exists()); } }