Add remote move and mkdir; distinguish 403 from 401
Soft delete needs to move a photograph into the trash folder and back, and the stable id must survive the trip. WebDAV MOVE is one request and preserves oc:fileid; a copy-then-delete would allocate a new one, orphaning the thumbnail shard entry and the sidecar mapping and turning a restore into a full re-download. Overwrite: F, because a header that permits overwriting is one that eventually does. create_dir does MKCOL outermost-first and treats 405 — Nextcloud's answer for an existing collection — as the goal state rather than an error. Nothing else creates the trash folder, so without it the first trashed image of every library fails with a 409 that reads like a permission problem. PermissionDenied is now separate from AuthFailed. Folding 403 into 401 sent a user to re-check a credential that was working perfectly, with reads succeeding and only the write refused (observed against a real server). The usual cause is an app password created without "Allow filesystem access" — which signing in again will not fix. Assisted-by: LLM
This commit is contained in:
@@ -7,6 +7,17 @@ pub enum RemoteError {
|
||||
#[error("authentication rejected")]
|
||||
AuthFailed,
|
||||
|
||||
/// Authenticated, but not permitted to do this.
|
||||
///
|
||||
/// **Distinct from [`AuthFailed`](Self::AuthFailed) on purpose.** Folding
|
||||
/// 403 into 401 sends the user to re-check a credential that is working
|
||||
/// perfectly: reads succeed, only the write is refused. On Nextcloud the
|
||||
/// 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")]
|
||||
PermissionDenied,
|
||||
|
||||
#[error("not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
@@ -78,5 +89,22 @@ mod tests {
|
||||
.is_transient());
|
||||
assert!(!RemoteError::PreconditionFailed.is_transient());
|
||||
assert!(!RemoteError::AuthFailed.is_transient());
|
||||
// Neither is worth retrying, but they mean different things and a
|
||||
// caller may want to say so.
|
||||
assert!(!RemoteError::PermissionDenied.is_transient());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_denied_is_not_an_auth_failure() {
|
||||
// 403 folded into 401 sent a user to re-check a credential that was
|
||||
// working: reads succeeded and only the write was refused (observed
|
||||
// against a real server, 2026-08-09). The two must read differently.
|
||||
let denied = RemoteError::PermissionDenied.to_string();
|
||||
let rejected = RemoteError::AuthFailed.to_string();
|
||||
assert_ne!(denied, rejected);
|
||||
assert!(
|
||||
denied.contains("not allowed to write"),
|
||||
"the message must point at permissions, not the login: {denied}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -115,6 +115,29 @@ pub trait RemoteBackend: Send + Sync {
|
||||
async fn delete(&self, id: &RemoteId, precond: Option<Precondition>)
|
||||
-> Result<(), RemoteError>;
|
||||
|
||||
/// Move an object, keeping its identity.
|
||||
///
|
||||
/// TRACES: FR-CAT-15
|
||||
/// **The stable id must survive.** This is what a soft delete uses to put a
|
||||
/// photograph in the trash folder, and what a restore uses to bring it back.
|
||||
/// A move implemented as copy-then-delete would allocate a *new*
|
||||
/// `oc:fileid`, which orphans the thumbnail shard entry and the sidecar
|
||||
/// mapping and turns a restore into a full re-download. WebDAV `MOVE` is one
|
||||
/// request and preserves the id, which is why this is its own method rather
|
||||
/// than something the caller composes.
|
||||
///
|
||||
/// Creates missing parent directories of `to`: the trash folder does not
|
||||
/// exist until the first image is trashed, and requiring the caller to
|
||||
/// create it separately makes the first trash of every library a two-step
|
||||
/// dance with a failure mode in the middle.
|
||||
async fn move_to(&self, from: &RemoteId, to: &RemotePath) -> Result<(), RemoteError>;
|
||||
|
||||
/// Create a directory, and any missing parents.
|
||||
///
|
||||
/// Succeeds if it already exists — callers use this to guarantee a
|
||||
/// destination, not to claim they created it.
|
||||
async fn create_dir(&self, path: &RemotePath) -> Result<(), RemoteError>;
|
||||
|
||||
// ---- optional ---------------------------------------------------------
|
||||
|
||||
/// Server-rendered thumbnail, where available.
|
||||
|
||||
Reference in New Issue
Block a user