Files
DarkRoom/core/dr-sync-nextcloud/src/lib.rs
T
dtourolleandClaude Opus 5 18f20170b3 Ask the file, not its folder, why the write was refused
The 403 probe read `oc:permissions` off the parent collection and warned
when `W` was missing. But Nextcloud reports `W` on files and `CK` on
collections, so a directory legitimately lacks `W`: the warning fired on
a healthy share and pointed at a mount that was fine.

Probe the file itself. Its permissions answer the question that matters,
and the status distinguishes the two cases the parent could not: a 404
means the sidecar does not exist and the refusal was about creating it,
while a 200 without `W` means it exists and cannot be updated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 20:38:48 +02:00

916 lines
35 KiB
Rust

//! Nextcloud connector — the only [`RemoteBackend`] implementation.
//!
//! 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 session;
pub use auth::{AppCredentials, LoginFlow};
pub use desktop_client::DesktopClient;
pub use session::{Session, SessionError, SessionStore};
/// 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,
},
})
}
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;
// Named from the destination so a resumed or abandoned upload is
// identifiable, and so two uploads cannot collide in one directory.
let token: String = path
.as_str()
.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();
let dir = format!(
"{}/remote.php/dav/uploads/{}/{token}",
self.server, self.login
);
self.mkcol_url(&dir).await?;
// 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
}
/// `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. Connect and total
// timeouts turn that into an error the UI can show. Generous enough for
// a slow phone on mobile data; the login poll has its own deadline.
.connect_timeout(std::time::Duration::from_secs(15))
.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)
}
}
/// 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 {
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}"),
}
}
}