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:
+80
-58
@@ -3,8 +3,20 @@
|
||||
//! The state machine lives here, separate from the Slint bindings, so it can
|
||||
//! be tested without a display server. `dr-ui` owns presentation; what a
|
||||
//! login *is* belongs to the connector.
|
||||
//!
|
||||
//! # Two shapes of sign-in, not two screens
|
||||
//!
|
||||
//! A Nextcloud account is established through a browser handshake the app
|
||||
//! polls for; a folder library is established by naming a directory. The model
|
||||
//! below does not know which is which — it asks the
|
||||
//! [`BackendProvider`](dr_sync::BackendProvider) whether the account it is
|
||||
//! being asked to make needs a credential
|
||||
//! ([`SignIn`](dr_sync::SignIn)), and the screen draws the waiting state only
|
||||
//! where there is something to wait for. Everything after that point — picking
|
||||
//! a library root, ticking formats, opening the grid — is identical, because
|
||||
//! it goes through `dr-sync` rather than through a connector.
|
||||
|
||||
use dr_sync_nextcloud::{Session, SessionStore};
|
||||
use dr_sync::{Account, AccountStore};
|
||||
use dr_types::{Format, FormatFilter};
|
||||
|
||||
/// What the launch screen is currently doing.
|
||||
@@ -18,14 +30,14 @@ pub enum LaunchState {
|
||||
/// handles the password itself (FR-NC-1).
|
||||
AwaitingApproval { login_url: String },
|
||||
/// An account is configured.
|
||||
SignedIn { session: Session },
|
||||
SignedIn { session: Account },
|
||||
/// Working; the reason is shown so a pause is never unexplained.
|
||||
///
|
||||
/// Carries the session where there is one, so a failure mid-work returns
|
||||
/// to the signed-in screen rather than signing the user out.
|
||||
Busy {
|
||||
message: String,
|
||||
session: Option<Box<Session>>,
|
||||
session: Option<Box<Account>>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -53,6 +65,12 @@ pub struct LaunchModel {
|
||||
pub state: LaunchState,
|
||||
/// Last-used server, prefilled so a returning user need not retype it.
|
||||
pub server_url: String,
|
||||
/// Last-used folder, prefilled for the same reason.
|
||||
///
|
||||
/// Separate from `server_url` rather than one "endpoint" field, because
|
||||
/// the screen shows both at once: someone deciding between the two should
|
||||
/// not have to clear one to try the other.
|
||||
pub folder_path: String,
|
||||
pub error: Option<String>,
|
||||
pub status: Option<String>,
|
||||
/// False where no secrets daemon exists (FR-NC-2). The screen must say so
|
||||
@@ -132,6 +150,7 @@ impl Default for LaunchModel {
|
||||
Self {
|
||||
state: LaunchState::SignedOut,
|
||||
server_url: String::new(),
|
||||
folder_path: String::new(),
|
||||
error: None,
|
||||
status: None,
|
||||
can_remember: true,
|
||||
@@ -143,14 +162,16 @@ impl Default for LaunchModel {
|
||||
|
||||
impl LaunchModel {
|
||||
/// Build from stored sessions, resuming the last account if there is one.
|
||||
pub fn from_store(store: &SessionStore) -> Self {
|
||||
pub fn from_store(store: &AccountStore) -> Self {
|
||||
let can_remember = store.can_remember();
|
||||
|
||||
match store.current() {
|
||||
Some(session) => {
|
||||
let filter = session.format_filter();
|
||||
let (server_url, folder_path) = prefill(&session);
|
||||
Self {
|
||||
server_url: session.server.clone(),
|
||||
server_url,
|
||||
folder_path,
|
||||
formats: Format::ALL
|
||||
.iter()
|
||||
.map(|f| (*f, filter.allows(*f)))
|
||||
@@ -175,7 +196,7 @@ impl LaunchModel {
|
||||
matches!(self.state, LaunchState::Busy { .. })
|
||||
}
|
||||
|
||||
pub fn session(&self) -> Option<&Session> {
|
||||
pub fn session(&self) -> Option<&Account> {
|
||||
match &self.state {
|
||||
LaunchState::SignedIn { session } => Some(session),
|
||||
// A session survives a busy period; a scan failure must not log
|
||||
@@ -245,7 +266,7 @@ impl LaunchModel {
|
||||
// --- transitions ---------------------------------------------------
|
||||
|
||||
pub fn begin_sign_in(&mut self, server: impl Into<String>) {
|
||||
self.server_url = normalise_server(&server.into());
|
||||
self.server_url = server.into();
|
||||
self.error = None;
|
||||
self.state = LaunchState::Busy {
|
||||
message: "Contacting server…".into(),
|
||||
@@ -263,7 +284,7 @@ impl LaunchModel {
|
||||
/// Security. That makes it the workable option where no browser can
|
||||
/// complete the handshake.
|
||||
pub fn begin_direct_sign_in(&mut self, server: impl Into<String>) {
|
||||
self.server_url = normalise_server(&server.into());
|
||||
self.server_url = server.into();
|
||||
self.error = None;
|
||||
self.state = LaunchState::Busy {
|
||||
message: "Checking the credentials…".into(),
|
||||
@@ -278,10 +299,14 @@ impl LaunchModel {
|
||||
};
|
||||
}
|
||||
|
||||
pub fn signed_in(&mut self, session: Session) {
|
||||
pub fn signed_in(&mut self, session: Account) {
|
||||
self.error = None;
|
||||
self.status = None;
|
||||
self.server_url = session.server.clone();
|
||||
let (server_url, folder_path) = prefill(&session);
|
||||
self.server_url = server_url;
|
||||
if !folder_path.is_empty() {
|
||||
self.folder_path = folder_path;
|
||||
}
|
||||
let filter = session.format_filter();
|
||||
// Adopt the session's stored selection, so a returning user sees the
|
||||
// tick-boxes they left.
|
||||
@@ -357,7 +382,7 @@ impl LaunchModel {
|
||||
/// Adopt the picker's current path as the library root.
|
||||
///
|
||||
/// Returns the session to persist, or `None` when signed out.
|
||||
pub fn choose_current_folder(&mut self) -> Option<Session> {
|
||||
pub fn choose_current_folder(&mut self) -> Option<Account> {
|
||||
let path = self.browser.as_ref()?.path.clone();
|
||||
let mut session = self.session()?.clone();
|
||||
session.root = path;
|
||||
@@ -378,25 +403,16 @@ impl LaunchModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalise a server address typed by hand.
|
||||
/// Which of the two entry fields an account's endpoint belongs in.
|
||||
///
|
||||
/// Users type `cloud.example.com`, not a URL. Assume HTTPS rather than
|
||||
/// failing, and never silently accept plain HTTP — NFR-SEC-3 requires TLS,
|
||||
/// and an unencrypted default would be a security decision made on the user's
|
||||
/// behalf without telling them.
|
||||
pub fn normalise_server(input: &str) -> String {
|
||||
let s = input.trim().trim_end_matches('/');
|
||||
if s.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
if s.starts_with("https://") {
|
||||
s.to_string()
|
||||
} else if let Some(rest) = s.strip_prefix("http://") {
|
||||
// Upgrade rather than accept. If the server genuinely has no TLS the
|
||||
// connection fails loudly, which is the correct outcome.
|
||||
format!("https://{rest}")
|
||||
/// A returning user should find what they typed last time where they typed
|
||||
/// it. Keyed on whether the connector has a login rather than on its id, so a
|
||||
/// third backend does not have to be named here to be prefilled correctly.
|
||||
fn prefill(account: &Account) -> (String, String) {
|
||||
if account.login.is_empty() {
|
||||
(String::new(), account.endpoint.clone())
|
||||
} else {
|
||||
format!("https://{s}")
|
||||
(account.endpoint.clone(), String::new())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,18 +420,17 @@ pub fn normalise_server(input: &str) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use dr_plat::EphemeralSecretStore;
|
||||
use dr_sync_nextcloud::AppCredentials;
|
||||
use dr_sync::Secret;
|
||||
|
||||
fn creds() -> AppCredentials {
|
||||
AppCredentials {
|
||||
server: "https://cloud.example".into(),
|
||||
login_name: "duncan".into(),
|
||||
app_password: "token".into(),
|
||||
}
|
||||
fn session_with_root(root: &str) -> Account {
|
||||
let mut s =
|
||||
Account::new("nextcloud", "https://cloud.example").with_login("duncan", "duncan");
|
||||
s.root = root.into();
|
||||
s
|
||||
}
|
||||
|
||||
fn session_with_root(root: &str) -> Session {
|
||||
let mut s = Session::new(&creds(), "duncan");
|
||||
fn folder_with_root(root: &str) -> Account {
|
||||
let mut s = Account::new("folder", "/mnt/photos");
|
||||
s.root = root.into();
|
||||
s
|
||||
}
|
||||
@@ -513,27 +528,34 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_addresses_are_normalised_to_https() {
|
||||
assert_eq!(normalise_server("cloud.example"), "https://cloud.example");
|
||||
assert_eq!(
|
||||
normalise_server("https://cloud.example/"),
|
||||
"https://cloud.example"
|
||||
);
|
||||
assert_eq!(
|
||||
normalise_server(" cloud.example "),
|
||||
"https://cloud.example"
|
||||
);
|
||||
assert_eq!(normalise_server(""), "");
|
||||
fn a_returning_user_finds_their_endpoint_where_they_typed_it() {
|
||||
// Two entry fields are shown at once. Prefilling the wrong one — a
|
||||
// folder path into the server box — reads as a corrupted setting.
|
||||
let mut m = LaunchModel::default();
|
||||
m.signed_in(session_with_root("PhotosRaw"));
|
||||
assert_eq!(m.server_url, "https://cloud.example");
|
||||
assert_eq!(m.folder_path, "");
|
||||
|
||||
let mut m = LaunchModel::default();
|
||||
m.signed_in(folder_with_root("2026"));
|
||||
assert_eq!(m.folder_path, "/mnt/photos");
|
||||
assert_eq!(m.server_url, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_http_is_upgraded_rather_than_accepted() {
|
||||
// NFR-SEC-3: TLS is required. Failing loudly beats silently sending a
|
||||
// credential in the clear.
|
||||
assert_eq!(
|
||||
normalise_server("http://cloud.example"),
|
||||
"https://cloud.example"
|
||||
);
|
||||
fn a_folder_library_reaches_the_grid_the_same_way_a_server_one_does() {
|
||||
// Everything past sign-in is backend-neutral, and this is the check
|
||||
// that keeps it so: no branch on the account's connector below here.
|
||||
let mut m = LaunchModel::default();
|
||||
m.signed_in(folder_with_root(""));
|
||||
assert!(m.is_signed_in());
|
||||
assert!(!m.can_open_library(), "no root chosen yet");
|
||||
assert_eq!(m.startup_action(false), Startup::ShowLaunchScreen);
|
||||
|
||||
m.signed_in(folder_with_root("2026"));
|
||||
assert!(m.can_open_library());
|
||||
assert_eq!(m.startup_action(false), Startup::OpenLibrary);
|
||||
assert_eq!(m.account_label(), "/mnt/photos/2026");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -554,13 +576,13 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
let store = SessionStore::open_at(
|
||||
let store = AccountStore::open_at(
|
||||
dir.join("sessions.json"),
|
||||
Box::new(EphemeralSecretStore::new()),
|
||||
);
|
||||
let mut s = session_with_root("PhotosRaw");
|
||||
s.set_format_filter(&FormatFilter::from_formats([Format::Cr2]));
|
||||
store.save(&s, &creds()).unwrap();
|
||||
store.save(&s, Some(&Secret::new("token"))).unwrap();
|
||||
|
||||
let m = LaunchModel::from_store(&store);
|
||||
assert!(m.is_signed_in());
|
||||
@@ -576,7 +598,7 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
let store = SessionStore::open_at(
|
||||
let store = AccountStore::open_at(
|
||||
dir.join("sessions.json"),
|
||||
Box::new(EphemeralSecretStore::new()),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user