Add live-server harness; centralise the rustls provider install
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.
This commit is contained in:
@@ -23,3 +23,4 @@ tokio = { workspace = true }
|
||||
[dev-dependencies]
|
||||
tokio.workspace = true
|
||||
env_logger.workspace = true
|
||||
dr-decode.workspace = true
|
||||
|
||||
@@ -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 <server-url> [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<AppCredentials, String> {
|
||||
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<String, String> {
|
||||
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<AppCredentials> {
|
||||
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::<String>())
|
||||
}
|
||||
}
|
||||
@@ -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")]
|
||||
|
||||
@@ -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<Self, RemoteError> {
|
||||
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<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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user