Files
DarkRoom/core/dr-sync/src/lib.rs
T
dtourolle f8a718f42e Add Nextcloud connector; reject VFS as a transfer mechanism
Investigated using the Nextcloud desktop client's Virtual Files as a
cache instead of talking to the server directly. Measured on this
machine (client 4.0.7): the configured folder holds 121,785 placeholders
against 10,267 materialised files, including 7,037 CR2 and 9,411 DNG.

Three findings, each independently disqualifying:

  - Linux VFS is *suffix* mode. A dehydrated IMG.CR2 exists only as
    IMG.CR2.nextcloud holding one byte; the real name is absent.
  - Reading a placeholder does not hydrate it. dd of the first 256KB
    returned 1 byte, the stub was unchanged, and the real name never
    appeared. There is no FUSE layer — the stub is an inert marker.
  - Even with hydration the granularity is wrong: VFS has two states,
    1 byte or all bytes, and the preview tier needs a ~256KB prefix of
    a 27MB file. That is ~100x what FR-NC-3 requires.

Recorded as ARCH §9.0. Coexistence is still supported: dr-types now
recognises *.nextcloud stubs, and the viewer lists them as "not
downloaded" rather than as corrupt files or not at all.

So the connector talks to the server directly, as D7 specified.
Implemented: Login Flow v2, PROPFIND with oc:fileid and nc:has-preview,
ETag pruning via a Depth:0 probe, range GET with local slicing when the
server ignores the header, conditional PUT, and /core/preview with
forceIcon=false. delta() returns Unsupported and says why.

Chunked upload v2 is not implemented yet — put() rejects bodies over
5MB explicitly rather than silently truncating.

Two bugs found by testing: my hand-computed epoch in a date test was a
day out (the parser was right), and quick-xml reaches EOF on truncated
input without erroring, so unbalanced elements needed an explicit check
— a half-parsed multistatus must not look like an empty directory.

83 tests passing.
2026-08-09 09:09:27 +02:00

204 lines
7.5 KiB
Rust

//! Pluggable remote storage for DarkRoom.
//!
//! Defines the [`RemoteBackend`] trait and the capability model the sync
//! engine adapts to. Only the Nextcloud connector is implemented
//! (`dr-sync-nextcloud`), but the boundary is designed so other backends can
//! be added without touching the engine.
//!
//! # 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 capability;
pub mod error;
pub mod types;
pub use capability::{Capabilities, ChangeDetection, ChunkConstraints, ServerPreviews};
pub use error::RemoteError;
pub use types::{
Cursor, EntryKind, Identity, Precondition, RemoteChange, RemoteEntry, RemoteId, RemotePath,
Validator,
};
/// 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>;
// ---- 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());
}
}