diff --git a/core/dr-sync-nextcloud/examples/put_probe.rs b/core/dr-sync-nextcloud/examples/put_probe.rs new file mode 100644 index 0000000..64dd51c --- /dev/null +++ b/core/dr-sync-nextcloud/examples/put_probe.rs @@ -0,0 +1,86 @@ +//! Why the server refused a write, in the server's own words. +//! +//! cargo run -p dr-sync-nextcloud --example put_probe -- +//! +//! `map_status` turns a response into a typed error and throws the body away, +//! which is right for the application and useless for diagnosis: a 403 from +//! Sabre carries an exception class and a sentence saying *which* rule +//! refused, and that is the whole of what distinguishes a read-only share from +//! an access-control rule from a lock. +//! +//! Reads the stored session and its keyring credential, so it exercises the +//! same account the app does. It writes a few bytes and deletes them again. + +use dr_plat::PlatformSecretStore; +use dr_sync_nextcloud::session::SessionStore; + +#[tokio::main(flavor = "current_thread")] +async fn main() { + let mut args = std::env::args().skip(1); + let (Some(server), Some(path)) = (args.next(), args.next()) else { + eprintln!("usage: put_probe "); + std::process::exit(2); + }; + + let sessions = SessionStore::open(Box::new(PlatformSecretStore::new())); + let Some(session) = sessions + .current() + .filter(|s| s.server == server.trim_end_matches('/')) + else { + eprintln!("no stored session for {server}"); + std::process::exit(1); + }; + let creds = match sessions.credentials(&session) { + Ok(c) => c, + Err(e) => { + eprintln!("credentials: {e}"); + std::process::exit(1); + } + }; + + let url = format!( + "{}/remote.php/dav/files/{}/{}", + session.server.trim_end_matches('/'), + session.user_id, + path + ); + println!("PUT {url}"); + + let client = reqwest::Client::new(); + let send = |method: reqwest::Method, body: Vec| { + let (url, user, pass) = ( + url.clone(), + creds.login_name.clone(), + creds.app_password.clone(), + ); + let client = client.clone(); + async move { + client + .request(method, &url) + .basic_auth(user, Some(pass)) + .body(body) + .send() + .await + } + }; + + match send(reqwest::Method::PUT, b"probe".to_vec()).await { + Ok(resp) => { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + println!("status {status}"); + // The interesting part: Sabre names the exception and the reason. + for line in body + .lines() + .filter(|l| l.contains("exception") || l.contains("message") || l.contains("Sabre")) + { + println!(" {}", line.trim()); + } + if status.is_success() { + let _ = send(reqwest::Method::DELETE, Vec::new()).await; + println!("(probe file removed)"); + } + } + Err(e) => println!("request failed: {e}"), + } +} diff --git a/core/dr-sync-nextcloud/src/lib.rs b/core/dr-sync-nextcloud/src/lib.rs index e345c45..10a72b7 100644 --- a/core/dr-sync-nextcloud/src/lib.rs +++ b/core/dr-sync-nextcloud/src/lib.rs @@ -335,7 +335,36 @@ impl RemoteBackend for NextcloudBackend { } let resp = req.body(body).send().await.map_err(map_send_error)?; - map_status(resp.status(), path.as_str())?; + + // 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::(); + log::warn!("PUT {url} -> {status}: {reason}"); + 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)