first commit
This commit is contained in:
+38
@@ -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
File diff suppressed because it is too large
Load Diff
+30
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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('/')
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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(¶ms).send().await;
|
||||
let resp = match resp_res {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
let src = e.source();
|
||||
return Err(format!(
|
||||
"Login failed sending request to {}: {} (source: {:?})",
|
||||
url, e, src
|
||||
)
|
||||
.into());
|
||||
}
|
||||
};
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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,
|
||||
}))
|
||||
})
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Model definitions (User, Laboratory, File, Folder, etc.)
|
||||
|
||||
pub mod file;
|
||||
pub mod folder;
|
||||
pub mod laboratory;
|
||||
pub mod user;
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user