`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.
242 lines
9.3 KiB
Rust
242 lines
9.3 KiB
Rust
//! Pluggable remote storage for DarkRoom.
|
|
//!
|
|
//! Defines the [`RemoteBackend`] trait and the capability model the sync
|
|
//! engine adapts to, plus the pieces that let the application hold a backend
|
|
//! without naming one: an [`Account`] that is configuration rather than a
|
|
//! server, and a [`BackendProvider`] registry that turns one into a live
|
|
//! connection.
|
|
//!
|
|
//! Two connectors ship: `dr-sync-nextcloud` and `dr-sync-folder`. Adding a
|
|
//! third is implementing those two traits and registering the result — see
|
|
//! [`provider`] for the whole contract.
|
|
//!
|
|
//! # Why capabilities rather than a common denominator
|
|
//!
|
|
//! Nextcloud's fast path relies on a behaviour that is *not* a WebDAV
|
|
//! guarantee: directory ETags propagate up the tree, so an unchanged root
|
|
//! ETag proves nothing anywhere in the library changed. That single property
|
|
//! turns a no-op sync over 50k images into one HTTP request.
|
|
//!
|
|
//! A trait built to what every backend can do would force full enumeration
|
|
//! every time — the exact cost the design exists to avoid. So backends
|
|
//! declare what they support and the engine picks a strategy (ARCH §8.1).
|
|
|
|
use std::ops::Range;
|
|
|
|
use async_trait::async_trait;
|
|
|
|
pub mod account;
|
|
pub mod capability;
|
|
pub mod error;
|
|
pub mod provider;
|
|
pub mod reachability;
|
|
pub mod scan;
|
|
pub mod types;
|
|
pub mod upload;
|
|
|
|
pub use account::{Account, AccountError, AccountStore, Connection, Secret, LEGACY_BACKEND};
|
|
pub use capability::{Capabilities, ChangeDetection, ChunkConstraints, ServerPreviews};
|
|
pub use error::RemoteError;
|
|
pub use provider::{BackendProvider, BackendRegistry, SignIn};
|
|
pub use reachability::{Connectivity, Reachability};
|
|
pub use scan::{scan, ScanProgress, ScanResult};
|
|
pub use types::{
|
|
Cursor, EntryKind, Identity, Precondition, RemoteChange, RemoteEntry, RemoteId, RemotePath,
|
|
Validator,
|
|
};
|
|
pub use upload::{destination, upload_original, Placed};
|
|
|
|
/// TRACES: FR-NC-12
|
|
/// A remote storage backend.
|
|
///
|
|
/// Implementations are expected to be cheap to clone or to be used behind an
|
|
/// `Arc`; the engine may call them concurrently.
|
|
#[async_trait]
|
|
pub trait RemoteBackend: Send + Sync {
|
|
/// What this backend supports. Read once at connect time and used to pick
|
|
/// a sync strategy.
|
|
fn capabilities(&self) -> &Capabilities;
|
|
|
|
/// Human-readable backend name, for logs and the UI.
|
|
fn name(&self) -> &str;
|
|
|
|
// ---- discovery --------------------------------------------------------
|
|
|
|
/// List one directory level.
|
|
///
|
|
/// `since` carries the validator the caller last saw, so backends able to
|
|
/// skip unchanged entries may do so. Backends that cannot simply ignore
|
|
/// it.
|
|
async fn list(
|
|
&self,
|
|
dir: &RemotePath,
|
|
since: Option<&Validator>,
|
|
) -> Result<Vec<RemoteEntry>, RemoteError>;
|
|
|
|
/// Fetch a directory's validator without listing its contents.
|
|
///
|
|
/// The cheap probe that makes ETag pruning work: one request against the
|
|
/// root answers "did anything change?". Backends without
|
|
/// [`ChangeDetection::PropagatingEtags`] return
|
|
/// [`RemoteError::Unsupported`].
|
|
async fn dir_validator(&self, dir: &RemotePath) -> Result<Validator, RemoteError>;
|
|
|
|
/// Ask what changed since a cursor.
|
|
///
|
|
/// Only meaningful for [`ChangeDetection::DeltaCursor`] backends; others
|
|
/// return [`RemoteError::Unsupported`].
|
|
async fn delta(&self, cursor: &Cursor) -> Result<(Vec<RemoteChange>, Cursor), RemoteError>;
|
|
|
|
// ---- transfer ---------------------------------------------------------
|
|
|
|
/// Fetch an object, optionally a byte range.
|
|
///
|
|
/// The range is a hint, not a guarantee: backends without range support
|
|
/// may return the whole object, and the caller slices. Correctness holds
|
|
/// either way; [`Capabilities::range_reads`] says whether it was cheap.
|
|
async fn get(&self, id: &RemoteId, range: Option<Range<u64>>) -> Result<Vec<u8>, RemoteError>;
|
|
|
|
/// Upload, optionally guarded by a precondition.
|
|
///
|
|
/// Backends handle chunking internally based on body size — chunked
|
|
/// upload is an implementation detail, not part of this interface, since
|
|
/// exposing it would leak one server's protocol into the abstraction.
|
|
async fn put(
|
|
&self,
|
|
path: &RemotePath,
|
|
body: Vec<u8>,
|
|
precond: Option<Precondition>,
|
|
) -> Result<Validator, RemoteError>;
|
|
|
|
/// Upload many small objects.
|
|
///
|
|
/// Defaults to sequential [`put`](Self::put) calls; backends with a bulk
|
|
/// endpoint override it. Sidecars are the motivating case — hundreds of
|
|
/// a few KB each.
|
|
async fn put_many(
|
|
&self,
|
|
items: Vec<(RemotePath, Vec<u8>)>,
|
|
) -> Result<Vec<Result<Validator, RemoteError>>, RemoteError> {
|
|
let mut out = Vec::with_capacity(items.len());
|
|
for (path, body) in items {
|
|
out.push(self.put(&path, body, None).await);
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// Delete an object.
|
|
async fn delete(&self, id: &RemoteId, precond: Option<Precondition>)
|
|
-> Result<(), RemoteError>;
|
|
|
|
/// Move an object, keeping its identity.
|
|
///
|
|
/// TRACES: FR-CAT-15
|
|
/// **The stable id must survive.** This is what a soft delete uses to put a
|
|
/// photograph in the trash folder, and what a restore uses to bring it back.
|
|
/// A move implemented as copy-then-delete would allocate a *new*
|
|
/// `oc:fileid`, which orphans the thumbnail shard entry and the sidecar
|
|
/// mapping and turns a restore into a full re-download. WebDAV `MOVE` is one
|
|
/// request and preserves the id, which is why this is its own method rather
|
|
/// than something the caller composes.
|
|
///
|
|
/// Creates missing parent directories of `to`: the trash folder does not
|
|
/// exist until the first image is trashed, and requiring the caller to
|
|
/// create it separately makes the first trash of every library a two-step
|
|
/// dance with a failure mode in the middle.
|
|
async fn move_to(&self, from: &RemoteId, to: &RemotePath) -> Result<(), RemoteError>;
|
|
|
|
/// Create a directory, and any missing parents.
|
|
///
|
|
/// Succeeds if it already exists — callers use this to guarantee a
|
|
/// destination, not to claim they created it.
|
|
async fn create_dir(&self, path: &RemotePath) -> Result<(), RemoteError>;
|
|
|
|
// ---- optional ---------------------------------------------------------
|
|
|
|
/// Server-rendered thumbnail, where available.
|
|
///
|
|
/// `Ok(None)` means the server has no preview for this object — which is
|
|
/// common for RAW, since stock Nextcloud ships no RAW preview provider
|
|
/// (ARCH §6.7). Callers must have a local fallback.
|
|
async fn thumbnail(&self, _id: &RemoteId, _size: u32) -> Result<Option<Vec<u8>>, RemoteError> {
|
|
Ok(None)
|
|
}
|
|
}
|
|
|
|
/// TRACES: FR-NC-4 | FR-NC-12
|
|
/// Which sync strategy the engine should use for a backend.
|
|
///
|
|
/// Derived from capabilities at connect time. Reported to the user so a slow
|
|
/// backend is visibly slow rather than mysteriously slow (FR-NC-12).
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum SyncStrategy {
|
|
/// Ask the server what changed. Cheapest.
|
|
Delta,
|
|
/// Probe the root ETag; recurse only where it differs. One request when
|
|
/// nothing changed.
|
|
EtagPruning,
|
|
/// Enumerate the tree, using per-entry ETags to avoid re-downloading.
|
|
FullListing,
|
|
/// Enumerate and compare modification times. Degraded — clock skew and
|
|
/// second-granularity timestamps both cause misses.
|
|
TimestampCompare,
|
|
}
|
|
|
|
impl SyncStrategy {
|
|
pub fn for_capabilities(caps: &Capabilities) -> Self {
|
|
match caps.change_detection {
|
|
ChangeDetection::DeltaCursor => SyncStrategy::Delta,
|
|
ChangeDetection::PropagatingEtags => SyncStrategy::EtagPruning,
|
|
ChangeDetection::LocalEtags => SyncStrategy::FullListing,
|
|
ChangeDetection::Timestamps => SyncStrategy::TimestampCompare,
|
|
}
|
|
}
|
|
|
|
/// A short description for the UI.
|
|
pub fn describe(self) -> &'static str {
|
|
match self {
|
|
SyncStrategy::Delta => "server change feed",
|
|
SyncStrategy::EtagPruning => "incremental (ETag pruning)",
|
|
SyncStrategy::FullListing => "full listing, cached by ETag",
|
|
SyncStrategy::TimestampCompare => "full listing by timestamp (degraded)",
|
|
}
|
|
}
|
|
|
|
/// Whether this strategy can prove "nothing changed" cheaply.
|
|
///
|
|
/// Where false, every sync costs at least one request per folder, which
|
|
/// the UI should warn about on large libraries.
|
|
pub fn has_cheap_noop(self) -> bool {
|
|
matches!(self, SyncStrategy::Delta | SyncStrategy::EtagPruning)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn strategy_follows_capability() {
|
|
let mut caps = Capabilities::minimal();
|
|
assert_eq!(
|
|
SyncStrategy::for_capabilities(&caps),
|
|
SyncStrategy::TimestampCompare
|
|
);
|
|
|
|
caps.change_detection = ChangeDetection::PropagatingEtags;
|
|
assert_eq!(
|
|
SyncStrategy::for_capabilities(&caps),
|
|
SyncStrategy::EtagPruning
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn only_delta_and_pruning_have_cheap_noop() {
|
|
assert!(SyncStrategy::Delta.has_cheap_noop());
|
|
assert!(SyncStrategy::EtagPruning.has_cheap_noop());
|
|
// These cost at least one request per folder, every time.
|
|
assert!(!SyncStrategy::FullListing.has_cheap_noop());
|
|
assert!(!SyncStrategy::TimestampCompare.has_cheap_noop());
|
|
}
|
|
}
|