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
+27 -2
View File
@@ -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]