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]