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:
@@ -23,8 +23,9 @@ use std::collections::HashMap;
|
||||
use std::time::Instant;
|
||||
|
||||
use dr_plat::PlatformSecretStore;
|
||||
use dr_sync::{Account, AccountStore, Secret};
|
||||
use dr_sync::{RemoteBackend, RemoteId, RemotePath, SyncStrategy};
|
||||
use dr_sync_nextcloud::{auth, AppCredentials, NextcloudBackend, Session, SessionStore};
|
||||
use dr_sync_nextcloud::{auth, AppCredentials, NextcloudBackend, NextcloudProvider};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
@@ -57,17 +58,20 @@ async fn main() {
|
||||
|
||||
// Sessions persist across runs: credentials in the platform keyring
|
||||
// (FR-NC-2), everything else as ordinary config.
|
||||
let sessions = SessionStore::open(Box::new(PlatformSecretStore::new()));
|
||||
let sessions = AccountStore::open(Box::new(PlatformSecretStore::new()));
|
||||
if !sessions.can_remember() {
|
||||
println!("note: no secrets daemon — sign-in will not persist this session");
|
||||
}
|
||||
|
||||
let existing = sessions
|
||||
.current()
|
||||
.filter(|s| s.server == server.trim_end_matches('/'));
|
||||
.filter(|s| s.endpoint == server.trim_end_matches('/'));
|
||||
|
||||
let (session, creds) = match existing {
|
||||
Some(s) => match sessions.credentials(&s) {
|
||||
Some(s) => match sessions
|
||||
.connection(&s, true)
|
||||
.and_then(|c| Ok(NextcloudProvider::credentials(&c)?))
|
||||
{
|
||||
Ok(c) => {
|
||||
println!("signed in: {}", s.describe());
|
||||
(s, c)
|
||||
@@ -235,7 +239,7 @@ fn describe_filter(f: &dr_types::FormatFilter) -> String {
|
||||
}
|
||||
|
||||
/// Run Login Flow v2 and persist the result.
|
||||
async fn sign_in(server: &str, sessions: &SessionStore) -> (Session, AppCredentials) {
|
||||
async fn sign_in(server: &str, sessions: &AccountStore) -> (Account, AppCredentials) {
|
||||
let client = match dr_sync_nextcloud::http_client("DarkRoom") {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
@@ -270,8 +274,8 @@ async fn sign_in(server: &str, sessions: &SessionStore) -> (Session, AppCredenti
|
||||
creds.login_name.clone()
|
||||
});
|
||||
|
||||
let session = Session::new(&creds, user_id);
|
||||
match sessions.save(&session, &creds) {
|
||||
let session = NextcloudProvider::account_from(&creds, user_id);
|
||||
match sessions.save(&session, Some(&Secret::new(&creds.app_password))) {
|
||||
Ok(()) => println!(" session saved to {}", sessions.config_path().display()),
|
||||
Err(e) => eprintln!(" could not persist session: {e}"),
|
||||
}
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
//! and deleted again, which tests creation and costs nothing.
|
||||
|
||||
use dr_plat::PlatformSecretStore;
|
||||
use dr_sync_nextcloud::session::SessionStore;
|
||||
use dr_sync::AccountStore;
|
||||
use dr_sync_nextcloud::NextcloudProvider;
|
||||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() {
|
||||
@@ -30,15 +31,19 @@ async fn main() {
|
||||
std::process::exit(2);
|
||||
};
|
||||
|
||||
let sessions = SessionStore::open(Box::new(PlatformSecretStore::new()));
|
||||
let sessions = AccountStore::open(Box::new(PlatformSecretStore::new()));
|
||||
let Some(session) = sessions
|
||||
.current()
|
||||
.filter(|s| s.server == server.trim_end_matches('/'))
|
||||
.filter(|s| s.endpoint == server.trim_end_matches('/'))
|
||||
else {
|
||||
eprintln!("no stored session for {server}");
|
||||
std::process::exit(1);
|
||||
};
|
||||
let creds = match sessions.credentials(&session) {
|
||||
let creds = match sessions
|
||||
.connection(&session, true)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|c| NextcloudProvider::credentials(&c).map_err(|e| e.to_string()))
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!("credentials: {e}");
|
||||
@@ -48,7 +53,7 @@ async fn main() {
|
||||
|
||||
let url = format!(
|
||||
"{}/remote.php/dav/files/{}/{}",
|
||||
session.server.trim_end_matches('/'),
|
||||
session.endpoint.trim_end_matches('/'),
|
||||
session.user_id,
|
||||
path
|
||||
);
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
//! One-shot write probe: PUT a tiny file, report the status, DELETE it.
|
||||
use dr_plat::PlatformSecretStore;
|
||||
use dr_sync::{RemoteBackend, RemoteId, RemotePath};
|
||||
use dr_sync_nextcloud::{NextcloudBackend, SessionStore};
|
||||
use dr_sync::{AccountStore, RemoteBackend, RemoteId, RemotePath};
|
||||
use dr_sync_nextcloud::{NextcloudBackend, NextcloudProvider};
|
||||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() {
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
||||
|
||||
let store = SessionStore::open(Box::new(PlatformSecretStore::new()));
|
||||
let store = AccountStore::open(Box::new(PlatformSecretStore::new()));
|
||||
let Some(session) = store.current() else {
|
||||
println!("no stored session");
|
||||
return;
|
||||
};
|
||||
let creds = match store.credentials(&session) {
|
||||
let creds = match store
|
||||
.connection(&session, true)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|c| NextcloudProvider::credentials(&c).map_err(|e| e.to_string()))
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
println!("credentials: {e}");
|
||||
|
||||
Reference in New Issue
Block a user