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:
@@ -360,6 +360,55 @@ impl RemoteBackend for NextcloudBackend {
|
|||||||
.take(300)
|
.take(300)
|
||||||
.collect::<String>();
|
.collect::<String>();
|
||||||
log::warn!("PUT {url} -> {status}: {reason}");
|
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(status, path.as_str())?;
|
||||||
// `map_status` returns `Err` for every non-success, so this is
|
// `map_status` returns `Err` for every non-success, so this is
|
||||||
// unreachable; stated rather than left to inference.
|
// unreachable; stated rather than left to inference.
|
||||||
|
|||||||
@@ -15,7 +15,22 @@ pub enum RemoteError {
|
|||||||
/// usual cause is an app password created without "Allow filesystem
|
/// usual cause is an app password created without "Allow filesystem
|
||||||
/// access", or a read-only share — neither of which signing in again will
|
/// access", or a read-only share — neither of which signing in again will
|
||||||
/// fix.
|
/// 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,
|
PermissionDenied,
|
||||||
|
|
||||||
#[error("not found: {0}")]
|
#[error("not found: {0}")]
|
||||||
@@ -170,10 +185,20 @@ mod tests {
|
|||||||
let denied = RemoteError::PermissionDenied.to_string();
|
let denied = RemoteError::PermissionDenied.to_string();
|
||||||
let rejected = RemoteError::AuthFailed.to_string();
|
let rejected = RemoteError::AuthFailed.to_string();
|
||||||
assert_ne!(denied, rejected);
|
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!(
|
assert!(
|
||||||
denied.contains("not allowed to write"),
|
denied.contains("folder") && denied.contains("permission"),
|
||||||
"the message must point at permissions, not the login: {denied}"
|
"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]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user