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.
121 lines
4.5 KiB
Rust
121 lines
4.5 KiB
Rust
use crate::cache::{create_authenticated_conn, load_cache_with_token_refresh};
|
|
use crate::commands::shared::{
|
|
find_file_by_name, find_folder, find_laboratory, find_subfolder_by_name, nfc, parse_remote_path,
|
|
};
|
|
use crate::error::response_error;
|
|
use anyhow::bail;
|
|
|
|
pub async fn cp(src_path: &str, dest_path: &str, recursive: bool) -> Result<(), anyhow::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 {
|
|
bail!("Remote host mismatched.");
|
|
}
|
|
if s_lab != d_lab {
|
|
bail!("Laboratory mismatched.");
|
|
}
|
|
|
|
let cache = load_cache_with_token_refresh(&s_remote).await?;
|
|
let conn = create_authenticated_conn(&s_remote, &cache)?;
|
|
let lab = find_laboratory(&conn, Some(&cache), &s_lab).await?;
|
|
let lab_id = lab.id;
|
|
|
|
// Split source path into parent directory and target name
|
|
let (s_dirname, s_basename_raw) = split_path(&s_path);
|
|
let s_basename = nfc(&s_basename_raw);
|
|
|
|
// If dest ends with '/', treat it as a directory and preserve src basename
|
|
let (d_dirname, d_basename_raw) = if dest_ends_with_slash {
|
|
(d_path.clone(), s_basename.clone())
|
|
} else {
|
|
split_path(&d_path)
|
|
};
|
|
let d_basename = nfc(&d_basename_raw);
|
|
|
|
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() {
|
|
bail!("File `{}` already exists.", d_basename);
|
|
}
|
|
if find_subfolder_by_name(&d_parent_folder.sub_folders, &d_basename).is_some() {
|
|
bail!(
|
|
"Cannot overwrite non-folder `{}` with folder `{}`.",
|
|
d_basename,
|
|
d_path
|
|
);
|
|
}
|
|
// No-op if source and destination are identical
|
|
if s_parent_folder.id == d_parent_folder.id && d_basename == s_basename {
|
|
return Ok(());
|
|
}
|
|
let body = serde_json::json!({"folder": d_parent_folder.id, "name": d_basename});
|
|
let resp = conn
|
|
.post_json(&format!("v3/files/{}/copy/", src_file_id), &body)
|
|
.await?;
|
|
if !resp.status().is_success() {
|
|
return Err(response_error("Copy failed", resp).await);
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
// Try source as a folder
|
|
let src_folder = match find_subfolder_by_name(&s_parent_folder.sub_folders, &s_basename) {
|
|
Some(f) => f,
|
|
None => bail!("File or folder `{}` not found.", s_basename),
|
|
};
|
|
if !recursive {
|
|
bail!("Cannot copy `{}`: Is a folder.", s_path);
|
|
}
|
|
let src_folder_id = src_folder.id.clone();
|
|
if find_file_by_name(&d_parent_files, &d_basename).is_some() {
|
|
bail!(
|
|
"Cannot overwrite non-folder `{}` with folder `{}`.",
|
|
d_basename,
|
|
s_path
|
|
);
|
|
}
|
|
if let Some(d_folder) = find_subfolder_by_name(&d_parent_folder.sub_folders, &d_basename) {
|
|
if d_folder.id == src_folder_id {
|
|
bail!("`{}` and `{}` are the same folder.", s_path, s_path);
|
|
}
|
|
bail!(
|
|
"Cannot move `{}` to `{}`: Folder not empty.",
|
|
s_path,
|
|
d_path
|
|
);
|
|
}
|
|
// No-op if source and destination are identical
|
|
if s_parent_folder.id == d_parent_folder.id && s_basename == d_basename {
|
|
return Ok(());
|
|
}
|
|
let body = serde_json::json!({"parent": d_parent_folder.id, "name": d_basename});
|
|
let resp = conn
|
|
.post_json(&format!("v3/folders/{}/copy/", src_folder_id), &body)
|
|
.await?;
|
|
if !resp.status().is_success() {
|
|
return Err(response_error("Copy failed", resp).await);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// 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())
|
|
}
|
|
}
|