Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
beee9b4f41
|
|||
|
7db5c4d53f
|
|||
|
e3cd864a0c
|
|||
|
32149109b4
|
|||
|
3f2ca938bd
|
|||
|
4e73766732
|
@@ -208,6 +208,16 @@ Show the tool name and version number.
|
||||
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
|
||||
|
||||
Show help for a command.
|
||||
|
||||
Vendored
+169
-4
@@ -185,6 +185,43 @@ fn load_cache_from_dir(remote: &str, config_dir: &Path) -> Result<Cache, anyhow:
|
||||
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(
|
||||
remote: &str,
|
||||
config_dir: &Path,
|
||||
@@ -247,6 +284,7 @@ pub async fn load_cache_with_token_refresh(remote: &str) -> Result<Cache, anyhow
|
||||
let lock = get_remote_lock(remote);
|
||||
let _guard = lock.lock().await;
|
||||
|
||||
ensure_cache_dir(&cache_dir_path(&crate::settings::SETTINGS.config_dirname))?;
|
||||
let lock_path = cache_file_path(remote).with_extension("lock");
|
||||
use fs2::FileExt;
|
||||
let lock_file = fs::OpenOptions::new()
|
||||
@@ -280,9 +318,71 @@ pub async fn load_cache_with_token_refresh(remote: &str) -> Result<Cache, anyhow
|
||||
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
|
||||
/// 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> {
|
||||
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)?
|
||||
.ok_or_else(|| anyhow!("Remote `{}` is not configured.", remote))?;
|
||||
let conn = MDRSConnection::new(&url);
|
||||
@@ -298,7 +398,7 @@ async fn refresh_and_persist(remote: &str, cache: &Cache) -> Result<Cache, anyho
|
||||
&updated_cache.laboratories,
|
||||
);
|
||||
|
||||
persist_cache(remote, &updated_cache)?;
|
||||
persist_cache_in_dir(remote, config_dir, &updated_cache)?;
|
||||
|
||||
Ok(updated_cache)
|
||||
}
|
||||
@@ -312,11 +412,26 @@ pub fn create_authenticated_conn(
|
||||
remote: &str,
|
||||
cache: &Cache,
|
||||
) -> 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)?
|
||||
.ok_or_else(|| anyhow!("Remote `{}` is not configured.", remote))?;
|
||||
Ok(MDRSConnection::new(&url)
|
||||
.with_remote(remote)
|
||||
.with_token(cache.token.access.clone()))
|
||||
Ok(MDRSConnection::new(&url).with_remote(remote))
|
||||
}
|
||||
|
||||
/// Create a connection for read-only commands, attaching a bearer token only
|
||||
/// when a valid login cache is available.
|
||||
pub async fn create_readonly_conn(
|
||||
remote: &str,
|
||||
) -> Result<(MDRSConnection, Option<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)]
|
||||
@@ -438,4 +553,54 @@ mod tests {
|
||||
.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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
use crate::cache::{create_authenticated_conn, load_cache_with_token_refresh};
|
||||
use crate::commands::shared::{
|
||||
find_file_by_name, find_folder, find_lab_in_cache, parse_remote_path,
|
||||
};
|
||||
use crate::cache::create_readonly_conn;
|
||||
use crate::commands::shared::{find_file_by_name, find_folder, find_laboratory, parse_remote_path};
|
||||
use anyhow::anyhow;
|
||||
|
||||
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?;
|
||||
let conn = create_authenticated_conn(&remote, &cache)?;
|
||||
let lab = find_lab_in_cache(&cache, &labname)?;
|
||||
let (conn, cache) = create_readonly_conn(&remote).await?;
|
||||
let lab = find_laboratory(&conn, cache.as_ref(), &labname).await?;
|
||||
let lab_id = lab.id;
|
||||
|
||||
// Split the file path into parent directory and filename
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
use crate::cache::{create_authenticated_conn, load_cache_with_token_refresh};
|
||||
use crate::cache::create_readonly_conn;
|
||||
|
||||
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 (conn, _) = create_readonly_conn(remote).await?;
|
||||
let labs = conn.list_laboratories().await?;
|
||||
|
||||
let header = ("Name", "PI", "Laboratory");
|
||||
|
||||
+4
-5
@@ -1,5 +1,5 @@
|
||||
use crate::cache::{create_authenticated_conn, load_cache_with_token_refresh};
|
||||
use crate::commands::shared::{find_folder, find_lab_in_cache, fmt_datetime, parse_remote_path};
|
||||
use crate::cache::create_readonly_conn;
|
||||
use crate::commands::shared::{find_folder, find_laboratory, fmt_datetime, parse_remote_path};
|
||||
use crate::connection::MDRSConnection;
|
||||
use crate::models::file::File;
|
||||
use crate::models::folder::{FolderDetail, FolderSimple};
|
||||
@@ -15,9 +15,8 @@ pub async fn ls(
|
||||
is_quiet: bool,
|
||||
) -> 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)?;
|
||||
let lab = find_lab_in_cache(&cache, &labname)?;
|
||||
let (conn, cache) = create_readonly_conn(&remote).await?;
|
||||
let lab = find_laboratory(&conn, cache.as_ref(), &labname).await?;
|
||||
|
||||
let folder = find_folder(&conn, lab.id, &path, password).await?;
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
use crate::cache::{create_authenticated_conn, load_cache_with_token_refresh};
|
||||
use crate::commands::shared::{find_folder, find_lab_in_cache, parse_remote_path};
|
||||
use crate::cache::create_readonly_conn;
|
||||
use crate::commands::shared::{find_folder, find_laboratory, parse_remote_path};
|
||||
|
||||
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)?;
|
||||
let lab = find_lab_in_cache(&cache, &labname)?;
|
||||
let (conn, cache) = create_readonly_conn(&remote).await?;
|
||||
let lab = find_laboratory(&conn, cache.as_ref(), &labname).await?;
|
||||
let folder = find_folder(&conn, lab.id, &folder_path, password).await?;
|
||||
|
||||
let resp = conn
|
||||
|
||||
@@ -3,6 +3,7 @@ use crate::connection::ApiRequestLimiter;
|
||||
use crate::connection::MDRSConnection;
|
||||
use crate::models::file::File;
|
||||
use crate::models::folder::{FolderDetail, FolderSimple};
|
||||
use crate::models::laboratory::Laboratory;
|
||||
use anyhow::{anyhow, bail};
|
||||
use unicode_normalization::UnicodeNormalization;
|
||||
|
||||
@@ -48,6 +49,32 @@ pub fn find_lab_in_cache<'a>(
|
||||
.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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -178,3 +205,42 @@ pub fn fmt_datetime(iso: &str) -> 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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user