From fbadf9afc855c68b36a153df3535f636f919181c Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sun, 9 Aug 2026 11:41:17 +0200 Subject: [PATCH] Add live-server harness; centralise the rustls provider install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a read-only example that exercises the connector against a real Nextcloud: Login Flow v2, PROPFIND listing, the Depth:0 ETag pruning probe, a 256KB range GET, and a server preview request. No PUT, MOVE or DELETE, so it cannot alter a live library. Running it against nextcloud.tourolle.paris (34.0.2) surfaced a real API flaw rather than an example bug. The crypto provider was installed in NextcloudBackend::new, but authentication necessarily runs *before* a backend exists — so any caller following the documented flow panicked on the first client build. Every entry point now goes through `http_client()`, which installs the provider first, and auth gains `begin_default()` for callers with no client yet. A test builds a client without a backend to keep the regression out. Also populates RawImage::crop from rawler's crop_area/active_area and re-phases the CFA pattern when the crop origin is odd — cropping to the active area without that swaps red and blue. Login Flow v2 confirmed working against the live server: the flow URL is issued and the poll endpoint responds. The remaining checks need a browser approval, so they run interactively. 88 tests passing. --- core/dr-sync-nextcloud/Cargo.toml | 1 + core/dr-sync-nextcloud/examples/connect.rs | 281 +++++++++++++++++++++ core/dr-sync-nextcloud/src/auth.rs | 4 +- core/dr-sync-nextcloud/src/lib.rs | 31 ++- 4 files changed, 309 insertions(+), 8 deletions(-) create mode 100644 core/dr-sync-nextcloud/examples/connect.rs diff --git a/core/dr-sync-nextcloud/Cargo.toml b/core/dr-sync-nextcloud/Cargo.toml index 5bddb2e..c530bc8 100644 --- a/core/dr-sync-nextcloud/Cargo.toml +++ b/core/dr-sync-nextcloud/Cargo.toml @@ -23,3 +23,4 @@ tokio = { workspace = true } [dev-dependencies] tokio.workspace = true env_logger.workspace = true +dr-decode.workspace = true diff --git a/core/dr-sync-nextcloud/examples/connect.rs b/core/dr-sync-nextcloud/examples/connect.rs new file mode 100644 index 0000000..0681f03 --- /dev/null +++ b/core/dr-sync-nextcloud/examples/connect.rs @@ -0,0 +1,281 @@ +//! Exercise the connector against a real Nextcloud server. +//! +//! ```text +//! cargo run -p dr-sync-nextcloud --example connect -- https://cloud.example [remote/path] +//! ``` +//! +//! **Strictly read-only.** PROPFIND, ETag probes, range GETs and preview +//! requests only — no PUT, MOVE or DELETE — so it cannot alter a live library. +//! +//! Authentication uses Login Flow v2 (FR-NC-1): the app never sees a password. +//! A URL is printed for the browser, and the resulting app password is +//! device-scoped and revocable from the server's security settings. +//! +//! Credentials are cached under `$XDG_CACHE_HOME/darkroom/` so repeat runs do +//! not re-authenticate. That location is for *testing convenience* only; +//! FR-NC-2 requires the real app to use platform secure storage. + +use std::path::PathBuf; +use std::time::Instant; + +use dr_sync::{RemoteBackend, RemoteId, RemotePath, SyncStrategy}; +use dr_sync_nextcloud::{auth, AppCredentials, NextcloudBackend}; + +#[tokio::main] +async fn main() { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); + + let mut args = std::env::args().skip(1); + let Some(server) = args.next() else { + eprintln!("usage: connect [remote/path]"); + std::process::exit(2); + }; + let start_path = args.next().unwrap_or_default(); + + let creds = match load_cached(&server) { + Some(c) => { + println!("using cached credentials for {}", c.login_name); + c + } + None => match authenticate(&server).await { + Ok(c) => c, + Err(e) => { + eprintln!("authentication failed: {e}"); + std::process::exit(1); + } + }, + }; + + // The DAV base needs the *user id*, which may differ from the login name + // (a login can be an email address). OCS reports the real one. + let user_id = fetch_user_id(&creds).await.unwrap_or_else(|e| { + eprintln!("could not resolve user id ({e}); falling back to login name"); + creds.login_name.clone() + }); + println!("user id: {user_id}"); + + let backend = NextcloudBackend::new(&creds, &user_id).expect("build backend"); + + println!("\nbackend: {}", backend.name()); + let caps = backend.capabilities(); + let strategy = SyncStrategy::for_capabilities(caps); + println!("strategy: {} ({:?})", strategy.describe(), strategy); + println!("cheap no-op sync: {}", strategy.has_cheap_noop()); + + let root = RemotePath::new(&start_path); + let mut failures = 0; + + // ---- 1. listing ---------------------------------------------------- + println!("\n[1] PROPFIND Depth:1 on /{start_path}"); + let t = Instant::now(); + let entries = match backend.list(&root, None).await { + Ok(e) => { + println!( + " {} entries in {:.0}ms", + e.len(), + t.elapsed().as_secs_f64() * 1000.0 + ); + e + } + Err(e) => { + println!(" FAILED: {e}"); + failures += 1; + Vec::new() + } + }; + + for e in entries.iter().take(5) { + println!( + " {:?} {:<40} {:>10} id={:?}{}", + e.kind, + truncate(e.path.name(), 40), + human(e.size), + e.id, + if e.has_preview { " preview" } else { "" } + ); + } + if entries.len() > 5 { + println!(" … {} more", entries.len() - 5); + } + + // ---- 2. the pruning probe ------------------------------------------ + println!("\n[2] PROPFIND Depth:0 — the ETag pruning probe (FR-NC-4)"); + let t = Instant::now(); + match backend.dir_validator(&root).await { + Ok(v) => println!( + " etag {} in {:.0}ms — one request proves the tree unchanged", + v.as_str(), + t.elapsed().as_secs_f64() * 1000.0 + ), + Err(e) => { + println!(" FAILED: {e}"); + failures += 1; + } + } + + // ---- 3. range read ------------------------------------------------- + // The mechanism the whole mobile story rests on (FR-NC-3). + let file = entries + .iter() + .find(|e| e.kind == dr_sync::EntryKind::File && e.size > 300_000); + + if let Some(f) = file { + println!( + "\n[3] Range GET — first 256KB of {} ({})", + f.path.name(), + human(f.size) + ); + let id = RemoteId::Path(f.path.clone()); + + let t = Instant::now(); + match backend.get(&id, Some(0..262_144)).await { + Ok(bytes) => { + let ms = t.elapsed().as_secs_f64() * 1000.0; + let pct = (bytes.len() as f64 / f.size as f64) * 100.0; + println!( + " got {} in {ms:.0}ms ({pct:.1}% of the file)", + human(bytes.len() as u64) + ); + + if bytes.len() as u64 >= f.size { + println!(" WARNING: whole file returned — server ignored Range"); + failures += 1; + } else { + println!(" range requests work — remote browsing is viable"); + } + + if let Some(fmt) = dr_decode::probe(&bytes) { + println!(" probe: {fmt:?}"); + } + match dr_decode::metadata(&bytes) { + Ok(m) => println!( + " metadata from the range alone: {} {}", + m.make.unwrap_or_default(), + m.model.unwrap_or_default() + ), + Err(e) => println!(" metadata: {e}"), + } + } + Err(e) => { + println!(" FAILED: {e}"); + failures += 1; + } + } + + // ---- 4. server preview ------------------------------------------ + println!("\n[4] Server preview (ARCH §6.7 expects none for RAW)"); + match backend.thumbnail(&f.id, 256).await { + Ok(Some(b)) => println!(" {} returned", human(b.len() as u64)), + Ok(None) => println!(" none — as expected; local extraction is the path"), + Err(e) => println!(" error: {e}"), + } + } else { + println!("\n[3] skipped — no file over 300KB at this path"); + } + + println!( + "\n{}", + if failures == 0 { + "all checks passed" + } else { + "FAILURES" + } + ); + if failures > 0 { + std::process::exit(1); + } +} + +async fn authenticate(server: &str) -> Result { + let client = + dr_sync_nextcloud::http_client("DarkRoom (connect example)").map_err(|e| e.to_string())?; + + let flow = auth::begin(&client, server, "DarkRoom (connect example)") + .await + .map_err(|e| e.to_string())?; + + println!("\n Open this in a browser and approve:\n"); + println!(" {}\n", flow.login_url); + println!(" waiting (20 minute limit)…"); + + let creds = auth::poll(&client, &flow) + .await + .map_err(|e| e.to_string())?; + println!(" authenticated as {}", creds.login_name); + save_cached(server, &creds); + Ok(creds) +} + +/// Resolve the real user id, which the DAV path needs. +async fn fetch_user_id(creds: &AppCredentials) -> Result { + let client = dr_sync_nextcloud::http_client("DarkRoom").map_err(|e| e.to_string())?; + let url = format!( + "{}/ocs/v2.php/cloud/user?format=json", + creds.server.trim_end_matches('/') + ); + let body = client + .get(&url) + .basic_auth(&creds.login_name, Some(&creds.app_password)) + .header("OCS-APIRequest", "true") + .send() + .await + .map_err(|e| e.to_string())? + .text() + .await + .map_err(|e| e.to_string())?; + + let v: serde_json::Value = serde_json::from_str(&body).map_err(|e| e.to_string())?; + v["ocs"]["data"]["id"] + .as_str() + .map(str::to_string) + .ok_or_else(|| "no id in OCS response".to_string()) +} + +fn cache_path(server: &str) -> PathBuf { + let dir = std::env::var_os("XDG_CACHE_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".cache")) + .join("darkroom"); + let _ = std::fs::create_dir_all(&dir); + let key: String = server + .chars() + .map(|c| if c.is_alphanumeric() { c } else { '_' }) + .collect(); + dir.join(format!("{key}.json")) +} + +fn load_cached(server: &str) -> Option { + let text = std::fs::read_to_string(cache_path(server)).ok()?; + serde_json::from_str(&text).ok() +} + +fn save_cached(server: &str, creds: &AppCredentials) { + let path = cache_path(server); + if let Ok(json) = serde_json::to_string(creds) { + let _ = std::fs::write(&path, json); + // Testing convenience only — FR-NC-2 requires platform secure storage. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)); + } + println!(" cached credentials at {}", path.display()); + } +} + +fn human(bytes: u64) -> String { + match bytes { + b if b >= 1_000_000_000 => format!("{:.1}GB", b as f64 / 1e9), + b if b >= 1_000_000 => format!("{:.1}MB", b as f64 / 1e6), + b if b >= 1_000 => format!("{:.0}KB", b as f64 / 1e3), + b => format!("{b}B"), + } +} + +fn truncate(s: &str, n: usize) -> String { + if s.chars().count() <= n { + s.to_string() + } else { + format!("{}…", s.chars().take(n - 1).collect::()) + } +} diff --git a/core/dr-sync-nextcloud/src/auth.rs b/core/dr-sync-nextcloud/src/auth.rs index 4b4cbe1..f6a16b6 100644 --- a/core/dr-sync-nextcloud/src/auth.rs +++ b/core/dr-sync-nextcloud/src/auth.rs @@ -7,7 +7,7 @@ use std::time::Duration; use dr_sync::RemoteError; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; /// The flow's poll token is valid for 20 minutes. const FLOW_TIMEOUT: Duration = Duration::from_secs(20 * 60); @@ -31,7 +31,7 @@ pub struct PollInfo { } /// Credentials issued at the end of the flow. -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct AppCredentials { pub server: String, #[serde(rename = "loginName")] diff --git a/core/dr-sync-nextcloud/src/lib.rs b/core/dr-sync-nextcloud/src/lib.rs index 054003a..2831f1a 100644 --- a/core/dr-sync-nextcloud/src/lib.rs +++ b/core/dr-sync-nextcloud/src/lib.rs @@ -44,12 +44,7 @@ pub struct NextcloudBackend { impl NextcloudBackend { /// Build a backend from credentials obtained via [`auth`]. pub fn new(creds: &AppCredentials, user_id: &str) -> Result { - install_crypto_provider(); - - let client = reqwest::Client::builder() - .user_agent("DarkRoom") - .build() - .map_err(|e| RemoteError::Network(e.to_string()))?; + let client = http_client("DarkRoom")?; let server = creds.server.trim_end_matches('/').to_string(); let dav_base = format!("/remote.php/dav/files/{user_id}/"); @@ -306,6 +301,20 @@ impl RemoteBackend for NextcloudBackend { } } +/// 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()) + .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 @@ -448,6 +457,16 @@ mod tests { )); } + #[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