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:
+81
-22
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user