From 5700c37016a1a6653b39b7135f121782160f89bd Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Mon, 17 Aug 2026 20:40:36 +0200 Subject: [PATCH] Look before overwriting the file we are asking about The probe PUT its bytes first and read the outcome, which answers the question by destroying the evidence: pointed at a real sidecar it would replace an edit with the word "probe", and on success delete it outright. PROPFIND first. Permissions and status usually settle create-versus-update on their own, and a path that already exists is now reported and left alone. `--write` still forces the update test for a file worth losing, and the cleanup DELETE fires only for a path the probe itself created. Co-Authored-By: Claude Opus 5 --- core/dr-sync-nextcloud/examples/put_probe.rs | 59 ++++++++++++++++++-- 1 file changed, 53 insertions(+), 6 deletions(-) diff --git a/core/dr-sync-nextcloud/examples/put_probe.rs b/core/dr-sync-nextcloud/examples/put_probe.rs index 64dd51c..57769fd 100644 --- a/core/dr-sync-nextcloud/examples/put_probe.rs +++ b/core/dr-sync-nextcloud/examples/put_probe.rs @@ -9,16 +9,24 @@ //! 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. +//! 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 mut args = std::env::args().skip(1); - let (Some(server), Some(path)) = (args.next(), args.next()) else { - eprintln!("usage: put_probe "); + 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); }; @@ -44,7 +52,7 @@ async fn main() { session.user_id, path ); - println!("PUT {url}"); + println!("{url}"); let client = reqwest::Client::new(); let send = |method: reqwest::Method, body: Vec| { @@ -58,12 +66,49 @@ async fn main() { 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(); @@ -76,7 +121,9 @@ async fn main() { { println!(" {}", line.trim()); } - if status.is_success() { + // 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)"); }