Files
DarkRoom/core/dr-sync-nextcloud/examples/put_probe.rs
T
dtourolleandClaude Opus 5 71554714e7 Log why the server refused a write, in its own words
A queued sidecar fails to upload with 403 on a credential that pushes the
catalog to the same library root in the same pass. `map_status` reduces every
non-success to a typed error, which is right for the application and leaves
nothing to work from: a read-only share, a file access control rule and a lock
all arrive as `PermissionDenied`.

Sabre says which in the response body. It is now logged on any failed PUT —
the URL, the status, and the first line naming the exception or message,
capped at 300 characters because an error page can be a whole document. Only
on failure; a success has no body worth reading.

Also adds a `put_probe` example that makes the same request from a stored
session and prints the reason, for diagnosing this from a desktop rather than
from a tablet's logcat. It needs a session on the machine it runs on, which is
why the log line above exists as well.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 20:08:07 +02:00

87 lines
2.9 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 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 <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!("PUT {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))
.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}"),
}
}