Files
DarkRoom/core/dr-sync/src/error.rs
T
dtourolle f12aece07e Make storage pluggable, and prove it with a folder backend
`RemoteBackend` existed from the first release and bought nothing it was
designed for. Seven files in `dr-ui` constructed a `NextcloudBackend`
directly, an account *was* a server URL beside a DAV user id, the local
cache directory was named after a hostname, and the launch screen knew
that signing in meant a browser handshake. The trait was real; the seam
was documentation.

A trait over operations is only a quarter of it. Pluggable storage needs
four things, and this adds the other three:

- **Capabilities** — already there, and the reason the engine can drive
  two backends at the speed each actually runs at.
- **Configuration** — `dr_sync::Account`: where a library lives, in
  whatever form its connector addresses, with no server in it. Loads
  every existing config unchanged (`backend` defaults to `nextcloud`,
  `endpoint` is stored under its historical `server` key), and
  `Account::namespace()` reproduces the old catalog directory byte for
  byte, because changing it would abandon a catalog, its thumbnail
  shards, and the sidecars holding unsynced offline work.
- **Registration** — `BackendProvider` and `BackendRegistry`.
  `ui/dr-ui/src/remote.rs` is now the only file above `dr-sync` that
  names a connector.

`Connection` (an account plus an optional `Secret`) replaces the
credentials-and-user-id pair that was threaded through fifteen
signatures in an order that could be swapped. `Secret`'s inner string is
reachable only through `expose()` and its `Debug` prints `Secret(***)`,
so the indirect leak — a `{:?}` on anything holding one — no longer
compiles into a leak.

Nextcloud is unchanged and keeps every peculiarity: propagating ETags,
chunked upload v2, `oc:fileid`, the `oc:permissions` probe on a refused
PUT, the 423 retry classification, Login Flow v2. Those are what the
capability model exists to serve, not something to hide.

`dr-sync-folder` is the second connector: a local disk, a network mount,
an external drive, or a folder a Nextcloud client already syncs. No
account, no credential — the route that works where no secrets daemon
does. It declares `LocalEtags` rather than claiming propagation a POSIX
directory cannot provide, which costs nothing because 50k `stat` calls
are not 50k PROPFINDs. Identity is a path hash, not an inode: an inode
survives a rename but differs between devices and is reused after a
delete, so two machines would disagree about which photograph a
thumbnail belonged to. Re-deriving a thumbnail is a cost; showing the
wrong one is a bug.

docs/storage.md is the contract — the traits, the four steps to add a
backend, and what each connector declares. ARCH §8.0 and §8.4a, and
FR-NC-13, say why.
2026-08-29 09:57:52 +02:00

244 lines
9.8 KiB
Rust

