Files
DarkRoom/ui/dr-ui/src/remote.rs
T
dtourolle c102ba9df2 Treat a placeholder as the photograph, not as a one-byte file
The folder connector was pointed at a Nextcloud VFS tree and got three
things wrong, the first of which loses work.

**A dehydrated sidecar read as absent.** `a.drsc` does not exist when the
client has dehydrated it — only `a.drsc.nextcloud` does — so `get` missed,
`.ok()` swallowed the `NotFound`, and the sidecar writer took that for
"there is no sidecar yet" and wrote a fresh document over the existing
one. Every edit another device had put there went with it. That function's
own doc comment calls this the exact loss the format's unknown-key
preservation exists to prevent.

**A stub was catalogued as a 1-byte image**, and ARCH §9.0 measured this
machine at 121,785 placeholders against 10,267 real files — so a folder
library on a synced tree was ~92% broken rows.

**Identity changed on hydration**, so downloading a photograph looked like
a delete and an add, orphaning its thumbnail and its face rows.

Entries now carry the photograph's own name and a `materialised` flag;
`get` on a stub returns the new `RemoteError::NotMaterialised`, which is
distinct from `NotFound` precisely because the sidecar writer must treat
them differently — it fetches the sidecar and merges, or leaves the entry
queued.

Hydration is a **borrow**. `BorrowPool` records what was on disk before it
asked, so `release_all` dehydrates only what a pass brought and leaves
what the user already had. Reference counted: the thumbnail pass and the
face pass meet on the same RAW, and without counting the first to finish
dehydrates the file the second is reading. A borrow against a plain folder
or a server does nothing, so a pass written for VFS runs everywhere.

Releasing means asking the client to dehydrate and never deleting: a
deletion inside a synced tree propagates to the server and removes the
photograph from every device.

Not a second backend — the capability is per *connection*, not per type,
since the same folder hydrates only while the client runs. The convention
arrives through a detector the registry supplies, so `dr-sync-folder`
still knows nothing about any client's protocol.

ARCH §9.0a records this as an amendment: finding 3 rejected hydration
because it costs 100× a range read, and that comparison assumed a
connector was available. A folder library has none.
2026-08-29 09:57:52 +02:00

109 lines
4.6 KiB
Rust

// 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
//! to, so that a second backend can be added without touching the code that
//! uses one (ARCH §8.1). Until this module existed that boundary was
//! documentation: seven files in `dr-ui` constructed a `NextcloudBackend`
//! directly and ten functions took one by concrete type, so the abstraction
//! bought nothing it was designed for and a WebDAV or local-folder backend
//! would have had nowhere to go.
//!
//! 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`.
//!
//! # Why the registry is built here and not in `dr-sync`
//!
//! `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 std::sync::{Arc, OnceLock};
use dr_sync::{Account, BackendProvider, BackendRegistry, Connection, RemoteBackend, RemoteError};
use dr_sync_folder::FolderProvider;
use dr_sync_nextcloud::{NextcloudProvider, NextcloudVfs};
/// 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));
// TRACES: FR-NC-6c
// The folder connector does the filesystem work and knows nothing
// about sync clients; the placeholder convention is supplied here,
// which is the one place that may name one. Detection is per folder
// and per connection — the same directory offers hydration while the
// client is running and not while it is down (ARCH §9.0).
r.register(Arc::new(FolderProvider::with_vfs_detector(|root| {
NextcloudVfs::looks_synced(root)
.then(|| Arc::new(NextcloudVfs::detect()) as Arc<dyn dr_sync_folder::Vfs>)
})));
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 a connector's, so a caller handles a failure
/// without learning which backend produced it.
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}");
}
}