//! 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 PROPFINDs before it writes. A path that already exists is reported and //! left alone: overwriting a real sidecar to learn whether we may overwrite it //! is a poor trade. Pass `--write` to test an update anyway, which is only //! sensible against a file you are willing to lose. Absent paths are written //! and deleted again, which tests creation and costs nothing. use dr_plat::PlatformSecretStore; use dr_sync_nextcloud::session::SessionStore; #[tokio::main(flavor = "current_thread")] async fn main() { let args: Vec = std::env::args().skip(1).collect(); let force = args.iter().any(|a| a == "--write"); let mut positional = args.iter().filter(|a| !a.starts_with("--")); let (Some(server), Some(path)) = (positional.next(), positional.next()) else { eprintln!("usage: put_probe [--write] "); 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!("{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)) // Ignored by PUT and DELETE; required by PROPFIND. .header("Depth", "0") .body(body) .send() .await } }; // What the server thinks of the path, before we touch it. `oc:permissions` // on a file carries `W` when it may be updated, and the status separates // "exists and refuses updates" from "does not exist yet". let propfind = send( reqwest::Method::from_bytes(b"PROPFIND").expect("valid method"), br#""#.to_vec(), ) .await; let exists = match propfind { Ok(resp) => { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); let perms = body .split("") .nth(1) .and_then(|t| t.split('<').next()) .unwrap_or("(not reported)"); println!("PROPFIND -> {status}, permissions: {perms}"); if status.is_success() && !perms.contains('W') { println!(" no W: the server will not let this account update it"); } status.is_success() } Err(e) => { println!("PROPFIND failed: {e}"); false } }; if exists && !force { println!("exists already; not overwriting it. Re-run with --write to test an update."); return; } println!("PUT (probe bytes)"); 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()); } // Only tidy up what we brought into being. Deleting a path that // was already there would turn a diagnostic into data loss. if status.is_success() && !exists { let _ = send(reqwest::Method::DELETE, Vec::new()).await; println!("(probe file removed)"); } } Err(e) => println!("request failed: {e}"), } }