From 769a5a68e2778336c324e27f3e7494d4770aa88b Mon Sep 17 00:00:00 2001 From: Yoshihiro OKUMURA Date: Mon, 20 Apr 2026 14:19:10 +0900 Subject: [PATCH] refactor: unify error handling with anyhow and add From conversions Phase 5: Replace all Box return types with anyhow::Result 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> --- src/api/files.rs | 30 ++- src/api/folders.rs | 45 ++++ src/api/users.rs | 5 +- src/cache/digest.rs | 123 ++++++++++ src/cache/mod.rs | 162 +++++++++++++ src/cache/types.rs | 42 ++++ src/cli.rs | 114 +++++++++ src/commands/chacl.rs | 10 +- src/commands/config.rs | 35 +-- src/commands/cp.rs | 42 ++-- src/commands/download.rs | 16 +- src/commands/file_metadata.rs | 8 +- src/commands/labs.rs | 4 +- src/commands/login.rs | 73 +++--- src/commands/logout.rs | 2 +- src/commands/ls.rs | 14 +- src/commands/metadata.rs | 5 +- src/commands/mkdir.rs | 16 +- src/commands/mv.rs | 40 ++-- src/commands/rm.rs | 20 +- src/commands/shared.rs | 422 ++++------------------------------ src/commands/upload.rs | 20 +- src/commands/whoami.rs | 2 +- src/connection.rs | 66 +----- src/error.rs | 25 ++ src/main.rs | 370 +++++++++-------------------- src/models/laboratory.rs | 19 ++ src/models/user.rs | 11 + src/settings.rs | 13 +- src/token.rs | 7 +- 30 files changed, 889 insertions(+), 872 deletions(-) create mode 100644 src/cache/digest.rs create mode 100644 src/cache/mod.rs create mode 100644 src/cache/types.rs create mode 100644 src/cli.rs create mode 100644 src/error.rs diff --git a/src/api/files.rs b/src/api/files.rs index 3d853e9..af5c7ef 100644 --- a/src/api/files.rs +++ b/src/api/files.rs @@ -9,11 +9,11 @@ struct FileListResponse { } impl MDRSConnection { - /// List all files in a folder, following pagination automatically + /// List all files in a folder, following pagination automatically. pub async fn list_all_files( &self, folder_id: &str, - ) -> Result, Box> { + ) -> Result, anyhow::Error> { let mut all_files = Vec::new(); let mut page: u32 = 1; loop { @@ -35,15 +35,17 @@ impl MDRSConnection { Ok(all_files) } + /// Upload a local file into the given remote folder. pub async fn upload_file( &self, folder_id: &str, file_path: &str, - ) -> Result<(), Box> { + ) -> Result<(), anyhow::Error> { use reqwest::multipart; +use anyhow::{anyhow, bail}; let file_name: String = std::path::Path::new(file_path) .file_name() - .unwrap() + .ok_or_else(|| anyhow!("Invalid file path: `{}`", file_path))? .to_string_lossy() .nfc() .collect(); @@ -54,8 +56,26 @@ impl MDRSConnection { .part("file", part); let resp = self.post_multipart("v3/files/", form).await?; if !resp.status().is_success() { - return Err(format!("Upload failed: {}", resp.status()).into()); + bail!("Upload failed: {}", resp.status()); } Ok(()) } + + /// Download a file from `url` and write it to `dest`. + pub async fn download_file( + &self, + url: &str, + dest: &str, + ) -> Result<(), anyhow::Error> { + let resp = self + .client + .get(url) + .headers(self.prepare_headers()) + .send() + .await?; + let bytes = resp.bytes().await?; + tokio::fs::write(dest, &bytes).await?; + Ok(()) + } } + diff --git a/src/api/folders.rs b/src/api/folders.rs index cdccc32..b813f2c 100644 --- a/src/api/folders.rs +++ b/src/api/folders.rs @@ -1,4 +1,5 @@ use crate::connection::MDRSConnection; +use anyhow::{bail}; pub use crate::models::folder::{FolderDetail, FolderSimple}; impl MDRSConnection { @@ -26,4 +27,48 @@ impl MDRSConnection { let resp = self.get(&format!("v3/folders/{}/", id)).await?; resp.json::().await } + + /// Create a new folder under `parent_id` (POST v3/folders/). + pub async fn create_folder( + &self, + parent_id: &str, + folder_name: &str, + ) -> reqwest::Result { + let body = serde_json::json!({ + "name": folder_name, + "parent_id": parent_id, + "description": "", + "template_id": -1, + }); + self.client + .post(self.build_url("v3/folders/")) + .headers(self.prepare_headers()) + .json(&body) + .send() + .await + } + + /// Authenticate against a password-locked folder (POST v3/folders/{id}/auth/). + /// Returns `Err` if the password is incorrect or the request fails. + pub async fn folder_auth( + &self, + folder_id: &str, + password: &str, + ) -> Result<(), anyhow::Error> { + let resp = self + .client + .post(self.build_url(&format!("v3/folders/{}/auth/", folder_id))) + .headers(self.prepare_headers()) + .json(&serde_json::json!({"password": password})) + .send() + .await?; + if resp.status() == reqwest::StatusCode::UNAUTHORIZED { + bail!("Password is incorrect."); + } + if !resp.status().is_success() { + bail!("Folder auth failed: {}", resp.status()); + } + Ok(()) + } } + diff --git a/src/api/users.rs b/src/api/users.rs index 286afb1..5c71c4d 100644 --- a/src/api/users.rs +++ b/src/api/users.rs @@ -1,6 +1,7 @@ use crate::connection::MDRSConnection; use crate::models::user::User as ModelUser; use serde::Deserialize; +use anyhow::{bail}; /// Full API response shape from GET v3/users/current/ #[derive(Debug, Deserialize)] @@ -40,7 +41,7 @@ impl MDRSConnection { pub async fn token_refresh( &self, refresh_token: &str, - ) -> Result> { + ) -> Result { let body = serde_json::json!({ "refresh": refresh_token }); let resp = self .client @@ -49,7 +50,7 @@ impl MDRSConnection { .send() .await?; if !resp.status().is_success() { - return Err(format!("Token refresh failed: {}", resp.status()).into()); + bail!("Token refresh failed: {}", resp.status()); } let r: TokenRefreshResponse = resp.json().await?; Ok(r.access) diff --git a/src/cache/digest.rs b/src/cache/digest.rs new file mode 100644 index 0000000..96f2589 --- /dev/null +++ b/src/cache/digest.rs @@ -0,0 +1,123 @@ +use super::types::{CacheLabsWrapper, CacheUser}; +use sha2::{Digest, Sha256}; + +// --------------------------------------------------------------------------- +// Python-compatible JSON serialization helpers +// +// Python's default json.dumps uses separators=(', ', ': ') and +// ensure_ascii=True. Field order follows dataclass definition order. +// The digest must be byte-for-byte identical to Python's CacheData.__calc_digest. +// --------------------------------------------------------------------------- + +/// Escape a string in Python json.dumps style: +/// - Special chars: `"`, `\`, and control chars → standard JSON escapes +/// - Non-ASCII chars → `\uXXXX` (matches Python's `ensure_ascii=True` default) +fn python_json_string(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => { + out.push_str(&format!("\\u{:04x}", c as u32)); + } + c if c.is_ascii() => out.push(c), + c => { + // Non-ASCII: BMP → \uXXXX, outside BMP → surrogate pair + let code = c as u32; + if code <= 0xFFFF { + out.push_str(&format!("\\u{:04x}", code)); + } else { + let code = code - 0x10000; + let high = 0xD800 + (code >> 10); + let low = 0xDC00 + (code & 0x3FF); + out.push_str(&format!("\\u{:04x}\\u{:04x}", high, low)); + } + } + } + } + out.push('"'); + out +} + +/// Serialize a `u32` slice as a Python-style JSON array: `[1, 2, 3]`. +fn python_json_u32_array(items: &[u32]) -> String { + if items.is_empty() { + return "[]".to_string(); + } + let inner: Vec = items.iter().map(|x| x.to_string()).collect(); + format!("[{}]", inner.join(", ")) +} + +/// Build the JSON array string that Python's `__calc_digest` hashes: +/// `[user_asdict_or_null, token_asdict, labs_asdict]` +/// +/// Field order matches each Python dataclass definition: +/// User: id, username, laboratory_ids, is_reviewer +/// Token: access, refresh +/// Laboratories: items +/// Laboratory: id, name, pi_name, full_name +pub fn python_digest_json( + user: Option<&CacheUser>, + access: &str, + refresh: &str, + labs: &CacheLabsWrapper, +) -> String { + let user_str = match user { + None => "null".to_string(), + Some(u) => format!( + "{{\"id\": {}, \"username\": {}, \"laboratory_ids\": {}, \"is_reviewer\": {}}}", + u.id, + python_json_string(&u.username), + python_json_u32_array(&u.laboratory_ids), + if u.is_reviewer { "true" } else { "false" } + ), + }; + + let token_str = format!( + "{{\"access\": {}, \"refresh\": {}}}", + python_json_string(access), + python_json_string(refresh) + ); + + let items: Vec = labs + .items + .iter() + .map(|lab| { + format!( + "{{\"id\": {}, \"name\": {}, \"pi_name\": {}, \"full_name\": {}}}", + lab.id, + python_json_string(&lab.name), + python_json_string(&lab.pi_name), + python_json_string(&lab.full_name) + ) + }) + .collect(); + + let items_str = if items.is_empty() { + "[]".to_string() + } else { + format!("[{}]", items.join(", ")) + }; + let labs_str = format!("{{\"items\": {}}}", items_str); + + format!("[{}, {}, {}]", user_str, token_str, labs_str) +} + +/// Compute the cache digest compatible with Python's `CacheData.__calc_digest`: +/// `hashlib.sha256(json.dumps([user, token, labs]).encode("utf-8")).hexdigest()` +pub fn compute_digest( + user: Option<&CacheUser>, + access: &str, + refresh: &str, + labs: &CacheLabsWrapper, +) -> String { + let json_str = python_digest_json(user, access, refresh, labs); + let mut hasher = Sha256::new(); + hasher.update(json_str.as_bytes()); + format!("{:x}", hasher.finalize()) +} diff --git a/src/cache/mod.rs b/src/cache/mod.rs new file mode 100644 index 0000000..82e7795 --- /dev/null +++ b/src/cache/mod.rs @@ -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>>>> = + 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_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 { + 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::(&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 { + 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 = 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 { + 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 { + 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())) +} diff --git a/src/cache/types.rs b/src/cache/types.rs new file mode 100644 index 0000000..d72f640 --- /dev/null +++ b/src/cache/types.rs @@ -0,0 +1,42 @@ +use serde::Deserialize; + +/// Access and refresh token pair stored in the login cache file. +#[derive(Deserialize, Clone)] +pub struct CacheToken { + pub access: String, + pub refresh: String, +} + +/// Minimal user fields stored in the cache, matching Python's `User` dataclass. +#[derive(Deserialize, Clone)] +pub struct CacheUser { + pub id: u32, + pub username: String, + pub laboratory_ids: Vec, + pub is_reviewer: bool, +} + +/// All four laboratory fields needed for digest computation. +#[derive(Deserialize, Clone)] +pub struct CacheLaboratory { + pub id: u32, + pub name: String, + #[serde(default)] + pub pi_name: String, + #[serde(default)] + pub full_name: String, +} + +/// Wrapper matching Python's `Laboratories` serialization: `{"items": [...]}`. +#[derive(Deserialize, Clone, Default)] +pub struct CacheLabsWrapper { + pub items: Vec, +} + +/// Full login cache, corresponding to the `.json` file written by `login`. +#[derive(Deserialize, Clone)] +pub struct Cache { + pub user: Option, + pub token: CacheToken, + pub laboratories: CacheLabsWrapper, +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..da228c5 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,114 @@ +use clap::{Parser, Subcommand}; + +use crate::commands::config_subcommand::*; + +#[derive(Parser)] +#[command(name = "mdrs")] +#[command(about = "MDRS Rust CLI client", long_about = None)] +pub struct Cli { + #[command(subcommand)] + pub command: Commands, +} + +#[derive(Subcommand)] +pub enum Commands { + /// Config management (create, update, list, delete) + #[command(subcommand)] + Config(ConfigSubcommand), + Login { + #[arg(short, long)] + username: Option, + #[arg(short, long)] + password: Option, + remote: String, + }, + /// Logout and remove cached credentials for a remote + Logout { + remote: String, + }, + Upload { + #[arg(short, long)] + recursive: bool, + #[arg(short = 's', long)] + skip_if_exists: bool, + local_path: String, + remote_path: String, + }, + Download { + #[arg(short, long)] + recursive: bool, + #[arg(short = 's', long)] + skip_if_exists: bool, + #[arg(short = 'p', long)] + password: Option, + #[arg(long)] + exclude: Vec, + remote_path: String, + local_path: String, + }, + Ls { + remote_path: String, + #[arg(short = 'p', long)] + password: Option, + #[arg(short = 'J', long = "json")] + json: bool, + #[arg(short = 'r', long)] + recursive: bool, + #[arg(short = 'q', long)] + quiet: bool, + }, + Whoami { + remote: String, + }, + Labs { + remote: String, + }, + Chacl { + /// Access level key: private, public, pw_open, cbs_open, 5kikan_open, + /// cbs_or_pw_open, 5kikan_or_pw_open, storage + access_level_key: String, + #[arg(short, long)] + recursive: bool, + #[arg(short = 'p', long)] + password: Option, + remote_path: String, + }, + Metadata { + #[arg(short = 'p', long)] + password: Option, + remote_path: String, + }, + Mkdir { + remote_path: String, + }, + Rm { + #[arg(short, long)] + recursive: bool, + remote_path: String, + }, + Mv { + src_path: String, + dest_path: String, + }, + Cp { + #[arg(short, long)] + recursive: bool, + src_path: String, + dest_path: String, + }, + /// Show metadata for a remote file + FileMetadata { + #[arg(short = 'p', long)] + password: Option, + remote_path: String, + }, + /// Show the version of this tool + Version, + /// Update this binary to the latest release + #[command(name = "selfupdate")] + SelfUpdate { + /// Skip the confirmation prompt + #[arg(short = 'y', long)] + yes: bool, + }, +} diff --git a/src/commands/chacl.rs b/src/commands/chacl.rs index 41b93a7..24fef97 100644 --- a/src/commands/chacl.rs +++ b/src/commands/chacl.rs @@ -1,13 +1,15 @@ +use crate::cache::{create_authenticated_conn, load_cache_with_token_refresh}; use crate::commands::shared::{ - create_authenticated_conn, find_folder, find_lab_in_cache, load_cache_with_token_refresh, parse_remote_path, + find_folder, find_lab_in_cache, parse_remote_path, }; +use anyhow::{bail}; pub async fn chacl( remote_path: &str, access_level_key: &str, recursive: bool, password: Option<&str>, -) -> Result<(), Box> { +) -> Result<(), anyhow::Error> { let access_level_id: u32 = match access_level_key { "private" => 0x0001, "public" => 0x0002, @@ -17,7 +19,7 @@ pub async fn chacl( "cbs_or_pw_open" => 0x0104, "5kikan_or_pw_open" => 0x0204, "storage" => 0x0000, - _ => return Err(format!("Unknown access level key: '{}'", access_level_key).into()), + _ => bail!("Unknown access level key: '{}'", access_level_key), }; let (remote, labname, folder_path) = parse_remote_path(remote_path)?; @@ -43,7 +45,7 @@ pub async fn chacl( .await?; if !resp.status().is_success() { - return Err(format!("ACL change failed: {}", resp.status()).into()); + bail!("ACL change failed: {}", resp.status()); } Ok(()) } diff --git a/src/commands/config.rs b/src/commands/config.rs index 6d56692..fa76e08 100644 --- a/src/commands/config.rs +++ b/src/commands/config.rs @@ -1,12 +1,13 @@ use configparser::ini::Ini; use std::fs; use std::path::PathBuf; +use anyhow::{bail}; fn config_path() -> PathBuf { crate::settings::SETTINGS.config_dirname.join("config.ini") } -fn sanitize_config_file(path: &PathBuf) -> Result<(), Box> { +fn sanitize_config_file(path: &PathBuf) -> Result<(), anyhow::Error> { if !path.exists() { return Ok(()); } @@ -28,7 +29,7 @@ fn sanitize_config_file(path: &PathBuf) -> Result<(), Box Ok(()) } -fn write_ini_atomic(path: &PathBuf, ini: &Ini) -> Result<(), Box> { +fn write_ini_atomic(path: &PathBuf, ini: &Ini) -> Result<(), anyhow::Error> { let tmp = path.with_extension("tmp"); // write to tmp path then rename for atomicity ini.write(&tmp)?; @@ -36,27 +37,27 @@ fn write_ini_atomic(path: &PathBuf, ini: &Ini) -> Result<(), Box Result, Box> { +pub fn get_remote_url(remote: &str) -> Result, anyhow::Error> { let path = config_path(); sanitize_config_file(&path)?; let path_str = path.to_string_lossy().to_string(); let mut conf = Ini::new(); if path.exists() { - let _ = conf.load(&path_str)?; + let _ = conf.load(&path_str).map_err(|e| anyhow::anyhow!("{}", e))?; } Ok(conf.get(remote, "url")) } -pub fn config_create(remote: &str, url: &str) -> Result<(), Box> { +pub fn config_create(remote: &str, url: &str) -> Result<(), anyhow::Error> { if !validate_url(url) { - return Err("Malformed URL".into()); + bail!("Malformed URL"); } let path = config_path(); sanitize_config_file(&path)?; let path_str = path.to_string_lossy().to_string(); let mut conf = Ini::new(); if path.exists() { - let _ = conf.load(&path_str)?; + let _ = conf.load(&path_str).map_err(|e| anyhow::anyhow!("{}", e))?; } // check if section exists let section_exists = conf @@ -64,7 +65,7 @@ pub fn config_create(remote: &str, url: &str) -> Result<(), Box Result<(), Box Result<(), Box> { +pub fn config_update(remote: &str, url: &str) -> Result<(), anyhow::Error> { if !validate_url(url) { - return Err("Malformed URL".into()); + bail!("Malformed URL"); } let path = config_path(); sanitize_config_file(&path)?; let path_str = path.to_string_lossy().to_string(); let mut conf = Ini::new(); if path.exists() { - let _ = conf.load(&path_str)?; + let _ = conf.load(&path_str).map_err(|e| anyhow::anyhow!("{}", e))?; } // ensure section exists let section_exists = conf @@ -93,14 +94,14 @@ pub fn config_update(remote: &str, url: &str) -> Result<(), Box Result<(), Box> { +pub fn config_list() -> Result<(), anyhow::Error> { let path = config_path(); if !path.exists() { return Ok(()); @@ -108,7 +109,7 @@ pub fn config_list() -> Result<(), Box> { sanitize_config_file(&path)?; let path_str = path.to_string_lossy().to_string(); let mut conf = Ini::new(); - let _ = conf.load(&path_str)?; + let _ = conf.load(&path_str).map_err(|e| anyhow::anyhow!("{}", e))?; let map = conf.get_map().unwrap_or_default(); for (sec, props) in map.iter() { if sec == "default" { @@ -120,18 +121,18 @@ pub fn config_list() -> Result<(), Box> { Ok(()) } -pub fn config_delete(remote: &str) -> Result<(), Box> { +pub fn config_delete(remote: &str) -> Result<(), anyhow::Error> { let path = config_path(); sanitize_config_file(&path)?; let path_str = path.to_string_lossy().to_string(); let mut conf = Ini::new(); if path.exists() { - let _ = conf.load(&path_str)?; + let _ = conf.load(&path_str).map_err(|e| anyhow::anyhow!("{}", e))?; } // fallback: reconstruct by removing the section in memory map and writing file let mut map = conf.get_map().unwrap_or_default(); if map.remove(remote).is_none() { - return Err(format!("Remote host `{}` is not exists.", remote).into()); + bail!("Remote host `{}` is not exists.", remote); } // build new Ini from map let mut new_ini = Ini::new(); diff --git a/src/commands/cp.rs b/src/commands/cp.rs index bf0d1d3..f68541d 100644 --- a/src/commands/cp.rs +++ b/src/commands/cp.rs @@ -1,22 +1,24 @@ +use crate::cache::{create_authenticated_conn, load_cache_with_token_refresh}; use crate::commands::shared::{ - create_authenticated_conn, find_file_by_name, find_folder, find_lab_in_cache, - find_subfolder_by_name, load_cache_with_token_refresh, nfc, parse_remote_path, + find_file_by_name, find_folder, find_lab_in_cache, + find_subfolder_by_name, nfc, parse_remote_path, }; +use anyhow::{bail}; pub async fn cp( src_path: &str, dest_path: &str, recursive: bool, -) -> Result<(), Box> { +) -> Result<(), anyhow::Error> { let (s_remote, s_lab, s_path) = parse_remote_path(src_path)?; let dest_ends_with_slash = dest_path.ends_with('/'); let (d_remote, d_lab, d_path) = parse_remote_path(dest_path)?; if s_remote != d_remote { - return Err("Remote host mismatched.".into()); + bail!("Remote host mismatched."); } if s_lab != d_lab { - return Err("Laboratory mismatched.".into()); + bail!("Laboratory mismatched."); } let cache = load_cache_with_token_refresh(&s_remote).await?; @@ -46,14 +48,10 @@ pub async fn cp( if let Some(src_file) = find_file_by_name(&s_parent_files, &s_basename) { let src_file_id = src_file.id.clone(); if find_file_by_name(&d_parent_files, &d_basename).is_some() { - return Err(format!("File `{}` already exists.", d_basename).into()); + bail!("File `{}` already exists.", d_basename); } if find_subfolder_by_name(&d_parent_folder.sub_folders, &d_basename).is_some() { - return Err(format!( - "Cannot overwrite non-folder `{}` with folder `{}`.", - d_basename, d_path - ) - .into()); + bail!("Cannot overwrite non-folder `{}` with folder `{}`.", d_basename, d_path); } // No-op if source and destination are identical if s_parent_folder.id == d_parent_folder.id && d_basename == s_basename { @@ -68,7 +66,7 @@ pub async fn cp( .send() .await?; if !resp.status().is_success() { - return Err(format!("Copy failed: {}", resp.status()).into()); + bail!("Copy failed: {}", resp.status()); } return Ok(()); } @@ -76,28 +74,20 @@ pub async fn cp( // Try source as a folder let src_folder = match find_subfolder_by_name(&s_parent_folder.sub_folders, &s_basename) { Some(f) => f, - None => return Err(format!("File or folder `{}` not found.", s_basename).into()), + None => bail!("File or folder `{}` not found.", s_basename), }; if !recursive { - return Err(format!("Cannot copy `{}`: Is a folder.", s_path).into()); + bail!("Cannot copy `{}`: Is a folder.", s_path); } let src_folder_id = src_folder.id.clone(); if find_file_by_name(&d_parent_files, &d_basename).is_some() { - return Err(format!( - "Cannot overwrite non-folder `{}` with folder `{}`.", - d_basename, s_path - ) - .into()); + bail!("Cannot overwrite non-folder `{}` with folder `{}`.", d_basename, s_path); } if let Some(d_folder) = find_subfolder_by_name(&d_parent_folder.sub_folders, &d_basename) { if d_folder.id == src_folder_id { - return Err( - format!("`{}` and `{}` are the same folder.", s_path, s_path).into(), - ); + bail!("`{}` and `{}` are the same folder.", s_path, s_path); } - return Err( - format!("Cannot move `{}` to `{}`: Folder not empty.", s_path, d_path).into(), - ); + bail!("Cannot move `{}` to `{}`: Folder not empty.", s_path, d_path); } // No-op if source and destination are identical if s_parent_folder.id == d_parent_folder.id && s_basename == d_basename { @@ -112,7 +102,7 @@ pub async fn cp( .send() .await?; if !resp.status().is_success() { - return Err(format!("Copy failed: {}", resp.status()).into()); + bail!("Copy failed: {}", resp.status()); } Ok(()) } diff --git a/src/commands/download.rs b/src/commands/download.rs index c73c8f9..86f05ac 100644 --- a/src/commands/download.rs +++ b/src/commands/download.rs @@ -1,11 +1,13 @@ +use crate::cache::{create_authenticated_conn, load_cache_with_token_refresh}; use crate::commands::shared::{ - create_authenticated_conn, find_file_by_name, find_folder, find_lab_in_cache, - find_subfolder_by_name, load_cache_with_token_refresh, parse_remote_path, + find_file_by_name, find_folder, find_lab_in_cache, + find_subfolder_by_name, parse_remote_path, }; use crate::connection::MDRSConnection; use futures::stream::{FuturesUnordered, StreamExt}; use std::path::PathBuf; use std::sync::Arc; +use anyhow::{anyhow, bail}; pub async fn download( remote_path: &str, @@ -14,7 +16,7 @@ pub async fn download( skip_if_exists: bool, password: Option<&str>, excludes: Vec, -) -> Result<(), Box> { +) -> Result<(), anyhow::Error> { let (remote, labname, r_path) = parse_remote_path(remote_path)?; let cache = load_cache_with_token_refresh(&remote).await?; let conn = Arc::new(create_authenticated_conn(&remote, &cache)?); @@ -22,9 +24,9 @@ pub async fn download( // Validate that local_path is an existing directory (matching Python's behaviour). let local_real = std::fs::canonicalize(local_path) - .map_err(|_| format!("Local directory `{}` not found.", local_path))?; + .map_err(|_| anyhow!("Local directory `{}` not found.", local_path))?; if !local_real.is_dir() { - return Err(format!("Local directory `{}` not found.", local_path).into()); + bail!("Local directory `{}` not found.", local_path); } // Split r_path into the parent directory path and the target basename. @@ -69,7 +71,7 @@ pub async fn download( let subfolder = find_subfolder_by_name(&parent_folder.sub_folders, &basename); if let Some(sub) = subfolder { if !recursive { - return Err(format!("Cannot download `{}`: Is a folder.", r_path_clean).into()); + bail!("Cannot download `{}`: Is a folder.", r_path_clean); } // Python downloads into local_path// (not directly into local_path). // We create that subdirectory first, then recurse into it. @@ -155,7 +157,7 @@ pub async fn download( return Ok(()); } - Err(format!("File or folder `{}` not found.", r_path_clean).into()) + Err(anyhow!("File or folder `{}` not found.", r_path_clean)) } /// Return true if the given lab/folder/file path matches any exclude pattern. diff --git a/src/commands/file_metadata.rs b/src/commands/file_metadata.rs index 758d99f..64e8bb8 100644 --- a/src/commands/file_metadata.rs +++ b/src/commands/file_metadata.rs @@ -1,9 +1,11 @@ +use crate::cache::{create_authenticated_conn, load_cache_with_token_refresh}; +use anyhow::{anyhow}; use crate::commands::shared::{ - create_authenticated_conn, find_file_by_name, find_folder, find_lab_in_cache, load_cache_with_token_refresh, + find_file_by_name, find_folder, find_lab_in_cache, parse_remote_path, }; -pub async fn file_metadata(remote_path: &str, password: Option<&str>) -> Result<(), Box> { +pub async fn file_metadata(remote_path: &str, password: Option<&str>) -> Result<(), anyhow::Error> { let (remote, labname, r_path) = parse_remote_path(remote_path)?; let cache = load_cache_with_token_refresh(&remote).await?; @@ -24,7 +26,7 @@ pub async fn file_metadata(remote_path: &str, password: Option<&str>) -> Result< let files = conn.list_all_files(&parent_folder.id).await?; let file = find_file_by_name(&files, &basename) - .ok_or_else(|| format!("File `{}` not found.", basename))?; + .ok_or_else(|| anyhow!("File `{}` not found.", basename))?; let resp = conn.get(&format!("v3/files/{}/metadata/", file.id)).await?; let json: serde_json::Value = resp.json().await?; diff --git a/src/commands/labs.rs b/src/commands/labs.rs index 353d8c2..95ebfdf 100644 --- a/src/commands/labs.rs +++ b/src/commands/labs.rs @@ -1,6 +1,6 @@ -use crate::commands::shared::{create_authenticated_conn, load_cache_with_token_refresh}; +use crate::cache::{create_authenticated_conn, load_cache_with_token_refresh}; -pub async fn labs(remote: &str) -> Result<(), Box> { +pub async fn labs(remote: &str) -> Result<(), anyhow::Error> { let cache = load_cache_with_token_refresh(remote).await?; let conn = create_authenticated_conn(remote, &cache)?; let labs = conn.list_laboratories().await?; diff --git a/src/commands/login.rs b/src/commands/login.rs index d865bad..5d88f3f 100644 --- a/src/commands/login.rs +++ b/src/commands/login.rs @@ -1,55 +1,55 @@ -use crate::commands::shared::{CacheLaboratory, CacheLabsWrapper, CacheUser, compute_digest}; +use anyhow::{anyhow, bail}; +use crate::cache::{CacheLabsWrapper, CacheUser, compute_digest}; use crate::connection::MDRSConnection; use crate::models::laboratory::Laboratories; use crate::models::user::User; use reqwest::Client; use serde::Deserialize; use serde_json::{Value, json}; -use std::error::Error; use std::fs; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; +/// Prompt for credentials if not supplied and then perform login. +/// This is the entry point called from `main`. +pub async fn run_login( + username: Option<&str>, + password: Option<&str>, + remote: &str, +) -> Result<(), anyhow::Error> { + use std::io::{self, Write}; + let username_val: String = match username { + Some(u) => u.to_string(), + None => { + print!("Username: "); + io::stdout().flush()?; + let mut s = String::new(); + io::stdin().read_line(&mut s)?; + s.trim().to_string() + } + }; + let password_val: String = match password { + Some(p) => p.to_string(), + None => rpassword::prompt_password("Password: ")?, + }; + login(&username_val, &password_val, remote).await +} + #[derive(Deserialize)] struct TokenResp { access: String, refresh: String, } -/// Convert an API `User` into a `CacheUser` (same fields, different type). -fn to_cache_user(u: &User) -> CacheUser { - CacheUser { - id: u.id, - username: u.username.clone(), - laboratory_ids: u.laboratory_ids.clone(), - is_reviewer: u.is_reviewer, - } -} - -/// Convert API `Laboratories` into `CacheLabsWrapper` (all four fields already present). -fn to_cache_labs(labs: &Laboratories) -> CacheLabsWrapper { - CacheLabsWrapper { - items: labs - .items - .iter() - .map(|l| CacheLaboratory { - id: l.id, - name: l.name.clone(), - pi_name: l.pi_name.clone(), - full_name: l.full_name.clone(), - }) - .collect(), - } -} pub async fn login( username: &str, password: &str, remote: &str, -) -> Result<(), Box> { +) -> Result<(), anyhow::Error> { // resolve remote label to URL from config let url_opt = crate::commands::config::get_remote_url(remote)?; - let base_url = url_opt.ok_or(format!("Remote host `{}` is not configured", remote))?; + let base_url = url_opt.ok_or_else(|| anyhow!("Remote host `{}` is not configured", remote))?; let conn0 = MDRSConnection::new(&base_url); let client = Client::new(); let url = conn0.build_url("v3/users/token/"); @@ -57,19 +57,12 @@ pub async fn login( let resp_res = client.post(&url).form(¶ms).send().await; let resp = match resp_res { Ok(r) => r, - Err(e) => { - let src = e.source(); - return Err(format!( - "Login failed sending request to {}: {} (source: {:?})", - url, e, src - ) - .into()); - } + Err(e) => bail!("Login failed sending request to {}: {}", url, e), }; let status = resp.status(); if !status.is_success() { let body = resp.text().await.unwrap_or_default(); - return Err(format!("Login failed: {} - {}", status, body).into()); + bail!("Login failed: {} - {}", status, body); } let token: TokenResp = resp.json().await?; @@ -80,8 +73,8 @@ pub async fn login( let labs: Laboratories = conn.list_laboratories().await.unwrap_or_default(); // convert to cache types (all four Laboratory fields required for digest) - let cache_user_opt: Option = user_opt.as_ref().map(to_cache_user); - let cache_labs = to_cache_labs(&labs); + let cache_user_opt: Option = user_opt.as_ref().map(|u| u.into()); + let cache_labs: CacheLabsWrapper = (&labs).into(); // compute Python-compatible digest let digest = compute_digest( diff --git a/src/commands/logout.rs b/src/commands/logout.rs index fd64c4d..3e4b63a 100644 --- a/src/commands/logout.rs +++ b/src/commands/logout.rs @@ -1,4 +1,4 @@ -pub fn logout(remote: &str) -> Result<(), Box> { +pub fn logout(remote: &str) -> Result<(), anyhow::Error> { let cache_path = crate::settings::SETTINGS .config_dirname .join("cache") diff --git a/src/commands/ls.rs b/src/commands/ls.rs index 97ecf6c..fa1813e 100644 --- a/src/commands/ls.rs +++ b/src/commands/ls.rs @@ -1,8 +1,8 @@ use crate::models::file::File; use crate::models::folder::{FolderDetail, FolderSimple}; +use crate::cache::{create_authenticated_conn, load_cache_with_token_refresh}; use crate::commands::shared::{ - create_authenticated_conn, find_folder, find_lab_in_cache, fmt_datetime, - load_cache_with_token_refresh, parse_remote_path, + find_folder, find_lab_in_cache, fmt_datetime, parse_remote_path, }; use crate::connection::MDRSConnection; use serde_json::{json, Value}; @@ -15,7 +15,7 @@ pub async fn ls( is_json: bool, is_recursive: bool, is_quiet: bool, -) -> Result<(), Box> { +) -> Result<(), anyhow::Error> { let (remote, labname, path) = parse_remote_path(remote_path)?; let cache = load_cache_with_token_refresh(&remote).await?; let conn = create_authenticated_conn(&remote, &cache)?; @@ -151,7 +151,7 @@ fn ls_plain_recursive<'a>( labname: &'a str, prefix: &'a str, password: Option<&'a str>, -) -> Pin>> + 'a>> { +) -> Pin> + 'a>> { Box::pin(async move { let files = conn.list_all_files(&folder.id).await?; let total_size: u64 = files.iter().map(|f| f.size).sum(); @@ -199,7 +199,7 @@ fn ls_plain_recursive<'a>( async fn get_folder_metadata( conn: &MDRSConnection, folder_id: &str, -) -> Result> { +) -> Result { let resp = conn .get(&format!("v3/folders/{}/metadata/", folder_id)) .await?; @@ -253,7 +253,7 @@ async fn build_folder_json_flat( conn: &MDRSConnection, folder: &FolderDetail, labname: &str, -) -> Result> { +) -> Result { let metadata = get_folder_metadata(conn, &folder.id).await?; let files = conn.list_all_files(&folder.id).await?; let files_json: Vec = files.iter().map(|f| file_to_json(f, &conn.url)).collect(); @@ -285,7 +285,7 @@ fn build_folder_json_recursive<'a>( conn: &'a MDRSConnection, folder: FolderDetail, labname: &'a str, -) -> Pin>> + 'a>> { +) -> Pin> + 'a>> { Box::pin(async move { let metadata = get_folder_metadata(conn, &folder.id).await?; let files = conn.list_all_files(&folder.id).await?; diff --git a/src/commands/metadata.rs b/src/commands/metadata.rs index f856c51..8be4088 100644 --- a/src/commands/metadata.rs +++ b/src/commands/metadata.rs @@ -1,8 +1,9 @@ +use crate::cache::{create_authenticated_conn, load_cache_with_token_refresh}; use crate::commands::shared::{ - create_authenticated_conn, find_folder, find_lab_in_cache, load_cache_with_token_refresh, parse_remote_path, + find_folder, find_lab_in_cache, parse_remote_path, }; -pub async fn metadata(remote_path: &str, password: Option<&str>) -> Result<(), Box> { +pub async fn metadata(remote_path: &str, password: Option<&str>) -> Result<(), anyhow::Error> { let (remote, labname, folder_path) = parse_remote_path(remote_path)?; let cache = load_cache_with_token_refresh(&remote).await?; let conn = create_authenticated_conn(&remote, &cache)?; diff --git a/src/commands/mkdir.rs b/src/commands/mkdir.rs index ea7c7c7..d977791 100644 --- a/src/commands/mkdir.rs +++ b/src/commands/mkdir.rs @@ -1,16 +1,18 @@ +use crate::cache::{create_authenticated_conn, load_cache_with_token_refresh}; use crate::commands::shared::{ - create_authenticated_conn, find_file_by_name, find_folder, find_lab_in_cache, - find_subfolder_by_name, load_cache_with_token_refresh, nfc, parse_remote_path, + find_file_by_name, find_folder, find_lab_in_cache, + find_subfolder_by_name, nfc, parse_remote_path, }; +use anyhow::{anyhow, bail}; -pub async fn mkdir(remote_path: &str) -> Result<(), Box> { +pub async fn mkdir(remote_path: &str) -> Result<(), anyhow::Error> { let (remote, labname, path) = parse_remote_path(remote_path)?; // Split into parent path and new folder name let path = path.trim_end_matches('/'); let last_slash = path .rfind('/') - .ok_or("Invalid path: cannot determine parent folder")?; + .ok_or_else(|| anyhow!("Invalid path: cannot determine parent folder"))?; let parent_path = if last_slash == 0 { "/" } else { @@ -18,7 +20,7 @@ pub async fn mkdir(remote_path: &str) -> Result<(), Box> }; let new_folder_name = &path[last_slash + 1..]; if new_folder_name.is_empty() { - return Err("Invalid path: folder name cannot be empty".into()); + bail!("Invalid path: folder name cannot be empty"); } let cache = load_cache_with_token_refresh(&remote).await?; @@ -31,14 +33,14 @@ pub async fn mkdir(remote_path: &str) -> Result<(), Box> if find_subfolder_by_name(&parent_folder.sub_folders, new_folder_name).is_some() || find_file_by_name(&files, new_folder_name).is_some() { - return Err(format!("Cannot create folder `{}`: File exists.", path).into()); + bail!("Cannot create folder `{}`: File exists.", path); } let resp = conn .create_folder(&parent_folder.id, &nfc(new_folder_name)) .await?; if !resp.status().is_success() { - return Err(format!("Failed to create folder: {}", resp.status()).into()); + bail!("Failed to create folder: {}", resp.status()); } Ok(()) } diff --git a/src/commands/mv.rs b/src/commands/mv.rs index f39497f..790554e 100644 --- a/src/commands/mv.rs +++ b/src/commands/mv.rs @@ -1,18 +1,20 @@ +use crate::cache::{create_authenticated_conn, load_cache_with_token_refresh}; use crate::commands::shared::{ - create_authenticated_conn, find_file_by_name, find_folder, find_lab_in_cache, - find_subfolder_by_name, load_cache_with_token_refresh, nfc, parse_remote_path, + find_file_by_name, find_folder, find_lab_in_cache, + find_subfolder_by_name, nfc, parse_remote_path, }; +use anyhow::{bail}; -pub async fn mv(src_path: &str, dest_path: &str) -> Result<(), Box> { +pub async fn mv(src_path: &str, dest_path: &str) -> Result<(), anyhow::Error> { let (s_remote, s_lab, s_path) = parse_remote_path(src_path)?; let dest_ends_with_slash = dest_path.ends_with('/'); let (d_remote, d_lab, d_path) = parse_remote_path(dest_path)?; if s_remote != d_remote { - return Err("Remote host mismatched.".into()); + bail!("Remote host mismatched."); } if s_lab != d_lab { - return Err("Laboratory mismatched.".into()); + bail!("Laboratory mismatched."); } let cache = load_cache_with_token_refresh(&s_remote).await?; @@ -42,14 +44,10 @@ pub async fn mv(src_path: &str, dest_path: &str) -> Result<(), Box Result<(), Box Result<(), Box f, - None => return Err(format!("File or folder `{}` not found.", s_basename).into()), + None => bail!("File or folder `{}` not found.", s_basename), }; let src_folder_id = src_folder.id.clone(); if find_file_by_name(&d_parent_files, &d_basename).is_some() { - return Err(format!( - "Cannot overwrite non-folder `{}` with folder `{}`.", - d_basename, s_path - ) - .into()); + bail!("Cannot overwrite non-folder `{}` with folder `{}`.", d_basename, s_path); } if let Some(d_folder) = find_subfolder_by_name(&d_parent_folder.sub_folders, &d_basename) { if d_folder.id == src_folder_id { - return Err( - format!("`{}` and `{}` are the same folder.", s_path, s_path).into(), - ); + bail!("`{}` and `{}` are the same folder.", s_path, s_path); } - return Err( - format!("Cannot move `{}` to `{}`: Folder not empty.", s_path, d_path).into(), - ); + bail!("Cannot move `{}` to `{}`: Folder not empty.", s_path, d_path); } // No-op if source and destination are identical if s_parent_folder.id == d_parent_folder.id && s_basename == d_basename { @@ -105,7 +95,7 @@ pub async fn mv(src_path: &str, dest_path: &str) -> Result<(), Box Result<(), Box> { +pub async fn rm(remote_path: &str, recursive: bool) -> Result<(), anyhow::Error> { let (remote, labname, path) = parse_remote_path(remote_path)?; // Split into parent path and target name let path = path.trim_end_matches('/'); - let last_slash = path.rfind('/').ok_or("Invalid path")?; + let last_slash = path.rfind('/').ok_or_else(|| anyhow!("Invalid path"))?; let parent_path = if last_slash == 0 { "/" } else { @@ -16,7 +18,7 @@ pub async fn rm(remote_path: &str, recursive: bool) -> Result<(), Box Result<(), Box Result<(), Box Result<(), Box, - pub is_reviewer: bool, -} - -/// All four fields of a laboratory, needed for digest computation. -#[derive(Deserialize, Clone)] -pub struct CacheLaboratory { - pub id: u32, - pub name: String, - #[serde(default)] - pub pi_name: String, - #[serde(default)] - pub full_name: String, -} - -/// Wrapper matching Python's `Laboratories` serialization: `{"items": [...]}`. -#[derive(Deserialize, Clone, Default)] -pub struct CacheLabsWrapper { - pub items: Vec, -} - -#[derive(Deserialize, Clone)] -pub struct Cache { - pub user: Option, - pub token: CacheToken, - pub laboratories: CacheLabsWrapper, -} - -// --------------------------------------------------------------------------- -// Digest computation — must produce exactly the same hash as Python's -// `CacheData.__calc_digest()`: -// hashlib.sha256( -// json.dumps([user_asdict, token_asdict, labs_asdict]).encode("utf-8") -// ).hexdigest() -// -// Python's default json.dumps uses separators=(', ', ': ') and -// ensure_ascii=True. Field order follows dataclass definition order. -// --------------------------------------------------------------------------- - -/// Escape a string in Python json.dumps style: -/// - Special chars: ", \, and control chars -> standard JSON escapes -/// - Non-ASCII chars -> \uXXXX (matches Python ensure_ascii=True default) -fn python_json_string(s: &str) -> String { - let mut out = String::with_capacity(s.len() + 2); - out.push('"'); - for c in s.chars() { - match c { - '"' => out.push_str("\\\""), - '\\' => out.push_str("\\\\"), - '\n' => out.push_str("\\n"), - '\r' => out.push_str("\\r"), - '\t' => out.push_str("\\t"), - c if (c as u32) < 0x20 => { - out.push_str(&format!("\\u{:04x}", c as u32)); - } - c if c.is_ascii() => out.push(c), - c => { - // Non-ASCII: encode as \uXXXX (BMP) or surrogate pair (outside BMP) - let code = c as u32; - if code <= 0xFFFF { - out.push_str(&format!("\\u{:04x}", code)); - } else { - let code = code - 0x10000; - let high = 0xD800 + (code >> 10); - let low = 0xDC00 + (code & 0x3FF); - out.push_str(&format!("\\u{:04x}\\u{:04x}", high, low)); - } - } - } - } - out.push('"'); - out -} - -/// Serialize a list of u32 as a Python-style JSON array: `[1, 2, 3]` -fn python_json_u32_array(items: &[u32]) -> String { - if items.is_empty() { - return "[]".to_string(); - } - let inner: Vec = items.iter().map(|x| x.to_string()).collect(); - format!("[{}]", inner.join(", ")) -} - -/// Build the JSON array string that Python's `__calc_digest` hashes: -/// [user_asdict_or_null, token_asdict, labs_asdict] -/// -/// Field order matches each Python dataclass definition: -/// User: id, username, laboratory_ids, is_reviewer -/// Token: access, refresh -/// Laboratories: items -/// Laboratory: id, name, pi_name, full_name -pub fn python_digest_json( - user: Option<&CacheUser>, - access: &str, - refresh: &str, - labs: &CacheLabsWrapper, -) -> String { - let user_str = match user { - None => "null".to_string(), - Some(u) => format!( - "{{\"id\": {}, \"username\": {}, \"laboratory_ids\": {}, \"is_reviewer\": {}}}", - u.id, - python_json_string(&u.username), - python_json_u32_array(&u.laboratory_ids), - if u.is_reviewer { "true" } else { "false" } - ), - }; - - let token_str = format!( - "{{\"access\": {}, \"refresh\": {}}}", - python_json_string(access), - python_json_string(refresh) - ); - - let items: Vec = labs - .items - .iter() - .map(|lab| { - format!( - "{{\"id\": {}, \"name\": {}, \"pi_name\": {}, \"full_name\": {}}}", - lab.id, - python_json_string(&lab.name), - python_json_string(&lab.pi_name), - python_json_string(&lab.full_name) - ) - }) - .collect(); - - let items_str = if items.is_empty() { - "[]".to_string() - } else { - format!("[{}]", items.join(", ")) - }; - let labs_str = format!("{{\"items\": {}}}", items_str); - - format!("[{}, {}, {}]", user_str, token_str, labs_str) -} - -/// Compute the cache digest compatible with Python's `CacheData.__calc_digest`. -pub fn compute_digest( - user: Option<&CacheUser>, - access: &str, - refresh: &str, - labs: &CacheLabsWrapper, -) -> String { - let json_str = python_digest_json(user, access, refresh, labs); - let mut hasher = Sha256::new(); - hasher.update(json_str.as_bytes()); - format!("{:x}", hasher.finalize()) -} - -// --------------------------------------------------------------------------- -// Per-remote async mutex map (in-process serialization) -// --------------------------------------------------------------------------- - -static REMOTE_LOCKS: 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 helper -// --------------------------------------------------------------------------- - -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> { - let cache_path = cache_file_path(remote); - if !cache_path.exists() { - return Err(format!( - "Not logged in to `{}`. Run `mdrs login {}` first.", - remote, remote - ) - .into()); - } - let data = fs::read_to_string(&cache_path)?; - serde_json::from_str::(&data).map_err(|e| { - format!( - "Cache for `{}` is invalid or outdated ({}). Run `mdrs login {}` to refresh it.", - remote, e, remote - ) - .into() - }) -} - -// --------------------------------------------------------------------------- -// 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. The lock file is stable (never renamed), so the flock -/// reliably protects the same inode for the lifetime of the critical section. -pub async fn load_cache_with_token_refresh( - remote: &str, -) -> Result> { - // Acquire the in-process async mutex for this remote first. - let lock = get_remote_lock(remote); - let _guard = lock.lock().await; - - // Acquire an exclusive cross-process file lock. This ensures that no - // other process can race through the read-check-refresh-write cycle at - // the same time as us. The lock file is separate from the cache file so - // that it never disappears (unlike a .tmp file that gets renamed away). - 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 the cache 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) { - return Err(format!( - "Session for `{}` has expired. Please run `mdrs login {}` again.", - remote, remote - ) - .into()); - } - - 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; - - // Release the cross-process file lock. The OS will also release it when - // lock_file is dropped, but we unlock explicitly for clarity. - 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. -/// Also recomputes the digest so Python can verify the cache. -async fn refresh_and_persist( - remote: &str, - cache: &Cache, -) -> Result> { - // Build a connection without Bearer token just to reach the refresh endpoint - let url = crate::commands::config::get_remote_url(remote)? - .ok_or_else(|| format!("Remote `{}` is not configured.", remote))?; - let conn = MDRSConnection::new(&url); - - let new_access = conn.token_refresh(&cache.token.refresh).await?; - - // Recompute the digest with the new access token so the Python client - // can still verify the cache after a token refresh. - let new_digest = compute_digest( - cache.user.as_ref(), - &new_access, - &cache.token.refresh, - &cache.laboratories, - ); - - // Persist the updated access token to the cache file with an exclusive file - // lock so that other processes do not read a partially written file. - 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. The caller already holds - // the exclusive .lock file, so no additional flock is needed here. - 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> { - let url = crate::commands::config::get_remote_url(remote)? - .ok_or_else(|| format!("Remote `{}` is not configured.", remote))?; - let mut conn = MDRSConnection::new(&url); - conn.token = Some(cache.token.access.clone()); - Ok(conn) -} - -// --------------------------------------------------------------------------- -// Path and lab helpers -// --------------------------------------------------------------------------- - -/// Parse "remote:/labname/path/" into (remote, labname, folder_path) +/// Parse "remote:/labname/path/" into (remote, labname, folder_path). pub fn parse_remote_path( remote_path: &str, -) -> Result<(String, String, String), Box> { +) -> Result<(String, String, String), anyhow::Error> { let parts: Vec<&str> = remote_path.splitn(2, ':').collect(); if parts.len() != 2 { - return Err("remote_path must be in the form 'remote:/labname/path/'".into()); + bail!("remote_path must be in the form 'remote:/labname/path/'"); } let remote = parts[0].to_string(); let rest = parts[1]; if !rest.starts_with('/') { - return Err("Path must be absolute (start with '/')".into()); + bail!("Path must be absolute (start with '/')"); } let folder_parts: Vec<&str> = rest.trim_start_matches('/').splitn(2, '/').collect(); let labname = folder_parts[0].to_string(); @@ -385,50 +32,56 @@ pub fn parse_remote_path( Ok((remote, labname, path)) } -/// Look up a laboratory by name in the cache +// --------------------------------------------------------------------------- +// Lab helpers +// --------------------------------------------------------------------------- + +/// Look up a laboratory by name in the cache. pub fn find_lab_in_cache<'a>( cache: &'a Cache, labname: &str, -) -> Result<&'a CacheLaboratory, Box> { +) -> Result<&'a CacheLaboratory, anyhow::Error> { cache .laboratories .items .iter() .find(|l| l.name == labname) - .ok_or_else(|| format!("Laboratory `{}` not found.", labname).into()) + .ok_or_else(|| anyhow!("Laboratory `{}` not found.", labname)) } +// --------------------------------------------------------------------------- +// Unicode helpers +// --------------------------------------------------------------------------- + /// Apply Unicode NFC normalization to a string. pub fn nfc(s: &str) -> String { s.chars().nfc().collect() } -/// Resolve a folder by path using the API (GET v3/folders/?path=...&laboratory_id=...) +// --------------------------------------------------------------------------- +// Folder / file search helpers +// --------------------------------------------------------------------------- + +/// Resolve a folder by path using the API (GET v3/folders/?path=...&laboratory_id=...). pub async fn find_folder( conn: &MDRSConnection, lab_id: u32, path: &str, password: Option<&str>, -) -> Result> { +) -> Result { let normalized_path = nfc(path); let folders = conn.list_folders_by_path(lab_id, &normalized_path).await?; if folders.is_empty() { - return Err(format!("Folder `{}` not found.", path).into()); + bail!("Folder `{}` not found.", path); } if folders.len() != 1 { - return Err( - format!("Ambiguous path `{}`: {} folders matched.", path, folders.len()).into(), - ); + bail!("Ambiguous path `{}`: {} folders matched.", path, folders.len()); } let folder_simple = &folders[0]; if folder_simple.lock { match password { None => { - return Err(format!( - "Folder `{}` is locked. Use -p/--password to provide a password.", - path - ) - .into()) + bail!("Folder `{}` is locked. Use -p/--password to provide a password.", path); } Some(pw) => conn.folder_auth(&folder_simple.id, pw).await?, } @@ -440,16 +93,27 @@ pub async fn find_folder( /// Find a file by name (NFC-normalized, case-insensitive) in a file list. pub fn find_file_by_name<'a>(files: &'a [File], name: &str) -> Option<&'a File> { let name_lower = nfc(name).to_lowercase(); - files.iter().find(|f| nfc(&f.name).to_lowercase() == name_lower) + files + .iter() + .find(|f| nfc(&f.name).to_lowercase() == name_lower) } /// Find a sub-folder by name (NFC-normalized, case-insensitive). -pub fn find_subfolder_by_name<'a>(subfolders: &'a [FolderSimple], name: &str) -> Option<&'a FolderSimple> { +pub fn find_subfolder_by_name<'a>( + subfolders: &'a [FolderSimple], + name: &str, +) -> Option<&'a FolderSimple> { let name_lower = nfc(name).to_lowercase(); - subfolders.iter().find(|f| nfc(&f.name).to_lowercase() == name_lower) + subfolders + .iter() + .find(|f| nfc(&f.name).to_lowercase() == name_lower) } -/// Format an ISO 8601 timestamp as "YYYY/MM/DD HH:MM:SS" +// --------------------------------------------------------------------------- +// Display helpers +// --------------------------------------------------------------------------- + +/// Format an ISO 8601 timestamp as "YYYY/MM/DD HH:MM:SS". pub fn fmt_datetime(iso: &str) -> String { let s = iso.trim(); let s = if let Some(pos) = s[10..].find(|c: char| c == '+' || c == '-') { diff --git a/src/commands/upload.rs b/src/commands/upload.rs index 2d83be1..aa79d1f 100644 --- a/src/commands/upload.rs +++ b/src/commands/upload.rs @@ -1,19 +1,21 @@ use crate::models::folder::FolderSimple; +use crate::cache::{create_authenticated_conn, load_cache_with_token_refresh}; use crate::commands::shared::{ - create_authenticated_conn, find_file_by_name, find_folder, find_lab_in_cache, - load_cache_with_token_refresh, nfc, parse_remote_path, + find_file_by_name, find_folder, find_lab_in_cache, + nfc, parse_remote_path, }; use futures::stream::{FuturesUnordered, StreamExt}; use std::path::PathBuf; use std::sync::Arc; use tokio::fs; +use anyhow::{anyhow, bail}; pub async fn upload( local_path: &str, remote_path: &str, recursive: bool, skip_if_exists: bool, -) -> Result<(), Box> { +) -> Result<(), anyhow::Error> { let (remote, labname, r_path) = parse_remote_path(remote_path)?; let cache = load_cache_with_token_refresh(&remote).await?; let conn = Arc::new(create_authenticated_conn(&remote, &cache)?); @@ -24,7 +26,7 @@ pub async fn upload( // trailing slashes and "./" prefixes are handled consistently (matching // Python's os.path.abspath behaviour). let local_abs = std::fs::canonicalize(local_path) - .map_err(|_| format!("File or directory `{}` not found.", local_path))?; + .map_err(|_| anyhow!("File or directory `{}` not found.", local_path))?; let local = local_abs.as_path(); if local.is_file() { @@ -42,7 +44,7 @@ pub async fn upload( println!("{}{}", dest_folder.path, filename); } else if local.is_dir() { if !recursive { - return Err(format!("Cannot upload `{}`: Is a directory.", local_path).into()); + bail!("Cannot upload `{}`: Is a directory.", local_path); } // Python always creates a sub-folder named after the local directory inside // remote_path. E.g. `upload ./mydir remote:/lab/path/` creates @@ -123,7 +125,7 @@ pub async fn upload( while futs.next().await.is_some() {} } } else { - return Err(format!("File or directory `{}` not found.", local_path).into()); + bail!("File or directory `{}` not found.", local_path); } Ok(()) @@ -135,17 +137,17 @@ async fn find_or_create_folder( parent_id: &str, existing: &[FolderSimple], name: &str, -) -> Result> { +) -> Result { if let Some(sf) = existing.iter().find(|f| nfc(&f.name).to_lowercase() == nfc(name).to_lowercase()) { return Ok(sf.id.clone()); } let resp = conn.create_folder(parent_id, &nfc(name)).await?; if !resp.status().is_success() { - return Err(format!("Failed to create remote folder: {}", name).into()); + bail!("Failed to create remote folder: {}", name); } let json: serde_json::Value = resp.json().await?; json["id"] .as_str() - .ok_or_else(|| format!("No id in create_folder response for {}", name).into()) + .ok_or_else(|| anyhow!("No id in create_folder response for {}", name)) .map(|s| s.to_string()) } diff --git a/src/commands/whoami.rs b/src/commands/whoami.rs index 3f98750..91278c9 100644 --- a/src/commands/whoami.rs +++ b/src/commands/whoami.rs @@ -11,7 +11,7 @@ struct WhoamiCache { user: Option, } -pub async fn whoami(remote: &str) -> Result<(), Box> { +pub async fn whoami(remote: &str) -> Result<(), anyhow::Error> { let cache_path = crate::settings::SETTINGS .config_dirname .join("cache") diff --git a/src/connection.rs b/src/connection.rs index b7a13aa..cd840dd 100644 --- a/src/connection.rs +++ b/src/connection.rs @@ -26,6 +26,7 @@ fn build_user_agent() -> String { ) } +/// HTTP transport layer for MDRS API calls. pub struct MDRSConnection { pub url: String, pub client: Client, @@ -64,13 +65,15 @@ impl MDRSConnection { let mut headers = HeaderMap::new(); headers.insert( USER_AGENT, - HeaderValue::from_str(&build_user_agent()).unwrap(), + HeaderValue::from_str(&build_user_agent()) + .expect("user-agent string contains invalid header characters"), ); headers.insert(ACCEPT, HeaderValue::from_static("application/json")); if let Some(token) = &self.token { headers.insert( AUTHORIZATION, - HeaderValue::from_str(&format!("Bearer {}", token)).unwrap(), + HeaderValue::from_str(&format!("Bearer {}", token)) + .expect("token contains invalid header characters"), ); } headers @@ -96,62 +99,5 @@ impl MDRSConnection { .send() .await } - - pub async fn download_file( - &self, - url: &str, - dest: &str, - ) -> Result<(), Box> { - let resp = self - .client - .get(url) - .headers(self.prepare_headers()) - .send() - .await?; - let bytes = resp.bytes().await?; - tokio::fs::write(dest, &bytes).await?; - Ok(()) - } - - pub async fn create_folder( - &self, - parent_id: &str, - folder_name: &str, - ) -> reqwest::Result { - let body = serde_json::json!({ - "name": folder_name, - "parent_id": parent_id, - "description": "", - "template_id": -1, - }); - self.client - .post(self.build_url("v3/folders/")) - .headers(self.prepare_headers()) - .json(&body) - .send() - .await - } - - /// Authenticate against a password-locked folder (POST v3/folders/{id}/auth/). - /// Returns Err if the password is incorrect or the request fails. - pub async fn folder_auth( - &self, - folder_id: &str, - password: &str, - ) -> Result<(), Box> { - let resp = self - .client - .post(self.build_url(&format!("v3/folders/{}/auth/", folder_id))) - .headers(self.prepare_headers()) - .json(&serde_json::json!({"password": password})) - .send() - .await?; - if resp.status() == reqwest::StatusCode::UNAUTHORIZED { - return Err("Password is incorrect.".into()); - } - if !resp.status().is_success() { - return Err(format!("Folder auth failed: {}", resp.status()).into()); - } - Ok(()) - } } + diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..d9ed377 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,25 @@ +/// Print the error message and exit with code 2. +/// JSON deserialization errors produce a friendlier message matching Python's +/// JSONDecodeError handling. +pub fn handle_error(e: anyhow::Error) -> ! { + if is_json_error(&*e) { + eprintln!( + "Unexpected response returned. Please check the configuration or the server's operational status." + ); + } else { + eprintln!("Error: {}", e); + } + std::process::exit(2); +} + +/// Walk the error source chain to detect `serde_json` parse errors. +fn is_json_error(e: &(dyn std::error::Error + 'static)) -> bool { + let mut source: Option<&(dyn std::error::Error + 'static)> = Some(e); + while let Some(err) = source { + if err.downcast_ref::().is_some() { + return true; + } + source = err.source(); + } + false +} diff --git a/src/main.rs b/src/main.rs index c7136d6..a7969b9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,211 +1,66 @@ -pub mod api; +mod api; +mod cache; +mod cli; mod commands; mod connection; +mod error; mod models; mod settings; mod token; -use clap::{Parser, Subcommand}; +use clap::Parser; +use cli::{Cli, Commands}; +use error::handle_error; -#[derive(Parser)] -#[command(name = "mdrs")] -#[command(about = "MDRS Rust CLI client", long_about = None)] -struct Cli { - #[command(subcommand)] - command: Commands, -} +fn run(cli: Cli) { + let build_rt = || { + tokio::runtime::Runtime::new() + .unwrap_or_else(|e| handle_error(e.into())) + }; -use commands::config_subcommand::*; - -#[derive(Subcommand)] -enum Commands { - /// Config management (create, update, list, delete) - #[command(subcommand)] - Config(ConfigSubcommand), - Login { - #[arg(short, long)] - username: Option, - #[arg(short, long)] - password: Option, - remote: String, - }, - /// Logout and remove cached credentials for a remote - Logout { - remote: String, - }, - Upload { - #[arg(short, long)] - recursive: bool, - #[arg(short = 's', long)] - skip_if_exists: bool, - local_path: String, - remote_path: String, - }, - Download { - #[arg(short, long)] - recursive: bool, - #[arg(short = 's', long)] - skip_if_exists: bool, - #[arg(short = 'p', long)] - password: Option, - #[arg(long)] - exclude: Vec, - remote_path: String, - local_path: String, - }, - Ls { - remote_path: String, - #[arg(short = 'p', long)] - password: Option, - #[arg(short = 'J', long = "json")] - json: bool, - #[arg(short = 'r', long)] - recursive: bool, - #[arg(short = 'q', long)] - quiet: bool, - }, - Whoami { - remote: String, - }, - Labs { - remote: String, - }, - Chacl { - /// Access level key: private, public, pw_open, cbs_open, 5kikan_open, - /// cbs_or_pw_open, 5kikan_or_pw_open, storage - access_level_key: String, - #[arg(short, long)] - recursive: bool, - #[arg(short = 'p', long)] - password: Option, - remote_path: String, - }, - Metadata { - #[arg(short = 'p', long)] - password: Option, - remote_path: String, - }, - Mkdir { - remote_path: String, - }, - Rm { - #[arg(short, long)] - recursive: bool, - remote_path: String, - }, - Mv { - src_path: String, - dest_path: String, - }, - Cp { - #[arg(short, long)] - recursive: bool, - src_path: String, - dest_path: String, - }, - /// Show metadata for a remote file - FileMetadata { - #[arg(short = 'p', long)] - password: Option, - remote_path: String, - }, - /// Show the version of this tool - Version, - /// Update this binary to the latest release - #[command(name = "selfupdate")] - SelfUpdate { - /// Skip the confirmation prompt - #[arg(short = 'y', long)] - yes: bool, - }, -} - -/// Print the error message in Python-compatible format and exit with code 2. -/// JSON deserialization errors show a friendlier message matching Python's JSONDecodeError handling. -fn handle_error(e: Box) -> ! { - if is_json_error(&*e) { - eprintln!( - "Unexpected response returned. Please check the configuration or the server's operational status." - ); - } else { - eprintln!("Error: {}", e); - } - std::process::exit(2); -} - -/// Walk the error source chain to detect serde_json parse errors. -fn is_json_error(e: &(dyn std::error::Error + 'static)) -> bool { - let mut source: Option<&(dyn std::error::Error + 'static)> = Some(e); - while let Some(err) = source { - if err.downcast_ref::().is_some() { - return true; + match cli.command { + Commands::Config(subcmd) => { + use commands::config_subcommand::ConfigSubcommand; + match subcmd { + ConfigSubcommand::Create(args) => { + if let Err(e) = commands::config::config_create(&args.remote, &args.url) { + handle_error(e); + } + } + ConfigSubcommand::Update(args) => { + if let Err(e) = commands::config::config_update(&args.remote, &args.url) { + handle_error(e); + } + } + ConfigSubcommand::List(_) => { + if let Err(e) = commands::config::config_list() { + handle_error(e); + } + } + ConfigSubcommand::Delete(args) => { + if let Err(e) = commands::config::config_delete(&args.remote) { + handle_error(e); + } + } + } } - source = err.source(); - } - false -} - -fn main() { - // Load .env file from current directory (silently ignore if not present). - dotenvy::dotenv().ok(); - - // Exit with code 130 on Ctrl+C, matching Python's KeyboardInterrupt handling. - ctrlc::set_handler(|| { - std::process::exit(130); - }) - .ok(); - - let cli = Cli::parse(); - match &cli.command { - Commands::Config(subcmd) => match subcmd { - ConfigSubcommand::Create(args) => { - if let Err(e) = crate::commands::config::config_create(&args.remote, &args.url) { - handle_error(e); - } - } - ConfigSubcommand::Update(args) => { - if let Err(e) = crate::commands::config::config_update(&args.remote, &args.url) { - handle_error(e); - } - } - ConfigSubcommand::List(_) => { - if let Err(e) = crate::commands::config::config_list() { - handle_error(e); - } - } - ConfigSubcommand::Delete(args) => { - if let Err(e) = crate::commands::config::config_delete(&args.remote) { - handle_error(e); - } - } - }, Commands::Login { username, password, remote, } => { - let remote = remote.trim_end_matches(':'); - use std::io::{self, Write}; - let username_val: String = match username { - Some(u) => u.clone(), - None => { - print!("Username: "); - io::stdout().flush().unwrap(); - let mut s = String::new(); - io::stdin().read_line(&mut s).unwrap(); - s.trim().to_string() - } - }; - let password_val: String = match password { - Some(p) => p.clone(), - None => { - rpassword::prompt_password("Password: ").unwrap() - } - }; - let rt = tokio::runtime::Runtime::new().unwrap(); - if let Err(e) = - rt.block_on(commands::login::login(&username_val, &password_val, remote)) - { + let remote = remote.trim_end_matches(':').to_string(); + if let Err(e) = build_rt().block_on(commands::login::run_login( + username.as_deref(), + password.as_deref(), + &remote, + )) { + handle_error(e); + } + } + Commands::Logout { remote } => { + let remote = remote.trim_end_matches(':').to_string(); + if let Err(e) = commands::logout::logout(&remote) { handle_error(e); } } @@ -215,15 +70,12 @@ fn main() { recursive, skip_if_exists, } => { - if let Err(e) = tokio::runtime::Runtime::new() - .unwrap() - .block_on(commands::upload::upload( - local_path, - remote_path, - *recursive, - *skip_if_exists, - )) - { + if let Err(e) = build_rt().block_on(commands::upload::upload( + &local_path, + &remote_path, + recursive, + skip_if_exists, + )) { handle_error(e); } } @@ -239,17 +91,14 @@ fn main() { .iter() .map(|e| e.trim_end_matches('/').to_lowercase()) .collect(); - if let Err(e) = tokio::runtime::Runtime::new() - .unwrap() - .block_on(commands::download::download( - remote_path, - local_path, - *recursive, - *skip_if_exists, - password.as_deref(), - excludes, - )) - { + if let Err(e) = build_rt().block_on(commands::download::download( + &remote_path, + &local_path, + recursive, + skip_if_exists, + password.as_deref(), + excludes, + )) { handle_error(e); } } @@ -260,59 +109,55 @@ fn main() { recursive, quiet, } => { - if let Err(e) = tokio::runtime::Runtime::new() - .unwrap() - .block_on(commands::ls::ls( - remote_path, - password.as_deref(), - *json, - *recursive, - *quiet, - )) - { + if let Err(e) = build_rt().block_on(commands::ls::ls( + &remote_path, + password.as_deref(), + json, + recursive, + quiet, + )) { handle_error(e); } } Commands::Whoami { remote } => { - let remote = remote.trim_end_matches(':'); - let rt = tokio::runtime::Runtime::new().unwrap(); - if let Err(e) = rt.block_on(commands::whoami::whoami(remote)) { + let remote = remote.trim_end_matches(':').to_string(); + if let Err(e) = build_rt().block_on(commands::whoami::whoami(&remote)) { handle_error(e); } } Commands::Labs { remote } => { - let remote = remote.trim_end_matches(':'); - let rt = tokio::runtime::Runtime::new().unwrap(); - if let Err(e) = rt.block_on(commands::labs::labs(remote)) { + let remote = remote.trim_end_matches(':').to_string(); + if let Err(e) = build_rt().block_on(commands::labs::labs(&remote)) { handle_error(e); } } - Commands::Chacl { access_level_key, recursive, password, remote_path, } => { - let rt = tokio::runtime::Runtime::new().unwrap(); - if let Err(e) = rt.block_on(crate::commands::chacl::chacl( - remote_path, - access_level_key, - *recursive, + if let Err(e) = build_rt().block_on(commands::chacl::chacl( + &remote_path, + &access_level_key, + recursive, password.as_deref(), )) { handle_error(e); } } - Commands::Metadata { remote_path, password } => { - let rt = tokio::runtime::Runtime::new().unwrap(); - if let Err(e) = rt.block_on(crate::commands::metadata::metadata(remote_path, password.as_deref())) { + Commands::Metadata { + remote_path, + password, + } => { + if let Err(e) = build_rt() + .block_on(commands::metadata::metadata(&remote_path, password.as_deref())) + { handle_error(e); } } Commands::Mkdir { remote_path } => { - let rt = tokio::runtime::Runtime::new().unwrap(); - if let Err(e) = rt.block_on(crate::commands::mkdir::mkdir(remote_path)) { + if let Err(e) = build_rt().block_on(commands::mkdir::mkdir(&remote_path)) { handle_error(e); } } @@ -320,20 +165,14 @@ fn main() { recursive, remote_path, } => { - let rt = tokio::runtime::Runtime::new().unwrap(); - if let Err(e) = rt.block_on(crate::commands::rm::rm(remote_path, *recursive)) { - handle_error(e); - } - } - Commands::Logout { remote } => { - let remote = remote.trim_end_matches(':'); - if let Err(e) = crate::commands::logout::logout(remote) { + if let Err(e) = build_rt().block_on(commands::rm::rm(&remote_path, recursive)) { handle_error(e); } } Commands::Mv { src_path, dest_path } => { - let rt = tokio::runtime::Runtime::new().unwrap(); - if let Err(e) = rt.block_on(crate::commands::mv::mv(src_path, dest_path)) { + if let Err(e) = + build_rt().block_on(commands::mv::mv(&src_path, &dest_path)) + { handle_error(e); } } @@ -342,14 +181,20 @@ fn main() { dest_path, recursive, } => { - let rt = tokio::runtime::Runtime::new().unwrap(); - if let Err(e) = rt.block_on(crate::commands::cp::cp(src_path, dest_path, *recursive)) { + if let Err(e) = + build_rt().block_on(commands::cp::cp(&src_path, &dest_path, recursive)) + { handle_error(e); } } - Commands::FileMetadata { remote_path, password } => { - let rt = tokio::runtime::Runtime::new().unwrap(); - if let Err(e) = rt.block_on(crate::commands::file_metadata::file_metadata(remote_path, password.as_deref())) { + Commands::FileMetadata { + remote_path, + password, + } => { + if let Err(e) = build_rt().block_on(commands::file_metadata::file_metadata( + &remote_path, + password.as_deref(), + )) { handle_error(e); } } @@ -357,10 +202,19 @@ fn main() { commands::version::version(); } Commands::SelfUpdate { yes } => { - let rt = tokio::runtime::Runtime::new().unwrap(); - if let Err(e) = rt.block_on(commands::selfupdate::selfupdate(*yes)) { + if let Err(e) = build_rt().block_on(commands::selfupdate::selfupdate(yes)) { handle_error(e.into()); } } } } + +fn main() { + // Load .env file from the current directory (silently ignore if not present). + dotenvy::dotenv().ok(); + + // Exit with code 130 on Ctrl+C, matching Python's KeyboardInterrupt handling. + ctrlc::set_handler(|| std::process::exit(130)).ok(); + + run(Cli::parse()); +} diff --git a/src/models/laboratory.rs b/src/models/laboratory.rs index aae1720..ed3c99f 100644 --- a/src/models/laboratory.rs +++ b/src/models/laboratory.rs @@ -13,3 +13,22 @@ pub struct Laboratory { pub struct Laboratories { pub items: Vec, } + +impl From<&Laboratory> for crate::cache::CacheLaboratory { + fn from(l: &Laboratory) -> Self { + crate::cache::CacheLaboratory { + id: l.id, + name: l.name.clone(), + pi_name: l.pi_name.clone(), + full_name: l.full_name.clone(), + } + } +} + +impl From<&Laboratories> for crate::cache::CacheLabsWrapper { + fn from(labs: &Laboratories) -> Self { + crate::cache::CacheLabsWrapper { + items: labs.items.iter().map(Into::into).collect(), + } + } +} diff --git a/src/models/user.rs b/src/models/user.rs index 8742998..63ffe93 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -8,3 +8,14 @@ pub struct User { pub laboratory_ids: Vec, pub is_reviewer: bool, } + +impl From<&User> for crate::cache::CacheUser { + fn from(u: &User) -> Self { + crate::cache::CacheUser { + id: u.id, + username: u.username.clone(), + laboratory_ids: u.laboratory_ids.clone(), + is_reviewer: u.is_reviewer, + } + } +} diff --git a/src/settings.rs b/src/settings.rs index 074a01b..5690c7b 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -14,16 +14,19 @@ impl Settings { let config_dirname = std::env::var("MDRS_CLIENT_CONFIG_DIRNAME") .ok() .map(|s| { - let expanded = if s.starts_with("~/") { + if s.starts_with("~/") { dirs::home_dir() - .unwrap() + .unwrap_or_else(|| std::path::PathBuf::from(".")) .join(&s[2..]) } else { std::path::PathBuf::from(&s) - }; - expanded + } }) - .unwrap_or_else(|| dirs::home_dir().unwrap().join(".mdrs-client")); + .unwrap_or_else(|| { + dirs::home_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join(".mdrs-client") + }); let concurrent = std::env::var("MDRS_CLIENT_CONCURRENT") .ok() diff --git a/src/token.rs b/src/token.rs index a020cdd..dd4c688 100644 --- a/src/token.rs +++ b/src/token.rs @@ -2,6 +2,7 @@ use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; use std::time::{SystemTime, UNIX_EPOCH}; +use anyhow::{anyhow, bail}; fn now_secs() -> i64 { SystemTime::now() @@ -11,16 +12,16 @@ fn now_secs() -> i64 { } /// Decode the `exp` field from a JWT payload without signature verification. -pub fn jwt_exp(token: &str) -> Result> { +pub fn jwt_exp(token: &str) -> Result { let parts: Vec<&str> = token.split('.').collect(); if parts.len() < 2 { - return Err("Invalid JWT: expected at least 2 dot-separated parts".into()); + bail!("Invalid JWT: expected at least 2 dot-separated parts"); } let payload_bytes = URL_SAFE_NO_PAD.decode(parts[1])?; let json: serde_json::Value = serde_json::from_slice(&payload_bytes)?; let exp = json["exp"] .as_i64() - .ok_or("JWT payload missing 'exp' field")?; + .ok_or_else(|| anyhow!("JWT payload missing 'exp' field"))?; Ok(exp) }