/// Failures from a remote backend.
#[derive(Debug, thiserror::Error)]
pub enum RemoteError {
#[error("not authenticated")]
Unauthenticated,
#[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.
/// 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}")]
NotFound(String),
/// The backend does not support this operation. Expected, not a bug —
/// callers check capabilities and adapt.
#[error("operation unsupported by this backend: {0}")]
Unsupported(&'static str),
/// The account is configured wrongly, or for a backend this build has no
/// connector for.
///
/// **Not a network failure and not an auth failure**, which is why it is
/// its own variant. A folder library whose directory has been unmounted,
/// or an account naming a backend a cut-down build was not compiled with,
/// produces a request that never leaves the process — reporting either as
/// `Network` would put the app into offline mode and tell the user their
/// connection is down, and reporting them as `AuthFailed` would send them
/// to re-enter a credential that is fine. The message names what is wrong
/// with the configuration, because that is the only thing that will fix
/// it.
#[error("account misconfigured: {0}")]
Configuration(String),
/// A conditional write failed: the remote changed underneath us. Triggers
/// the sidecar merge path (ARCH §8.5).
#[error("precondition failed — remote was modified")]
PreconditionFailed,
#[error("quota exceeded")]
QuotaExceeded,
#[error("network error: {0}")]
Network(String),
/// The TLS handshake was refused: an untrusted issuer, an expired
/// certificate, a hostname mismatch.
///
/// **Distinct from [`Network`](Self::Network) on purpose.** The server is
/// there and answering — the connection is refused on trust grounds, which
/// no amount of waiting repairs. Folding it into `Network` puts the app
/// into offline mode and tells the user their connection is down, sending
/// them to inspect a network that is working perfectly (observed
/// 2026-08-12: a Let's Encrypt chain anchored at ISRG Root YE, absent from
/// the compiled-in root store, reported as "you are offline" on a server
/// answering in 19 ms).
#[error("TLS error: {0}")]
Tls(String),
#[error("unexpected server response: {status} {detail}")]
Server { status: u16, detail: String },
#[error("malformed response: {0}")]
Protocol(String),
#[error("operation cancelled")]
Cancelled,
}
impl RemoteError {
/// Whether retrying might succeed.
pub fn is_transient(&self) -> bool {
match self {
RemoteError::Network(_) => true,
// Not transient: a rejected certificate is rejected identically on
// every retry. Retrying one buys nothing and hides the cause.
RemoteError::Tls(_) => false,
RemoteError::Server { status, .. } => {
// 5xx and 429 are worth retrying; other 4xx are not.
//
// 423 Locked is the exception, and it is not hypothetical:
// Nextcloud's file locking returns it on a plain *read* under
// concurrency, and the same range re-read seconds later
// succeeds. Treating it as permanent marks an image
// permanently undated over a lock that lasted moments.
*status >= 500 || *status == 429 || *status == 423
}
_ => false,
}
}
/// Whether this failure means *the server could not be reached*, as
/// opposed to the server answering and refusing.
///
/// The distinction is the whole basis of offline mode (FR-CAT-9). A 403
/// and a dead connection are both "the operation failed", but only one of
/// them is fixed by waiting, and only one of them should put the whole app
/// into a degraded mode. Signing the user out — or showing "you are
/// offline" — because a single file was forbidden would be a much worse
/// error than the one it reported.
///
/// A 5xx is deliberately **not** offline: the server is up and talking, it
/// is just failing, and a retry is the right response rather than a
/// mode change. 429 and 423 likewise — those are the server working
/// correctly under load.
///
/// [`Tls`](Self::Tls) is likewise not offline. The host resolved, the
/// socket connected, and the peer answered; only trust failed. Reporting
/// that as offline is what made a root-store gap look like a dead network.
pub fn indicates_offline(&self) -> bool {
matches!(self, RemoteError::Network(_))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_tls_failure_is_not_offline() {
// The regression this guards: a rejected certificate was reported as
// `Network`, which put the whole app into offline mode and told the
// user their connection was down — while the server answered in 19 ms.
let e = RemoteError::Tls("invalid peer certificate: UnknownIssuer".into());
assert!(
!e.indicates_offline(),
"trust failure is not unreachability"
);
assert!(
!e.is_transient(),
"a rejected certificate is rejected identically on every retry"
);
}
#[test]
fn a_dead_connection_is_still_offline() {
// The other side of the split: separating TLS out must not stop a
// genuine transport failure from reaching offline mode.
assert!(RemoteError::Network("dns error".into()).indicates_offline());
}
#[test]
fn transient_errors_are_retryable() {
assert!(RemoteError::Network("timeout".into()).is_transient());
assert!(RemoteError::Server {
status: 503,
detail: String::new()
}
.is_transient());
assert!(RemoteError::Server {
status: 429,
detail: String::new()
}
.is_transient());
}
#[test]
fn client_errors_are_not_retryable() {
assert!(!RemoteError::Server {
status: 404,
detail: String::new()
}
.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);
// 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("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]
fn a_lock_is_transient() {
// Observed against a real server: 12 concurrent range reads produced
// 423 on some files, and the identical request succeeded moments
// later. Classing it with the permanent 4xx left those images
// undated for good.
assert!(RemoteError::Server {
status: 423,
detail: String::new()
}
.is_transient());
// Still permanent, so the exception stays narrow.
assert!(!RemoteError::Server {
status: 404,
detail: String::new()
}
.is_transient());
assert!(!RemoteError::Server {
status: 400,
detail: String::new()
}
.is_transient());
}
}