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>
This commit is contained in:
@@ -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 -- <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}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -335,7 +335,36 @@ impl RemoteBackend for NextcloudBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let resp = req.body(body).send().await.map_err(map_send_error)?;
|
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::<String>();
|
||||||
|
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()
|
resp.headers()
|
||||||
.get(reqwest::header::ETAG)
|
.get(reqwest::header::ETAG)
|
||||||
|
|||||||
Reference in New Issue
Block a user