The upload directory was named from the destination path alone, on the reasoning that two files could then not collide. Two devices uploading the same file could, and did: both wrote 00001…00009 into one directory, and whichever MOVEd first assembled a mix of the two — a catalog of exactly the right size whose pages came from two different databases. SQLite called it malformed, every client declined to overwrite it, and collections stopped syncing on all of them for a week. A transfer that died on a phone's link left its chunks there for the next device to assemble in, by the same mechanism. The name now carries a nonce as well, so no two uploads share a directory, and a failed transfer deletes its own directory on the way out rather than leaving 5 MB chunks for the server to sweep eventually.
1046 lines
41 KiB
Rust
1046 lines
41 KiB
Rust
//! TRACES: R6 | NFR-SEC-3
|
|
//! Nextcloud connector.
|
|
//!
|
|
//! One of two [`RemoteBackend`] implementations, registered through
|
|
//! [`NextcloudProvider`]. What an *account* is no longer lives here — that is
|
|
//! [`dr_sync::Account`], which has no server in it — so this crate is the
|
|
//! protocol and nothing else.
|
|
//!
|
|
//! Hand-rolled over `reqwest` rather than built on a WebDAV crate (D7). No
|
|
//! mature Nextcloud crate exists, and the operations that matter here are
|
|
//! Nextcloud extensions: `oc:fileid`, propagating directory ETags, chunked
|
|
//! upload v2, and Login Flow v2. A general WebDAV client exposes none of them.
|
|
|
|
use std::ops::Range;
|
|
|
|
use async_trait::async_trait;
|
|
use dr_sync::{
|
|
Capabilities, ChangeDetection, ChunkConstraints, Cursor, Precondition, RemoteBackend,
|
|
RemoteChange, RemoteEntry, RemoteError, RemoteId, RemotePath, ServerPreviews, Validator,
|
|
};
|
|
|
|
pub mod auth;
|
|
pub mod desktop_client;
|
|
mod propfind;
|
|
pub mod provider;
|
|
|
|
pub use auth::{AppCredentials, LoginFlow};
|
|
pub use desktop_client::{DesktopClient, NextcloudVfs};
|
|
pub use provider::NextcloudProvider;
|
|
|
|
/// Chunk sizes Nextcloud's chunked upload v2 accepts.
|
|
const CHUNKS: ChunkConstraints = ChunkConstraints {
|
|
min_chunk: 5 * 1024 * 1024,
|
|
max_chunk: 5 * 1024 * 1024 * 1024,
|
|
// Below the 5 MB floor a single PUT is simpler and no slower.
|
|
single_shot_below: 5 * 1024 * 1024,
|
|
max_chunks: 10_000,
|
|
};
|
|
|
|
/// TRACES: FR-NC-12 | M-5
|
|
/// A connected Nextcloud account.
|
|
pub struct NextcloudBackend {
|
|
client: reqwest::Client,
|
|
server: String,
|
|
login: String,
|
|
password: String,
|
|
/// `/remote.php/dav/files/<user>/` — the prefix stripped from hrefs.
|
|
dav_base: String,
|
|
caps: Capabilities,
|
|
}
|
|
|
|
impl NextcloudBackend {
|
|
/// Build a backend from credentials obtained via [`auth`].
|
|
pub fn new(creds: &AppCredentials, user_id: &str) -> Result<Self, RemoteError> {
|
|
let client = http_client("DarkRoom")?;
|
|
|
|
let server = creds.server.trim_end_matches('/').to_string();
|
|
let dav_base = format!("/remote.php/dav/files/{user_id}/");
|
|
|
|
Ok(Self {
|
|
client,
|
|
server,
|
|
login: creds.login_name.clone(),
|
|
password: creds.app_password.clone(),
|
|
dav_base,
|
|
caps: Capabilities {
|
|
// The property that makes a no-op sync one request (ARCH §8.1).
|
|
change_detection: ChangeDetection::PropagatingEtags,
|
|
stable_ids: true,
|
|
range_reads: true,
|
|
chunked_upload: Some(CHUNKS),
|
|
bulk_upload: true,
|
|
conditional_write: true,
|
|
// Stock Nextcloud ships no RAW preview provider (ARCH §6.7).
|
|
// Probed per-account at setup and upgraded where present.
|
|
server_previews: ServerPreviews::CommonFormatsOnly,
|
|
// The server answers for everything it lists. Placeholders
|
|
// belong to a locally *synced folder*, which is the folder
|
|
// connector's business (`desktop_client::NextcloudVfs`).
|
|
materialisation: dr_sync::Materialisation::Always,
|
|
},
|
|
})
|
|
}
|
|
|
|
fn url_for(&self, path: &RemotePath) -> String {
|
|
let p = path.as_str();
|
|
if p.is_empty() {
|
|
format!("{}{}", self.server, self.dav_base)
|
|
} else {
|
|
format!("{}{}{}", self.server, self.dav_base, encode_path(p))
|
|
}
|
|
}
|
|
|
|
fn url_for_id(&self, id: &RemoteId) -> Result<String, RemoteError> {
|
|
match id {
|
|
RemoteId::Path(p) => Ok(self.url_for(p)),
|
|
// A stable id alone is not addressable over WebDAV; the caller
|
|
// holds the path alongside it in the catalog.
|
|
RemoteId::Stable(_) => Err(RemoteError::Unsupported(
|
|
"fetch by fileid requires a path; use RemoteId::Path",
|
|
)),
|
|
}
|
|
}
|
|
|
|
/// TRACES: FR-NC-7
|
|
/// Upload a large body with chunked upload v2.
|
|
///
|
|
/// `MKCOL` an upload directory, `PUT` each chunk into it under a numeric
|
|
/// name, then `MOVE` the `.file` pseudo-entry to the destination, which is
|
|
/// where the server assembles them.
|
|
///
|
|
/// The alternative — refusing anything over the single-shot threshold —
|
|
/// is what blocked thumbnail shards from ever reaching the server: they
|
|
/// are 25 MB by design.
|
|
///
|
|
/// `OC-Total-Length` is sent on every chunk so quota is checked up front
|
|
/// rather than at assembly, when the bytes have already been transferred.
|
|
async fn put_chunked(
|
|
&self,
|
|
path: &RemotePath,
|
|
body: Vec<u8>,
|
|
) -> Result<Validator, RemoteError> {
|
|
let total = body.len() as u64;
|
|
// TRACES: FR-NC-9
|
|
// Named from the destination, so an abandoned upload is identifiable,
|
|
// **and from a nonce, so no two uploads ever share a directory.**
|
|
//
|
|
// The name used to be the destination alone, on the reasoning that two
|
|
// *files* could then not collide. Two *devices* uploading the same file
|
|
// could, and did: both wrote `00001`…`00009` into one directory, and
|
|
// whichever `MOVE`d first assembled a mix of the two — a catalog of
|
|
// exactly the right size whose pages came from two different
|
|
// databases. SQLite called it malformed, every client then declined to
|
|
// overwrite it, and collections stopped syncing on all of them for a
|
|
// week. An upload that died on a phone's link left its chunks there
|
|
// for the next device to assemble in, by the same mechanism.
|
|
let token = format!(
|
|
"{}-{}",
|
|
sanitise_for_upload_dir(path.as_str()),
|
|
upload_nonce()
|
|
);
|
|
let dir = format!(
|
|
"{}/remote.php/dav/uploads/{}/{token}",
|
|
self.server, self.login
|
|
);
|
|
|
|
self.mkcol_url(&dir).await?;
|
|
|
|
// Whatever happens below, the directory does not outlive the attempt.
|
|
// With a unique name a leftover is only quota rather than corruption,
|
|
// but a phone that abandons uploads all day would still leave dozens
|
|
// of 5 MB chunks behind, and the server only sweeps them eventually.
|
|
let result = self.put_chunks_and_assemble(path, &dir, body, total).await;
|
|
if result.is_err() {
|
|
self.discard_upload_dir(&dir).await;
|
|
}
|
|
result
|
|
}
|
|
|
|
/// The transfer half of [`put_chunked`](Self::put_chunked): the chunks,
|
|
/// then the `MOVE` that assembles them. Split out so that a failure at any
|
|
/// point returns to one place that cleans up.
|
|
async fn put_chunks_and_assemble(
|
|
&self,
|
|
path: &RemotePath,
|
|
dir: &str,
|
|
body: Vec<u8>,
|
|
total: u64,
|
|
) -> Result<Validator, RemoteError> {
|
|
// Chunks are numbered from 1 and must sort correctly as strings, which
|
|
// is why they are zero-padded rather than bare integers.
|
|
let chunk_size = CHUNKS.min_chunk as usize;
|
|
for (i, chunk) in body.chunks(chunk_size).enumerate() {
|
|
if i + 1 > CHUNKS.max_chunks as usize {
|
|
return Err(RemoteError::Protocol(format!(
|
|
"{total} bytes exceeds {} chunks",
|
|
CHUNKS.max_chunks
|
|
)));
|
|
}
|
|
let resp = self
|
|
.client
|
|
.put(format!("{dir}/{:05}", i + 1))
|
|
.basic_auth(&self.login, Some(&self.password))
|
|
.header("OC-Total-Length", total.to_string())
|
|
.body(chunk.to_vec())
|
|
.send()
|
|
.await
|
|
.map_err(map_send_error)?;
|
|
map_status(resp.status(), path.as_str())?;
|
|
}
|
|
|
|
// Assemble. The destination is an absolute URL in the Destination
|
|
// header, and `Overwrite: T` because a re-uploaded shard replaces the
|
|
// one already there.
|
|
let resp = self
|
|
.client
|
|
.request(
|
|
reqwest::Method::from_bytes(b"MOVE").expect("valid method"),
|
|
format!("{dir}/.file"),
|
|
)
|
|
.basic_auth(&self.login, Some(&self.password))
|
|
.header("Destination", self.url_for(path))
|
|
.header("Overwrite", "T")
|
|
.header("OC-Total-Length", total.to_string())
|
|
.send()
|
|
.await
|
|
.map_err(map_send_error)?;
|
|
map_status(resp.status(), path.as_str())?;
|
|
|
|
// The MOVE response carries the assembled file's ETag on Nextcloud,
|
|
// but not on every version; fall back to asking rather than failing an
|
|
// upload that in fact succeeded.
|
|
if let Some(v) = resp
|
|
.headers()
|
|
.get(reqwest::header::ETAG)
|
|
.and_then(|v| v.to_str().ok())
|
|
{
|
|
return Ok(Validator::new(v));
|
|
}
|
|
self.dir_validator(path).await
|
|
}
|
|
|
|
/// Best-effort `DELETE` of an upload directory whose transfer failed.
|
|
///
|
|
/// Errors are logged and dropped: this runs on the way out of a failure,
|
|
/// and the failure is what the caller needs to hear about. A connection
|
|
/// that has just died will refuse this too, and that is fine — the
|
|
/// server sweeps abandoned upload directories on its own; this only
|
|
/// spares it the wait when the link is still up.
|
|
async fn discard_upload_dir(&self, dir: &str) {
|
|
let attempt = self
|
|
.client
|
|
.delete(dir)
|
|
.basic_auth(&self.login, Some(&self.password))
|
|
.send()
|
|
.await;
|
|
match attempt {
|
|
Ok(resp) if resp.status().is_success() || resp.status() == 404 => {}
|
|
Ok(resp) => log::debug!("leaving abandoned upload {dir}: {}", resp.status()),
|
|
Err(e) => log::debug!("leaving abandoned upload {dir}: {e}"),
|
|
}
|
|
}
|
|
|
|
/// `MKCOL` at an absolute URL, treating "already there" as success.
|
|
async fn mkcol_url(&self, url: &str) -> Result<(), RemoteError> {
|
|
let resp = self
|
|
.client
|
|
.request(
|
|
reqwest::Method::from_bytes(b"MKCOL").expect("valid method"),
|
|
url,
|
|
)
|
|
.basic_auth(&self.login, Some(&self.password))
|
|
.send()
|
|
.await
|
|
.map_err(map_send_error)?;
|
|
|
|
// 405 is "already exists", which is exactly what we want.
|
|
if resp.status() == 405 {
|
|
return Ok(());
|
|
}
|
|
map_status(resp.status(), url)
|
|
}
|
|
|
|
async fn propfind(
|
|
&self,
|
|
path: &RemotePath,
|
|
depth: &str,
|
|
body: &'static str,
|
|
) -> Result<String, RemoteError> {
|
|
let resp = self
|
|
.client
|
|
.request(
|
|
reqwest::Method::from_bytes(b"PROPFIND").expect("valid method"),
|
|
self.url_for(path),
|
|
)
|
|
.basic_auth(&self.login, Some(&self.password))
|
|
.header("Depth", depth)
|
|
.header(reqwest::header::CONTENT_TYPE, "application/xml")
|
|
.body(body)
|
|
.send()
|
|
.await
|
|
.map_err(map_send_error)?;
|
|
|
|
map_status(resp.status(), path.as_str())?;
|
|
resp.text().await.map_err(map_send_error)
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl RemoteBackend for NextcloudBackend {
|
|
fn capabilities(&self) -> &Capabilities {
|
|
&self.caps
|
|
}
|
|
|
|
fn name(&self) -> &str {
|
|
"Nextcloud"
|
|
}
|
|
|
|
async fn list(
|
|
&self,
|
|
dir: &RemotePath,
|
|
_since: Option<&Validator>,
|
|
) -> Result<Vec<RemoteEntry>, RemoteError> {
|
|
// Depth 1 only. Depth: infinity is frequently disabled and
|
|
// prohibitively expensive where it is not (ARCH §8.4).
|
|
let xml = self.propfind(dir, "1", propfind::PROPFIND_BODY).await?;
|
|
propfind::parse_multistatus(&xml, &self.dav_base)
|
|
}
|
|
|
|
async fn dir_validator(&self, dir: &RemotePath) -> Result<Validator, RemoteError> {
|
|
// The pruning probe: Depth 0, ETag only. Against the root this is the
|
|
// single request that proves a 50k-image library unchanged.
|
|
let xml = self
|
|
.propfind(dir, "0", propfind::PROPFIND_ETAG_ONLY)
|
|
.await?;
|
|
propfind::parse_self_etag(&xml)
|
|
}
|
|
|
|
async fn delta(&self, _cursor: &Cursor) -> Result<(Vec<RemoteChange>, Cursor), RemoteError> {
|
|
// Verified absent: Nextcloud's Directory.php does not implement
|
|
// ISyncCollection, and sync tokens exist only for CalDAV/CardDAV
|
|
// (ARCH §6.6). The engine falls back to ETag pruning.
|
|
Err(RemoteError::Unsupported(
|
|
"Nextcloud has no RFC 6578 sync-collection for files",
|
|
))
|
|
}
|
|
|
|
async fn get(&self, id: &RemoteId, range: Option<Range<u64>>) -> Result<Vec<u8>, RemoteError> {
|
|
let url = self.url_for_id(id)?;
|
|
let mut req = self
|
|
.client
|
|
.get(&url)
|
|
.basic_auth(&self.login, Some(&self.password));
|
|
|
|
let wanted = range.clone();
|
|
if let Some(r) = &range {
|
|
req = req.header(
|
|
reqwest::header::RANGE,
|
|
format!("bytes={}-{}", r.start, r.end.saturating_sub(1)),
|
|
);
|
|
}
|
|
|
|
let resp = req.send().await.map_err(map_send_error)?;
|
|
let status = resp.status();
|
|
map_status(status, &url)?;
|
|
|
|
let body = resp.bytes().await.map_err(map_send_error)?.to_vec();
|
|
|
|
// Nextcloud does not advertise Accept-Ranges, so support is detected
|
|
// by the response code rather than by probing with HEAD (ARCH §6.7).
|
|
// A 200 to a ranged request means the server ignored it and sent
|
|
// everything; slice locally so callers still get what they asked for.
|
|
if let Some(r) = wanted {
|
|
if status.as_u16() == 200 && body.len() as u64 > r.end {
|
|
let start = r.start.min(body.len() as u64) as usize;
|
|
let end = r.end.min(body.len() as u64) as usize;
|
|
return Ok(body[start..end].to_vec());
|
|
}
|
|
}
|
|
|
|
Ok(body)
|
|
}
|
|
|
|
async fn put(
|
|
&self,
|
|
path: &RemotePath,
|
|
body: Vec<u8>,
|
|
precond: Option<Precondition>,
|
|
) -> Result<Validator, RemoteError> {
|
|
// Chunked upload is an implementation detail of put, chosen by size —
|
|
// exposing it on the trait would leak this protocol (ARCH §8.3).
|
|
//
|
|
// A precondition cannot ride on a chunked upload: the guard belongs to
|
|
// the assembling MOVE, not to the individual chunks, and Nextcloud
|
|
// does not honour `If-Match` there. Large bodies are shards and
|
|
// catalog snapshots, which are written whole and never merged, so
|
|
// there is no conflict to guard against.
|
|
if body.len() as u64 >= CHUNKS.single_shot_below && precond.is_none() {
|
|
return self.put_chunked(path, body).await;
|
|
}
|
|
|
|
let mut req = self
|
|
.client
|
|
.put(self.url_for(path))
|
|
.basic_auth(&self.login, Some(&self.password));
|
|
|
|
match &precond {
|
|
Some(Precondition::IfMatch(v)) => {
|
|
req = req.header(reqwest::header::IF_MATCH, format!("\"{}\"", v.as_str()));
|
|
}
|
|
Some(Precondition::IfAbsent) => {
|
|
req = req.header(reqwest::header::IF_NONE_MATCH, "*");
|
|
}
|
|
None => {}
|
|
}
|
|
|
|
let resp = req.body(body).send().await.map_err(map_send_error)?;
|
|
|
|
// A refused write is the one status whose *body* matters. Sabre names
|
|
// the exception class and the rule that refused — a read-only share, a
|
|
// file access control rule, a lock — and `map_status` reduces all of
|
|
// them to one typed error. That is right for the application and
|
|
// useless for working out which of them it is, so the reason is logged
|
|
// before it is discarded.
|
|
//
|
|
// Only on failure, and only the first line: a success has no body
|
|
// worth reading and an error page can be a whole document.
|
|
if !resp.status().is_success() {
|
|
let status = resp.status();
|
|
let url = self.url_for(path);
|
|
// `text()` consumes the response, which is why this branch returns
|
|
// rather than falling through to read the headers below.
|
|
let body = resp.text().await.unwrap_or_default();
|
|
let reason = body
|
|
.lines()
|
|
.map(str::trim)
|
|
.find(|l| l.contains("message") || l.contains("exception"))
|
|
.unwrap_or_else(|| body.trim())
|
|
.chars()
|
|
.take(300)
|
|
.collect::<String>();
|
|
log::warn!("PUT {url} -> {status}: {reason}");
|
|
|
|
// A bare `Sabre\DAV\Exception\Forbidden` carries no reason, so
|
|
// ask the server what rights it thinks we have on the parent.
|
|
// Nextcloud answers in `oc:permissions` — a letter set where `W`
|
|
// and `C` are write and create. Their absence is a read-only
|
|
// share or mount, which no amount of retrying will change; their
|
|
// presence means the refusal is about the *file* rather than the
|
|
// folder, which points at an access-control rule on the name.
|
|
//
|
|
// Read-only, one request, and only on a refusal — this cannot
|
|
// make anything worse and it is the difference between a
|
|
// server-side fix and a client-side one.
|
|
if status == reqwest::StatusCode::FORBIDDEN {
|
|
// The *file*, not the folder. `W` is reported on files and
|
|
// `CK` on collections, so a directory legitimately lacks `W`
|
|
// and reading that as read-only would send someone to change a
|
|
// mount that is fine. If the file exists without `W` the
|
|
// refusal is an update it will not allow; a 404 here means it
|
|
// does not exist and the refusal was about creating it.
|
|
let target = path.as_str().to_string();
|
|
let probe = self
|
|
.client
|
|
.request(
|
|
reqwest::Method::from_bytes(b"PROPFIND").expect("valid method"),
|
|
self.url_for(path),
|
|
)
|
|
.basic_auth(&self.login, Some(&self.password))
|
|
.header("Depth", "0")
|
|
.header(reqwest::header::CONTENT_TYPE, "application/xml")
|
|
.body(
|
|
r#"<?xml version="1.0"?><d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns"><d:prop> <oc:permissions/></d:prop></d:propfind>"#,
|
|
)
|
|
.send()
|
|
.await;
|
|
match probe {
|
|
Ok(r) => {
|
|
let status = r.status();
|
|
let text = r.text().await.unwrap_or_default();
|
|
let perms = text
|
|
.split("<oc:permissions>")
|
|
.nth(1)
|
|
.and_then(|t| t.split('<').next())
|
|
.unwrap_or("(not reported)");
|
|
log::warn!(
|
|
" target {target} -> {status} permissions: {perms} \
|
|
(on a file W = update; a 404 means it does not exist yet)"
|
|
);
|
|
}
|
|
Err(e) => log::warn!(" could not read {target} permissions: {e}"),
|
|
}
|
|
}
|
|
|
|
map_status(status, path.as_str())?;
|
|
// `map_status` returns `Err` for every non-success, so this is
|
|
// unreachable; stated rather than left to inference.
|
|
unreachable!("a non-success status always maps to an error");
|
|
}
|
|
|
|
resp.headers()
|
|
.get(reqwest::header::ETAG)
|
|
.and_then(|v| v.to_str().ok())
|
|
.map(Validator::new)
|
|
.ok_or_else(|| RemoteError::Protocol("no ETag on PUT response".into()))
|
|
}
|
|
|
|
async fn delete(
|
|
&self,
|
|
id: &RemoteId,
|
|
precond: Option<Precondition>,
|
|
) -> Result<(), RemoteError> {
|
|
let url = self.url_for_id(id)?;
|
|
let mut req = self
|
|
.client
|
|
.delete(&url)
|
|
.basic_auth(&self.login, Some(&self.password));
|
|
|
|
if let Some(Precondition::IfMatch(v)) = &precond {
|
|
req = req.header(reqwest::header::IF_MATCH, format!("\"{}\"", v.as_str()));
|
|
}
|
|
|
|
let resp = req.send().await.map_err(map_send_error)?;
|
|
map_status(resp.status(), &url)
|
|
}
|
|
|
|
/// TRACES: FR-CAT-15
|
|
/// WebDAV `MOVE`, which preserves `oc:fileid`.
|
|
///
|
|
/// That preservation is the whole reason this is a `MOVE` and not a
|
|
/// `GET`+`PUT`+`DELETE`: the file id is what the thumbnail store keys on and
|
|
/// what the sidecar mapping records, so a move that allocated a new one
|
|
/// would orphan both and turn a trash-then-restore into a full re-download
|
|
/// of every affected file.
|
|
///
|
|
/// `Overwrite: F` — a move must never destroy something already at the
|
|
/// destination. The trash path carries the image id precisely so this cannot
|
|
/// normally fire, but a header that permits overwriting is a header that
|
|
/// eventually does.
|
|
async fn move_to(&self, from: &RemoteId, to: &RemotePath) -> Result<(), RemoteError> {
|
|
let src = self.url_for_id(from)?;
|
|
let dest = self.url_for(to);
|
|
|
|
// The parent has to exist; MOVE does not create it. Nothing else
|
|
// creates the trash folder, so the first trashed image would otherwise
|
|
// fail with a 409 that reads like a permission problem.
|
|
if let Some(parent) = to.parent() {
|
|
self.create_dir(&parent).await?;
|
|
}
|
|
|
|
let resp = self
|
|
.client
|
|
.request(
|
|
reqwest::Method::from_bytes(b"MOVE").expect("valid method"),
|
|
&src,
|
|
)
|
|
.basic_auth(&self.login, Some(&self.password))
|
|
.header("Destination", &dest)
|
|
.header("Overwrite", "F")
|
|
.send()
|
|
.await
|
|
.map_err(map_send_error)?;
|
|
|
|
map_status(resp.status(), &src)
|
|
}
|
|
|
|
/// `MKCOL`, treating "already there" as success.
|
|
///
|
|
/// Callers use this to guarantee a destination exists, not to claim they
|
|
/// created it — so `405 Method Not Allowed`, which is what Nextcloud returns
|
|
/// for an existing collection, is the goal state and not an error.
|
|
///
|
|
/// Parents are created outermost-first: `MKCOL` fails with `409` if the
|
|
/// parent is missing, and the trash folder's parent is the library root,
|
|
/// which may itself be several levels down.
|
|
async fn create_dir(&self, path: &RemotePath) -> Result<(), RemoteError> {
|
|
// Build the chain of ancestors, shallowest first.
|
|
let mut chain = Vec::new();
|
|
let mut current = Some(path.clone());
|
|
while let Some(p) = current {
|
|
if p.as_str().is_empty() {
|
|
break;
|
|
}
|
|
current = p.parent();
|
|
chain.push(p);
|
|
}
|
|
chain.reverse();
|
|
|
|
for dir in chain {
|
|
let url = self.url_for(&dir);
|
|
let resp = self
|
|
.client
|
|
.request(
|
|
reqwest::Method::from_bytes(b"MKCOL").expect("valid method"),
|
|
&url,
|
|
)
|
|
.basic_auth(&self.login, Some(&self.password))
|
|
.send()
|
|
.await
|
|
.map_err(map_send_error)?;
|
|
|
|
// 405 is "already a collection here", which is exactly what the
|
|
// caller wanted. Anything else is reported.
|
|
if resp.status() == reqwest::StatusCode::METHOD_NOT_ALLOWED {
|
|
continue;
|
|
}
|
|
map_status(resp.status(), &url)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn thumbnail(&self, id: &RemoteId, size: u32) -> Result<Option<Vec<u8>>, RemoteError> {
|
|
let RemoteId::Stable(file_id) = id else {
|
|
return Ok(None);
|
|
};
|
|
|
|
// forceIcon=false is mandatory: the default returns a generic
|
|
// mimetype icon for files the server cannot render, which would
|
|
// otherwise be cached as though it were a thumbnail (ARCH §6.7).
|
|
let url = format!(
|
|
"{}/core/preview?fileId={file_id}&x={size}&y={size}&a=true&forceIcon=false",
|
|
self.server
|
|
);
|
|
|
|
let resp = self
|
|
.client
|
|
.get(&url)
|
|
.basic_auth(&self.login, Some(&self.password))
|
|
.send()
|
|
.await
|
|
.map_err(map_send_error)?;
|
|
|
|
// 404 means no preview for this file — expected for RAW, and a
|
|
// fall-through rather than an error.
|
|
if resp.status().as_u16() == 404 {
|
|
return Ok(None);
|
|
}
|
|
map_status(resp.status(), &url)?;
|
|
|
|
Ok(Some(resp.bytes().await.map_err(map_send_error)?.to_vec()))
|
|
}
|
|
}
|
|
|
|
/// Roots that are publicly trusted but not yet in the compiled-in set.
|
|
///
|
|
/// # Why this exists
|
|
///
|
|
/// `webpki-roots` is generated from Mozilla's CA program, and lags it. A CA
|
|
/// that has begun issuing from a new root is therefore trusted by every
|
|
/// browser and by the platform store while still being unknown to this binary
|
|
/// — and because [`http_client`] deliberately bypasses the platform store to
|
|
/// keep Android working, there is nothing to fall back to. The handshake fails
|
|
/// with `UnknownIssuer` against a server that is entirely healthy.
|
|
///
|
|
/// Observed 2026-08-12: a Let's Encrypt chain anchored at ISRG Root YE. The
|
|
/// server offered the full cross-signed path up to ISRG Root X1, which *is*
|
|
/// bundled, but rustls anchors on the first certificate it recognises as
|
|
/// self-issued and stops rather than continuing to the cross-sign — so the
|
|
/// complete chain did not help.
|
|
///
|
|
/// Each entry is a root that Mozilla already trusts. Removing one once
|
|
/// `webpki-roots` catches up is safe; the set is additive and duplicates are
|
|
/// harmless.
|
|
const EXTRA_ROOTS: &[(&str, &[u8])] = &[(
|
|
// ISRG Root YE — cross-signed by ISRG Root X2, valid to 2032-09-02.
|
|
// SHA-256 0FC0901CCA2BAE9E9FDBB02D50D02F1094F7B366720869 91B9E897626DC485F0
|
|
"ISRG Root YE",
|
|
include_bytes!("../certs/isrg-root-ye.pem"),
|
|
)];
|
|
|
|
/// Parse [`EXTRA_ROOTS`] into reqwest certificates.
|
|
///
|
|
/// A malformed entry is skipped rather than fatal: it costs one root, and
|
|
/// failing here would take down every connection including the ones that never
|
|
/// needed the extra anchor.
|
|
fn extra_roots() -> impl Iterator<Item = reqwest::Certificate> {
|
|
EXTRA_ROOTS
|
|
.iter()
|
|
.filter_map(|(name, pem)| match reqwest::Certificate::from_pem(pem) {
|
|
Ok(c) => Some(c),
|
|
Err(e) => {
|
|
log::warn!("bundled root {name} unusable: {e}");
|
|
None
|
|
}
|
|
})
|
|
}
|
|
|
|
/// Build an HTTP client with the crypto provider already installed.
|
|
///
|
|
/// Every entry point that creates a client must go through here. The auth
|
|
/// flow runs *before* a backend exists, so leaving the install to
|
|
/// `NextcloudBackend::new` means `auth::begin` panics on first use — which is
|
|
/// exactly what happened.
|
|
pub fn http_client(user_agent: &str) -> Result<reqwest::Client, RemoteError> {
|
|
install_crypto_provider();
|
|
reqwest::Client::builder()
|
|
.user_agent(user_agent.to_string())
|
|
// Verify against the roots compiled into the binary rather than the
|
|
// platform store — D7's escape hatch, and what makes TLS work on
|
|
// Android at all.
|
|
//
|
|
// Without this, reqwest builds a `rustls_platform_verifier::Verifier`,
|
|
// which reads Android's trust store over JNI and panics during the
|
|
// handshake unless Java initialised it first. The panic lands inside a
|
|
// tokio task, so tokio swallows it: the worker thread simply stops, the
|
|
// channel closes, and the UI reports a failure with no error and no log
|
|
// line to explain it.
|
|
//
|
|
// `tls_certs_only` is the flag reqwest branches on to skip the platform
|
|
// verifier entirely (see its ClientBuilder TLS setup); the webpki-roots
|
|
// feature supplies the roots it then uses. Spike S3 revisits this to
|
|
// honour user-installed and enterprise CAs, which bundled roots cannot.
|
|
//
|
|
// Not empty: webpki-roots trails Mozilla's store, so a root that is
|
|
// already issuing publicly-trusted certificates can be missing from it
|
|
// for months. [`EXTRA_ROOTS`] carries those, and is additive — the
|
|
// webpki-roots set is still installed alongside.
|
|
.tls_certs_only(extra_roots())
|
|
// A request that hangs forever is indistinguishable from a worker that
|
|
// died, and cost a long time to tell apart once. These turn that into
|
|
// an error the UI can show.
|
|
.connect_timeout(std::time::Duration::from_secs(15))
|
|
// **Inactivity, not duration.** This was a 60-second *total* timeout,
|
|
// which is not a hang detector at all — it is a floor on link speed.
|
|
// A face shard runs to 25 MB, so it demanded a sustained 425 KB/s or
|
|
// the transfer failed; and having failed it was retried on the next
|
|
// pass, and failed again, for ever. A tablet on ordinary wifi could
|
|
// therefore never finish adopting a library's faces, and nothing said
|
|
// why: each attempt looked like a network blip rather than an
|
|
// arithmetic impossibility.
|
|
//
|
|
// `read_timeout` fires when *no bytes arrive* for the given period,
|
|
// which is the condition actually worth failing on. A slow transfer
|
|
// that is still moving now finishes, however long it takes, while a
|
|
// connection that has genuinely died is still caught in a minute.
|
|
.read_timeout(std::time::Duration::from_secs(60))
|
|
.build()
|
|
.map_err(|e| RemoteError::Network(e.to_string()))
|
|
}
|
|
|
|
/// Install the rustls crypto provider, once per process.
|
|
///
|
|
/// Required because we build reqwest with `rustls-no-provider` rather than
|
|
/// `rustls`: the default provider is aws-lc-rs, whose `aws-lc-sys` crate is C
|
|
/// and does not cross-compile for Android. `ring` is pure Rust apart from a
|
|
/// small assembly core that builds fine under the NDK.
|
|
///
|
|
/// Done here rather than left to callers so there is no way to construct a
|
|
/// client that panics on first use.
|
|
fn install_crypto_provider() {
|
|
use std::sync::Once;
|
|
static ONCE: Once = Once::new();
|
|
ONCE.call_once(|| {
|
|
// Errs only if a provider is already installed, which is fine.
|
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
|
});
|
|
}
|
|
|
|
/// Translate a transport failure into a typed error.
|
|
///
|
|
/// `reqwest::Error`'s own `Display` is uninformative for exactly the failures
|
|
/// that matter — every one of them renders as "error sending request for url
|
|
/// (…)" regardless of cause. The reason lives in the `source()` chain, so this
|
|
/// walks it: without that, a rejected certificate and an unplugged cable
|
|
/// produce identical text, and the app cannot tell "you are offline" from
|
|
/// "I do not trust this server".
|
|
///
|
|
/// TLS is separated because it is the one transport failure that waiting never
|
|
/// fixes, and the one that must not trigger offline mode (FR-CAT-9).
|
|
fn map_send_error(e: reqwest::Error) -> RemoteError {
|
|
// The full chain, which is what makes the log line actionable: rustls
|
|
// reports "invalid peer certificate: UnknownIssuer" only at depth 1.
|
|
let mut detail = e.to_string();
|
|
let mut src: Option<&dyn std::error::Error> = std::error::Error::source(&e);
|
|
let mut tls = false;
|
|
while let Some(s) = src {
|
|
let text = s.to_string();
|
|
// rustls surfaces every verification failure through this wording:
|
|
// UnknownIssuer, Expired, NotValidForName, BadSignature.
|
|
if text.contains("invalid peer certificate")
|
|
|| text.contains("certificate")
|
|
|| text.contains("CertificateError")
|
|
{
|
|
tls = true;
|
|
}
|
|
detail.push_str(": ");
|
|
detail.push_str(&text);
|
|
src = std::error::Error::source(s);
|
|
}
|
|
|
|
if tls {
|
|
RemoteError::Tls(detail)
|
|
} else {
|
|
RemoteError::Network(detail)
|
|
}
|
|
}
|
|
|
|
/// The destination path reduced to characters every WebDAV server accepts in
|
|
/// an upload directory name.
|
|
fn sanitise_for_upload_dir(path: &str) -> String {
|
|
path.bytes()
|
|
.map(|b| match b {
|
|
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' => (b as char).to_string(),
|
|
_ => "-".to_string(),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// A token no other upload — on this device or any other — will produce.
|
|
///
|
|
/// Nanoseconds since the epoch, the process id, and a counter, mixed rather
|
|
/// than concatenated so the name stays short. Two devices would have to start
|
|
/// an upload in the same nanosecond from the same pid to collide, and the
|
|
/// counter separates two uploads this process starts in one tick. No
|
|
/// randomness crate is pulled in for this: unique is the requirement, not
|
|
/// unguessable.
|
|
fn upload_nonce() -> String {
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
|
let nanos = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_nanos() as u64)
|
|
.unwrap_or(0);
|
|
let pid = u64::from(std::process::id());
|
|
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
|
|
format!("{:016x}", nanos ^ (pid << 40) ^ n.rotate_left(20))
|
|
}
|
|
|
|
/// Translate an HTTP status into a typed error.
|
|
fn map_status(status: reqwest::StatusCode, what: &str) -> Result<(), RemoteError> {
|
|
match status.as_u16() {
|
|
200..=299 => Ok(()),
|
|
401 => Err(RemoteError::AuthFailed),
|
|
// Not an auth failure: the credential authenticated fine and reads
|
|
// work. Reporting this as "authentication rejected" sends the user to
|
|
// re-check a working login (observed 2026-08-09: PROPFIND 207, PUT
|
|
// 403 `Sabre\DAV\Exception\Forbidden`, same app password).
|
|
403 => Err(RemoteError::PermissionDenied),
|
|
404 => Err(RemoteError::NotFound(what.to_string())),
|
|
// Drives the sidecar merge path rather than an overwrite (ARCH §8.5).
|
|
412 => Err(RemoteError::PreconditionFailed),
|
|
507 => Err(RemoteError::QuotaExceeded),
|
|
s => Err(RemoteError::Server {
|
|
status: s,
|
|
detail: what.to_string(),
|
|
}),
|
|
}
|
|
}
|
|
|
|
/// Percent-encode each path segment, leaving separators intact.
|
|
fn encode_path(path: &str) -> String {
|
|
path.split('/')
|
|
.map(|seg| {
|
|
seg.bytes()
|
|
.map(|b| match b {
|
|
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
|
(b as char).to_string()
|
|
}
|
|
_ => format!("%{b:02X}"),
|
|
})
|
|
.collect::<String>()
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("/")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
#[test]
|
|
fn two_uploads_of_one_destination_never_share_a_directory() {
|
|
// The collision that assembled two devices' chunks into one file.
|
|
// Same path, back to back, same process: still two names.
|
|
let a = format!(
|
|
"{}-{}",
|
|
super::sanitise_for_upload_dir("PhotosRaw/.darkroom-derived/catalog.sqlite"),
|
|
super::upload_nonce()
|
|
);
|
|
let b = format!(
|
|
"{}-{}",
|
|
super::sanitise_for_upload_dir("PhotosRaw/.darkroom-derived/catalog.sqlite"),
|
|
super::upload_nonce()
|
|
);
|
|
assert_ne!(a, b);
|
|
assert!(a.starts_with("PhotosRaw--darkroom-derived-catalog-sqlite-"));
|
|
}
|
|
|
|
#[test]
|
|
fn upload_directory_names_are_plain() {
|
|
assert_eq!(
|
|
super::sanitise_for_upload_dir("Photos Raw/été/x.sqlite"),
|
|
"Photos-Raw---t---x-sqlite"
|
|
);
|
|
assert!(super::upload_nonce().bytes().all(|b| b.is_ascii_hexdigit()));
|
|
}
|
|
|
|
use super::*;
|
|
|
|
fn backend() -> NextcloudBackend {
|
|
NextcloudBackend::new(
|
|
&AppCredentials {
|
|
server: "https://cloud.example/".into(),
|
|
login_name: "duncan".into(),
|
|
app_password: "token".into(),
|
|
},
|
|
"duncan",
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn capabilities_declare_the_nextcloud_fast_path() {
|
|
let b = backend();
|
|
let c = b.capabilities();
|
|
// Propagating ETags are what make a no-op sync one request.
|
|
assert_eq!(c.change_detection, ChangeDetection::PropagatingEtags);
|
|
assert!(c.stable_ids);
|
|
assert!(c.range_reads);
|
|
assert!(c.conditional_write);
|
|
// Stock Nextcloud cannot render RAW previews.
|
|
assert_eq!(c.server_previews, ServerPreviews::CommonFormatsOnly);
|
|
}
|
|
|
|
#[test]
|
|
fn strategy_selects_etag_pruning() {
|
|
use dr_sync::SyncStrategy;
|
|
let s = SyncStrategy::for_capabilities(backend().capabilities());
|
|
assert_eq!(s, SyncStrategy::EtagPruning);
|
|
assert!(s.has_cheap_noop());
|
|
}
|
|
|
|
#[test]
|
|
fn urls_include_the_dav_prefix() {
|
|
let b = backend();
|
|
assert_eq!(
|
|
b.url_for(&RemotePath::new("Photos/2026")),
|
|
"https://cloud.example/remote.php/dav/files/duncan/Photos/2026"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn root_url_has_no_trailing_segment() {
|
|
let b = backend();
|
|
assert_eq!(
|
|
b.url_for(&RemotePath::root()),
|
|
"https://cloud.example/remote.php/dav/files/duncan/"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn path_segments_are_encoded_but_separators_are_not() {
|
|
assert_eq!(encode_path("My Trip/a b.CR2"), "My%20Trip/a%20b.CR2");
|
|
assert_eq!(encode_path("Photos/2026"), "Photos/2026");
|
|
}
|
|
|
|
#[test]
|
|
fn fetching_by_bare_fileid_is_rejected_clearly() {
|
|
// Callers hold the path alongside the id; failing loudly beats
|
|
// constructing a URL that cannot work.
|
|
let b = backend();
|
|
assert!(matches!(
|
|
b.url_for_id(&RemoteId::Stable(42)),
|
|
Err(RemoteError::Unsupported(_))
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn addressing_by_path_is_what_works() {
|
|
// The counterpart to the rejection above, and the reason trash and
|
|
// purge pass `RemoteId::Path` even when the catalog knows the fileid:
|
|
// every id-taking method routes through here, so a `Stable` turns a
|
|
// trash into "operation unsupported by this backend".
|
|
let b = backend();
|
|
assert_eq!(
|
|
b.url_for_id(&RemoteId::Path(RemotePath::new("Photos/_MG_8154.dng")))
|
|
.unwrap(),
|
|
"https://cloud.example/remote.php/dav/files/duncan/Photos/_MG_8154.dng"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn statuses_map_to_actionable_errors() {
|
|
use reqwest::StatusCode;
|
|
assert!(map_status(StatusCode::OK, "x").is_ok());
|
|
assert!(matches!(
|
|
map_status(StatusCode::UNAUTHORIZED, "x"),
|
|
Err(RemoteError::AuthFailed)
|
|
));
|
|
// 412 must be distinguishable — it drives the sidecar merge.
|
|
assert!(matches!(
|
|
map_status(StatusCode::PRECONDITION_FAILED, "x"),
|
|
Err(RemoteError::PreconditionFailed)
|
|
));
|
|
assert!(matches!(
|
|
map_status(StatusCode::INSUFFICIENT_STORAGE, "x"),
|
|
Err(RemoteError::QuotaExceeded)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn http_client_works_before_any_backend_exists() {
|
|
// The auth flow builds a client before a backend is constructed. If
|
|
// the crypto provider is installed only in NextcloudBackend::new,
|
|
// this panics — which is exactly what happened against the live
|
|
// server.
|
|
let c = http_client("test");
|
|
assert!(c.is_ok(), "client must build without a backend");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delta_is_unsupported_and_says_why() {
|
|
// Verified absent in the server; the engine must fall back rather
|
|
// than treat this as a failure.
|
|
let b = backend();
|
|
assert!(matches!(
|
|
b.delta(&Cursor::new("x")).await,
|
|
Err(RemoteError::Unsupported(_))
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn every_bundled_root_parses() {
|
|
// A typo'd or truncated PEM would otherwise be discovered only as a
|
|
// silently missing anchor on the one server that needs it.
|
|
assert_eq!(
|
|
extra_roots().count(),
|
|
EXTRA_ROOTS.len(),
|
|
"every bundled root must parse"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn isrg_root_ye_is_bundled_until_webpki_roots_carries_it() {
|
|
// The anchor this crate had to supply itself. Delete this and the
|
|
// entry it guards once webpki-roots ships Root YE.
|
|
assert!(EXTRA_ROOTS.iter().any(|(n, _)| *n == "ISRG Root YE"));
|
|
}
|
|
}
|
|
|
|
/// Tests that need the real server. Run with `--ignored`.
|
|
#[cfg(test)]
|
|
mod live {
|
|
/// TRACES: FR-NC-12
|
|
/// The regression: a Root YE chain must complete the handshake.
|
|
///
|
|
/// Ignored because it needs the network, but kept because the unit tests
|
|
/// cannot catch this — a bundled root that parses is not the same as a
|
|
/// bundled root that verifies, and the gap between those two is exactly
|
|
/// what reported a healthy server as offline for a day.
|
|
#[tokio::test]
|
|
#[ignore = "needs network"]
|
|
async fn a_lets_encrypt_root_ye_server_verifies() {
|
|
let client = super::http_client("DarkRoom-test").expect("client");
|
|
// Any host on the newer ISRG anchor exercises this.
|
|
let r = client
|
|
.get("https://nextcloud.tourolle.paris/remote.php/dav/files/dtourolle/")
|
|
.send()
|
|
.await;
|
|
|
|
match r {
|
|
// 401 is a completed TLS handshake; auth is not what is under test.
|
|
Ok(resp) => assert_eq!(resp.status().as_u16(), 401, "handshake completed"),
|
|
Err(e) => panic!("handshake failed: {e}"),
|
|
}
|
|
}
|
|
}
|