Diagnose the refused sidecar: the library mount is create-only

Ratings and edits made on the tablet queue and are then refused on reconnect,
on a credential that pushes the catalog to the same library root in the same
sync pass. Chased on the device, since that is where the account lives.

A failed PUT now logs the server's own words, and on a 403 asks the parent
what rights it reports. The answer:

    PUT .../PhotosRaw/2026/2026-08-03/_MG_9221.drsc -> 403
      <s:exception>Sabre\DAV\Exception\Forbidden</s:exception>
      parent permissions: MGNVCK

`M` mounted, `G` readable, `N` renameable, `V` moveable, `CK` create files and
folders. Absent: `W`, update an existing file, and `D`, delete. So `PhotosRaw`
is a mounted share that accepts a file once and refuses every change to it
afterwards.

That is the whole bug, and it is not one this side can retry its way out of. A
sidecar is rewritten on every rating and every edit, so the first judgement on
a photograph is written and every later one is refused — which reads as sync
being broken rather than as a share missing one permission. **The fix is to
grant update, and ideally delete, on that mount.**

`PermissionDenied` now says so, rather than "not allowed to write here", which
sent the reader to re-check a login that was working. Its test asserts the
intent — points at the folder, never at the credential — rather than a
phrase, so saying it better cannot read as a regression.

Also adds a `put_probe` example that makes the same request from a stored
session, for diagnosing this from a desktop when one is signed in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 20:19:27 +02:00
co-authored by Claude Opus 5
parent 71554714e7
commit 6621c11ad6
2 changed files with 76 additions and 2 deletions
+49
View File
@@ -360,6 +360,55 @@ impl RemoteBackend for NextcloudBackend {
.take(300)
.collect::<String>();
log::warn!("PUT {url} -> {status}: {reason}");
// A bare `Sabre\DAV\Exception\Forbidden` carries no reason, so
// ask the server what rights it thinks we have on the parent.
// Nextcloud answers in `oc:permissions` — a letter set where `W`
// and `C` are write and create. Their absence is a read-only
// share or mount, which no amount of retrying will change; their
// presence means the refusal is about the *file* rather than the
// folder, which points at an access-control rule on the name.
//
// Read-only, one request, and only on a refusal — this cannot
// make anything worse and it is the difference between a
// server-side fix and a client-side one.
if status == reqwest::StatusCode::FORBIDDEN {
let parent = path
.as_str()
.rsplit_once('/')
.map(|(head, _)| head)
.unwrap_or("");
let probe = self
.client
.request(
reqwest::Method::from_bytes(b"PROPFIND").expect("valid method"),
self.url_for(&RemotePath::new(parent)),
)
.basic_auth(&self.login, Some(&self.password))
.header("Depth", "0")
.header(reqwest::header::CONTENT_TYPE, "application/xml")
.body(
r#"<?xml version="1.0"?><d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns"><d:prop> <oc:permissions/></d:prop></d:propfind>"#,
)
.send()
.await;
match probe {
Ok(r) => {
let text = r.text().await.unwrap_or_default();
let perms = text
.split("<oc:permissions>")
.nth(1)
.and_then(|t| t.split('<').next())
.unwrap_or("(not reported)");
log::warn!(
" parent {parent} permissions: {perms} \
(W = write, C = create; absent means read-only)"
);
}
Err(e) => log::warn!(" could not read parent permissions: {e}"),
}
}
map_status(status, path.as_str())?;
// `map_status` returns `Err` for every non-success, so this is
// unreachable; stated rather than left to inference.