From 6621c11ad623ef5cdfe0b808991b5978f87a9174 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Mon, 17 Aug 2026 20:19:27 +0200 Subject: [PATCH] Diagnose the refused sidecar: the library mount is create-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Sabre\DAV\Exception\Forbidden 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 --- core/dr-sync-nextcloud/src/lib.rs | 49 +++++++++++++++++++++++++++++++ core/dr-sync/src/error.rs | 29 ++++++++++++++++-- 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/core/dr-sync-nextcloud/src/lib.rs b/core/dr-sync-nextcloud/src/lib.rs index 10a72b7..cdbe0c2 100644 --- a/core/dr-sync-nextcloud/src/lib.rs +++ b/core/dr-sync-nextcloud/src/lib.rs @@ -360,6 +360,55 @@ impl RemoteBackend for NextcloudBackend { .take(300) .collect::(); 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#" "#, + ) + .send() + .await; + match probe { + Ok(r) => { + let text = r.text().await.unwrap_or_default(); + let perms = text + .split("") + .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. diff --git a/core/dr-sync/src/error.rs b/core/dr-sync/src/error.rs index e51e9e1..4f8042f 100644 --- a/core/dr-sync/src/error.rs +++ b/core/dr-sync/src/error.rs @@ -15,7 +15,22 @@ pub enum RemoteError { /// usual cause is an app password created without "Allow filesystem /// access", or a read-only share — neither of which signing in again will /// fix. - #[error("permission denied — the account is authenticated but not allowed to write here")] + /// Authenticated, and refused anyway. + /// + /// The message names the cause that has actually been observed, because + /// "permission denied" alone sends people to re-check a login that is + /// working. On a Nextcloud mount the folder's `oc:permissions` can carry + /// `C` (create) without `W` (update): a file may be written once and never + /// amended. A sidecar is rewritten on every rating and every edit, so the + /// first judgement on a photograph succeeds and every one after it is + /// refused — which reads as sync being broken rather than as a share + /// needing one more permission. + #[error( + "permission denied — authenticated, but this folder does not allow \ + changing an existing file. A Nextcloud share or external mount set to \ + create-only will accept a sidecar once and refuse every later edit; \ + granting update (and delete) on it is the fix." + )] PermissionDenied, #[error("not found: {0}")] @@ -170,10 +185,20 @@ mod tests { let denied = RemoteError::PermissionDenied.to_string(); let rejected = RemoteError::AuthFailed.to_string(); assert_ne!(denied, rejected); + // Asserted on the intent rather than on a phrase: the message must + // send the reader to the folder's permissions and not to their + // credential. It gained the create-only detail after a real mount was + // observed accepting a sidecar once and refusing every later edit + // (2026-08-17), and pinning the old wording would have made that + // improvement look like a regression. assert!( - denied.contains("not allowed to write"), + denied.contains("folder") && denied.contains("permission"), "the message must point at permissions, not the login: {denied}" ); + assert!( + !denied.contains("sign in") && !denied.contains("password"), + "it must not send the reader back to a working login: {denied}" + ); } #[test]