Files
mdrs-client-rust/src/commands/download.rs
T
orrisroot a16f73543d fix: stream transfers and close gaps against the Python client
A review against the Python client, which shares this client's config
and login cache, found the transfers holding whole files in memory and
several commands answering differently from their Python counterparts.

- Stream uploads and downloads instead of buffering the whole file, so
  memory no longer scales with file size times concurrency. Uploads
  still declare a Content-Length rather than going out chunked.
- Write a download beside its destination and move it into place once
  complete, and refuse a destination that cannot be written, so a
  failed transfer leaves what was already there untouched.
- Report the server's own error detail instead of the bare status.
- List locked sub-folders in `ls --json --recursive` using the given
  password and skip only those that cannot be unlocked, instead of
  failing the whole listing.
- Name each folder's laboratory in JSON output and sort its entries,
  matching the Python client's schema and order.
- Ask the server who is logged in for `whoami`, rather than trusting
  the cached name.
- Collapse repeated separators and refuse `..` in remote paths.
- Fall back to the API when a laboratory is missing from the cache, so
  a newly added one no longer needs a fresh login.
- Verify the login cache digest on read, as the Python client does.
- Accept `-e` as the short form of `--exclude`.
- Satisfy clippy and rustfmt across the crate.
2026-09-04 16:31:14 +09:00

554 lines
19 KiB
Rust

