//! 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//` — the prefix stripped from hrefs. dav_base: String, caps: Capabilities, /// Collections this backend has seen exist, so [`create_dir`] asks the /// server about each one once. /// /// Every `MOVE` guarantees its destination's parent, and did so with a /// `MKCOL` for each ancestor down from the account root — for a trash /// folder three levels deep that was three round trips of `405 Method /// Not Allowed` before the one request that moved anything, on every /// image of a batch. A backend lives for one job (`dr_ui::remote:: /// connect` builds one per worker), so a folder deleted by another /// client mid-job is the one case this can get wrong, and it is reported /// as the `409` the `MOVE` then earns rather than hidden. /// /// [`create_dir`]: RemoteBackend::create_dir known_dirs: std::sync::Mutex>, } impl NextcloudBackend { /// Build a backend from credentials obtained via [`auth`]. pub fn new(creds: &AppCredentials, user_id: &str) -> Result { 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, known_dirs: Default::default(), 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 { 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, ) -> Result { 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, total: u64, ) -> Result { // 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 { 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, 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 { // 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, 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>) -> Result, 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 get_reporting( &self, id: &RemoteId, progress: &(dyn Fn(u64, Option) + Send + Sync), ) -> Result, RemoteError> { let url = self.url_for_id(id)?; let mut resp = self .client .get(&url) .basic_auth(&self.login, Some(&self.password)) .send() .await .map_err(map_send_error)?; map_status(resp.status(), &url)?; // Read chunk by chunk rather than with `bytes()`, which is the same // transfer with nothing to say until it ends. let declared = resp.content_length(); let mut body = Vec::with_capacity(declared.unwrap_or(0) as usize); progress(0, declared); while let Some(chunk) = resp.chunk().await.map_err(map_send_error)? { body.extend_from_slice(&chunk); progress(body.len() as u64, declared); } Ok(body) } async fn put( &self, path: &RemotePath, body: Vec, precond: Option, ) -> Result { // 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::(); 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#" "#, ) .send() .await; match probe { Ok(r) => { let status = r.status(); let text = r.text().await.unwrap_or_default(); let perms = text .split("") .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, ) -> 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 { // Asked once per backend — see `known_dirs`. The lock is held // across no await: it is taken to look, and again to record. let known = self .known_dirs .lock() .map(|k| k.contains(dir.as_str())) .unwrap_or(false); if known { continue; } 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 { map_status(resp.status(), &url)?; } if let Ok(mut k) = self.known_dirs.lock() { k.insert(dir.as_str().to_string()); } } Ok(()) } async fn thumbnail(&self, id: &RemoteId, size: u32) -> Result>, 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 { 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 { 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()) // TRACES: NFR-SEC-3 // Refuse `http` here, below every URL this crate builds, rather than // trusting each place a URL comes from. `normalise_endpoint` upgrades // what the user types, but the login flow's poll endpoint, the account // an older build saved and a redirect all arrive from somewhere else, // and any of them naming `http://` would send the app password in // Basic auth in the clear. reqwest checks this before connecting and // again on every redirect, so a refused request never opens a socket. .https_only(true) // 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; // A URL the client refused to send — `http` under `https_only`, on the // first request or a redirect. Nothing left the process, so this is the // account's configuration, not the network: reported as `Network` it // would put the app into offline mode over a connection that is fine. let mut refused = e.is_builder(); while let Some(s) = src { let text = s.to_string(); if text.contains("URL scheme is not allowed") { refused = true; } // 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 refused { RemoteError::Configuration(detail) } else 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::() }) .collect::>() .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"); } /// TRACES: NFR-SEC-3 #[tokio::test] async fn plain_http_is_refused_before_a_connection_opens() { // A listener that would take the connection if one were made. The // app password travels in a header, so a request that reached the // socket has already leaked it; failing on the response is too late. let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); listener.set_nonblocking(true).unwrap(); let url = format!("http://{}/remote.php/dav/", listener.local_addr().unwrap()); let err = http_client("test") .unwrap() .get(&url) .basic_auth("duncan", Some("app-password")) .send() .await .expect_err("http must be refused"); assert!( matches!(map_send_error(err), RemoteError::Configuration(_)), "a refused scheme is configuration, not the network" ); assert_eq!( listener.accept().err().map(|e| e.kind()), Some(std::io::ErrorKind::WouldBlock), "nothing may have connected" ); } #[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}"), } } }