Files
mdrs-client-rust/src/commands/shared.rs
T
orrisroot 0d474e7913 fix: align all command outputs with Python reference implementation
- fix(connection): fix create_folder API body (name, parent_id, description, template_id)
- feat(shared): add Unicode NFC normalization helper and find_subfolder_by_name()
- feat(Cargo): add unicode-normalization dependency
- fix(shared,mkdir,rm,cp,mv,upload): apply NFC normalization to path and name comparisons
- fix(labs): rewrite output as aligned table (Name/PI/Laboratory), remove cache fallback
- fix(mkdir): silent on success; align error message with Python
- fix(rm): silent on success; use find_subfolder_by_name for NFC-aware lookup
- fix(cp): silent on success; align all error messages; add no-op when src==dest
- fix(mv): silent on success; align all error messages; add no-op when src==dest
- fix(login): change output to 'Login Successful'
- fix(logout): remove all output (silent like Python)
- fix(chacl): remove success message (silent like Python)
- fix(metadata): use compact JSON output (to_string instead of to_string_pretty)
- fix(file_metadata): use compact JSON output
- fix(ls): use compact JSON output; add blank line after entries in recursive plain mode
- fix(config): silent on create/update/delete; add colon in list short format;
  remove empty-state messages; align error messages ('is already exists.' / 'is not exists.')

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-17 18:45:52 +09:00

447 lines
15 KiB
Rust

use crate::models::file::File;
use crate::models::folder::{FolderDetail, FolderSimple};
use crate::connection::MDRSConnection;
use serde::Deserialize;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::fs;
use std::sync::{Arc, LazyLock, Mutex};
use unicode_normalization::UnicodeNormalization;
// ---------------------------------------------------------------------------
// Cache structs — matching Python's cache format exactly
// ---------------------------------------------------------------------------
#[derive(Deserialize, Clone)]
pub struct CacheToken {
pub access: String,
pub refresh: String,
}
/// Minimal user fields stored in cache — matches Python's `User` dataclass.
#[derive(Deserialize, Clone)]
pub struct CacheUser {
pub id: u32,
pub username: String,
pub laboratory_ids: Vec<u32>,
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<CacheLaboratory>,
}
#[derive(Deserialize, Clone)]
pub struct Cache {
pub user: Option<CacheUser>,
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<String> = 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<String> = 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<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 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<Cache, Box<dyn std::error::Error>> {
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::<Cache>(&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 the cache file serializes across separate processes
/// on the same host.
pub async fn load_cache_with_token_refresh(
remote: &str,
) -> Result<Cache, Box<dyn std::error::Error>> {
// Acquire the in-process async mutex for this remote
let lock = get_remote_lock(remote);
let _guard = lock.lock().await;
// Re-read the cache inside the lock (another task may have already refreshed)
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)
}
/// 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<String, Box<dyn std::error::Error>> {
// 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, while holding an exclusive
// flock on the .tmp file for cross-process safety.
let tmp_path = cache_path.with_extension("tmp");
{
use fs2::FileExt;
use std::io::Write;
let mut tmp_file = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&tmp_path)?;
tmp_file.lock_exclusive()?;
tmp_file.write_all(serde_json::to_string(&obj)?.as_bytes())?;
tmp_file.flush()?;
tmp_file.unlock()?;
}
#[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, Box<dyn std::error::Error>> {
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)
pub fn parse_remote_path(
remote_path: &str,
) -> Result<(String, String, String), Box<dyn std::error::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());
}
let remote = parts[0].to_string();
let rest = parts[1];
if !rest.starts_with('/') {
return Err("Path must be absolute (start with '/')".into());
}
let folder_parts: Vec<&str> = rest.trim_start_matches('/').splitn(2, '/').collect();
let labname = folder_parts[0].to_string();
let path = if folder_parts.len() > 1 && !folder_parts[1].is_empty() {
format!("/{}", folder_parts[1].trim_end_matches('/'))
} else {
"/".to_string()
};
Ok((remote, labname, path))
}
/// 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<dyn std::error::Error>> {
cache
.laboratories
.items
.iter()
.find(|l| l.name == labname)
.ok_or_else(|| format!("Laboratory `{}` not found.", labname).into())
}
/// 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=...)
pub async fn find_folder(
conn: &MDRSConnection,
lab_id: u32,
path: &str,
password: Option<&str>,
) -> Result<FolderDetail, Box<dyn std::error::Error>> {
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());
}
if folders.len() != 1 {
return Err(
format!("Ambiguous path `{}`: {} folders matched.", path, folders.len()).into(),
);
}
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())
}
Some(pw) => conn.folder_auth(&folder_simple.id, pw).await?,
}
}
let folder = conn.retrieve_folder(&folder_simple.id).await?;
Ok(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)
}
/// 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> {
let name_lower = nfc(name).to_lowercase();
subfolders.iter().find(|f| nfc(&f.name).to_lowercase() == name_lower)
}
/// 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 == '-') {
&s[..10 + pos]
} else {
s.trim_end_matches('Z')
};
if s.len() >= 19 {
let date = s[..10].replace('-', "/");
let time = &s[11..19];
format!("{} {}", date, time)
} else {
iso.to_string()
}
}