first commit

This commit is contained in:
2026-04-17 16:52:04 +09:00
commit 872d27a4e4
38 changed files with 6144 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
# Build output
/target
# Dependency lock file (include for binaries, exclude for libraries)
# Cargo.lock is intentionally kept for binary crates to ensure reproducible builds.
# Uncomment the next line if this were a library crate:
# Cargo.lock
# Editor and IDE files
.idea/
.vscode/
*.iml
*.swp
*.swo
*~
# OS-generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
Thumbs.db
ehthumbs.db
# Environment variable overrides (may contain secrets)
.env
.env.local
.env.*.local
# Rust toolchain overrides
rust-toolchain
rust-toolchain.toml
# Benchmark and profiling artifacts
perf.data
perf.data.old
flamegraph.svg
Generated
+2951
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
[package]
name = "mdrs-client-rust"
version = "0.1.0"
edition = "2024"
license = "MIT"
authors = ["Neuroinformatics Unit, RIKEN CBS"]
description = "A command-line client for uploading and downloading files to and from an MDRS-based repository."
[[bin]]
name = "mdrs"
path = "src/main.rs"
[dependencies]
clap = { version = "4.5", features = ["derive"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1.37", features = ["full"] }
futures = "0.3"
dirs = "5.0"
anyhow = "1.0.102"
configparser = "3.1.0"
validators = "0.25.3"
sha2 = "0.10"
rpassword = "7.0"
base64 = "0.22"
fs2 = "0.4"
ctrlc = "3"
os_info = "3"
dotenvy = "0.15"
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026- Neuroinformatics Unit, RIKEN CBS
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+213
View File
@@ -0,0 +1,213 @@
# mdrs
`mdrs` is a command-line client for uploading and downloading files to and from an MDRS-based repository.
## Installing
### Build from source
```shell
cargo build --release
```
The compiled binary will be available at `target/release/mdrs`.
To build a fully static binary for Linux:
```shell
cargo build --release --target x86_64-unknown-linux-musl
```
## Configuration
### Environment variables
The following environment variables can be set, either in the shell or via a `.env` file placed in the working directory:
| Variable | Default | Description |
|---|---|---|
| `MDRS_CLIENT_CONFIG_DIRNAME` | `~/.mdrs-client` | Directory where config and login cache files are stored |
| `MDRS_CLIENT_CONCURRENT` | `10` | Number of concurrent file transfers for upload/download |
## Example Usage
### config create
Register a remote host.
```shell
mdrs config create neurodata https://neurodata.riken.jp/api
```
### config update
Update the URL of a registered remote host.
```shell
mdrs config update neurodata https://neurodata.riken.jp/api
```
### config list
List registered remote hosts.
```shell
mdrs config list
mdrs config list -l
```
### config delete
Remove a registered remote host.
```shell
mdrs config delete neurodata
```
### login
Log in to a remote host and cache credentials.
```shell
mdrs login neurodata:
Username: (enter your login name)
Password: (enter your password)
mdrs login -u USERNAME -p PASSWORD neurodata:
```
### logout
Log out from a remote host and remove cached credentials.
```shell
mdrs logout neurodata:
```
### whoami
Print the currently logged-in user name.
```shell
mdrs whoami neurodata:
```
### labs
List all laboratories accessible to the current user.
```shell
mdrs labs neurodata:
```
### ls
List the contents of a remote folder.
```shell
mdrs ls neurodata:/NIU/Repository/
mdrs ls -p SHARING_PASSWORD neurodata:/NIU/Repository/PW_Open/
mdrs ls -r neurodata:/NIU/Repository/Dataset1/
mdrs ls -J -r neurodata:/NIU/Repository/Dataset1/
mdrs ls -q neurodata:/NIU/Repository/
```
### mkdir
Create a new remote folder.
```shell
mdrs mkdir neurodata:/NIU/Repository/TEST
```
### upload
Upload a file or directory to a remote folder.
```shell
mdrs upload ./sample.dat neurodata:/NIU/Repository/TEST/
mdrs upload -r ./dataset neurodata:/NIU/Repository/TEST/
mdrs upload -r --skip-if-exists ./dataset neurodata:/NIU/Repository/TEST/
```
### download
Download a file or folder from a remote path.
```shell
mdrs download neurodata:/NIU/Repository/TEST/sample.dat ./
mdrs download -r neurodata:/NIU/Repository/TEST/dataset/ ./
mdrs download -p SHARING_PASSWORD neurodata:/NIU/Repository/PW_Open/Readme.dat ./
mdrs download -r --exclude /NIU/Repository/TEST/dataset/skip neurodata:/NIU/Repository/TEST/dataset/ ./
mdrs download -r --skip-if-exists neurodata:/NIU/Repository/TEST/dataset/ ./
```
### mv
Move or rename a file or folder on the remote.
```shell
mdrs mv neurodata:/NIU/Repository/TEST/sample.dat neurodata:/NIU/Repository/TEST2/sample2.dat
mdrs mv neurodata:/NIU/Repository/TEST/dataset neurodata:/NIU/Repository/TEST2/
```
### cp
Copy a file or folder on the remote.
```shell
mdrs cp neurodata:/NIU/Repository/TEST/sample.dat neurodata:/NIU/Repository/TEST2/sample2.dat
mdrs cp -r neurodata:/NIU/Repository/TEST/dataset neurodata:/NIU/Repository/TEST2/
```
### rm
Remove a file or folder from the remote.
```shell
mdrs rm neurodata:/NIU/Repository/TEST/sample.dat
mdrs rm -r neurodata:/NIU/Repository/TEST/dataset
```
### chacl
Change the access level of a remote folder.
```shell
mdrs chacl private neurodata:/NIU/Repository/Private
mdrs chacl cbs_open -r neurodata:/NIU/Repository/CBS_Open
mdrs chacl pw_open -r -p SHARING_PASSWORD neurodata:/NIU/Repository/PW_Open
```
Available access levels: `private`, `public`, `pw_open`, `cbs_open`, `5kikan_open`, `cbs_pw_open`, `5kikan_pw_open`
### metadata
Show metadata for a remote folder.
```shell
mdrs metadata neurodata:/NIU/Repository/TEST/
mdrs metadata -p SHARING_PASSWORD neurodata:/NIU/Repository/PW_Open/
```
### file-metadata
Show metadata for a remote file.
```shell
mdrs file-metadata neurodata:/NIU/Repository/TEST/dataset/sample.dat
mdrs file-metadata -p SHARING_PASSWORD neurodata:/NIU/Repository/PW_Open/Readme.txt
```
### help
Show help for a command.
```shell
mdrs --help
mdrs upload --help
```
## License
[MIT](LICENSE) © 2026- Neuroinformatics Unit, RIKEN CBS
+17
View File
@@ -0,0 +1,17 @@
fn main() {
let output = std::process::Command::new("rustc")
.arg("--version")
.output()
.expect("Failed to run rustc --version");
let full = String::from_utf8(output.stdout).unwrap();
// "rustc 1.79.0 (129f3b996 2024-06-10)" → "1.79.0"
let version = full
.trim()
.trim_start_matches("rustc ")
.split_whitespace()
.next()
.unwrap_or("unknown")
.to_string();
println!("cargo:rustc-env=RUSTC_VERSION={}", version);
println!("cargo:rerun-if-changed=build.rs");
}
+59
View File
@@ -0,0 +1,59 @@
use crate::connection::MDRSConnection;
pub use crate::models::file::File;
#[derive(serde::Deserialize)]
struct FileListResponse {
pub next: Option<String>,
pub results: Vec<File>,
}
impl MDRSConnection {
/// List all files in a folder, following pagination automatically
pub async fn list_all_files(
&self,
folder_id: &str,
) -> Result<Vec<File>, Box<dyn std::error::Error>> {
let mut all_files = Vec::new();
let mut page: u32 = 1;
loop {
let resp = self
.client
.get(self.build_url("v3/files/"))
.headers(self.prepare_headers())
.query(&[("folder_id", folder_id), ("page", &page.to_string())])
.send()
.await?;
let list: FileListResponse = resp.json().await?;
let has_next = list.next.is_some();
all_files.extend(list.results);
if !has_next {
break;
}
page += 1;
}
Ok(all_files)
}
pub async fn upload_file(
&self,
folder_id: &str,
file_path: &str,
) -> Result<(), Box<dyn std::error::Error>> {
use reqwest::multipart;
let file_name = std::path::Path::new(file_path)
.file_name()
.unwrap()
.to_string_lossy()
.to_string();
let file_bytes = tokio::fs::read(file_path).await?;
let part = multipart::Part::bytes(file_bytes).file_name(file_name.clone());
let form = multipart::Form::new()
.text("folder_id", folder_id.to_string())
.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());
}
Ok(())
}
}
+29
View File
@@ -0,0 +1,29 @@
use crate::connection::MDRSConnection;
pub use crate::models::folder::{FolderDetail, FolderSimple};
impl MDRSConnection {
/// List folders matching the given path under a laboratory (GET v3/folders/?path=...&laboratory_id=...)
pub async fn list_folders_by_path(
&self,
lab_id: u32,
path: &str,
) -> Result<Vec<FolderSimple>, reqwest::Error> {
let resp = self
.client
.get(self.build_url("v3/folders/"))
.headers(self.prepare_headers())
.query(&[
("laboratory_id", lab_id.to_string()),
("path", path.to_string()),
])
.send()
.await?;
resp.json::<Vec<FolderSimple>>().await
}
/// Retrieve full folder details including sub_folders (GET v3/folders/{id}/)
pub async fn retrieve_folder(&self, id: &str) -> Result<FolderDetail, reqwest::Error> {
let resp = self.get(&format!("v3/folders/{}/", id)).await?;
resp.json::<FolderDetail>().await
}
}
+25
View File
@@ -0,0 +1,25 @@
use crate::connection::MDRSConnection;
use crate::models::laboratory::{Laboratories, Laboratory};
use serde::Deserialize;
/// API response is a flat list; we wrap it in `Laboratories { items }` for cache compatibility.
#[derive(Debug, Deserialize)]
struct LabListResponse {
results: Option<Vec<Laboratory>>,
}
impl MDRSConnection {
pub async fn list_laboratories(&self) -> Result<Laboratories, reqwest::Error> {
let resp = self.get("v3/laboratories/").await?;
// The API may return a paginated object or a direct array
let text = resp.text().await?;
let items: Vec<Laboratory> = if let Ok(list) = serde_json::from_str::<Vec<Laboratory>>(&text) {
list
} else if let Ok(paged) = serde_json::from_str::<LabListResponse>(&text) {
paged.results.unwrap_or_default()
} else {
vec![]
};
Ok(Laboratories { items })
}
}
+6
View File
@@ -0,0 +1,6 @@
// API module (add users, files, folders, laboratories, etc. here)
pub mod files;
pub mod folders;
pub mod laboratories;
pub mod users;
+57
View File
@@ -0,0 +1,57 @@
use crate::connection::MDRSConnection;
use crate::models::user::User as ModelUser;
use serde::Deserialize;
/// Full API response shape from GET v3/users/current/
#[derive(Debug, Deserialize)]
struct UsersApiCurrentResponse {
id: u32,
username: String,
laboratories: Vec<UsersCurrentResponseLaboratory>,
is_reviewer: bool,
}
#[derive(Debug, Deserialize)]
struct UsersCurrentResponseLaboratory {
id: u32,
}
#[derive(Deserialize)]
struct TokenRefreshResponse {
access: String,
}
impl MDRSConnection {
/// Fetch current user and return the slim 4-field model matching the Python cache format.
pub async fn get_current_user(&self) -> Result<ModelUser, reqwest::Error> {
let resp = self.get("v3/users/current/").await?;
let obj = resp.json::<UsersApiCurrentResponse>().await?;
let laboratory_ids = obj.laboratories.into_iter().map(|l| l.id).collect();
Ok(ModelUser {
id: obj.id,
username: obj.username,
laboratory_ids,
is_reviewer: obj.is_reviewer,
})
}
/// Refresh the access token using the refresh token.
/// POST v3/users/token/refresh/ {refresh: ...} -> {access: new_access}
pub async fn token_refresh(
&self,
refresh_token: &str,
) -> Result<String, Box<dyn std::error::Error>> {
let body = serde_json::json!({ "refresh": refresh_token });
let resp = self
.client
.post(self.build_url("v3/users/token/refresh/"))
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
return Err(format!("Token refresh failed: {}", resp.status()).into());
}
let r: TokenRefreshResponse = resp.json().await?;
Ok(r.access)
}
}
+50
View File
@@ -0,0 +1,50 @@
use crate::commands::shared::{
create_authenticated_conn, find_folder, find_lab_in_cache, load_cache_with_token_refresh, parse_remote_path,
};
pub async fn chacl(
remote_path: &str,
access_level_key: &str,
recursive: bool,
password: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
let access_level_id: u32 = match access_level_key {
"private" => 0x0001,
"public" => 0x0002,
"pw_open" => 0x0004,
"cbs_open" => 0x0100,
"5kikan_open" => 0x0200,
"cbs_or_pw_open" => 0x0104,
"5kikan_or_pw_open" => 0x0204,
"storage" => 0x0000,
_ => return Err(format!("Unknown access level key: '{}'", access_level_key).into()),
};
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 folder = find_folder(&conn, lab.id, &folder_path, None).await?;
let mut data = serde_json::Map::new();
data.insert("access_level".to_string(), serde_json::json!(access_level_id));
if recursive {
data.insert("lower".to_string(), serde_json::json!(1));
}
if let Some(pw) = password {
data.insert("password".to_string(), serde_json::json!(pw));
}
let resp = conn
.client
.post(conn.build_url(&format!("v3/folders/{}/acl/", folder.id)))
.headers(conn.prepare_headers())
.json(&serde_json::Value::Object(data))
.send()
.await?;
if !resp.status().is_success() {
return Err(format!("ACL change failed: {}", resp.status()).into());
}
println!("ACL changed successfully for: {}", remote_path);
Ok(())
}
+163
View File
@@ -0,0 +1,163 @@
use configparser::ini::Ini;
use std::fs;
use std::path::PathBuf;
fn config_path() -> PathBuf {
crate::settings::SETTINGS.config_dirname.join("config.ini")
}
fn sanitize_config_file(path: &PathBuf) -> Result<(), Box<dyn std::error::Error>> {
if !path.exists() {
return Ok(());
}
let text = fs::read_to_string(path)?;
let lines: Vec<&str> = text.lines().collect();
if let Some(first) = lines.first() {
if first.trim() == path.to_string_lossy() {
// remove the first line and write atomically
let new_text = if lines.len() > 1 {
lines[1..].join("\n")
} else {
String::new()
};
let tmp = path.with_extension("tmp");
fs::write(&tmp, new_text.as_bytes())?;
fs::rename(&tmp, path)?;
}
}
Ok(())
}
fn write_ini_atomic(path: &PathBuf, ini: &Ini) -> Result<(), Box<dyn std::error::Error>> {
let tmp = path.with_extension("tmp");
// write to tmp path then rename for atomicity
ini.write(&tmp)?;
fs::rename(&tmp, path)?;
Ok(())
}
pub fn get_remote_url(remote: &str) -> Result<Option<String>, Box<dyn std::error::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)?;
}
Ok(conf.get(remote, "url"))
}
pub fn config_create(remote: &str, url: &str) -> Result<(), Box<dyn std::error::Error>> {
if !validate_url(url) {
return Err("Malformed URL".into());
}
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)?;
}
// check if section exists
let section_exists = conf
.get_map()
.map(|m| m.contains_key(remote))
.unwrap_or(false);
if section_exists {
return Err(format!("Remote host `{}` already exists.", remote).into());
}
// set url
conf.set(remote, "url", Some(url.to_string()));
// ensure parent dir exists
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
write_ini_atomic(&path, &conf)?;
println!("Created remote host `{}`.", remote);
Ok(())
}
pub fn config_update(remote: &str, url: &str) -> Result<(), Box<dyn std::error::Error>> {
if !validate_url(url) {
return Err("Malformed URL".into());
}
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)?;
}
// ensure section exists
let section_exists = conf
.get_map()
.map(|m| m.contains_key(remote))
.unwrap_or(false);
if !section_exists {
return Err(format!("Remote host `{}` does not exist.", remote).into());
}
conf.set(remote, "url", Some(url.to_string()));
write_ini_atomic(&path, &conf)?;
println!("Updated remote host `{}`.", remote);
Ok(())
}
pub fn config_list(long: bool) -> Result<(), Box<dyn std::error::Error>> {
let path = config_path();
if !path.exists() {
println!("No config file found at {}", path.to_string_lossy());
return Ok(());
}
sanitize_config_file(&path)?;
let path_str = path.to_string_lossy().to_string();
let mut conf = Ini::new();
let _ = conf.load(&path_str)?;
let map = conf.get_map().unwrap_or_default();
let mut printed = false;
for (sec, props) in map.iter() {
if sec == "default" {
continue;
}
if !long {
println!("{}", sec);
printed = true;
} else {
let url = props.get("url").and_then(|v| v.clone()).unwrap_or_default();
println!("{}:\t{}", sec, url);
printed = true;
}
}
if !printed {
println!("No remotes configured");
}
Ok(())
}
pub fn config_delete(remote: &str) -> Result<(), Box<dyn std::error::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)?;
}
// 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 `{}` does not exist.", remote).into());
}
// build new Ini from map
let mut new_ini = Ini::new();
for (sec, props) in map.iter() {
for (k, v) in props.iter() {
new_ini.set(sec, k, v.clone());
}
}
write_ini_atomic(&path, &new_ini)?;
println!("Deleted remote host `{}`.", remote);
Ok(())
}
fn validate_url(url: &str) -> bool {
validators::url::Url::parse(url).is_ok()
}
+36
View File
@@ -0,0 +1,36 @@
use clap::{Args, Subcommand};
#[derive(Subcommand, Debug)]
pub enum ConfigSubcommand {
/// Create a new remote host
Create(ConfigCreateArgs),
/// Update an existing remote host
Update(ConfigUpdateArgs),
/// List all remote hosts
List(ConfigListArgs),
/// Delete a remote host
Delete(ConfigDeleteArgs),
}
#[derive(Args, Debug)]
pub struct ConfigCreateArgs {
pub remote: String,
pub url: String,
}
#[derive(Args, Debug)]
pub struct ConfigUpdateArgs {
pub remote: String,
pub url: String,
}
#[derive(Args, Debug)]
pub struct ConfigListArgs {
#[arg(short, long)]
pub long: bool,
}
#[derive(Args, Debug)]
pub struct ConfigDeleteArgs {
pub remote: String,
}
+121
View File
@@ -0,0 +1,121 @@
use crate::commands::shared::{
create_authenticated_conn, find_file_by_name, find_folder, find_lab_in_cache, load_cache_with_token_refresh,
parse_remote_path,
};
pub async fn cp(
src_path: &str,
dest_path: &str,
recursive: bool,
) -> Result<(), Box<dyn std::error::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("Source and destination must use the same remote.".into());
}
if s_lab != d_lab {
return Err("Source and destination must be in the same laboratory.".into());
}
let cache = load_cache_with_token_refresh(&s_remote).await?;
let conn = create_authenticated_conn(&s_remote, &cache)?;
let lab = find_lab_in_cache(&cache, &s_lab)?;
let lab_id = lab.id;
// Split source path into parent directory and target name
let (s_dirname, s_basename) = split_path(&s_path);
// If dest ends with '/', treat it as a directory and preserve src basename
let (d_dirname, d_basename) = if dest_ends_with_slash {
(d_path.clone(), s_basename.clone())
} else {
split_path(&d_path)
};
let s_parent_folder = find_folder(&conn, lab_id, &s_dirname, None).await?;
let s_parent_files = conn.list_all_files(&s_parent_folder.id).await?;
let d_parent_folder = find_folder(&conn, lab_id, &d_dirname, None).await?;
let d_parent_files = conn.list_all_files(&d_parent_folder.id).await?;
// Try source as a file first
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());
}
if d_parent_folder
.sub_folders
.iter()
.any(|f| f.name.to_lowercase() == d_basename.to_lowercase())
{
return Err("Cannot overwrite non-folder with folder.".into());
}
let body = serde_json::json!({"folder": d_parent_folder.id, "name": d_basename});
let resp = conn
.client
.post(conn.build_url(&format!("v3/files/{}/copy/", src_file_id)))
.headers(conn.prepare_headers())
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
return Err(format!("Copy failed: {}", resp.status()).into());
}
println!("Copied: {} -> {}", src_path, dest_path);
return Ok(());
}
// Try source as a folder
if let Some(src_folder) = s_parent_folder
.sub_folders
.iter()
.find(|f| f.name.to_lowercase() == s_basename.to_lowercase())
{
if !recursive {
return Err(
format!("{}: is a folder (use -r to copy folders)", src_path).into(),
);
}
let src_folder_id = src_folder.id.clone();
if find_file_by_name(&d_parent_files, &d_basename).is_some() {
return Err(format!("File `{}` already exists.", d_basename).into());
}
if d_parent_folder
.sub_folders
.iter()
.any(|f| f.name.to_lowercase() == d_basename.to_lowercase())
{
return Err("Folder not empty.".into());
}
let body = serde_json::json!({"parent": d_parent_folder.id, "name": d_basename});
let resp = conn
.client
.post(conn.build_url(&format!("v3/folders/{}/copy/", src_folder_id)))
.headers(conn.prepare_headers())
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
return Err(format!("Copy failed: {}", resp.status()).into());
}
println!("Copied: {} -> {}", src_path, dest_path);
return Ok(());
}
Err(format!("Source `{}` not found.", src_path).into())
}
/// Split a path into (parent_dir, basename).
/// e.g. "/path/to/item" -> ("/path/to", "item")
fn split_path(path: &str) -> (String, String) {
let path = path.trim_end_matches('/');
if let Some(pos) = path.rfind('/') {
let d = if pos == 0 { "/" } else { &path[..pos] };
(d.to_string(), path[pos + 1..].to_string())
} else {
("/".to_string(), path.to_string())
}
}
+187
View File
@@ -0,0 +1,187 @@
use crate::commands::shared::{
create_authenticated_conn, find_file_by_name, find_folder, find_lab_in_cache, load_cache_with_token_refresh,
parse_remote_path,
};
use crate::connection::MDRSConnection;
use futures::stream::{FuturesUnordered, StreamExt};
use std::path::Path;
use std::sync::Arc;
pub async fn download(
remote_path: &str,
local_path: &str,
recursive: bool,
skip_if_exists: bool,
password: Option<&str>,
excludes: Vec<String>,
) -> Result<(), Box<dyn std::error::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)?);
let lab = find_lab_in_cache(&cache, &labname)?;
// 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))?;
if !local_real.is_dir() {
return Err(format!("Local directory `{}` not found.", local_path).into());
}
let local_dir_base = local_real.to_string_lossy().to_string();
// Split r_path into the parent directory path and the target basename.
// Trailing slashes are already stripped by parse_remote_path, so this is safe.
let r_path_clean = r_path.trim_end_matches('/');
let (parent_path, basename) = match r_path_clean.rfind('/') {
Some(0) => ("/".to_string(), r_path_clean[1..].to_string()),
Some(pos) => (
r_path_clean[..pos].to_string(),
r_path_clean[pos + 1..].to_string(),
),
None => ("/".to_string(), r_path_clean.to_string()),
};
let parent_folder = find_folder(&conn, lab.id, &parent_path, password).await?;
let files = conn.list_all_files(&parent_folder.id).await?;
// Case 1: basename matches a file in the parent folder.
if let Some(file) = find_file_by_name(&files, &basename) {
if is_excluded(&excludes, &lab.name, &parent_folder.path, Some(&file.name)) {
return Ok(());
}
// Python always places the downloaded file inside the local directory.
let dest = format!("{}/{}", local_dir_base, file.name);
if skip_if_exists {
if Path::new(&dest).exists() {
if let Ok(meta) = std::fs::metadata(&dest) {
if meta.len() == file.size {
println!("{}", dest);
return Ok(());
}
}
}
}
let url = make_absolute_url(&conn, &file.download_url);
conn.download_file(&url, &dest).await?;
println!("{}", dest);
return Ok(());
}
// Case 2: basename matches a sub-folder.
let subfolder = parent_folder
.sub_folders
.iter()
.find(|f| f.name.to_lowercase() == basename.to_lowercase());
if let Some(sub) = subfolder {
if !recursive {
return Err(format!("Cannot download `{}`: Is a folder.", r_path_clean).into());
}
// Python downloads into local_path/<remote_folder_name>/ (not directly into local_path).
// We create that subdirectory first, then recurse into it.
let top_local = format!("{}/{}", local_dir_base, sub.name);
// Iterative DFS: each entry is (remote_folder_id, local_dir)
let mut stack: Vec<(String, String)> = vec![(sub.id.clone(), top_local)];
while let Some((folder_id, local_dir)) = stack.pop() {
let folder = conn.retrieve_folder(&folder_id).await?;
if is_excluded(&excludes, &lab.name, &folder.path, None) {
continue;
}
tokio::fs::create_dir_all(&local_dir).await?;
println!("{}", local_dir);
let dir_files = conn.list_all_files(&folder_id).await?;
// Download files in this folder (up to 10 concurrent).
let mut futs: FuturesUnordered<tokio::task::JoinHandle<()>> =
FuturesUnordered::new();
for f in &dir_files {
if is_excluded(&excludes, &lab.name, &folder.path, Some(&f.name)) {
continue;
}
let dest_path = format!("{}/{}", local_dir, f.name);
if skip_if_exists {
if Path::new(&dest_path).exists() {
if let Ok(meta) = std::fs::metadata(&dest_path) {
if meta.len() == f.size {
println!("{}", dest_path);
continue;
}
}
}
}
let url = make_absolute_url(&conn, &f.download_url);
let conn = conn.clone();
let _fname = f.name.clone();
futs.push(tokio::spawn(async move {
match conn.download_file(&url, &dest_path).await {
Ok(_) => println!("{}", dest_path),
Err(_) => {
eprintln!("Failed: {}", dest_path);
if Path::new(&dest_path).is_file() {
let _ = std::fs::remove_file(&dest_path);
}
}
}
}));
if futs.len() >= crate::settings::SETTINGS.concurrent {
let _ = futs.next().await;
}
}
while futs.next().await.is_some() {}
// Push sub-folders onto the stack for recursive processing.
for sf in &folder.sub_folders {
if sf.lock {
match password {
Some(pw) => {
if conn.folder_auth(&sf.id, pw).await.is_err() {
continue;
}
}
None => continue,
}
}
let sub_local = format!("{}/{}", local_dir, sf.name);
stack.push((sf.id.clone(), sub_local));
}
}
return Ok(());
}
Err(format!("File or folder `{}` not found.", r_path_clean).into())
}
/// Return true if the given lab/folder/file path matches any exclude pattern.
/// Constructs: `/{lab_name}{folder_path}{file_name}` lowercased, trailing slash stripped.
/// `folder_path` is expected to already start (and end) with "/".
fn is_excluded(excludes: &[String], lab_name: &str, folder_path: &str, file_name: Option<&str>) -> bool {
if excludes.is_empty() {
return false;
}
let path = format!(
"/{}{}{}",
lab_name,
folder_path,
file_name.unwrap_or("")
)
.trim_end_matches('/')
.to_lowercase();
excludes.iter().any(|e| e == &path)
}
/// Return an absolute URL for a file download.
/// If the API returns a relative path, prepend the connection's base URL.
fn make_absolute_url(conn: &MDRSConnection, url: &str) -> String {
if url.starts_with("http") {
url.to_string()
} else {
format!(
"{}/{}",
conn.url.trim_end_matches('/'),
url.trim_start_matches('/')
)
}
}
+33
View File
@@ -0,0 +1,33 @@
use crate::commands::shared::{
create_authenticated_conn, find_file_by_name, find_folder, find_lab_in_cache, load_cache_with_token_refresh,
parse_remote_path,
};
pub async fn file_metadata(remote_path: &str, password: Option<&str>) -> Result<(), Box<dyn std::error::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 lab_id = lab.id;
// Split the file path into parent directory and filename
let path = r_path.trim_end_matches('/');
let (dirname, basename) = if let Some(pos) = path.rfind('/') {
let d = if pos == 0 { "/" } else { &path[..pos] };
(d.to_string(), path[pos + 1..].to_string())
} else {
("/".to_string(), path.to_string())
};
let parent_folder = find_folder(&conn, lab_id, &dirname, password).await?;
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))?;
let resp = conn.get(&format!("v3/files/{}/metadata/", file.id)).await?;
let json: serde_json::Value = resp.json().await?;
println!("{}", serde_json::to_string_pretty(&json)?);
Ok(())
}
+56
View File
@@ -0,0 +1,56 @@
use crate::connection::MDRSConnection;
use std::fs;
use std::sync::Arc;
pub async fn labs(
conn: Arc<MDRSConnection>,
remote_label: &str,
) -> Result<(), Box<dyn std::error::Error>> {
// Try API first
match conn.list_laboratories().await {
Ok(labs) => {
println!("Laboratories:");
for lab in labs.items {
println!(
" {} (PI: {}, Full: {})",
lab.name, lab.pi_name, lab.full_name
);
}
return Ok(());
}
Err(_) => {
// fallback to cache
}
}
// fallback: read cache file using remote_label
let cache_path = crate::settings::SETTINGS
.config_dirname
.join("cache")
.join(format!("{}.json", remote_label));
if !cache_path.exists() {
println!("No laboratories available (API failed and no cache)");
return Ok(());
}
let text = fs::read_to_string(&cache_path)?;
let v: serde_json::Value = serde_json::from_str(&text)?;
// Cache stores laboratories as `{"items": [...]}` (Python-compatible format)
let labs_arr = v
.get("laboratories")
.and_then(|l| l.get("items"))
.and_then(|a| a.as_array());
if let Some(arr) = labs_arr {
println!("Laboratories (from cache):");
for lab in arr {
let name = lab.get("name").and_then(|s| s.as_str()).unwrap_or("");
let pi = lab.get("pi_name").and_then(|s| s.as_str()).unwrap_or("");
let full = lab.get("full_name").and_then(|s| s.as_str()).unwrap_or("");
println!(" {} (PI: {}, Full: {})", name, pi, full);
}
} else {
println!("No laboratories found in cache");
}
Ok(())
}
+151
View File
@@ -0,0 +1,151 @@
use crate::commands::shared::{CacheLaboratory, 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;
#[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<dyn std::error::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 conn0 = MDRSConnection::new(&base_url);
let client = Client::new();
let url = conn0.build_url("v3/users/token/");
let params = [("username", username), ("password", password)];
let resp_res = client.post(&url).form(&params).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());
}
};
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(format!("Login failed: {} - {}", status, body).into());
}
let token: TokenResp = resp.json().await?;
// fetch current user and laboratories with the new access token
let mut conn = MDRSConnection::new(&base_url);
conn.token = Some(token.access.clone());
let user_opt: Option<User> = conn.get_current_user().await.ok();
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<CacheUser> = user_opt.as_ref().map(to_cache_user);
let cache_labs = to_cache_labs(&labs);
// compute Python-compatible digest
let digest = compute_digest(
cache_user_opt.as_ref(),
&token.access,
&token.refresh,
&cache_labs,
);
// build the cache JSON — field order matches Python's dataclass layout:
// user (id, username, laboratory_ids, is_reviewer)
// token (access, refresh)
// laboratories (items)
// digest
let user_val: Value = match &cache_user_opt {
Some(u) => json!({
"id": u.id,
"username": u.username,
"laboratory_ids": u.laboratory_ids,
"is_reviewer": u.is_reviewer
}),
None => Value::Null,
};
let labs_items: Vec<Value> = cache_labs
.items
.iter()
.map(|l| {
json!({
"id": l.id,
"name": l.name,
"pi_name": l.pi_name,
"full_name": l.full_name
})
})
.collect();
let obj = json!({
"user": user_val,
"token": {"access": token.access, "refresh": token.refresh},
"laboratories": {"items": labs_items},
"digest": digest
});
// write cache file: {config_dirname}/cache/<remote>.json
let cache_dir = crate::settings::SETTINGS.config_dirname.join("cache");
fs::create_dir_all(&cache_dir)?;
#[cfg(unix)]
{
let mut perms = fs::metadata(&cache_dir)?.permissions();
perms.set_mode(0o700);
fs::set_permissions(&cache_dir, perms)?;
}
let cache_file = cache_dir.join(format!("{}.json", remote));
let tmp = cache_file.with_extension("tmp");
fs::write(&tmp, serde_json::to_vec_pretty(&obj)?)?;
#[cfg(unix)]
{
let mut perms = fs::metadata(&tmp)?.permissions();
perms.set_mode(0o600);
fs::set_permissions(&tmp, perms)?;
}
fs::rename(&tmp, &cache_file)?;
println!("Login successful and cached for {}.", remote);
Ok(())
}
+15
View File
@@ -0,0 +1,15 @@
use std::fs;
pub fn logout(remote: &str) -> Result<(), Box<dyn std::error::Error>> {
let cache_path = crate::settings::SETTINGS
.config_dirname
.join("cache")
.join(format!("{}.json", remote));
if cache_path.exists() {
fs::remove_file(&cache_path)?;
println!("Logged out from {}", remote);
} else {
println!("No login cache found for {}", remote);
}
Ok(())
}
+315
View File
@@ -0,0 +1,315 @@
use crate::models::file::File;
use crate::models::folder::{FolderDetail, FolderSimple};
use crate::commands::shared::{
create_authenticated_conn, find_folder, find_lab_in_cache, fmt_datetime,
load_cache_with_token_refresh, parse_remote_path,
};
use crate::connection::MDRSConnection;
use serde_json::{json, Value};
use std::future::Future;
use std::pin::Pin;
pub async fn ls(
remote_path: &str,
password: Option<&str>,
is_json: bool,
is_recursive: bool,
is_quick: bool,
) -> Result<(), Box<dyn std::error::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 folder = find_folder(&conn, lab.id, &path, password).await?;
if is_json {
let output = if is_recursive {
build_folder_json_recursive(&conn, folder, &labname).await?
} else {
build_folder_json_flat(&conn, &folder, &labname).await?
};
println!("{}", serde_json::to_string_pretty(&output)?);
} else if is_recursive {
let prefix = format!("{}:/{}", remote, labname);
ls_plain_recursive(&conn, folder, &labname, &prefix, password).await?;
} else {
let files = conn.list_all_files(&folder.id).await?;
let mut sub_folders = folder.sub_folders.clone();
sub_folders.sort_by(|a, b| a.name.cmp(&b.name));
let mut files_sorted = files;
files_sorted.sort_by(|a, b| a.name.cmp(&b.name));
print_folder_plain(
&sub_folders,
&files_sorted,
folder.access_level_name(),
&labname,
!is_quick,
);
}
Ok(())
}
// ---------------------------------------------------------------------------
// Plain-text output helpers
// ---------------------------------------------------------------------------
fn print_folder_plain(
sub_folders: &[FolderSimple],
files: &[File],
folder_access: &str,
labname: &str,
show_header: bool,
) {
let header = ("Type", "Access", "Laboratory", "Size", "Date", "Name");
let mut w_type = header.0.len();
let mut w_access = header.1.len();
let w_lab = header.2.len().max(labname.len());
let mut w_size = header.3.len();
let mut w_date = header.4.len();
let mut w_name = header.5.len();
// [d], [l], [f] are each 3 chars; ensure w_type accommodates them
w_type = w_type.max(3);
for sf in sub_folders {
w_access = w_access.max(sf.access_level_name().len());
w_size = w_size.max(sf.size.to_string().len());
w_date = w_date.max(fmt_datetime(&sf.updated_at).len());
w_name = w_name.max(sf.name.len());
}
for f in files {
w_access = w_access.max(folder_access.len());
w_size = w_size.max(f.size.to_string().len());
w_date = w_date.max(fmt_datetime(&f.updated_at).len());
w_name = w_name.max(f.name.len());
}
if show_header {
println!(
"{:<w_type$} {:<w_access$} {:<w_lab$} {:>w_size$} {:<w_date$} {:<w_name$}",
header.0,
header.1,
header.2,
header.3,
header.4,
header.5,
w_type = w_type,
w_access = w_access,
w_lab = w_lab,
w_size = w_size,
w_date = w_date,
w_name = w_name,
);
let line_len = w_type + 2 + w_access + 2 + w_lab + 2 + w_size + 2 + w_date + 2 + w_name;
println!("{}", "-".repeat(line_len));
}
for sf in sub_folders {
let folder_type = if sf.lock { "[l]" } else { "[d]" };
println!(
"{:<w_type$} {:<w_access$} {:<w_lab$} {:>w_size$} {:<w_date$} {:<w_name$}",
folder_type,
sf.access_level_name(),
labname,
sf.size,
fmt_datetime(&sf.updated_at),
sf.name,
w_type = w_type,
w_access = w_access,
w_lab = w_lab,
w_size = w_size,
w_date = w_date,
w_name = w_name,
);
}
for f in files {
println!(
"{:<w_type$} {:<w_access$} {:<w_lab$} {:>w_size$} {:<w_date$} {:<w_name$}",
"[f]",
folder_access,
labname,
f.size,
fmt_datetime(&f.updated_at),
f.name,
w_type = w_type,
w_access = w_access,
w_lab = w_lab,
w_size = w_size,
w_date = w_date,
w_name = w_name,
);
}
}
/// Recursively list folder contents in plain-text mode (no header, path prefix per folder).
fn ls_plain_recursive<'a>(
conn: &'a MDRSConnection,
folder: FolderDetail,
labname: &'a str,
prefix: &'a str,
password: Option<&'a str>,
) -> Pin<Box<dyn Future<Output = Result<(), Box<dyn std::error::Error>>> + '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();
println!("{}{}:", prefix, folder.path);
println!("total {}", total_size);
let access = folder.access_level_name();
let mut sub_folders = folder.sub_folders.clone();
sub_folders.sort_by(|a, b| a.name.cmp(&b.name));
let mut files_sorted = files;
files_sorted.sort_by(|a, b| a.name.cmp(&b.name));
print_folder_plain(&sub_folders, &files_sorted, access, labname, false);
for sf in sub_folders {
if sf.lock {
match password {
None => continue,
Some(pw) => {
if conn.folder_auth(&sf.id, pw).await.is_err() {
continue;
}
}
}
}
match conn.retrieve_folder(&sf.id).await {
Ok(sub_detail) => {
ls_plain_recursive(conn, sub_detail, labname, prefix, password).await?;
}
Err(_) => continue,
}
}
Ok(())
})
}
// ---------------------------------------------------------------------------
// JSON output helpers
// ---------------------------------------------------------------------------
async fn get_folder_metadata(
conn: &MDRSConnection,
folder_id: &str,
) -> Result<Value, Box<dyn std::error::Error>> {
let resp = conn
.get(&format!("v3/folders/{}/metadata/", folder_id))
.await?;
if resp.status().is_success() {
Ok(resp.json::<Value>().await?)
} else {
Ok(json!({}))
}
}
fn file_to_json(f: &File, base_url: &str) -> Value {
let download_url = if f.download_url.starts_with("http") {
f.download_url.clone()
} else {
format!(
"{}{}",
base_url.trim_end_matches('/'),
f.download_url
)
};
json!({
"id": f.id,
"name": f.name,
"type": f.r#type,
"size": f.size,
"description": f.description,
"metadata": f.metadata,
"download_url": download_url,
"created_at": f.created_at,
"updated_at": f.updated_at,
})
}
fn folder_simple_to_json(sf: &FolderSimple) -> Value {
json!({
"id": sf.id,
"pid": sf.pid,
"name": sf.name,
"access_level": sf.access_level_name(),
"lock": sf.lock,
"size": sf.size,
"laboratory_id": sf.laboratory_id,
"description": sf.description,
"created_at": sf.created_at,
"updated_at": sf.updated_at,
})
}
/// Build JSON for the top-level folder without recursing into sub-folders.
async fn build_folder_json_flat(
conn: &MDRSConnection,
folder: &FolderDetail,
labname: &str,
) -> Result<Value, Box<dyn std::error::Error>> {
let metadata = get_folder_metadata(conn, &folder.id).await?;
let files = conn.list_all_files(&folder.id).await?;
let files_json: Vec<Value> = files.iter().map(|f| file_to_json(f, &conn.url)).collect();
let sub_folders_json: Vec<Value> = folder
.sub_folders
.iter()
.map(folder_simple_to_json)
.collect();
Ok(json!({
"id": folder.id,
"pid": folder.pid,
"name": folder.name,
"size": folder.size,
"access_level": folder.access_level_name(),
"lock": folder.lock,
"laboratory": labname,
"description": folder.description,
"created_at": folder.created_at,
"updated_at": folder.updated_at,
"metadata": metadata,
"sub_folders": sub_folders_json,
"files": files_json,
}))
}
/// Build JSON for a folder, recursively expanding every sub-folder.
fn build_folder_json_recursive<'a>(
conn: &'a MDRSConnection,
folder: FolderDetail,
labname: &'a str,
) -> Pin<Box<dyn Future<Output = Result<Value, Box<dyn std::error::Error>>> + 'a>> {
Box::pin(async move {
let metadata = get_folder_metadata(conn, &folder.id).await?;
let files = conn.list_all_files(&folder.id).await?;
let files_json: Vec<Value> = files.iter().map(|f| file_to_json(f, &conn.url)).collect();
let mut sub_folders_json = Vec::new();
for sf in &folder.sub_folders {
let sf_detail = conn.retrieve_folder(&sf.id).await?;
let sf_json = build_folder_json_recursive(conn, sf_detail, labname).await?;
sub_folders_json.push(sf_json);
}
Ok(json!({
"id": folder.id,
"pid": folder.pid,
"name": folder.name,
"size": folder.size,
"access_level": folder.access_level_name(),
"lock": folder.lock,
"laboratory": labname,
"description": folder.description,
"created_at": folder.created_at,
"updated_at": folder.updated_at,
"metadata": metadata,
"sub_folders": sub_folders_json,
"files": files_json,
}))
})
}
+18
View File
@@ -0,0 +1,18 @@
use crate::commands::shared::{
create_authenticated_conn, find_folder, find_lab_in_cache, load_cache_with_token_refresh, parse_remote_path,
};
pub async fn metadata(remote_path: &str, password: Option<&str>) -> Result<(), Box<dyn std::error::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 folder = find_folder(&conn, lab.id, &folder_path, password).await?;
let resp = conn
.get(&format!("v3/folders/{}/metadata/", folder.id))
.await?;
let json: serde_json::Value = resp.json().await?;
println!("{}", serde_json::to_string_pretty(&json)?);
Ok(())
}
+51
View File
@@ -0,0 +1,51 @@
use crate::commands::shared::{
create_authenticated_conn, find_file_by_name, find_folder, find_lab_in_cache, load_cache_with_token_refresh,
parse_remote_path,
};
pub async fn mkdir(remote_path: &str) -> Result<(), Box<dyn std::error::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")?;
let parent_path = if last_slash == 0 {
"/"
} else {
&path[..last_slash]
};
let new_folder_name = &path[last_slash + 1..];
if new_folder_name.is_empty() {
return Err("Invalid path: folder name cannot be empty".into());
}
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 parent_folder = find_folder(&conn, lab.id, parent_path, None).await?;
// Check for name conflict in sub-folders
if parent_folder
.sub_folders
.iter()
.any(|f| f.name == new_folder_name)
{
return Err(format!("'{}' already exists as a folder", new_folder_name).into());
}
// Check for name conflict in files
let files = conn.list_all_files(&parent_folder.id).await?;
if find_file_by_name(&files, new_folder_name).is_some() {
return Err(format!("'{}' already exists as a file", new_folder_name).into());
}
let resp = conn
.create_folder(&parent_folder.id, new_folder_name)
.await?;
if !resp.status().is_success() {
return Err(format!("Failed to create folder: {}", resp.status()).into());
}
println!("Created folder: {}", new_folder_name);
Ok(())
}
+19
View File
@@ -0,0 +1,19 @@
// CLI command implementations (login, upload, download, ls, whoami, etc.)
pub mod chacl;
pub mod config;
pub mod config_subcommand;
pub mod cp;
pub mod download;
pub mod file_metadata;
pub mod labs;
pub mod login;
pub mod logout;
pub mod ls;
pub mod metadata;
pub mod mkdir;
pub mod mv;
pub mod rm;
pub mod shared;
pub mod upload;
pub mod whoami;
+112
View File
@@ -0,0 +1,112 @@
use crate::commands::shared::{
create_authenticated_conn, find_file_by_name, find_folder, find_lab_in_cache, load_cache_with_token_refresh,
parse_remote_path,
};
pub async fn mv(src_path: &str, dest_path: &str) -> Result<(), Box<dyn std::error::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("Source and destination must use the same remote.".into());
}
if s_lab != d_lab {
return Err("Source and destination must be in the same laboratory.".into());
}
let cache = load_cache_with_token_refresh(&s_remote).await?;
let conn = create_authenticated_conn(&s_remote, &cache)?;
let lab = find_lab_in_cache(&cache, &s_lab)?;
let lab_id = lab.id;
// Split source path into parent directory and target name
let (s_dirname, s_basename) = split_path(&s_path);
// If dest ends with '/', treat it as a directory and preserve src basename
let (d_dirname, d_basename) = if dest_ends_with_slash {
(d_path.clone(), s_basename.clone())
} else {
split_path(&d_path)
};
let s_parent_folder = find_folder(&conn, lab_id, &s_dirname, None).await?;
let s_parent_files = conn.list_all_files(&s_parent_folder.id).await?;
let d_parent_folder = find_folder(&conn, lab_id, &d_dirname, None).await?;
let d_parent_files = conn.list_all_files(&d_parent_folder.id).await?;
// Try source as a file first
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());
}
if d_parent_folder
.sub_folders
.iter()
.any(|f| f.name.to_lowercase() == d_basename.to_lowercase())
{
return Err("Cannot overwrite non-folder with folder.".into());
}
let body = serde_json::json!({"folder": d_parent_folder.id, "name": d_basename});
let resp = conn
.client
.post(conn.build_url(&format!("v3/files/{}/move/", src_file_id)))
.headers(conn.prepare_headers())
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
return Err(format!("Move failed: {}", resp.status()).into());
}
println!("Moved: {} -> {}", src_path, dest_path);
return Ok(());
}
// Try source as a folder
if let Some(src_folder) = s_parent_folder
.sub_folders
.iter()
.find(|f| f.name.to_lowercase() == s_basename.to_lowercase())
{
let src_folder_id = src_folder.id.clone();
if find_file_by_name(&d_parent_files, &d_basename).is_some() {
return Err(format!("File `{}` already exists.", d_basename).into());
}
if d_parent_folder
.sub_folders
.iter()
.any(|f| f.name.to_lowercase() == d_basename.to_lowercase())
{
return Err("Folder not empty.".into());
}
let body = serde_json::json!({"parent": d_parent_folder.id, "name": d_basename});
let resp = conn
.client
.post(conn.build_url(&format!("v3/folders/{}/move/", src_folder_id)))
.headers(conn.prepare_headers())
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
return Err(format!("Move failed: {}", resp.status()).into());
}
println!("Moved: {} -> {}", src_path, dest_path);
return Ok(());
}
Err(format!("Source `{}` not found.", src_path).into())
}
/// Split a path into (parent_dir, basename).
/// e.g. "/path/to/item" -> ("/path/to", "item")
fn split_path(path: &str) -> (String, String) {
let path = path.trim_end_matches('/');
if let Some(pos) = path.rfind('/') {
let d = if pos == 0 { "/" } else { &path[..pos] };
(d.to_string(), path[pos + 1..].to_string())
} else {
("/".to_string(), path.to_string())
}
}
+67
View File
@@ -0,0 +1,67 @@
use crate::commands::shared::{
create_authenticated_conn, find_file_by_name, find_folder, find_lab_in_cache, load_cache_with_token_refresh,
parse_remote_path,
};
pub async fn rm(remote_path: &str, recursive: bool) -> Result<(), Box<dyn std::error::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 parent_path = if last_slash == 0 {
"/"
} else {
&path[..last_slash]
};
let target_name = &path[last_slash + 1..];
if target_name.is_empty() {
return Err("Cannot remove root folder".into());
}
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 parent_folder = find_folder(&conn, lab.id, parent_path, None).await?;
// Check if target is a file
let files = conn.list_all_files(&parent_folder.id).await?;
if let Some(file) = find_file_by_name(&files, target_name) {
let resp = conn
.client
.delete(conn.build_url(&format!("v3/files/{}/", file.id)))
.headers(conn.prepare_headers())
.send()
.await?;
if !resp.status().is_success() {
return Err(format!("Failed to delete file: {}", resp.status()).into());
}
println!("Deleted file: {}", target_name);
return Ok(());
}
// Check if target is a sub-folder
if let Some(subfolder) = parent_folder
.sub_folders
.iter()
.find(|f| f.name == target_name)
{
if !recursive {
return Err(format!("'{}': Is a folder", target_name).into());
}
let resp = conn
.client
.delete(conn.build_url(&format!("v3/folders/{}/", subfolder.id)))
.headers(conn.prepare_headers())
.query(&[("recursive", "true")])
.send()
.await?;
if !resp.status().is_success() {
return Err(format!("Failed to delete folder: {}", resp.status()).into());
}
println!("Deleted folder: {}", target_name);
return Ok(());
}
Err(format!("'{}': No such file or directory", target_name).into())
}
+433
View File
@@ -0,0 +1,433 @@
use crate::models::file::File;
use crate::models::folder::FolderDetail;
use crate::connection::MDRSConnection;
use serde::Deserialize;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::fs;
use std::sync::{Arc, LazyLock, Mutex};
// ---------------------------------------------------------------------------
// 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())
}
/// 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 folders = conn.list_folders_by_path(lab_id, 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 (case-insensitive) in a file list
pub fn find_file_by_name<'a>(files: &'a [File], name: &str) -> Option<&'a File> {
let name_lower = name.to_lowercase();
files.iter().find(|f| 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()
}
}
+143
View File
@@ -0,0 +1,143 @@
use crate::models::folder::FolderSimple;
use crate::commands::shared::{
create_authenticated_conn, find_file_by_name, find_folder, find_lab_in_cache, load_cache_with_token_refresh,
parse_remote_path,
};
use futures::stream::{FuturesUnordered, StreamExt};
use std::path::PathBuf;
use std::sync::Arc;
use tokio::fs;
pub async fn upload(
local_path: &str,
remote_path: &str,
recursive: bool,
skip_if_exists: bool,
) -> Result<(), Box<dyn std::error::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)?);
let lab = find_lab_in_cache(&cache, &labname)?;
let dest_folder = find_folder(&conn, lab.id, &r_path, None).await?;
// Normalize local_path: resolve to an absolute canonical path so that
// 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))?;
let local = local_abs.as_path();
if local.is_file() {
let filename = local.file_name().unwrap().to_string_lossy().to_string();
let remote_files = conn.list_all_files(&dest_folder.id).await?;
if skip_if_exists {
if let Some(rf) = find_file_by_name(&remote_files, &filename) {
if rf.size == std::fs::metadata(local)?.len() {
println!("{}{}", dest_folder.path, filename);
return Ok(());
}
}
}
conn.upload_file(&dest_folder.id, &local.to_string_lossy()).await?;
println!("{}{}", dest_folder.path, filename);
} else if local.is_dir() {
if !recursive {
return Err(format!("Cannot upload `{}`: Is a directory.", local_path).into());
}
// Python always creates a sub-folder named after the local directory inside
// remote_path. E.g. `upload ./mydir remote:/lab/path/` creates
// `/lab/path/mydir/` on the remote and uploads into that folder.
let local_basename = local.file_name().unwrap().to_string_lossy().to_string();
let top_remote_id = find_or_create_folder(&conn, &dest_folder.id, &dest_folder.sub_folders, &local_basename).await?;
let top_folder = conn.retrieve_folder(&top_remote_id).await?;
println!("{}", top_folder.path.trim_end_matches('/'));
// Iterative depth-first walk: each entry is (local_dir, remote_folder_id)
let mut stack: Vec<(PathBuf, String)> =
vec![(local.to_path_buf(), top_remote_id)];
while let Some((local_dir, remote_id)) = stack.pop() {
let folder_detail = conn.retrieve_folder(&remote_id).await?;
let remote_files = conn.list_all_files(&remote_id).await?;
let mut entries = fs::read_dir(&local_dir).await?;
let mut subdirs: Vec<PathBuf> = Vec::new();
let mut files: Vec<PathBuf> = Vec::new();
while let Some(entry) = entries.next_entry().await? {
let p = entry.path();
if p.is_dir() {
subdirs.push(p);
} else {
files.push(p);
}
}
// Ensure each local sub-directory exists on the remote side
for subdir in subdirs {
let dirname = subdir.file_name().unwrap().to_string_lossy().to_string();
let sub_remote_id = find_or_create_folder(&conn, &remote_id, &folder_detail.sub_folders, &dirname).await?;
let sub_folder = conn.retrieve_folder(&sub_remote_id).await?;
println!("{}", sub_folder.path.trim_end_matches('/'));
stack.push((subdir, sub_remote_id));
}
// Upload files in this directory (up to 10 concurrent)
let mut futs: FuturesUnordered<tokio::task::JoinHandle<()>> =
FuturesUnordered::new();
for file_path in files {
let filename = file_path.file_name().unwrap().to_string_lossy().to_string();
let file_path_str = file_path.to_string_lossy().to_string();
if skip_if_exists {
if let Some(rf) = find_file_by_name(&remote_files, &filename) {
if let Ok(meta) = std::fs::metadata(&file_path) {
if rf.size == meta.len() {
let remote_path_prefix = folder_detail.path.clone();
println!("{}{}", remote_path_prefix, filename);
continue;
}
}
}
}
let conn = conn.clone();
let folder_id = remote_id.clone();
let remote_path_prefix = folder_detail.path.clone();
let fname = filename.clone();
futs.push(tokio::spawn(async move {
match conn.upload_file(&folder_id, &file_path_str).await {
Ok(_) => println!("{}{}", remote_path_prefix, fname),
Err(e) => eprintln!("Error: {}", e),
}
}));
if futs.len() >= crate::settings::SETTINGS.concurrent {
let _ = futs.next().await;
}
}
while futs.next().await.is_some() {}
}
} else {
return Err(format!("File or directory `{}` not found.", local_path).into());
}
Ok(())
}
/// Find an existing sub-folder by name or create it, returning its ID.
async fn find_or_create_folder(
conn: &crate::connection::MDRSConnection,
parent_id: &str,
existing: &[FolderSimple],
name: &str,
) -> Result<String, Box<dyn std::error::Error>> {
if let Some(sf) = existing.iter().find(|f| f.name == name) {
return Ok(sf.id.clone());
}
let resp = conn.create_folder(parent_id, name).await?;
if !resp.status().is_success() {
return Err(format!("Failed to create remote folder: {}", name).into());
}
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())
.map(|s| s.to_string())
}
+32
View File
@@ -0,0 +1,32 @@
use serde::Deserialize;
use std::fs;
#[derive(Deserialize)]
struct CacheUser {
username: String,
}
#[derive(Deserialize)]
struct WhoamiCache {
user: Option<CacheUser>,
}
pub async fn whoami(remote: &str) -> Result<(), Box<dyn std::error::Error>> {
let cache_path = crate::settings::SETTINGS
.config_dirname
.join("cache")
.join(format!("{}.json", remote));
if !cache_path.exists() {
println!("(Anonymous)");
return Ok(());
}
let data = fs::read_to_string(&cache_path)?;
match serde_json::from_str::<WhoamiCache>(&data) {
Ok(cache) => match cache.user {
Some(user) => println!("{}", user.username),
None => println!("(Anonymous)"),
},
Err(_) => println!("(Anonymous)"),
}
Ok(())
}
+137
View File
@@ -0,0 +1,137 @@
use reqwest::Client;
use reqwest::header::{ACCEPT, AUTHORIZATION, HeaderMap, HeaderValue, USER_AGENT};
fn build_user_agent() -> String {
let info = os_info::get();
let mut parts = vec![info.os_type().to_string()];
if *info.version() != os_info::Version::Unknown {
parts.push(info.version().to_string());
}
if let Some(codename) = info.codename() {
parts.push(codename.to_string());
}
if let Some(edition) = info.edition() {
parts.push(edition.to_string());
}
let arch = info
.architecture()
.unwrap_or(std::env::consts::ARCH)
.to_string();
parts.push(arch);
format!(
"MdrsClient/{} (Rust {} - {})",
env!("CARGO_PKG_VERSION"),
env!("RUSTC_VERSION"),
parts.join(" ")
)
}
pub struct MDRSConnection {
pub url: String,
pub client: Client,
pub token: Option<String>,
}
impl MDRSConnection {
pub fn new(url: &str) -> Self {
MDRSConnection {
url: url.to_string(),
client: Client::new(),
token: None,
}
}
pub fn build_url(&self, path: &str) -> String {
format!("{}/{}", self.url.trim_end_matches('/'), path)
}
pub fn prepare_headers(&self) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(
USER_AGENT,
HeaderValue::from_str(&build_user_agent()).unwrap(),
);
headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
if let Some(token) = &self.token {
headers.insert(
AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {}", token)).unwrap(),
);
}
headers
}
pub async fn get(&self, path: &str) -> reqwest::Result<reqwest::Response> {
self.client
.get(self.build_url(path))
.headers(self.prepare_headers())
.send()
.await
}
pub async fn post_multipart(
&self,
path: &str,
form: reqwest::multipart::Form,
) -> reqwest::Result<reqwest::Response> {
self.client
.post(self.build_url(path))
.headers(self.prepare_headers())
.multipart(form)
.send()
.await
}
pub async fn download_file(
&self,
url: &str,
dest: &str,
) -> Result<(), Box<dyn std::error::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(())
}
pub async fn create_folder(
&self,
parent_id: &str,
folder_name: &str,
) -> reqwest::Result<reqwest::Response> {
let body = serde_json::json!({"parent": parent_id, "name": folder_name});
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<dyn std::error::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 {
return Err("Password is incorrect.".into());
}
if !resp.status().is_success() {
return Err(format!("Folder auth failed: {}", resp.status()).into());
}
Ok(())
}
}
+360
View File
@@ -0,0 +1,360 @@
pub mod api;
mod commands;
mod connection;
mod models;
mod settings;
mod token;
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(name = "mdrs")]
#[command(about = "MDRS Rust CLI client", long_about = None)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
use commands::config_subcommand::*;
#[derive(Subcommand)]
enum Commands {
/// Config management (create, update, list, delete)
#[command(subcommand)]
Config(ConfigSubcommand),
Login {
#[arg(short, long)]
username: Option<String>,
#[arg(short, long)]
password: Option<String>,
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<String>,
#[arg(long)]
exclude: Vec<String>,
remote_path: String,
local_path: String,
},
Ls {
remote_path: String,
#[arg(short = 'p', long)]
password: Option<String>,
#[arg(short = 'J', long = "json")]
json: bool,
#[arg(short = 'r', long)]
recursive: bool,
#[arg(short = 'q', long)]
quick: 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<String>,
remote_path: String,
},
Metadata {
#[arg(short = 'p', long)]
password: Option<String>,
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<String>,
remote_path: String,
},
}
/// 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<dyn std::error::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::<serde_json::Error>().is_some() {
return true;
}
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(args) => {
if let Err(e) = crate::commands::config::config_list(args.long) {
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))
{
handle_error(e);
}
}
Commands::Upload {
local_path,
remote_path,
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,
))
{
handle_error(e);
}
}
Commands::Download {
remote_path,
local_path,
recursive,
skip_if_exists,
password,
exclude,
} => {
let excludes: Vec<String> = exclude
.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,
))
{
handle_error(e);
}
}
Commands::Ls {
remote_path,
password,
json,
recursive,
quick,
} => {
if let Err(e) = tokio::runtime::Runtime::new()
.unwrap()
.block_on(commands::ls::ls(
remote_path,
password.as_deref(),
*json,
*recursive,
*quick,
))
{
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)) {
handle_error(e);
}
}
Commands::Labs { remote } => {
let remote = remote.trim_end_matches(':');
match crate::commands::config::get_remote_url(remote) {
Ok(Some(url)) => {
let conn = std::sync::Arc::new(crate::connection::MDRSConnection::new(&url));
let rt = tokio::runtime::Runtime::new().unwrap();
if let Err(e) = rt.block_on(crate::commands::labs::labs(conn.clone(), remote)) {
handle_error(e);
}
}
Ok(None) => {
eprintln!("Error: Remote host `{}` is not configured", remote);
std::process::exit(2);
}
Err(e) => {
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,
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())) {
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)) {
handle_error(e);
}
}
Commands::Rm {
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) {
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)) {
handle_error(e);
}
}
Commands::Cp {
src_path,
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)) {
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())) {
handle_error(e);
}
}
}
}
+15
View File
@@ -0,0 +1,15 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct File {
pub id: String,
pub name: String,
pub r#type: String,
pub size: u64,
pub thumbnail: Option<String>,
pub description: String,
pub metadata: serde_json::Value,
pub download_url: String,
pub created_at: String,
pub updated_at: String,
}
+70
View File
@@ -0,0 +1,70 @@
use serde::{Deserialize, Serialize};
/// Lightweight folder entry returned by the list endpoint (v3/folders/?path=...&laboratory_id=...)
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct FolderSimple {
pub id: String,
pub pid: Option<String>,
pub name: String,
pub access_level: u32,
pub lock: bool,
pub size: u64,
pub laboratory_id: u32,
pub description: String,
pub created_at: String,
pub updated_at: String,
}
impl FolderSimple {
pub fn access_level_name(&self) -> &'static str {
access_level_label(self.access_level)
}
}
/// Detailed folder returned by the retrieve endpoint (v3/folders/{id}/)
#[derive(Debug, Serialize, Deserialize)]
pub struct FolderDetail {
pub id: String,
pub pid: Option<String>,
pub name: String,
pub access_level: u32,
pub lock: bool,
pub size: u64,
pub laboratory_id: u32,
pub description: String,
pub created_at: String,
pub updated_at: String,
pub sub_folders: Vec<FolderSimple>,
pub path: String,
}
impl FolderDetail {
pub fn access_level_name(&self) -> &'static str {
access_level_label(self.access_level)
}
}
pub fn access_level_label(level: u32) -> &'static str {
if (level & 0x0204) == 0x0204 {
return "5Kikan or PW Open";
}
if (level & 0x0104) == 0x0104 {
return "CBS or PW Open";
}
if (level & 0x0200) == 0x0200 {
return "5Kikan Open";
}
if (level & 0x0100) == 0x0100 {
return "CBS Open";
}
if (level & 0x0004) == 0x0004 {
return "PW Open";
}
if (level & 0x0002) == 0x0002 {
return "Public";
}
if (level & 0x0001) == 0x0001 {
return "Private";
}
"Storage"
}
+15
View File
@@ -0,0 +1,15 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Laboratory {
pub id: u32,
pub name: String,
pub pi_name: String,
pub full_name: String,
}
/// Wrapper matching Python's `Laboratories` dataclass serialization: `{"items": [...]}`.
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct Laboratories {
pub items: Vec<Laboratory>,
}
+6
View File
@@ -0,0 +1,6 @@
// Model definitions (User, Laboratory, File, Folder, etc.)
pub mod file;
pub mod folder;
pub mod laboratory;
pub mod user;
+10
View File
@@ -0,0 +1,10 @@
use serde::{Deserialize, Serialize};
/// Minimal user model matching the Python cache format.
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
pub struct User {
pub id: u32,
pub username: String,
pub laboratory_ids: Vec<u32>,
pub is_reviewer: bool,
}
+40
View File
@@ -0,0 +1,40 @@
use std::sync::LazyLock;
pub struct Settings {
/// Base directory for config and cache files.
/// Controlled by `MDRS_CLIENT_CONFIG_DIRNAME` env var (default: `~/.mdrs-client`).
pub config_dirname: std::path::PathBuf,
/// Maximum number of concurrent upload/download workers.
/// Controlled by `MDRS_CLIENT_CONCURRENT` env var (default: 10).
pub concurrent: usize,
}
impl Settings {
fn load() -> Self {
let config_dirname = std::env::var("MDRS_CLIENT_CONFIG_DIRNAME")
.ok()
.map(|s| {
let expanded = if s.starts_with("~/") {
dirs::home_dir()
.unwrap()
.join(&s[2..])
} else {
std::path::PathBuf::from(&s)
};
expanded
})
.unwrap_or_else(|| dirs::home_dir().unwrap().join(".mdrs-client"));
let concurrent = std::env::var("MDRS_CLIENT_CONCURRENT")
.ok()
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(10);
Settings {
config_dirname,
concurrent,
}
}
}
pub static SETTINGS: LazyLock<Settings> = LazyLock::new(Settings::load);
+43
View File
@@ -0,0 +1,43 @@
// JWT utilities for token expiry checking (no signature verification required)
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use std::time::{SystemTime, UNIX_EPOCH};
fn now_secs() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64
}
/// Decode the `exp` field from a JWT payload without signature verification.
pub fn jwt_exp(token: &str) -> Result<i64, Box<dyn std::error::Error>> {
let parts: Vec<&str> = token.split('.').collect();
if parts.len() < 2 {
return Err("Invalid JWT: expected at least 2 dot-separated parts".into());
}
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(exp)
}
/// Returns true when the access token expires within 10 seconds
/// and the refresh token has not yet expired (with 10-second buffer).
/// This mirrors Python's `Token.is_refresh_required`.
pub fn is_refresh_required(access: &str, refresh: &str) -> bool {
let t = now_secs();
let access_exp = jwt_exp(access).unwrap_or(0);
let refresh_exp = jwt_exp(refresh).unwrap_or(0);
(t + 10) > access_exp && (t - 10) < refresh_exp
}
/// Returns true when the refresh token itself has expired (with 10-second buffer).
/// This mirrors Python's `Token.is_expired`.
pub fn is_expired(refresh: &str) -> bool {
let t = now_secs();
let refresh_exp = jwt_exp(refresh).unwrap_or(0);
(t - 10) > refresh_exp
}