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.
This commit is contained in:
2026-08-29 09:57:52 +02:00
parent 1b8b7998a2
commit f12aece07e
40 changed files with 3617 additions and 960 deletions
+81 -22
View File
@@ -1,4 +1,4 @@
// TRACES: FR-NC-12
// TRACES: FR-NC-12 | FR-NC-13
//! The one place the interface names a backend.
//!
//! `dr-sync` defines [`RemoteBackend`] and a capability model the engine adapts
@@ -9,32 +9,91 @@
//! bought nothing it was designed for and a WebDAV or local-folder backend
//! would have had nowhere to go.
//!
//! Everything above this module now works through `&dyn RemoteBackend`. Adding
//! a backend is implementing the trait and changing [`connect`] — not editing
//! seven files.
//! Everything above this module works through `&dyn RemoteBackend`, and every
//! account it opens is a [`dr_sync::Account`] — configuration with no server
//! in it. Adding a backend is implementing two traits and adding a line to
//! [`registry`]; nothing else in `dr-ui` changes. See `docs/storage.md`.
//!
//! ## What is deliberately still Nextcloud-shaped
//! # Why the registry is built here and not in `dr-sync`
//!
//! Credentials. [`AppCredentials`] is an app password obtained through Login
//! Flow v2, which is a Nextcloud protocol rather than a general notion of
//! "how one authenticates to a remote". Abstracting it needs a decision about
//! what an account *is* across backends — an OAuth token, a bucket key pair
//! and an app password have no useful common shape — and inventing one before
//! a second backend exists would produce a wrong answer confidently. That is
//! the remaining half of this seam, and it is a design problem rather than a
//! mechanical one.
//! `dr-sync` must not depend on any connector, or the engine would drag a TLS
//! stack into a build that only wanted a folder. So the crate that already
//! depends on all of them — the interface — is where the list lives. It is
//! the only file in the application that names one.
use dr_sync::{RemoteBackend, RemoteError};
use dr_sync_nextcloud::{AppCredentials, NextcloudBackend};
use std::sync::{Arc, OnceLock};
/// Open a connection to the configured remote.
use dr_sync::{Account, BackendProvider, BackendRegistry, Connection, RemoteBackend, RemoteError};
use dr_sync_folder::FolderProvider;
use dr_sync_nextcloud::NextcloudProvider;
/// Every storage backend this build has, in the order the launch screen
/// offers them.
///
/// Built once. A provider is stateless — it holds no connection and no
/// credential — so one instance serves every thread that asks.
pub fn registry() -> &'static BackendRegistry {
static REGISTRY: OnceLock<BackendRegistry> = OnceLock::new();
REGISTRY.get_or_init(|| {
let mut r = BackendRegistry::new();
r.register(Arc::new(NextcloudProvider));
r.register(Arc::new(FolderProvider));
r
})
}
/// The connector serving an account.
///
/// Errors when this build has none — a configuration file outlives the binary
/// that wrote it, and saying *which* backend is missing beats "could not open
/// library".
pub fn provider_for(account: &Account) -> Result<&'static Arc<dyn BackendProvider>, RemoteError> {
registry().for_account(account)
}
/// Whether an account's credential has to be fetched from secure storage.
///
/// Asked of the connector rather than inferred from the account, because an
/// empty login might be a folder library or might be a damaged record, and
/// guessing turns the second into a silent unauthenticated connection.
pub fn needs_secret(account: &Account) -> bool {
provider_for(account).is_ok_and(|p| p.sign_in().needs_secret())
}
/// Open a connection to a configured remote.
///
/// Returns the trait object every caller should hold. The error type is
/// `dr-sync`'s rather than the connector's, so a caller handles a failure
/// `dr-sync`'s rather than a connector's, so a caller handles a failure
/// without learning which backend produced it.
pub(crate) fn connect(
creds: &AppCredentials,
user_id: &str,
) -> Result<Box<dyn RemoteBackend>, RemoteError> {
Ok(Box::new(NextcloudBackend::new(creds, user_id)?))
pub(crate) fn connect(conn: &Connection) -> Result<Box<dyn RemoteBackend>, RemoteError> {
registry().connect(conn)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn both_backends_are_registered() {
// The list the launch screen offers. A backend missing from here is a
// backend the user cannot choose, however complete its connector is.
let ids: Vec<&str> = registry().providers().iter().map(|p| p.id()).collect();
assert!(ids.contains(&"nextcloud"), "{ids:?}");
assert!(ids.contains(&"folder"), "{ids:?}");
}
#[test]
fn only_the_backend_with_a_login_wants_a_credential() {
assert!(needs_secret(&Account::new("nextcloud", "https://x")));
assert!(!needs_secret(&Account::new("folder", "/mnt/photos")));
}
#[test]
fn an_account_for_an_unknown_backend_names_it() {
let e = provider_for(&Account::new("s3", "bucket"))
.err()
.expect("no such connector")
.to_string();
assert!(e.contains("s3"), "{e}");
}
}