Files
mdrs-client-rust/src/commands/download.rs
T
orrisroot a67f9a72a6 fix(auth): refresh tokens before authenticated requests
Move token refresh checks into the shared Rust connection/API path so long-running authenticated operations stop reusing stale access tokens. This covers recursive download and upload traversal, recursive ls via the shared APIs, and direct authenticated commands such as cp, mv, rm, and chacl.

Also surface HTTP failures earlier in the affected API methods instead of failing later during response parsing.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-20 16:26:47 +09:00

184 lines
7.1 KiB
Rust

use crate::cache::{create_authenticated_conn, load_cache_with_token_refresh};
use crate::commands::shared::{
find_file_by_name, find_folder, find_lab_in_cache, find_subfolder_by_name, parse_remote_path,
};
use crate::connection::MDRSConnection;
use anyhow::{anyhow, bail};
use futures::stream::{FuturesUnordered, StreamExt};
use std::path::PathBuf;
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<(), anyhow::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(|_| anyhow!("Local directory `{}` not found.", local_path))?;
if !local_real.is_dir() {
bail!("Local directory `{}` not found.", local_path);
}
// 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 = local_real.join(&file.name);
if skip_if_exists {
if dest.exists() {
if let Ok(meta) = std::fs::metadata(&dest) {
if meta.len() == file.size {
println!("{}", dest.display());
return Ok(());
}
}
}
}
let url = make_absolute_url(&conn, &file.download_url);
conn.download_file(&url, &dest.to_string_lossy()).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);
// Iterative DFS: each entry is (remote_folder_id, local_dir)
let mut stack: Vec<(String, PathBuf)> = 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.display());
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 = local_dir.join(&f.name);
if skip_if_exists {
if dest_path.exists() {
if let Ok(meta) = std::fs::metadata(&dest_path) {
if meta.len() == f.size {
println!("{}", dest_path.display());
continue;
}
}
}
}
let url = make_absolute_url(&conn, &f.download_url);
let conn = conn.clone();
futs.push(tokio::spawn(async move {
let dest_str = dest_path.to_string_lossy().to_string();
match conn.download_file(&url, &dest_str).await {
Ok(_) => println!("{}", dest_path.display()),
Err(_) => {
eprintln!("Failed: {}", dest_path.display());
if 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 = local_dir.join(&sf.name);
stack.push((sf.id.clone(), sub_local));
}
}
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('/')
)
}
}