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.
483 lines
16 KiB
Rust
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(_))
|
|
));
|
|
}
|
|
}
|