4 Commits

Author SHA1 Message Date
orrisroot 459dd1cd7c chore(release): sync lockfile version
Release / build-linux-x86_64 (push) Successful in 2m16s
Release / build-linux-aarch64 (push) Successful in 2m3s
Record the Rust package version bump in Cargo.lock so the
repository stays consistent after updating the crate version
to 2.0.0.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-20 18:19:06 +09:00
orrisroot 9d29aad463 chore(release): bump version to 2.0.0
Update the Rust package manifest to 2.0.0 so CLI version
reporting and release-related flows pick up the new version
from Cargo metadata.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-20 18:18:06 +09:00
orrisroot d25ab69d13 perf(transfer): parallelize folder traversal API calls
Use a shared API request limiter across recursive upload and
download traversal so folder detail fetches, file listings,
folder auth, and transfers can run concurrently under one
budget.

Refactor the traversal loops into task-driven pipelines while
preserving skip-if-exists, excludes, cleanup, and current
output behavior.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-20 17:25:14 +09:00
orrisroot 14991b18fb perf(cache): reuse auth state in memory
Cache parsed auth state per remote and validate it with on-disk\nfile metadata so repeated authenticated API calls can skip\nredundant open/read/JSON parse work within one process.\n\nCentralize cache load, persist, and removal helpers in the cache\nmodule, reuse them from login, logout, and whoami, and update\nthe refresh path to persist structured cache data directly.\n\nAdd targeted cache tests for memory reuse, invalidation after\nexternal writes, persist updates, and cache removal.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-20 16:51:26 +09:00
7 changed files with 24 additions and 259 deletions
-10
View File
@@ -208,16 +208,6 @@ Show the tool name and version number.
mdrs version mdrs version
``` ```
### selfupdate
Update the current `mdrs` binary to the latest published release for
the same build target.
```shell
mdrs selfupdate
mdrs selfupdate -y
```
### help ### help
Show help for a command. Show help for a command.
+4 -169
View File
@@ -185,43 +185,6 @@ fn load_cache_from_dir(remote: &str, config_dir: &Path) -> Result<Cache, anyhow:
Ok(cache) Ok(cache)
} }
fn load_cache_if_present_from_dir(
remote: &str,
config_dir: &Path,
) -> Result<Option<Cache>, anyhow::Error> {
let cache_path = cache_file_path_in(config_dir, remote);
let snapshot = match read_cache_snapshot(&cache_path) {
Ok(snapshot) => snapshot,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
invalidate_cached_entry(config_dir, remote);
return Ok(None);
}
Err(e) => return Err(e.into()),
};
if let Some(cache) = cached_entry(config_dir, remote, &snapshot) {
return Ok(Some(cache));
}
let data = match fs::read_to_string(&cache_path) {
Ok(data) => data,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
invalidate_cached_entry(config_dir, remote);
return Ok(None);
}
Err(e) => return Err(e.into()),
};
let cache = match parse_cache(remote, &data) {
Ok(cache) => cache,
Err(_) => {
remove_cache_in_dir(remote, config_dir)?;
return Ok(None);
}
};
update_cached_entry(config_dir, remote, snapshot, cache.clone());
Ok(Some(cache))
}
fn persist_cache_in_dir( fn persist_cache_in_dir(
remote: &str, remote: &str,
config_dir: &Path, config_dir: &Path,
@@ -284,7 +247,6 @@ pub async fn load_cache_with_token_refresh(remote: &str) -> Result<Cache, anyhow
let lock = get_remote_lock(remote); let lock = get_remote_lock(remote);
let _guard = lock.lock().await; let _guard = lock.lock().await;
ensure_cache_dir(&cache_dir_path(&crate::settings::SETTINGS.config_dirname))?;
let lock_path = cache_file_path(remote).with_extension("lock"); let lock_path = cache_file_path(remote).with_extension("lock");
use fs2::FileExt; use fs2::FileExt;
let lock_file = fs::OpenOptions::new() let lock_file = fs::OpenOptions::new()
@@ -318,71 +280,9 @@ pub async fn load_cache_with_token_refresh(remote: &str) -> Result<Cache, anyhow
result result
} }
async fn load_cache_with_token_refresh_optional_from_dir(
remote: &str,
config_dir: &Path,
) -> Result<Option<Cache>, anyhow::Error> {
let lock = get_remote_lock(remote);
let _guard = lock.lock().await;
ensure_cache_dir(&cache_dir_path(config_dir))?;
let lock_path = cache_file_path_in(config_dir, remote).with_extension("lock");
use fs2::FileExt;
let lock_file = fs::OpenOptions::new()
.write(true)
.create(true)
.open(&lock_path)?;
lock_file.lock_exclusive()?;
let result: Result<Option<Cache>, anyhow::Error> = async {
let Some(mut cache) = load_cache_if_present_from_dir(remote, config_dir)? else {
return Ok(None);
};
if crate::token::is_expired(&cache.token.refresh) {
remove_cache_in_dir(remote, config_dir)?;
return Ok(None);
}
if crate::token::is_refresh_required(&cache.token.access, &cache.token.refresh) {
cache = refresh_and_persist_in_dir(remote, config_dir, &cache).await?;
}
Ok(Some(cache))
}
.await;
lock_file.unlock()?;
result
}
/// Load cache when present and refresh its token if needed.
///
/// Unlike `load_cache_with_token_refresh`, this returns `Ok(None)` when the user
/// is effectively anonymous: no cache file exists, the cache is invalid, or the
/// refresh token has already expired. This mirrors the Python client behavior
/// used by read-only commands.
pub async fn load_cache_with_token_refresh_optional(
remote: &str,
) -> Result<Option<Cache>, anyhow::Error> {
load_cache_with_token_refresh_optional_from_dir(
remote,
&crate::settings::SETTINGS.config_dirname,
)
.await
}
/// Call the token-refresh endpoint and write the new access token back to the /// 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. /// cache file. The caller must already hold the per-remote async mutex.
async fn refresh_and_persist(remote: &str, cache: &Cache) -> Result<Cache, anyhow::Error> { async fn refresh_and_persist(remote: &str, cache: &Cache) -> Result<Cache, anyhow::Error> {
refresh_and_persist_in_dir(remote, &crate::settings::SETTINGS.config_dirname, cache).await
}
async fn refresh_and_persist_in_dir(
remote: &str,
config_dir: &Path,
cache: &Cache,
) -> Result<Cache, anyhow::Error> {
let url = crate::commands::config::get_remote_url(remote)? let url = crate::commands::config::get_remote_url(remote)?
.ok_or_else(|| anyhow!("Remote `{}` is not configured.", remote))?; .ok_or_else(|| anyhow!("Remote `{}` is not configured.", remote))?;
let conn = MDRSConnection::new(&url); let conn = MDRSConnection::new(&url);
@@ -398,7 +298,7 @@ async fn refresh_and_persist_in_dir(
&updated_cache.laboratories, &updated_cache.laboratories,
); );
persist_cache_in_dir(remote, config_dir, &updated_cache)?; persist_cache(remote, &updated_cache)?;
Ok(updated_cache) Ok(updated_cache)
} }
@@ -412,26 +312,11 @@ pub fn create_authenticated_conn(
remote: &str, remote: &str,
cache: &Cache, cache: &Cache,
) -> Result<MDRSConnection, anyhow::Error> { ) -> Result<MDRSConnection, anyhow::Error> {
Ok(create_remote_conn(remote)?.with_token(cache.token.access.clone()))
}
/// Create an unauthenticated `MDRSConnection` for the given remote label.
pub fn create_remote_conn(remote: &str) -> Result<MDRSConnection, anyhow::Error> {
let url = crate::commands::config::get_remote_url(remote)? let url = crate::commands::config::get_remote_url(remote)?
.ok_or_else(|| anyhow!("Remote `{}` is not configured.", remote))?; .ok_or_else(|| anyhow!("Remote `{}` is not configured.", remote))?;
Ok(MDRSConnection::new(&url).with_remote(remote)) Ok(MDRSConnection::new(&url)
} .with_remote(remote)
.with_token(cache.token.access.clone()))
/// Create a connection for read-only commands, attaching a bearer token only
/// when a valid login cache is available.
pub async fn create_readonly_conn(
remote: &str,
) -> Result<(MDRSConnection, Option<Cache>), anyhow::Error> {
let conn = create_remote_conn(remote)?;
match load_cache_with_token_refresh_optional(remote).await? {
Some(cache) => Ok((conn.with_token(cache.token.access.clone()), Some(cache))),
None => Ok((conn, None)),
}
} }
#[cfg(test)] #[cfg(test)]
@@ -553,54 +438,4 @@ mod tests {
.contains(&format!("Not logged in to `{remote}`")) .contains(&format!("Not logged in to `{remote}`"))
); );
} }
fn make_jwt_with_exp(exp: i64) -> String {
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"none","typ":"JWT"}"#);
let payload = URL_SAFE_NO_PAD.encode(format!(r#"{{"exp":{exp}}}"#));
format!("{header}.{payload}.")
}
#[test]
fn load_cache_if_present_returns_none_when_cache_missing() {
let dir = tempdir().unwrap();
let remote = remote_name("missing", dir.path());
let loaded = load_cache_if_present_from_dir(&remote, dir.path()).unwrap();
assert!(loaded.is_none());
}
#[test]
fn load_cache_if_present_clears_invalid_cache() {
let dir = tempdir().unwrap();
let remote = remote_name("invalid", dir.path());
let cache_dir = cache_dir_path(dir.path());
ensure_cache_dir(&cache_dir).unwrap();
let cache_path = cache_file_path_in(dir.path(), &remote);
fs::write(&cache_path, b"{invalid json").unwrap();
let loaded = load_cache_if_present_from_dir(&remote, dir.path()).unwrap();
assert!(loaded.is_none());
assert!(!cache_path.exists());
}
#[tokio::test]
async fn optional_cache_load_treats_expired_session_as_anonymous() {
let dir = tempdir().unwrap();
let remote = remote_name("expired", dir.path());
let mut cache = sample_cache("alice");
cache.token.access = make_jwt_with_exp(0);
cache.token.refresh = make_jwt_with_exp(0);
persist_cache_in_dir(&remote, dir.path(), &cache).unwrap();
let loaded = load_cache_with_token_refresh_optional_from_dir(&remote, dir.path())
.await
.unwrap();
assert!(loaded.is_none());
assert!(!cache_file_path_in(dir.path(), &remote).exists());
}
} }
+7 -4
View File
@@ -1,12 +1,15 @@
use crate::cache::create_readonly_conn; use crate::cache::{create_authenticated_conn, load_cache_with_token_refresh};
use crate::commands::shared::{find_file_by_name, find_folder, find_laboratory, parse_remote_path}; use crate::commands::shared::{
find_file_by_name, find_folder, find_lab_in_cache, parse_remote_path,
};
use anyhow::anyhow; use anyhow::anyhow;
pub async fn file_metadata(remote_path: &str, password: Option<&str>) -> Result<(), anyhow::Error> { 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 (remote, labname, r_path) = parse_remote_path(remote_path)?;
let (conn, cache) = create_readonly_conn(&remote).await?; let cache = load_cache_with_token_refresh(&remote).await?;
let lab = find_laboratory(&conn, cache.as_ref(), &labname).await?; let conn = create_authenticated_conn(&remote, &cache)?;
let lab = find_lab_in_cache(&cache, &labname)?;
let lab_id = lab.id; let lab_id = lab.id;
// Split the file path into parent directory and filename // Split the file path into parent directory and filename
+3 -2
View File
@@ -1,7 +1,8 @@
use crate::cache::create_readonly_conn; use crate::cache::{create_authenticated_conn, load_cache_with_token_refresh};
pub async fn labs(remote: &str) -> Result<(), anyhow::Error> { pub async fn labs(remote: &str) -> Result<(), anyhow::Error> {
let (conn, _) = create_readonly_conn(remote).await?; let cache = load_cache_with_token_refresh(remote).await?;
let conn = create_authenticated_conn(remote, &cache)?;
let labs = conn.list_laboratories().await?; let labs = conn.list_laboratories().await?;
let header = ("Name", "PI", "Laboratory"); let header = ("Name", "PI", "Laboratory");
+5 -4
View File
@@ -1,5 +1,5 @@
use crate::cache::create_readonly_conn; use crate::cache::{create_authenticated_conn, load_cache_with_token_refresh};
use crate::commands::shared::{find_folder, find_laboratory, fmt_datetime, parse_remote_path}; use crate::commands::shared::{find_folder, find_lab_in_cache, fmt_datetime, parse_remote_path};
use crate::connection::MDRSConnection; use crate::connection::MDRSConnection;
use crate::models::file::File; use crate::models::file::File;
use crate::models::folder::{FolderDetail, FolderSimple}; use crate::models::folder::{FolderDetail, FolderSimple};
@@ -15,8 +15,9 @@ pub async fn ls(
is_quiet: bool, is_quiet: bool,
) -> Result<(), anyhow::Error> { ) -> Result<(), anyhow::Error> {
let (remote, labname, path) = parse_remote_path(remote_path)?; let (remote, labname, path) = parse_remote_path(remote_path)?;
let (conn, cache) = create_readonly_conn(&remote).await?; let cache = load_cache_with_token_refresh(&remote).await?;
let lab = find_laboratory(&conn, cache.as_ref(), &labname).await?; let conn = create_authenticated_conn(&remote, &cache)?;
let lab = find_lab_in_cache(&cache, &labname)?;
let folder = find_folder(&conn, lab.id, &path, password).await?; let folder = find_folder(&conn, lab.id, &path, password).await?;
+5 -4
View File
@@ -1,10 +1,11 @@
use crate::cache::create_readonly_conn; use crate::cache::{create_authenticated_conn, load_cache_with_token_refresh};
use crate::commands::shared::{find_folder, find_laboratory, parse_remote_path}; use crate::commands::shared::{find_folder, find_lab_in_cache, parse_remote_path};
pub async fn metadata(remote_path: &str, password: Option<&str>) -> Result<(), anyhow::Error> { pub async fn metadata(remote_path: &str, password: Option<&str>) -> Result<(), anyhow::Error> {
let (remote, labname, folder_path) = parse_remote_path(remote_path)?; let (remote, labname, folder_path) = parse_remote_path(remote_path)?;
let (conn, cache) = create_readonly_conn(&remote).await?; let cache = load_cache_with_token_refresh(&remote).await?;
let lab = find_laboratory(&conn, cache.as_ref(), &labname).await?; let conn = create_authenticated_conn(&remote, &cache)?;
let lab = find_lab_in_cache(&cache, &labname)?;
let folder = find_folder(&conn, lab.id, &folder_path, password).await?; let folder = find_folder(&conn, lab.id, &folder_path, password).await?;
let resp = conn let resp = conn
-66
View File
@@ -3,7 +3,6 @@ use crate::connection::ApiRequestLimiter;
use crate::connection::MDRSConnection; use crate::connection::MDRSConnection;
use crate::models::file::File; use crate::models::file::File;
use crate::models::folder::{FolderDetail, FolderSimple}; use crate::models::folder::{FolderDetail, FolderSimple};
use crate::models::laboratory::Laboratory;
use anyhow::{anyhow, bail}; use anyhow::{anyhow, bail};
use unicode_normalization::UnicodeNormalization; use unicode_normalization::UnicodeNormalization;
@@ -49,32 +48,6 @@ pub fn find_lab_in_cache<'a>(
.ok_or_else(|| anyhow!("Laboratory `{}` not found.", labname)) .ok_or_else(|| anyhow!("Laboratory `{}` not found.", labname))
} }
/// Resolve a laboratory by name using cached laboratories when available, and
/// falling back to the API when the user is anonymous.
pub async fn find_laboratory(
conn: &MDRSConnection,
cache: Option<&Cache>,
labname: &str,
) -> Result<Laboratory, anyhow::Error> {
if let Some(cache) = cache {
if let Ok(lab) = find_lab_in_cache(cache, labname) {
return Ok(Laboratory {
id: lab.id,
name: lab.name.clone(),
pi_name: lab.pi_name.clone(),
full_name: lab.full_name.clone(),
});
}
}
conn.list_laboratories()
.await?
.items
.into_iter()
.find(|lab| lab.name == labname)
.ok_or_else(|| anyhow!("Laboratory `{}` not found.", labname))
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Unicode helpers // Unicode helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -205,42 +178,3 @@ pub fn fmt_datetime(iso: &str) -> String {
iso.to_string() iso.to_string()
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
#[tokio::test]
async fn find_laboratory_falls_back_to_api_without_authorization() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 4096];
let n = stream.read(&mut buf).await.unwrap();
let req = String::from_utf8_lossy(&buf[..n]);
assert!(req.starts_with("GET /v3/laboratories/ HTTP/1.1"));
assert!(!req.contains("\r\nAuthorization: Bearer "));
let body =
r#"[{"id":1,"name":"public-lab","pi_name":"PI","full_name":"Public Laboratory"}]"#;
let response = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
body.len(),
body
);
stream.write_all(response.as_bytes()).await.unwrap();
});
let conn = MDRSConnection::new(&format!("http://{addr}"));
let lab = find_laboratory(&conn, None, "public-lab").await.unwrap();
assert_eq!(lab.id, 1);
assert_eq!(lab.name, "public-lab");
server.await.unwrap();
}
}