use crate::cache::create_readonly_conn;
use crate::commands::shared::{
find_file_by_name, find_folder_by_doi, find_folder_limited, find_laboratory,
find_subfolder_by_name, is_doi, parse_doi_remote_path, parse_remote_path,
};
use crate::connection::{ApiRequestLimiter, MDRSConnection};
use anyhow::{anyhow, bail};
use std::path::PathBuf;
use std::sync::Arc;
use tokio::task::JoinSet;
pub async fn download(
remote_path: &str,
local_path: &str,
recursive: bool,
skip_if_exists: bool,
password: Option<&str>,
excludes: Vec<String>,
) -> Result<(), anyhow::Error> {
let limiter = ApiRequestLimiter::new(crate::settings::SETTINGS.concurrent);
// Validate that local_path is an existing directory (matching Python's behaviour).
let local_real = std::fs::canonicalize(local_path)
.map_err(|_| anyhow!("Local directory `{}` not found.", local_path))?;
if !local_real.is_dir() {
bail!("Local directory `{}` not found.", local_path);
}
// Detect DOI path: "remote:10.xxxx/prefix.ID[/optional/sub/path]"
if is_doi(remote_path.split_once(':').map(|x| x.1).unwrap_or("")) {
let (remote, doi, subpath) = parse_doi_remote_path(remote_path)?;
let (raw_conn, _cache) = create_readonly_conn(&remote).await?;
let (doi_folder, lab) = find_folder_by_doi(&raw_conn, &doi, password).await?;
let abs_path = format!("{}{}", doi_folder.path.trim_end_matches('/'), subpath);
let abs_path_clean = abs_path.trim_end_matches('/');
// Check if the target is a folder.
// If it is, we download the directory. If it is not, we download the file or subfolder.
let (folder, is_folder) = match find_folder_limited(
&raw_conn,
&limiter,
lab.id,
abs_path_clean,
password,
)
.await
{
Ok(f) => (f, true),
Err(_) => {
let (parent_path, _) = match abs_path_clean.rfind('/') {
Some(0) => ("/".to_string(), abs_path_clean[1..].to_string()),
Some(pos) => (
abs_path_clean[..pos].to_string(),
abs_path_clean[pos + 1..].to_string(),
),
None => ("/".to_string(), abs_path_clean.to_string()),
};
let parent =
find_folder_limited(&raw_conn, &limiter, lab.id, &parent_path, password)
.await?;
(parent, false)
}
};
let conn = Arc::new(raw_conn);
let excludes = Arc::new(excludes);
if is_folder {
let path = folder.path.clone();
let lab_name = Arc::new(lab.name);
let top_local = local_real.join(&folder.name);
let password_owned = password.map(str::to_string);
let mut folder_tasks: JoinSet<Result<DownloadFolderTaskResult, anyhow::Error>> =
JoinSet::new();
let mut download_tasks: JoinSet<Result<(), anyhow::Error>> = JoinSet::new();
let mut errors = Vec::new();
if !recursive {
// Single-folder, non-recursive: download the files inside the DOI folder.
let files = conn.list_all_files_limited(&folder.id, &limiter).await?;
for file in &files {
if is_excluded(&excludes, lab_name.as_str(), &path, Some(&file.name)) {
continue;
}
let dest = local_real.join(&file.name);
if skip_if_exists
&& dest.exists()
&& let Ok(meta) = std::fs::metadata(&dest)
&& meta.len() == file.size
{
println!("{}", dest.display());
continue;
}
let url = make_absolute_url(&conn, &file.download_url);
conn.download_file_limited(&url, &dest.to_string_lossy(), &limiter)
.await?;
println!("{}", dest.display());
}
return Ok(());
}
let session = DownloadSession {
conn: conn.clone(),
limiter,
lab_name,
excludes,
password: password_owned,
skip_if_exists,
};
spawn_download_folder_task(
&mut folder_tasks,
session.clone(),
folder.id.clone(),
top_local,
);
drive_download_tasks(&mut folder_tasks, &mut download_tasks, &mut errors, session)
.await;
if !errors.is_empty() {
bail!(errors.join("\n"));
}
return Ok(());
}
// Case: target is a file or subfolder inside the resolved folder.
let basename = match abs_path_clean.rfind('/') {
Some(pos) => abs_path_clean[pos + 1..].to_string(),
None => abs_path_clean.to_string(),
};
let files = conn.list_all_files_limited(&folder.id, &limiter).await?;
// Case 1: basename matches a file in the folder.
if let Some(file) = find_file_by_name(&files, &basename) {
if is_excluded(&excludes, &lab.name, &folder.path, Some(&file.name)) {
return Ok(());
}
let dest = local_real.join(&file.name);
if skip_if_exists
&& dest.exists()
&& let Ok(meta) = std::fs::metadata(&dest)
&& meta.len() == file.size
{
println!("{}", dest.display());
return Ok(());
}
let url = make_absolute_url(&conn, &file.download_url);
conn.download_file_limited(&url, &dest.to_string_lossy(), &limiter)
.await?;
println!("{}", dest.display());
return Ok(());
}
// Case 2: basename matches a sub-folder.
let subfolder = find_subfolder_by_name(&folder.sub_folders, &basename);
if let Some(sub) = subfolder {
if !recursive {
bail!("Cannot download `{}`: Is a folder.", abs_path_clean);
}
let top_local = local_real.join(&sub.name);
let mut folder_tasks: JoinSet<Result<DownloadFolderTaskResult, anyhow::Error>> =
JoinSet::new();
let mut download_tasks: JoinSet<Result<(), anyhow::Error>> = JoinSet::new();
let mut errors = Vec::new();
let lab_name = Arc::new(lab.name.clone());
let password_owned = password.map(str::to_string);
let session = DownloadSession {
conn: conn.clone(),
limiter,
lab_name,
excludes,
password: password_owned,
skip_if_exists,
};
spawn_download_folder_task(
&mut folder_tasks,
session.clone(),
sub.id.clone(),
top_local,
);
drive_download_tasks(&mut folder_tasks, &mut download_tasks, &mut errors, session)
.await;
if !errors.is_empty() {
bail!(errors.join("\n"));
}
return Ok(());
}
bail!("File or folder `{}` not found.", abs_path_clean);
}
// Normal path: "remote:/labname/path/..."
let (remote, labname, r_path) = parse_remote_path(remote_path)?;
let (raw_conn, cache) = create_readonly_conn(&remote).await?;
let conn = Arc::new(raw_conn);
let lab = find_laboratory(&conn, cache.as_ref(), &labname).await?;
// 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_limited(&conn, &limiter, lab.id, &parent_path, password).await?;
let files = conn
.list_all_files_limited(&parent_folder.id, &limiter)
.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 = local_real.join(&file.name);
if skip_if_exists
&& dest.exists()
&& let Ok(meta) = std::fs::metadata(&dest)
&& meta.len() == file.size
{
println!("{}", dest.display());
return Ok(());
}
let url = make_absolute_url(&conn, &file.download_url);
conn.download_file_limited(&url, &dest.to_string_lossy(), &limiter)
.await?;
println!("{}", dest.display());
return Ok(());
}
// Case 2: basename matches a sub-folder.
let subfolder = find_subfolder_by_name(&parent_folder.sub_folders, &basename);
if let Some(sub) = subfolder {
if !recursive {
bail!("Cannot download `{}`: Is a folder.", r_path_clean);
}
// 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 = local_real.join(&sub.name);
let mut folder_tasks: JoinSet<Result<DownloadFolderTaskResult, anyhow::Error>> =
JoinSet::new();
let mut download_tasks: JoinSet<Result<(), anyhow::Error>> = JoinSet::new();
let mut errors = Vec::new();
let excludes = Arc::new(excludes);
let lab_name = Arc::new(lab.name.clone());
let password = password.map(str::to_string);
let session = DownloadSession {
conn: conn.clone(),
limiter,
lab_name,
excludes,
password,
skip_if_exists,
};
spawn_download_folder_task(
&mut folder_tasks,
session.clone(),
sub.id.clone(),
top_local,
);
drive_download_tasks(&mut folder_tasks, &mut download_tasks, &mut errors, session).await;
if !errors.is_empty() {
bail!(errors.join("\n"));
}
return Ok(());
}
Err(anyhow!("File or folder `{}` not found.", r_path_clean))
}
/// 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('/')
)
}
}
struct DownloadFolderTaskResult {
child_folders: Vec<(String, PathBuf)>,
download_jobs: Vec<DownloadJob>,
}
struct DownloadJob {
url: String,
dest_path: PathBuf,
}
/// What every folder of one recursive download shares: the connection and its request
/// budget, the laboratory the paths are named against, what to leave out, the password
/// for locked folders, and whether files already on disk may be left alone.
#[derive(Clone)]
struct DownloadSession {
conn: Arc<MDRSConnection>,
limiter: ApiRequestLimiter,
lab_name: Arc<String>,
excludes: Arc<Vec<String>>,
password: Option<String>,
skip_if_exists: bool,
}
fn spawn_download_folder_task(
folder_tasks: &mut JoinSet<Result<DownloadFolderTaskResult, anyhow::Error>>,
session: DownloadSession,
folder_id: String,
local_dir: PathBuf,
) {
folder_tasks.spawn(async move { process_download_folder(session, folder_id, local_dir).await });
}
fn spawn_download_task(
download_tasks: &mut JoinSet<Result<(), anyhow::Error>>,
conn: Arc<MDRSConnection>,
limiter: ApiRequestLimiter,
job: DownloadJob,
) {
download_tasks.spawn(async move {
let dest_str = job.dest_path.to_string_lossy().to_string();
match conn
.download_file_limited(&job.url, &dest_str, &limiter)
.await
{
Ok(()) => {
println!("{}", job.dest_path.display());
Ok(())
}
Err(err) => {
// Nothing to clear up: a failed transfer writes only to its own scratch
// file, and removes that itself.
Err(anyhow!(
"Failed to download {}: {}",
job.dest_path.display(),
err
))
}
}
});
}
async fn process_download_folder(
session: DownloadSession,
folder_id: String,
local_dir: PathBuf,
) -> Result<DownloadFolderTaskResult, anyhow::Error> {
let DownloadSession {
conn,
limiter,
lab_name,
excludes,
password,
skip_if_exists,
} = session;
let folder = conn.retrieve_folder_limited(&folder_id, &limiter).await?;
if is_excluded(excludes.as_slice(), lab_name.as_str(), &folder.path, None) {
return Ok(DownloadFolderTaskResult {
child_folders: Vec::new(),
download_jobs: Vec::new(),
});
}
tokio::fs::create_dir_all(&local_dir).await?;
println!("{}", local_dir.display());
let dir_files = conn.list_all_files_limited(&folder_id, &limiter).await?;
let mut download_jobs = Vec::new();
for file in &dir_files {
if is_excluded(
excludes.as_slice(),
lab_name.as_str(),
&folder.path,
Some(&file.name),
) {
continue;
}
let dest_path = local_dir.join(&file.name);
if skip_if_exists
&& dest_path.exists()
&& let Ok(meta) = std::fs::metadata(&dest_path)
&& meta.len() == file.size
{
println!("{}", dest_path.display());
continue;
}
download_jobs.push(DownloadJob {
url: make_absolute_url(&conn, &file.download_url),
dest_path,
});
}
let mut child_folder_tasks: JoinSet<Result<Option<(String, PathBuf)>, anyhow::Error>> =
JoinSet::new();
for sub_folder in folder.sub_folders {
let conn = conn.clone();
let limiter = limiter.clone();
let password = password.clone();
let sub_local = local_dir.join(&sub_folder.name);
child_folder_tasks.spawn(async move {
if sub_folder.lock {
match password.as_deref() {
Some(pw) => {
if conn
.folder_auth_limited(&sub_folder.id, pw, &limiter)
.await
.is_err()
{
return Ok(None);
}
}
None => return Ok(None),
}
}
Ok(Some((sub_folder.id, sub_local)))
});
}
let mut child_folders = Vec::new();
while let Some(result) = child_folder_tasks.join_next().await {
if let Some(child) = flatten_join_result(result)? {
child_folders.push(child);
}
}
Ok(DownloadFolderTaskResult {
child_folders,
download_jobs,
})
}
async fn drive_download_tasks(
folder_tasks: &mut JoinSet<Result<DownloadFolderTaskResult, anyhow::Error>>,
download_tasks: &mut JoinSet<Result<(), anyhow::Error>>,
errors: &mut Vec<String>,
session: DownloadSession,
) {
loop {
match (folder_tasks.is_empty(), download_tasks.is_empty()) {
(true, true) => break,
(false, true) => {
if let Some(result) = folder_tasks.join_next().await {
handle_download_folder_result(
result,
folder_tasks,
download_tasks,
errors,
&session,
);
}
}
(true, false) => {
if let Some(result) = download_tasks.join_next().await
&& let Err(err) = flatten_join_result(result)
{
errors.push(err.to_string());
}
}
(false, false) => {
tokio::select! {
result = folder_tasks.join_next() => {
if let Some(result) = result {
handle_download_folder_result(
result,
folder_tasks,
download_tasks,
errors,
&session,
);
}
}
result = download_tasks.join_next() => {
if let Some(result) = result
&& let Err(err) = flatten_join_result(result) {
errors.push(err.to_string());
}
}
}
}
}
}
}
fn handle_download_folder_result(
result: Result<Result<DownloadFolderTaskResult, anyhow::Error>, tokio::task::JoinError>,
folder_tasks: &mut JoinSet<Result<DownloadFolderTaskResult, anyhow::Error>>,
download_tasks: &mut JoinSet<Result<(), anyhow::Error>>,
errors: &mut Vec<String>,
session: &DownloadSession,
) {
match flatten_join_result(result) {
Ok(task_result) => {
for (folder_id, local_dir) in task_result.child_folders {
spawn_download_folder_task(folder_tasks, session.clone(), folder_id, local_dir);
}
for job in task_result.download_jobs {
spawn_download_task(
download_tasks,
session.conn.clone(),
session.limiter.clone(),
job,
);
}
}
Err(err) => errors.push(err.to_string()),
}
}
fn flatten_join_result<T>(
result: Result<Result<T, anyhow::Error>, tokio::task::JoinError>,
) -> Result<T, anyhow::Error> {
match result {
Ok(inner) => inner,
Err(err) => Err(anyhow!("Task join failed: {}", err)),
}
}