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>
916 lines
35 KiB
Rust
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}"),
|
|
}
|
|
}
|
|
}
|