Files
DarkRoom/core/dr-sync-nextcloud/src/lib.rs
T
dtourolle 09e3043f4c Add secure credential storage, sessions, and a launch screen
Login now persists properly rather than through the JSON file the test
harness was using.

  dr-plat            SecretStore trait plus a Secret Service backend.
                     Verified against the live GNOME Keyring: store,
                     retrieve, delete, confirm-gone all round-trip.
  Session/SessionStore   splits credentials from settings — the app
                     password goes to the keyring (FR-NC-2), while
                     server, login, chosen root and format selection are
                     ordinary config. A test asserts the credential never
                     appears in the config file.
  LaunchModel        the launch-screen state machine, testable without a
                     display server: sign in, approve in browser, choose
                     folder, tick formats, sign out.
  launch.slint       the screen itself, in its own file.

Absence of a secrets daemon is an explicit degraded mode, not a silent
fallback to plaintext — the screen says sign-in will not persist rather
than letting the user find out next launch. Android's Keystore backend
fails loudly for the same reason: a no-op store would look like it
worked and then lose the credential.

Two bugs caught by tests rather than by running it:

  - fail() after busy() signed the user out, because busy() had already
    discarded the session. A failed *scan* would have logged you out.
    Busy now carries the session.
  - normalise_server upgrades http:// to https:// rather than accepting
    it. NFR-SEC-3 requires TLS, and silently sending a credential in the
    clear is not a decision to make on the user's behalf.

launch.slint is not yet wired into app.slint. Calling slint_build::compile
twice replaces the generated module rather than adding to it, which broke
the other in-flight work on dr-ui; I reverted that immediately. Wiring it
needs an import inside app.slint, which is that work's file to change.

419 tests passing across ten crates.
2026-08-09 15:20:39 +02:00

483 lines
16 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",
)),
}
}
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(|e| RemoteError::Network(e.to_string()))?;
map_status(resp.status(), path.as_str())?;
resp.text()
.await
.map_err(|e| RemoteError::Network(e.to_string()))
}
}
#[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(|e| RemoteError::Network(e.to_string()))?;
let status = resp.status();
map_status(status, &url)?;
let body = resp
.bytes()
.await
.map_err(|e| RemoteError::Network(e.to_string()))?
.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).
if body.len() as u64 >= CHUNKS.single_shot_below {
return Err(RemoteError::Unsupported(
"chunked upload v2 not implemented yet",
));
}
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(|e| RemoteError::Network(e.to_string()))?;
map_status(resp.status(), path.as_str())?;
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(|e| RemoteError::Network(e.to_string()))?;
map_status(resp.status(), &url)
}
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(|e| RemoteError::Network(e.to_string()))?;
// 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(|e| RemoteError::Network(e.to_string()))?
.to_vec(),
))
}
}
/// 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())
.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 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 | 403 => Err(RemoteError::AuthFailed),
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 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(_))
));
}
}