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.
This commit is contained in:
2026-09-04 16:31:14 +09:00
parent c24a285cf5
commit a16f73543d
26 changed files with 899 additions and 324 deletions
Generated
+47 -18
View File
@@ -878,9 +878,9 @@ dependencies = [
[[package]]
name = "js-sys"
version = "0.3.100"
version = "0.3.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162"
checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
dependencies = [
"cfg-if",
"futures-util",
@@ -907,9 +907,9 @@ checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libredox"
version = "0.1.17"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3"
checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652"
dependencies = [
"libc",
]
@@ -980,6 +980,7 @@ dependencies = [
"tar",
"tempfile",
"tokio",
"tokio-util",
"unicode-normalization",
"zip",
]
@@ -1493,12 +1494,14 @@ dependencies = [
"sync_wrapper",
"tokio",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
"webpki-roots",
]
@@ -1924,6 +1927,19 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"pin-project-lite",
"tokio",
]
[[package]]
name = "tower"
version = "0.5.3"
@@ -2080,9 +2096,9 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.3+wasi-0.2.9"
version = "1.0.4+wasi-0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
dependencies = [
"wit-bindgen 0.57.1",
]
@@ -2098,9 +2114,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
version = "0.2.123"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563"
checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
dependencies = [
"cfg-if",
"once_cell",
@@ -2111,9 +2127,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.73"
version = "0.4.76"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54568702fabf5d4849ce2b90fadfa64168a097eaf4b351ce9df8b687a0086aaf"
checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -2121,9 +2137,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.123"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc"
checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -2131,9 +2147,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.123"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b"
checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
dependencies = [
"bumpalo",
"proc-macro2",
@@ -2144,9 +2160,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.123"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92"
checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
dependencies = [
"unicode-ident",
]
@@ -2173,6 +2189,19 @@ dependencies = [
"wasmparser",
]
[[package]]
name = "wasm-streams"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
dependencies = [
"futures-util",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "wasmparser"
version = "0.244.0"
@@ -2187,9 +2216,9 @@ dependencies = [
[[package]]
name = "web-sys"
version = "0.3.100"
version = "0.3.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e0871acf327f283dc6da28a1696cdc64fb355ba9f935d052021fa77f35cce69"
checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
dependencies = [
"js-sys",
"wasm-bindgen",
+2 -1
View File
@@ -12,10 +12,11 @@ path = "src/main.rs"
[dependencies]
clap = { version = "4.5", features = ["derive"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls", "stream"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0.150"
tokio = { version = "1.52.3", features = ["full"] }
tokio-util = { version = "0.7", features = ["io"] }
futures = "0.3"
dirs = "6.0.0"
anyhow = "1.0.102"
+1 -1
View File
@@ -143,7 +143,7 @@ Download a file or folder from a remote path. You can also specify a DOI path.
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 -e /NIU/Repository/TEST/dataset/skip neurodata:/NIU/Repository/TEST/dataset/ ./
mdrs download -r --skip-if-exists neurodata:/NIU/Repository/TEST/dataset/ ./
# DOI access examples:
+2 -2
View File
@@ -1,6 +1,6 @@
use crate::connection::MDRSConnection;
use crate::error::response_error;
use crate::models::doi::DoiResponse;
use anyhow::bail;
impl MDRSConnection {
/// Retrieve the folder associated with a DOI suffix ID (GET v3/doi/{id}/).
@@ -10,7 +10,7 @@ impl MDRSConnection {
pub async fn retrieve_doi(&self, id: &str) -> Result<DoiResponse, anyhow::Error> {
let resp = self.get(&format!("v3/doi/{}/", id)).await?;
if !resp.status().is_success() {
bail!("DOI lookup failed: {}", resp.status());
return Err(response_error("DOI lookup failed", resp).await);
}
Ok(resp.json::<DoiResponse>().await?)
}
+339 -13
View File
@@ -1,8 +1,30 @@
use crate::connection::{ApiRequestLimiter, MDRSConnection};
use crate::error::response_error;
pub use crate::models::file::File;
use anyhow::bail;
use unicode_normalization::UnicodeNormalization;
/// Read and write size for a streamed transfer. Large enough that the syscalls are not
/// what limits the transfer, small enough that ten of them in flight cost little.
const TRANSFER_CHUNK_BYTES: usize = 64 * 1024;
/// Name a scratch file beside the destination for a download in progress.
///
/// Beside it, so the move into place is a rename within one directory and cannot fail
/// half way. Unique, so two transfers heading for the same name cannot clear up after
/// each other.
fn partial_path(dest: &std::path::Path) -> std::path::PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let mut name = dest.file_name().unwrap_or_default().to_os_string();
name.push(format!(
".{}-{}.mdrspart",
std::process::id(),
COUNTER.fetch_add(1, Ordering::Relaxed)
));
dest.with_file_name(name)
}
#[derive(serde::Deserialize)]
struct FileListResponse {
pub next: Option<String>,
@@ -21,7 +43,7 @@ impl MDRSConnection {
];
let resp = self.get_with_query("v3/files/", &params).await?;
if !resp.status().is_success() {
anyhow::bail!("List files failed: {}", resp.status());
return Err(response_error("List files failed", resp).await);
}
let list: FileListResponse = resp.json().await?;
let has_next = list.next.is_some();
@@ -50,7 +72,7 @@ impl MDRSConnection {
let _permit = limiter.acquire().await?;
let resp = self.get_with_query("v3/files/", &params).await?;
if !resp.status().is_success() {
anyhow::bail!("List files failed: {}", resp.status());
return Err(response_error("List files failed", resp).await);
}
let list: FileListResponse = resp.json().await?;
let has_next = list.next.is_some();
@@ -79,9 +101,17 @@ impl MDRSConnection {
.to_string_lossy()
.nfc()
.collect();
// The file is handed to the request as a stream rather than a buffer: a
// repository holds recordings far larger than the machine's memory, and ten of
// them may be in flight at once. The length travels with it so the request keeps
// a Content-Length and does not have to be sent chunked.
let build_form = || async {
let file_bytes = tokio::fs::read(file_path).await?;
let part = multipart::Part::bytes(file_bytes).file_name(file_name.clone());
let file = tokio::fs::File::open(file_path).await?;
let length = file.metadata().await?.len();
let stream = tokio_util::io::ReaderStream::with_capacity(file, TRANSFER_CHUNK_BYTES);
let part =
multipart::Part::stream_with_length(reqwest::Body::wrap_stream(stream), length)
.file_name(file_name.clone());
Ok::<_, anyhow::Error>(
multipart::Form::new()
.text("folder_id", folder_id.to_string())
@@ -95,7 +125,7 @@ impl MDRSConnection {
.await?;
if resp.status() != reqwest::StatusCode::UNAUTHORIZED {
if !resp.status().is_success() {
bail!("Upload failed: {}", resp.status());
return Err(response_error("Upload failed", resp).await);
}
return Ok(());
}
@@ -105,7 +135,7 @@ impl MDRSConnection {
// for the refusal would just cost a second full transfer to be told the same.
let retry_conn = self.connection_with_fresh_token_for_retry().await?;
if retry_conn.token == sent_with {
bail!("Upload failed: {}", resp.status());
return Err(response_error("Upload failed", resp).await);
}
let resp = retry_conn
.post_multipart("v3/files/", build_form().await?)
@@ -115,7 +145,7 @@ impl MDRSConnection {
bail!(crate::connection::SERVER_BUSY_MESSAGE);
}
if !resp.status().is_success() {
bail!("Upload failed: {}", resp.status());
return Err(response_error("Upload failed", resp).await);
}
Ok(())
}
@@ -127,14 +157,310 @@ impl MDRSConnection {
dest: &str,
limiter: &ApiRequestLimiter,
) -> Result<(), anyhow::Error> {
use futures::StreamExt;
use tokio::io::AsyncWriteExt;
let _permit = limiter.acquire().await?;
let dest_path = std::path::Path::new(dest);
// Checked before anything is fetched. The finished file is moved into place, and
// a rename would replace a destination the mode says is protected, which is not
// what this client or the Python one has ever done.
if dest_path.exists() {
tokio::fs::OpenOptions::new()
.write(true)
.open(dest_path)
.await
.map_err(|e| anyhow::anyhow!("Cannot write `{}`: {}", dest, e))?;
}
let resp = self.get_url(url).await?;
if !resp.status().is_success() {
bail!("Download failed: {}", resp.status());
return Err(response_error("Download failed", resp).await);
}
// Written as it arrives, into a scratch file that is moved into place only once
// the whole body has landed. Holding the body in memory first would cost the size
// of the file, times however many transfers are running; writing straight to the
// destination would destroy whatever is already there the moment the transfer
// starts, and leave a truncated file behind if it does not finish.
let part_path = partial_path(dest_path);
let result = async {
let mut file = tokio::io::BufWriter::with_capacity(
TRANSFER_CHUNK_BYTES,
tokio::fs::File::create(&part_path).await?,
);
let mut stream = resp.bytes_stream();
while let Some(chunk) = stream.next().await {
file.write_all(&chunk?).await?;
}
file.flush().await?;
Ok::<(), anyhow::Error>(())
}
.await;
match result {
Ok(()) => Ok(tokio::fs::rename(&part_path, dest_path).await?),
Err(err) => {
// Only the scratch file goes. Anything already at the destination was not
// written by this transfer and is not this transfer's to remove.
let _ = tokio::fs::remove_file(&part_path).await;
Err(err)
}
}
let bytes = resp.bytes().await?;
drop(_permit);
tokio::fs::write(dest, &bytes).await?;
Ok(())
}
}
#[cfg(test)]
mod transfer_tests {
use super::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
/// Read one HTTP request in full, using its Content-Length to know where it ends.
async fn read_request(stream: &mut tokio::net::TcpStream) -> (String, Vec<u8>) {
let mut buf = Vec::new();
let mut chunk = [0u8; 8192];
let header_end = loop {
let n = stream.read(&mut chunk).await.unwrap();
buf.extend_from_slice(&chunk[..n]);
if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
break pos + 4;
}
assert!(n > 0, "connection closed before the headers ended");
};
let head = String::from_utf8_lossy(&buf[..header_end]).to_string();
let length: usize = head
.lines()
.find_map(|l| {
l.to_ascii_lowercase()
.strip_prefix("content-length:")
.map(|v| v.trim().to_string())
})
.expect("request must carry a Content-Length")
.parse()
.unwrap();
while buf.len() < header_end + length {
let n = stream.read(&mut chunk).await.unwrap();
assert!(n > 0, "connection closed before the body ended");
buf.extend_from_slice(&chunk[..n]);
}
(head, buf[header_end..].to_vec())
}
/// The file goes out as a stream, and the request still declares its length, so the
/// server is not asked to accept a chunked upload.
#[tokio::test]
async fn an_upload_streams_the_file_with_a_declared_length() {
let payload: Vec<u8> = (0..300_000u32).map(|i| (i % 251) as u8).collect();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("recording.dat");
std::fs::write(&path, &payload).unwrap();
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let expected = payload.clone();
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let (head, body) = read_request(&mut stream).await;
stream
.write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 15\r\nconnection: close\r\n\r\n{\"id\":\"new\"}")
.await
.unwrap();
(head, body, expected)
});
let conn = MDRSConnection::new(&format!("http://{addr}"));
let limiter = ApiRequestLimiter::new(1);
conn.upload_file_limited("folder-1", path.to_str().unwrap(), &limiter)
.await
.unwrap();
let (head, body, expected) = server.await.unwrap();
assert!(
!head
.to_ascii_lowercase()
.contains("transfer-encoding: chunked"),
"the upload must not be chunked:\n{head}"
);
assert!(
body.windows(expected.len()).any(|w| w == expected),
"the file bytes must reach the server"
);
assert!(
body.windows(8).any(|w| w == b"folder-1"),
"the folder id must reach the server"
);
assert!(
String::from_utf8_lossy(&body).contains("recording.dat"),
"the file name must reach the server"
);
}
/// The body is written to disk as it arrives rather than being held whole.
#[tokio::test]
async fn a_download_writes_what_the_server_sent() {
let payload: Vec<u8> = (0..300_000u32).map(|i| (i % 241) as u8).collect();
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let served = payload.clone();
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut chunk = [0u8; 4096];
let _ = stream.read(&mut chunk).await.unwrap();
let head = format!(
"HTTP/1.1 200 OK\r\ncontent-length: {}\r\nconnection: close\r\n\r\n",
served.len()
);
stream.write_all(head.as_bytes()).await.unwrap();
stream.write_all(&served).await.unwrap();
});
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("out.dat");
let conn = MDRSConnection::new(&format!("http://{addr}"));
let limiter = ApiRequestLimiter::new(1);
conn.download_file_limited(
&format!("http://{addr}/v3/files/1/download/"),
dest.to_str().unwrap(),
&limiter,
)
.await
.unwrap();
server.await.unwrap();
assert_eq!(std::fs::read(&dest).unwrap(), payload);
}
/// A transfer that dies part way must not take the copy already on disk with it.
#[tokio::test]
async fn an_interrupted_download_leaves_the_existing_file_intact() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut chunk = [0u8; 4096];
let _ = stream.read(&mut chunk).await.unwrap();
// Promises 300000 bytes, sends 1000, then hangs up.
stream
.write_all(
b"HTTP/1.1 200 OK\r\ncontent-length: 300000\r\nconnection: close\r\n\r\n",
)
.await
.unwrap();
stream.write_all(&vec![b'x'; 1000]).await.unwrap();
stream.shutdown().await.unwrap();
});
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("existing.dat");
std::fs::write(&dest, b"the copy already here").unwrap();
let conn = MDRSConnection::new(&format!("http://{addr}"));
let limiter = ApiRequestLimiter::new(1);
let outcome = conn
.download_file_limited(
&format!("http://{addr}/v3/files/1/download/"),
dest.to_str().unwrap(),
&limiter,
)
.await;
server.await.unwrap();
assert!(outcome.is_err(), "a truncated body is a failed download");
assert_eq!(std::fs::read(&dest).unwrap(), b"the copy already here");
let leftovers: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().to_string())
.filter(|name| name != "existing.dat")
.collect();
assert!(
leftovers.is_empty(),
"no scratch file may be left: {leftovers:?}"
);
}
/// A destination this call could not even open is not a file it may delete.
#[cfg(unix)]
#[tokio::test]
async fn a_download_that_cannot_be_written_leaves_the_existing_file_intact() {
use std::os::unix::fs::PermissionsExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut chunk = [0u8; 4096];
let _ = stream.read(&mut chunk).await.unwrap();
stream
.write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 2\r\nconnection: close\r\n\r\nhi")
.await
.unwrap();
});
// A writable directory holding a file this process may not open for writing.
// That is what separates "could not write it" from "may delete it": unlink needs
// the directory, not the file.
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("precious.dat");
std::fs::write(&dest, b"do not touch").unwrap();
let mut perms = std::fs::metadata(&dest).unwrap().permissions();
perms.set_mode(0o444);
std::fs::set_permissions(&dest, perms).unwrap();
if std::fs::OpenOptions::new().write(true).open(&dest).is_ok() {
// Running with rights that ignore the mode, so there is nothing to observe.
return;
}
let conn = MDRSConnection::new(&format!("http://{addr}"));
let limiter = ApiRequestLimiter::new(1);
let outcome = conn
.download_file_limited(
&format!("http://{addr}/v3/files/1/download/"),
dest.to_str().unwrap(),
&limiter,
)
.await;
// Nothing was fetched, so the stub is still waiting to be connected to.
server.abort();
let message = outcome.unwrap_err().to_string();
assert!(
message.starts_with("Cannot write "),
"the refusal must name the problem, got: {message}"
);
assert_eq!(std::fs::read(&dest).unwrap(), b"do not touch");
}
/// A refused download leaves nothing behind that a later run could mistake for the file.
#[tokio::test]
async fn a_refused_download_leaves_no_file() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut chunk = [0u8; 4096];
let _ = stream.read(&mut chunk).await.unwrap();
let body = r#"{"type":"client_error","errors":[{"code":"permission_denied","detail":"Access is denied.","attr":null}]}"#;
let head = format!(
"HTTP/1.1 403 Forbidden\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
body.len(),
body
);
stream.write_all(head.as_bytes()).await.unwrap();
});
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("out.dat");
let conn = MDRSConnection::new(&format!("http://{addr}"));
let limiter = ApiRequestLimiter::new(1);
let err = conn
.download_file_limited(
&format!("http://{addr}/v3/files/1/download/"),
dest.to_str().unwrap(),
&limiter,
)
.await
.unwrap_err();
server.await.unwrap();
assert_eq!(err.to_string(), "Download failed: Access is denied.");
assert!(!dest.exists());
}
}
+12 -7
View File
@@ -1,4 +1,5 @@
use crate::connection::{ApiRequestLimiter, MDRSConnection};
use crate::error::response_error;
pub use crate::models::folder::{FolderDetail, FolderSimple};
use anyhow::{anyhow, bail};
@@ -15,7 +16,7 @@ impl MDRSConnection {
];
let resp = self.get_with_query("v3/folders/", &params).await?;
if !resp.status().is_success() {
bail!("List folders failed: {}", resp.status());
return Err(response_error("List folders failed", resp).await);
}
Ok(resp.json::<Vec<FolderSimple>>().await?)
}
@@ -34,7 +35,7 @@ impl MDRSConnection {
let _permit = limiter.acquire().await?;
let resp = self.get_with_query("v3/folders/", &params).await?;
if !resp.status().is_success() {
bail!("List folders failed: {}", resp.status());
return Err(response_error("List folders failed", resp).await);
}
Ok(resp.json::<Vec<FolderSimple>>().await?)
}
@@ -43,7 +44,7 @@ impl MDRSConnection {
pub async fn retrieve_folder(&self, id: &str) -> Result<FolderDetail, anyhow::Error> {
let resp = self.get(&format!("v3/folders/{}/", id)).await?;
if !resp.status().is_success() {
bail!("Retrieve folder failed: {}", resp.status());
return Err(response_error("Retrieve folder failed", resp).await);
}
Ok(resp.json::<FolderDetail>().await?)
}
@@ -57,7 +58,7 @@ impl MDRSConnection {
let _permit = limiter.acquire().await?;
let resp = self.get(&format!("v3/folders/{}/", id)).await?;
if !resp.status().is_success() {
bail!("Retrieve folder failed: {}", resp.status());
return Err(response_error("Retrieve folder failed", resp).await);
}
Ok(resp.json::<FolderDetail>().await?)
}
@@ -94,7 +95,11 @@ impl MDRSConnection {
let _permit = limiter.acquire().await?;
let resp = self.post_json("v3/folders/", &body).await?;
if !resp.status().is_success() {
bail!("Failed to create remote folder: {}", folder_name);
return Err(response_error(
&format!("Failed to create remote folder `{}`", folder_name),
resp,
)
.await);
}
let json: serde_json::Value = resp.json().await?;
json["id"]
@@ -116,7 +121,7 @@ impl MDRSConnection {
bail!("Password is incorrect.");
}
if !resp.status().is_success() {
bail!("Folder auth failed: {}", resp.status());
return Err(response_error("Folder auth failed", resp).await);
}
Ok(())
}
@@ -140,7 +145,7 @@ impl MDRSConnection {
bail!("Password is incorrect.");
}
if !resp.status().is_success() {
bail!("Folder auth failed: {}", resp.status());
return Err(response_error("Folder auth failed", resp).await);
}
Ok(())
}
+2 -1
View File
@@ -1,4 +1,5 @@
use crate::connection::MDRSConnection;
use crate::error::response_error;
use crate::models::laboratory::{Laboratories, Laboratory};
use serde::Deserialize;
@@ -12,7 +13,7 @@ impl MDRSConnection {
pub async fn list_laboratories(&self) -> Result<Laboratories, anyhow::Error> {
let resp = self.get("v3/laboratories/").await?;
if !resp.status().is_success() {
anyhow::bail!("List laboratories failed: {}", resp.status());
return Err(response_error("List laboratories failed", resp).await);
}
// The API may return a paginated object or a direct array
let text = resp.text().await?;
+3 -3
View File
@@ -1,6 +1,6 @@
use crate::connection::MDRSConnection;
use crate::error::response_error;
use crate::models::user::User as ModelUser;
use anyhow::bail;
use serde::Deserialize;
use std::time::Duration;
@@ -44,7 +44,7 @@ impl MDRSConnection {
pub async fn get_current_user(&self) -> Result<ModelUser, anyhow::Error> {
let resp = self.get("v3/users/current/").await?;
if !resp.status().is_success() {
bail!("Get current user failed: {}", resp.status());
return Err(response_error("Get current user failed", resp).await);
}
let obj = resp.json::<UsersApiCurrentResponse>().await?;
let laboratory_ids = obj.laboratories.into_iter().map(|l| l.id).collect();
@@ -74,7 +74,7 @@ impl MDRSConnection {
.send()
.await?;
if !resp.status().is_success() {
bail!("Token refresh failed: {}", resp.status());
return Err(response_error("Token refresh failed", resp).await);
}
let r: TokenRefreshResponse = resp.json().await?;
Ok(RefreshedToken {
+67 -6
View File
@@ -139,14 +139,32 @@ fn write_cache_file(cache_path: &Path, cache: &Cache) -> Result<(), anyhow::Erro
}
fn parse_cache(remote: &str, data: &str) -> Result<Cache, anyhow::Error> {
serde_json::from_str::<Cache>(data).map_err(|e| {
let cache = serde_json::from_str::<Cache>(data).map_err(|e| {
anyhow!(
"Cache for `{}` is invalid or outdated ({}). Run `mdrs login {}` to refresh it.",
remote,
e,
remote
)
})
})?;
// The digest is what says the file is still the one this client wrote. The Python
// client checks it on every read and the two share the file, so a cache one of them
// would refuse must not be honoured by the other.
let expected = compute_digest(
cache.user.as_ref(),
&cache.token.access,
&cache.token.refresh,
&cache.laboratories,
);
if cache.digest != expected {
bail!(
"Cache for `{}` has been altered or was written by an incompatible version. \
Run `mdrs login {}` to refresh it.",
remote,
remote
);
}
Ok(cache)
}
fn load_cache_from_dir(remote: &str, config_dir: &Path) -> Result<Cache, anyhow::Error> {
@@ -213,7 +231,11 @@ fn load_cache_if_present_from_dir(
};
let cache = match parse_cache(remote, &data) {
Ok(cache) => cache,
Err(_) => {
Err(e) => {
// Said out loud before the credentials go. The alternative is a command that
// quietly carries on anonymously and fails later for a reason that looks
// unrelated to the cache it just threw away.
eprintln!("{e}");
remove_cache_in_dir(remote, config_dir)?;
return Ok(None);
}
@@ -290,6 +312,9 @@ pub async fn load_cache_with_token_refresh(remote: &str) -> Result<Cache, anyhow
let lock_file = fs::OpenOptions::new()
.write(true)
.create(true)
// Nothing is ever written into it: the file exists only to be flocked, and
// truncating it would touch a file other processes are holding open.
.truncate(false)
.open(&lock_path)?;
lock_file.lock_exclusive()?;
@@ -331,6 +356,9 @@ async fn load_cache_with_token_refresh_optional_from_dir(
let lock_file = fs::OpenOptions::new()
.write(true)
.create(true)
// Nothing is ever written into it: the file exists only to be flocked, and
// truncating it would touch a file other processes are holding open.
.truncate(false)
.open(&lock_path)?;
lock_file.lock_exclusive()?;
@@ -443,7 +471,7 @@ mod tests {
use tempfile::tempdir;
fn sample_cache(username: &str) -> Cache {
Cache {
let mut cache = Cache {
user: Some(CacheUser {
id: 1,
username: username.to_string(),
@@ -462,8 +490,15 @@ mod tests {
full_name: "Laboratory".to_string(),
}],
},
digest: format!("digest-{username}"),
}
digest: String::new(),
};
cache.digest = compute_digest(
cache.user.as_ref(),
&cache.token.access,
&cache.token.refresh,
&cache.laboratories,
);
cache
}
fn remote_name(prefix: &str, config_dir: &Path) -> String {
@@ -575,6 +610,25 @@ mod tests {
assert!(loaded.is_none());
}
/// A cache whose contents no longer match its digest is not a session to act on.
#[test]
fn load_cache_refuses_a_tampered_cache() {
let dir = tempdir().unwrap();
let remote = remote_name("tampered", dir.path());
let mut cache = sample_cache("alice");
persist_cache_in_dir(&remote, dir.path(), &cache).unwrap();
// Someone edits the stored token but leaves the digest as it was.
cache.token.access = "someone-elses-access".to_string();
let cache_path = cache_file_path_in(dir.path(), &remote);
fs::write(&cache_path, serde_json::to_vec_pretty(&cache).unwrap()).unwrap();
invalidate_cached_entry(dir.path(), &remote);
let err = load_cache_from_dir(&remote, dir.path()).unwrap_err();
assert!(err.to_string().contains("has been altered"));
}
#[test]
fn load_cache_if_present_clears_invalid_cache() {
let dir = tempdir().unwrap();
@@ -597,6 +651,13 @@ mod tests {
let mut cache = sample_cache("alice");
cache.token.access = make_jwt_with_exp(0);
cache.token.refresh = make_jwt_with_exp(0);
// Re-stamped, so this exercises the expired session rather than the digest check.
cache.digest = compute_digest(
cache.user.as_ref(),
&cache.token.access,
&cache.token.refresh,
&cache.laboratories,
);
persist_cache_in_dir(&remote, dir.path(), &cache).unwrap();
let loaded = load_cache_with_token_refresh_optional_from_dir(&remote, dir.path())
+1 -1
View File
@@ -41,7 +41,7 @@ pub enum Commands {
skip_if_exists: bool,
#[arg(short = 'p', long)]
password: Option<String>,
#[arg(long)]
#[arg(short = 'e', long)]
exclude: Vec<String>,
remote_path: String,
local_path: String,
+4 -3
View File
@@ -1,5 +1,6 @@
use crate::cache::{create_authenticated_conn, load_cache_with_token_refresh};
use crate::commands::shared::{find_folder, find_lab_in_cache, parse_remote_path};
use crate::commands::shared::{find_folder, find_laboratory, parse_remote_path};
use crate::error::response_error;
use anyhow::bail;
pub async fn chacl(
@@ -23,7 +24,7 @@ pub async fn chacl(
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 lab = find_laboratory(&conn, Some(&cache), &labname).await?;
let folder = find_folder(&conn, lab.id, &folder_path, None).await?;
let mut data = serde_json::Map::new();
@@ -45,7 +46,7 @@ pub async fn chacl(
.await?;
if !resp.status().is_success() {
bail!("ACL change failed: {}", resp.status());
return Err(response_error("ACL change failed", resp).await);
}
Ok(())
}
+12 -12
View File
@@ -13,18 +13,18 @@ fn sanitize_config_file(path: &PathBuf) -> Result<(), anyhow::Error> {
}
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)?;
}
if let Some(first) = lines.first()
&& 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(())
}
+5 -5
View File
@@ -1,8 +1,8 @@
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, nfc,
parse_remote_path,
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> {
@@ -19,7 +19,7 @@ pub async fn cp(src_path: &str, dest_path: &str, recursive: bool) -> Result<(),
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 = find_laboratory(&conn, Some(&cache), &s_lab).await?;
let lab_id = lab.id;
// Split source path into parent directory and target name
@@ -62,7 +62,7 @@ pub async fn cp(src_path: &str, dest_path: &str, recursive: bool) -> Result<(),
.post_json(&format!("v3/files/{}/copy/", src_file_id), &body)
.await?;
if !resp.status().is_success() {
bail!("Copy failed: {}", resp.status());
return Err(response_error("Copy failed", resp).await);
}
return Ok(());
}
@@ -102,7 +102,7 @@ pub async fn cp(src_path: &str, dest_path: &str, recursive: bool) -> Result<(),
.post_json(&format!("v3/folders/{}/copy/", src_folder_id), &body)
.await?;
if !resp.status().is_success() {
bail!("Copy failed: {}", resp.status());
return Err(response_error("Copy failed", resp).await);
}
Ok(())
}
+107 -163
View File
@@ -27,7 +27,7 @@ pub async fn download(
}
// Detect DOI path: "remote:10.xxxx/prefix.ID[/optional/sub/path]"
if is_doi(remote_path.splitn(2, ':').nth(1).unwrap_or("")) {
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?;
@@ -84,15 +84,13 @@ pub async fn download(
continue;
}
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());
continue;
}
}
}
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)
@@ -102,30 +100,23 @@ pub async fn download(
return Ok(());
}
spawn_download_folder_task(
&mut folder_tasks,
conn.clone(),
limiter.clone(),
lab_name.clone(),
excludes.clone(),
folder.id.clone(),
top_local,
password_owned.clone(),
skip_if_exists,
);
drive_download_tasks(
&mut folder_tasks,
&mut download_tasks,
&mut errors,
conn.clone(),
let session = DownloadSession {
conn: conn.clone(),
limiter,
lab_name,
excludes,
password_owned,
password: password_owned,
skip_if_exists,
)
.await;
};
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"));
@@ -146,15 +137,13 @@ pub async fn download(
return Ok(());
}
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(());
}
}
}
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)
@@ -177,30 +166,23 @@ pub async fn download(
let lab_name = Arc::new(lab.name.clone());
let password_owned = password.map(str::to_string);
spawn_download_folder_task(
&mut folder_tasks,
conn.clone(),
limiter.clone(),
lab_name.clone(),
excludes.clone(),
sub.id.clone(),
top_local,
password_owned.clone(),
skip_if_exists,
);
drive_download_tasks(
&mut folder_tasks,
&mut download_tasks,
&mut errors,
conn.clone(),
let session = DownloadSession {
conn: conn.clone(),
limiter,
lab_name,
excludes,
password_owned,
password: password_owned,
skip_if_exists,
)
.await;
};
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"));
@@ -242,15 +224,13 @@ pub async fn download(
}
// 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(());
}
}
}
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)
@@ -277,30 +257,22 @@ pub async fn download(
let lab_name = Arc::new(lab.name.clone());
let password = password.map(str::to_string);
spawn_download_folder_task(
&mut folder_tasks,
conn.clone(),
limiter.clone(),
lab_name.clone(),
excludes.clone(),
sub.id.clone(),
top_local,
password.clone(),
skip_if_exists,
);
drive_download_tasks(
&mut folder_tasks,
&mut download_tasks,
&mut errors,
conn.clone(),
let session = DownloadSession {
conn: conn.clone(),
limiter,
lab_name,
excludes,
password,
skip_if_exists,
)
.await;
};
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"));
@@ -353,30 +325,26 @@ struct DownloadJob {
dest_path: PathBuf,
}
fn spawn_download_folder_task(
folder_tasks: &mut JoinSet<Result<DownloadFolderTaskResult, anyhow::Error>>,
/// 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>>,
folder_id: String,
local_dir: PathBuf,
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(
conn,
limiter,
lab_name,
excludes,
folder_id,
local_dir,
password,
skip_if_exists,
)
.await
});
folder_tasks.spawn(async move { process_download_folder(session, folder_id, local_dir).await });
}
fn spawn_download_task(
@@ -396,9 +364,8 @@ fn spawn_download_task(
Ok(())
}
Err(err) => {
if job.dest_path.is_file() {
let _ = std::fs::remove_file(&job.dest_path);
}
// 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(),
@@ -410,15 +377,18 @@ fn spawn_download_task(
}
async fn process_download_folder(
conn: Arc<MDRSConnection>,
limiter: ApiRequestLimiter,
lab_name: Arc<String>,
excludes: Arc<Vec<String>>,
session: DownloadSession,
folder_id: String,
local_dir: PathBuf,
password: Option<String>,
skip_if_exists: bool,
) -> 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) {
@@ -443,13 +413,13 @@ async fn process_download_folder(
continue;
}
let dest_path = local_dir.join(&file.name);
if skip_if_exists && dest_path.exists() {
if let Ok(meta) = std::fs::metadata(&dest_path) {
if meta.len() == file.size {
println!("{}", dest_path.display());
continue;
}
}
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),
@@ -500,12 +470,7 @@ async fn drive_download_tasks(
folder_tasks: &mut JoinSet<Result<DownloadFolderTaskResult, anyhow::Error>>,
download_tasks: &mut JoinSet<Result<(), anyhow::Error>>,
errors: &mut Vec<String>,
conn: Arc<MDRSConnection>,
limiter: ApiRequestLimiter,
lab_name: Arc<String>,
excludes: Arc<Vec<String>>,
password: Option<String>,
skip_if_exists: bool,
session: DownloadSession,
) {
loop {
match (folder_tasks.is_empty(), download_tasks.is_empty()) {
@@ -517,20 +482,15 @@ async fn drive_download_tasks(
folder_tasks,
download_tasks,
errors,
conn.clone(),
limiter.clone(),
lab_name.clone(),
excludes.clone(),
password.clone(),
skip_if_exists,
&session,
);
}
}
(true, false) => {
if let Some(result) = download_tasks.join_next().await {
if let Err(err) = flatten_join_result(result) {
errors.push(err.to_string());
}
if let Some(result) = download_tasks.join_next().await
&& let Err(err) = flatten_join_result(result)
{
errors.push(err.to_string());
}
}
(false, false) => {
@@ -542,21 +502,15 @@ async fn drive_download_tasks(
folder_tasks,
download_tasks,
errors,
conn.clone(),
limiter.clone(),
lab_name.clone(),
excludes.clone(),
password.clone(),
skip_if_exists,
&session,
);
}
}
result = download_tasks.join_next() => {
if let Some(result) = result {
if let Err(err) = flatten_join_result(result) {
if let Some(result) = result
&& let Err(err) = flatten_join_result(result) {
errors.push(err.to_string());
}
}
}
}
}
@@ -569,30 +523,20 @@ fn handle_download_folder_result(
folder_tasks: &mut JoinSet<Result<DownloadFolderTaskResult, anyhow::Error>>,
download_tasks: &mut JoinSet<Result<(), anyhow::Error>>,
errors: &mut Vec<String>,
conn: Arc<MDRSConnection>,
limiter: ApiRequestLimiter,
lab_name: Arc<String>,
excludes: Arc<Vec<String>>,
password: Option<String>,
skip_if_exists: bool,
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,
conn.clone(),
limiter.clone(),
lab_name.clone(),
excludes.clone(),
folder_id,
local_dir,
password.clone(),
skip_if_exists,
);
spawn_download_folder_task(folder_tasks, session.clone(), folder_id, local_dir);
}
for job in task_result.download_jobs {
spawn_download_task(download_tasks, conn.clone(), limiter.clone(), job);
spawn_download_task(
download_tasks,
session.conn.clone(),
session.limiter.clone(),
job,
);
}
}
Err(err) => errors.push(err.to_string()),
+5 -1
View File
@@ -1,10 +1,11 @@
use crate::cache::create_readonly_conn;
use crate::commands::shared::{find_file_by_name, resolve_remote_file};
use crate::error::response_error;
use anyhow::anyhow;
pub async fn file_metadata(remote_path: &str, password: Option<&str>) -> Result<(), anyhow::Error> {
let remote = remote_path
.splitn(2, ':')
.split(':')
.next()
.ok_or_else(|| anyhow!("Invalid remote path"))?;
let (conn, cache) = create_readonly_conn(remote).await?;
@@ -17,6 +18,9 @@ pub async fn file_metadata(remote_path: &str, password: Option<&str>) -> Result<
.ok_or_else(|| anyhow!("File `{}` not found.", basename))?;
let resp = conn.get(&format!("v3/files/{}/metadata/", file.id)).await?;
if !resp.status().is_success() {
return Err(response_error("Failed to get file metadata", resp).await);
}
let json: serde_json::Value = resp.json().await?;
println!("{}", serde_json::to_string(&json)?);
Ok(())
+76 -17
View File
@@ -1,12 +1,19 @@
use crate::cache::create_readonly_conn;
use crate::cache::{Cache, create_readonly_conn};
use crate::commands::shared::{fmt_datetime, resolve_remote_folder};
use crate::connection::MDRSConnection;
use crate::models::file::File;
use crate::models::folder::{FolderDetail, FolderSimple};
use serde_json::{Value, json};
use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
/// Laboratory names by id, for the JSON output's per-folder `laboratory` field.
type LaboratoryNames = HashMap<u32, String>;
/// Shown for a folder whose laboratory the client cannot name, matching the Python client.
const UNKNOWN_LABORATORY: &str = "(invalid)";
pub async fn ls(
remote_path: &str,
password: Option<&str>,
@@ -15,7 +22,7 @@ pub async fn ls(
is_quiet: bool,
) -> Result<(), anyhow::Error> {
let remote = remote_path
.splitn(2, ':')
.split(':')
.next()
.ok_or_else(|| anyhow::anyhow!("Invalid remote path"))?;
let (conn, cache) = create_readonly_conn(remote).await?;
@@ -24,10 +31,11 @@ pub async fn ls(
let labname = lab.name;
if is_json {
let names = laboratory_names(&conn, cache.as_ref()).await;
let output = if is_recursive {
build_folder_json_recursive(&conn, folder, &labname).await?
build_folder_json_recursive(&conn, folder, &names, password).await?
} else {
build_folder_json_flat(&conn, &folder, &labname).await?
build_folder_json_flat(&conn, &folder, &names).await?
};
println!("{}", serde_json::to_string(&output)?);
} else if is_recursive {
@@ -235,7 +243,7 @@ fn file_to_json(f: &File, base_url: &str) -> Value {
})
}
fn folder_simple_to_json(sf: &FolderSimple) -> Value {
fn folder_simple_to_json(sf: &FolderSimple, names: &LaboratoryNames) -> Value {
json!({
"id": sf.id,
"pid": sf.pid,
@@ -243,26 +251,58 @@ fn folder_simple_to_json(sf: &FolderSimple) -> Value {
"access_level": sf.access_level_name(),
"lock": sf.lock,
"size": sf.size,
"laboratory_id": sf.laboratory_id,
"laboratory": laboratory_name(names, sf.laboratory_id),
"description": sf.description,
"created_at": sf.created_at,
"updated_at": sf.updated_at,
})
}
/// Name the laboratory a folder belongs to, so a listing does not hand out an id the
/// reader has no way to resolve.
fn laboratory_name(names: &LaboratoryNames, id: u32) -> String {
names
.get(&id)
.cloned()
.unwrap_or_else(|| UNKNOWN_LABORATORY.to_string())
}
/// Collect laboratory names, preferring what the login cache already holds so a listing
/// does not pay for a request it does not need.
async fn laboratory_names(conn: &MDRSConnection, cache: Option<&Cache>) -> LaboratoryNames {
if let Some(cache) = cache.filter(|c| !c.laboratories.items.is_empty()) {
return cache
.laboratories
.items
.iter()
.map(|lab| (lab.id, lab.name.clone()))
.collect();
}
match conn.list_laboratories().await {
Ok(labs) => labs
.items
.into_iter()
.map(|lab| (lab.id, lab.name))
.collect(),
Err(_) => LaboratoryNames::new(),
}
}
/// Build JSON for the top-level folder without recursing into sub-folders.
async fn build_folder_json_flat(
conn: &MDRSConnection,
folder: &FolderDetail,
labname: &str,
names: &LaboratoryNames,
) -> Result<Value, anyhow::Error> {
let metadata = get_folder_metadata(conn, &folder.id).await?;
let files = conn.list_all_files(&folder.id).await?;
let mut files = conn.list_all_files(&folder.id).await?;
files.sort_by(|a, b| a.name.cmp(&b.name));
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
let mut sub_folders = folder.sub_folders.clone();
sub_folders.sort_by(|a, b| a.name.cmp(&b.name));
let sub_folders_json: Vec<Value> = sub_folders
.iter()
.map(folder_simple_to_json)
.map(|sf| folder_simple_to_json(sf, names))
.collect();
Ok(json!({
@@ -272,7 +312,7 @@ async fn build_folder_json_flat(
"size": folder.size,
"access_level": folder.access_level_name(),
"lock": folder.lock,
"laboratory": labname,
"laboratory": laboratory_name(names, folder.laboratory_id),
"description": folder.description,
"created_at": folder.created_at,
"updated_at": folder.updated_at,
@@ -286,17 +326,36 @@ async fn build_folder_json_flat(
fn build_folder_json_recursive<'a>(
conn: &'a MDRSConnection,
folder: FolderDetail,
labname: &'a str,
names: &'a LaboratoryNames,
password: Option<&'a str>,
) -> Pin<Box<dyn Future<Output = Result<Value, anyhow::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 mut files = conn.list_all_files(&folder.id).await?;
files.sort_by(|a, b| a.name.cmp(&b.name));
let files_json: Vec<Value> = files.iter().map(|f| file_to_json(f, &conn.url)).collect();
let mut sub_folders = folder.sub_folders.clone();
sub_folders.sort_by(|a, b| a.name.cmp(&b.name));
let mut sub_folders_json = Vec::new();
for sf in &folder.sub_folders {
for sf in &sub_folders {
// A folder the caller cannot unlock is left out rather than ending the whole
// listing: a tree usually holds a locked folder or two, and the rest of it is
// still what the user asked to see. Anything else is reported, because a
// listing that quietly drops a branch cannot be told from one where the
// branch is empty, and this output is read by programs.
if sf.lock {
match password {
None => continue,
Some(pw) => {
if conn.folder_auth(&sf.id, pw).await.is_err() {
continue;
}
}
}
}
let sf_detail = conn.retrieve_folder(&sf.id).await?;
let sf_json = build_folder_json_recursive(conn, sf_detail, labname).await?;
let sf_json = build_folder_json_recursive(conn, sf_detail, names, password).await?;
sub_folders_json.push(sf_json);
}
@@ -307,7 +366,7 @@ fn build_folder_json_recursive<'a>(
"size": folder.size,
"access_level": folder.access_level_name(),
"lock": folder.lock,
"laboratory": labname,
"laboratory": laboratory_name(names, folder.laboratory_id),
"description": folder.description,
"created_at": folder.created_at,
"updated_at": folder.updated_at,
+5 -1
View File
@@ -1,9 +1,10 @@
use crate::cache::create_readonly_conn;
use crate::commands::shared::resolve_remote_folder;
use crate::error::response_error;
pub async fn metadata(remote_path: &str, password: Option<&str>) -> Result<(), anyhow::Error> {
let remote = remote_path
.splitn(2, ':')
.split(':')
.next()
.ok_or_else(|| anyhow::anyhow!("Invalid remote path"))?;
let (conn, cache) = create_readonly_conn(remote).await?;
@@ -13,6 +14,9 @@ pub async fn metadata(remote_path: &str, password: Option<&str>) -> Result<(), a
let resp = conn
.get(&format!("v3/folders/{}/metadata/", folder.id))
.await?;
if !resp.status().is_success() {
return Err(response_error("Failed to get folder metadata", resp).await);
}
let json: serde_json::Value = resp.json().await?;
println!("{}", serde_json::to_string(&json)?);
Ok(())
+4 -4
View File
@@ -1,8 +1,8 @@
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, nfc,
parse_remote_path,
find_file_by_name, find_folder, find_laboratory, find_subfolder_by_name, nfc, parse_remote_path,
};
use crate::error::response_error;
use anyhow::{anyhow, bail};
pub async fn mkdir(remote_path: &str) -> Result<(), anyhow::Error> {
@@ -25,7 +25,7 @@ pub async fn mkdir(remote_path: &str) -> Result<(), anyhow::Error> {
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 = find_laboratory(&conn, Some(&cache), &labname).await?;
let parent_folder = find_folder(&conn, lab.id, parent_path, None).await?;
// Check for name conflict in sub-folders or files
@@ -40,7 +40,7 @@ pub async fn mkdir(remote_path: &str) -> Result<(), anyhow::Error> {
.create_folder(&parent_folder.id, &nfc(new_folder_name))
.await?;
if !resp.status().is_success() {
bail!("Failed to create folder: {}", resp.status());
return Err(response_error("Failed to create folder", resp).await);
}
Ok(())
}
+5 -5
View File
@@ -1,8 +1,8 @@
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, nfc,
parse_remote_path,
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 mv(src_path: &str, dest_path: &str) -> Result<(), anyhow::Error> {
@@ -19,7 +19,7 @@ pub async fn mv(src_path: &str, dest_path: &str) -> Result<(), anyhow::Error> {
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 = find_laboratory(&conn, Some(&cache), &s_lab).await?;
let lab_id = lab.id;
// Split source path into parent directory and target name
@@ -62,7 +62,7 @@ pub async fn mv(src_path: &str, dest_path: &str) -> Result<(), anyhow::Error> {
.post_json(&format!("v3/files/{}/move/", src_file_id), &body)
.await?;
if !resp.status().is_success() {
bail!("Move failed: {}", resp.status());
return Err(response_error("Move failed", resp).await);
}
return Ok(());
}
@@ -99,7 +99,7 @@ pub async fn mv(src_path: &str, dest_path: &str) -> Result<(), anyhow::Error> {
.post_json(&format!("v3/folders/{}/move/", src_folder_id), &body)
.await?;
if !resp.status().is_success() {
bail!("Move failed: {}", resp.status());
return Err(response_error("Move failed", resp).await);
}
Ok(())
}
+5 -4
View File
@@ -1,7 +1,8 @@
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,
find_file_by_name, find_folder, find_laboratory, find_subfolder_by_name, parse_remote_path,
};
use crate::error::response_error;
use anyhow::{anyhow, bail};
pub async fn rm(remote_path: &str, recursive: bool) -> Result<(), anyhow::Error> {
@@ -22,7 +23,7 @@ pub async fn rm(remote_path: &str, recursive: bool) -> Result<(), anyhow::Error>
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 = find_laboratory(&conn, Some(&cache), &labname).await?;
let parent_folder = find_folder(&conn, lab.id, parent_path, None).await?;
// Check if target is a file
@@ -30,7 +31,7 @@ pub async fn rm(remote_path: &str, recursive: bool) -> Result<(), anyhow::Error>
if let Some(file) = find_file_by_name(&files, target_name) {
let resp = conn.delete(&format!("v3/files/{}/", file.id)).await?;
if !resp.status().is_success() {
bail!("Failed to delete file: {}", resp.status());
return Err(response_error("Failed to delete file", resp).await);
}
return Ok(());
}
@@ -47,7 +48,7 @@ pub async fn rm(remote_path: &str, recursive: bool) -> Result<(), anyhow::Error>
)
.await?;
if !resp.status().is_success() {
bail!("Failed to delete folder: {}", resp.status());
return Err(response_error("Failed to delete folder", resp).await);
}
return Ok(());
}
+78 -17
View File
@@ -158,6 +158,10 @@ pub async fn find_folder_by_doi(
// ---------------------------------------------------------------------------
/// Parse "remote:/labname/path/" into (remote, labname, folder_path).
///
/// The path is put in one form before anything is done with it: repeated and trailing
/// separators go, `.` segments go, and a `..` is refused rather than sent on. The Python
/// client does the same, and both talk to the same server.
pub fn parse_remote_path(remote_path: &str) -> Result<(String, String, String), anyhow::Error> {
let parts: Vec<&str> = remote_path.splitn(2, ':').collect();
if parts.len() != 2 {
@@ -168,12 +172,22 @@ pub fn parse_remote_path(remote_path: &str) -> Result<(String, String, String),
if !rest.starts_with('/') {
bail!("Path must be absolute (start with '/')");
}
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 {
let mut segments = Vec::new();
for segment in rest.split('/') {
match segment {
"" | "." => continue,
".." => bail!("Path traversal found."),
other => segments.push(other),
}
}
if segments.is_empty() {
bail!("Laboratory name is missing from `{}`.", remote_path);
}
let labname = segments.remove(0).to_string();
let path = if segments.is_empty() {
"/".to_string()
} else {
format!("/{}", segments.join("/"))
};
Ok((remote, labname, path))
}
@@ -202,15 +216,15 @@ pub async fn find_laboratory(
cache: Option<&Cache>,
labname: &str,
) -> Result<Laboratory, anyhow::Error> {
if let Some(cache) = cache {
if let Ok(lab) = find_lab_in_cache(cache, labname) {
return Ok(Laboratory {
id: lab.id,
name: lab.name.clone(),
pi_name: lab.pi_name.clone(),
full_name: lab.full_name.clone(),
});
}
if let Some(cache) = cache
&& let Ok(lab) = find_lab_in_cache(cache, labname)
{
return Ok(Laboratory {
id: lab.id,
name: lab.name.clone(),
pi_name: lab.pi_name.clone(),
full_name: lab.full_name.clone(),
});
}
conn.list_laboratories()
@@ -338,7 +352,7 @@ pub fn find_subfolder_by_name<'a>(
/// 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 == '-') {
let s = if let Some(pos) = s[10..].find(['+', '-']) {
&s[..10 + pos]
} else {
s.trim_end_matches('Z')
@@ -361,7 +375,7 @@ pub async fn resolve_remote_folder(
remote_path: &str,
password: Option<&str>,
) -> Result<(FolderDetail, Laboratory), anyhow::Error> {
let path_component = remote_path.splitn(2, ':').nth(1).unwrap_or("");
let path_component = remote_path.split_once(':').map(|x| x.1).unwrap_or("");
if is_doi(path_component) {
let (_, doi, subpath) = parse_doi_remote_path(remote_path)?;
let (doi_folder, lab) = find_folder_by_doi(conn, &doi, password).await?;
@@ -395,7 +409,7 @@ pub async fn resolve_remote_file(
remote_path: &str,
password: Option<&str>,
) -> Result<(FolderDetail, String), anyhow::Error> {
let path_component = remote_path.splitn(2, ':').nth(1).unwrap_or("");
let path_component = remote_path.split_once(':').map(|x| x.1).unwrap_or("");
if is_doi(path_component) {
let (_, doi, subpath) = parse_doi_remote_path(remote_path)?;
let (doi_folder, lab) = find_folder_by_doi(conn, &doi, password).await?;
@@ -473,6 +487,53 @@ mod tests {
server.await.unwrap();
}
// ------------------------------------------------------------------
// Remote path parsing
// ------------------------------------------------------------------
#[test]
fn parse_remote_path_splits_remote_lab_and_path() {
let (remote, lab, path) = parse_remote_path("neurodata:/mylab/a/b/").unwrap();
assert_eq!(
(remote.as_str(), lab.as_str(), path.as_str()),
("neurodata", "mylab", "/a/b")
);
}
#[test]
fn parse_remote_path_gives_the_lab_root_its_own_form() {
let (_, lab, path) = parse_remote_path("neurodata:/mylab").unwrap();
assert_eq!((lab.as_str(), path.as_str()), ("mylab", "/"));
}
/// Both clients talk to the same server, so a doubled separator or a `.` segment has
/// to reach it as the same path from either one.
#[test]
fn parse_remote_path_collapses_redundant_segments() {
let (_, lab, path) = parse_remote_path("neurodata://mylab//a/./b//").unwrap();
assert_eq!((lab.as_str(), path.as_str()), ("mylab", "/a/b"));
}
#[test]
fn parse_remote_path_refuses_a_parent_segment() {
for path in [
"neurodata:/mylab/../other",
"neurodata:/mylab/a/..",
"neurodata:/../mylab",
] {
let err = parse_remote_path(path).unwrap_err();
assert!(
err.to_string().contains("Path traversal found."),
"{path} should be refused"
);
}
}
#[test]
fn parse_remote_path_refuses_a_path_without_a_laboratory() {
assert!(parse_remote_path("neurodata:/").is_err());
}
// ------------------------------------------------------------------
// DOI helper unit tests
// ------------------------------------------------------------------
+21 -25
View File
@@ -1,6 +1,6 @@
use crate::cache::{create_authenticated_conn, load_cache_with_token_refresh};
use crate::commands::shared::{
find_file_by_name, find_folder_limited, find_lab_in_cache, nfc, parse_remote_path,
find_file_by_name, find_folder_limited, find_laboratory, nfc, parse_remote_path,
};
use crate::connection::{ApiRequestLimiter, MDRSConnection};
use crate::models::folder::FolderSimple;
@@ -20,7 +20,7 @@ pub async fn upload(
let cache = load_cache_with_token_refresh(&remote).await?;
let conn = Arc::new(create_authenticated_conn(&remote, &cache)?);
let limiter = ApiRequestLimiter::new(crate::settings::SETTINGS.concurrent);
let lab = find_lab_in_cache(&cache, &labname)?;
let lab = find_laboratory(&conn, Some(&cache), &labname).await?;
let dest_folder = find_folder_limited(&conn, &limiter, lab.id, &r_path, None).await?;
// Normalize local_path: resolve to an absolute canonical path so that
@@ -35,13 +35,12 @@ pub async fn upload(
let remote_files = conn
.list_all_files_limited(&dest_folder.id, &limiter)
.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(());
}
}
if skip_if_exists
&& let Some(rf) = find_file_by_name(&remote_files, &filename)
&& rf.size == std::fs::metadata(local)?.len()
{
println!("{}{}", dest_folder.path, filename);
return Ok(());
}
conn.upload_file_limited(&dest_folder.id, &local.to_string_lossy(), &limiter)
.await?;
@@ -219,15 +218,13 @@ async fn process_upload_folder(
let mut upload_jobs = Vec::new();
for file_path in files {
let filename = file_path.file_name().unwrap().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() {
println!("{}{}", folder_detail.path, filename);
continue;
}
}
}
if skip_if_exists
&& let Some(rf) = find_file_by_name(&remote_files, &filename)
&& let Ok(meta) = std::fs::metadata(&file_path)
&& rf.size == meta.len()
{
println!("{}{}", folder_detail.path, filename);
continue;
}
upload_jobs.push(UploadJob {
folder_id: remote_id.clone(),
@@ -267,10 +264,10 @@ async fn drive_upload_tasks(
}
}
(true, false) => {
if let Some(result) = upload_tasks.join_next().await {
if let Err(err) = flatten_join_result(result) {
errors.push(err.to_string());
}
if let Some(result) = upload_tasks.join_next().await
&& let Err(err) = flatten_join_result(result)
{
errors.push(err.to_string());
}
}
(false, false) => {
@@ -289,11 +286,10 @@ async fn drive_upload_tasks(
}
}
result = upload_tasks.join_next() => {
if let Some(result) = result {
if let Err(err) = flatten_join_result(result) {
if let Some(result) = result
&& let Err(err) = flatten_join_result(result) {
errors.push(err.to_string());
}
}
}
}
}
+10 -5
View File
@@ -1,9 +1,14 @@
use crate::cache::create_readonly_conn;
/// Ask the server who it thinks is calling.
///
/// The cached name is only what was true at login: an account disabled or removed since
/// then would still answer from the cache, which is the one thing this command exists to
/// tell the user. A session the server no longer honours reads as anonymous.
pub async fn whoami(remote: &str) -> Result<(), anyhow::Error> {
match crate::cache::load_cache(remote) {
Ok(cache) => match cache.user {
Some(user) => println!("{}", user.username),
None => println!("(Anonymous)"),
},
let (conn, _cache) = create_readonly_conn(remote).await?;
match conn.get_current_user().await {
Ok(user) => println!("{}", user.username),
Err(_) => println!("(Anonymous)"),
}
Ok(())
+80
View File
@@ -1,3 +1,33 @@
use anyhow::anyhow;
use serde::Deserialize;
/// The error envelope the API answers with (drf-standardized-errors).
#[derive(Deserialize)]
struct ApiErrors {
errors: Vec<ApiError>,
}
#[derive(Deserialize)]
struct ApiError {
detail: String,
}
/// Turn a refused response into an error carrying what the server actually said.
///
/// A status code on its own leaves the user guessing. The reason - a permission, a
/// quota, a name already taken - is in the body the API sends along with it, and that
/// is the part worth showing.
pub async fn response_error(context: &str, resp: reqwest::Response) -> anyhow::Error {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
match serde_json::from_str::<ApiErrors>(&body) {
Ok(parsed) if !parsed.errors.is_empty() => {
anyhow!("{}: {}", context, parsed.errors[0].detail)
}
_ => anyhow!("{}: {}", context, status),
}
}
/// Print the error message and exit with code 2.
/// JSON deserialization errors produce a friendlier message matching Python's
/// JSONDecodeError handling.
@@ -23,3 +53,53 @@ fn is_json_error(e: &(dyn std::error::Error + 'static)) -> bool {
}
false
}
#[cfg(test)]
mod response_error_tests {
use super::*;
async fn refused_with(body: &'static str, content_type: &'static str) -> String {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 4096];
let _ = stream.read(&mut buf).await.unwrap();
let response = format!(
"HTTP/1.1 403 Forbidden\r\ncontent-type: {}\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
content_type,
body.len(),
body
);
stream.write_all(response.as_bytes()).await.unwrap();
});
let resp = reqwest::get(format!("http://{addr}/")).await.unwrap();
let message = response_error("Upload failed", resp).await.to_string();
server.await.unwrap();
message
}
/// The reason the server gave is what the user needs, not the number that carried it.
#[tokio::test]
async fn the_servers_own_message_is_reported() {
let message = refused_with(
r#"{"type":"client_error","errors":[{"code":"permission_denied","detail":"You do not have permission.","attr":null}]}"#,
"application/json",
)
.await;
assert_eq!(message, "Upload failed: You do not have permission.");
}
/// A gateway that answers in HTML has nothing to quote, so the status still stands in.
#[tokio::test]
async fn the_status_stands_in_when_the_body_says_nothing() {
let message = refused_with("<html>nope</html>", "text/html").await;
assert_eq!(message, "Upload failed: 403 Forbidden");
}
}
+1 -1
View File
@@ -201,7 +201,7 @@ fn run(cli: Cli) {
}
Commands::SelfUpdate { yes } => {
if let Err(e) = build_rt().block_on(commands::selfupdate::selfupdate(yes)) {
handle_error(e.into());
handle_error(e);
}
}
}
+5 -8
View File
@@ -13,14 +13,11 @@ impl Settings {
fn load() -> Self {
let config_dirname = std::env::var("MDRS_CLIENT_CONFIG_DIRNAME")
.ok()
.map(|s| {
if s.starts_with("~/") {
dirs::home_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join(&s[2..])
} else {
std::path::PathBuf::from(&s)
}
.map(|s| match s.strip_prefix("~/") {
Some(rest) => dirs::home_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join(rest),
None => std::path::PathBuf::from(&s),
})
.unwrap_or_else(|| {
dirs::home_dir()