refactor: unify error handling with anyhow and add From conversions
Phase 5: Replace all Box<dyn Error> return types with anyhow::Result<T>
throughout the codebase. Replace string-based Err("msg".into()) and
format!().into() patterns with bail!() and anyhow!() macros. Fix
dirs::home_dir().unwrap() in settings.rs to use a fallback path instead
of panicking when HOME is unset. Remove stray use std::error::Error
imports no longer needed.
Phase 6: Add From<&User> for CacheUser in models/user.rs and
From<&Laboratory>/From<&Laboratories> for CacheLaboratory/CacheLabsWrapper
in models/laboratory.rs. Simplify commands/login.rs to use .into()
conversions, removing the redundant to_cache_user() and to_cache_labs()
helper functions.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Vendored
+162
@@ -0,0 +1,162 @@
|
||||
pub mod digest;
|
||||
pub mod types;
|
||||
|
||||
pub use digest::compute_digest;
|
||||
pub use types::{Cache, CacheLaboratory, CacheLabsWrapper, CacheUser};
|
||||
|
||||
use anyhow::{anyhow, bail};
|
||||
use crate::connection::MDRSConnection;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::sync::{Arc, LazyLock, Mutex};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-remote async mutex map (in-process serialization)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static REMOTE_LOCKS: LazyLock<Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
fn get_remote_lock(remote: &str) -> Arc<tokio::sync::Mutex<()>> {
|
||||
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_file_path(remote: &str) -> std::path::PathBuf {
|
||||
crate::settings::SETTINGS
|
||||
.config_dirname
|
||||
.join("cache")
|
||||
.join(format!("{}.json", remote))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<Cache, anyhow::Error> {
|
||||
let cache_path = cache_file_path(remote);
|
||||
if !cache_path.exists() {
|
||||
bail!("Not logged in to `{}`. Run `mdrs login {}` first.", remote, remote);
|
||||
}
|
||||
let data = fs::read_to_string(&cache_path)?;
|
||||
serde_json::from_str::<Cache>(&data).map_err(|e| anyhow!("Cache for `{}` is invalid or outdated ({}). Run `mdrs login {}` to refresh it.", remote, e, remote))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<Cache, anyhow::Error> {
|
||||
let lock = get_remote_lock(remote);
|
||||
let _guard = lock.lock().await;
|
||||
|
||||
let lock_path = cache_file_path(remote).with_extension("lock");
|
||||
use fs2::FileExt;
|
||||
let lock_file = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.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<Cache, anyhow::Error> = 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) {
|
||||
let new_access = refresh_and_persist(remote, &cache).await?;
|
||||
cache.token.access = new_access;
|
||||
}
|
||||
|
||||
Ok(cache)
|
||||
}
|
||||
.await;
|
||||
|
||||
lock_file.unlock()?;
|
||||
result
|
||||
}
|
||||
|
||||
/// 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<String, anyhow::Error> {
|
||||
let url = crate::commands::config::get_remote_url(remote)?
|
||||
.ok_or_else(|| anyhow!("Remote `{}` is not configured.", remote))?;
|
||||
let conn = MDRSConnection::new(&url);
|
||||
|
||||
let new_access = conn.token_refresh(&cache.token.refresh).await?;
|
||||
|
||||
let new_digest = compute_digest(
|
||||
cache.user.as_ref(),
|
||||
&new_access,
|
||||
&cache.token.refresh,
|
||||
&cache.laboratories,
|
||||
);
|
||||
|
||||
let cache_path = cache_file_path(remote);
|
||||
let raw = fs::read_to_string(&cache_path)?;
|
||||
let mut obj: serde_json::Value = serde_json::from_str(&raw)?;
|
||||
|
||||
obj["token"]["access"] = serde_json::Value::String(new_access.clone());
|
||||
obj["digest"] = serde_json::Value::String(new_digest);
|
||||
|
||||
// Write atomically: write to .tmp then rename.
|
||||
let tmp_path = cache_path.with_extension("tmp");
|
||||
{
|
||||
use std::io::Write;
|
||||
let mut tmp_file = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.open(&tmp_path)?;
|
||||
tmp_file.write_all(serde_json::to_string(&obj)?.as_bytes())?;
|
||||
tmp_file.flush()?;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
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(new_access)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Connection helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Create an authenticated `MDRSConnection` for the given remote label.
|
||||
pub fn create_authenticated_conn(
|
||||
remote: &str,
|
||||
cache: &Cache,
|
||||
) -> Result<MDRSConnection, anyhow::Error> {
|
||||
let url = crate::commands::config::get_remote_url(remote)?
|
||||
.ok_or_else(|| anyhow!("Remote `{}` is not configured.", remote))?;
|
||||
Ok(MDRSConnection::new(&url).with_token(cache.token.access.clone()))
|
||||
}
|
||||
Reference in New Issue
Block a user