Files
DarkRoom/core/dr-sync-nextcloud/examples/put_probe.rs
T
dtourolleandClaude Opus 5 5700c37016 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 <noreply@anthropic.com>
2026-08-17 20:40:36 +02:00

134 lines
5.0 KiB
Rust

//! Why the server refused a write, in the server's own words.
//!
//! cargo run -p dr-sync-nextcloud --example put_probe -- <server> <remote/path>
//!
//! `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<String> = 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] <server-url> <remote/path>");
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<u8>| {
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#"<?xml version="1.0"?><d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns"><d:prop><oc:permissions/></d:prop></d:propfind>"#.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("<oc:permissions>")
.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}"),
}
}