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:
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "dr-sync-folder"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
dr-types.workspace = true
|
||||
dr-sync.workspace = true
|
||||
async-trait.workspace = true
|
||||
thiserror.workspace = true
|
||||
log.workspace = true
|
||||
# Filesystem work runs on the blocking pool rather than on the async worker
|
||||
# that called it — see the module docs.
|
||||
tokio = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true }
|
||||
@@ -0,0 +1,612 @@
|
||||
// TRACES: FR-NC-13 | FR-NC-12
|
||||
//! A library that is just a directory.
|
||||
//!
|
||||
//! The second [`RemoteBackend`], and the one that exists to prove the first
|
||||
//! was an abstraction rather than a description. It serves a plain folder: a
|
||||
//! local disk, an NFS or SMB mount, a Nextcloud desktop client's synced copy,
|
||||
//! an external drive. No server, no account, no credential.
|
||||
//!
|
||||
//! # What it is honestly worse at, and why that is fine
|
||||
//!
|
||||
//! Nextcloud's fast path rests on directory ETags propagating up the tree, so
|
||||
//! one request against the root proves a 50k-image library unchanged. A POSIX
|
||||
//! directory's mtime says only that its own entry list changed — not that a
|
||||
//! grandchild's *contents* did — so there is nothing here to propagate and
|
||||
//! [`ChangeDetection::LocalEtags`] is the truthful answer. The engine reads
|
||||
//! that and walks the tree every scan instead of pruning it.
|
||||
//!
|
||||
//! Which costs almost nothing, because the walk that was expensive was
|
||||
//! expensive for a reason this backend does not have. Fifty thousand
|
||||
//! `stat` calls against a local filesystem take well under a second; fifty
|
||||
//! thousand `PROPFIND`s do not. The capability model is what lets both be
|
||||
//! driven by the same engine at the speed each one actually runs at.
|
||||
//!
|
||||
//! # Identity
|
||||
//!
|
||||
//! [`RemoteId::Stable`] here is a hash of the path relative to the library
|
||||
//! root. That gives the catalog what it needs — a `u64` that names a
|
||||
//! photograph, is the same on every device looking at the same folder, and
|
||||
//! does not change when the file is edited — which is what keys the thumbnail
|
||||
//! shards and the face index (`catalog.md` §10.1).
|
||||
//!
|
||||
//! It does **not** survive a rename, and [`Capabilities::stable_ids`] says so.
|
||||
//! A moved photograph is seen as a delete and an add, and its thumbnail is
|
||||
//! derived again. That is the documented degradation for a backend without
|
||||
//! server-assigned ids, and it is the right trade here: the alternative,
|
||||
//! keying on the inode, is stable across a rename but *differs between
|
||||
//! devices* and is reused by the filesystem after a delete — so two machines
|
||||
//! would disagree about which photograph a thumbnail belonged to, and a
|
||||
//! recycled inode would silently attach an old thumbnail to a new image.
|
||||
//! Re-deriving a thumbnail is a cost; showing the wrong one is a bug.
|
||||
//!
|
||||
//! # Blocking
|
||||
//!
|
||||
//! Every filesystem call goes through the blocking pool. On a local disk that
|
||||
//! is overkill; on the NFS mount this backend is most useful over, a stalled
|
||||
//! server would otherwise wedge the async worker that made the call and every
|
||||
//! other request sharing it.
|
||||
|
||||
use std::io::{Read, Seek, SeekFrom, Write};
|
||||
use std::ops::Range;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use dr_sync::{
|
||||
Account, BackendProvider, Capabilities, ChangeDetection, Connection, Cursor, EntryKind,
|
||||
Precondition, RemoteBackend, RemoteChange, RemoteEntry, RemoteError, RemoteId, RemotePath,
|
||||
ServerPreviews, SignIn, Validator,
|
||||
};
|
||||
|
||||
/// The id written to [`Account::backend`] for a folder library.
|
||||
///
|
||||
/// On-disk configuration: changing it orphans every folder account.
|
||||
pub const BACKEND_ID: &str = "folder";
|
||||
|
||||
/// TRACES: FR-NC-13
|
||||
/// Registers the folder connector.
|
||||
///
|
||||
/// See [`dr_sync::provider`] for what each method is for.
|
||||
pub struct FolderProvider;
|
||||
|
||||
impl BackendProvider for FolderProvider {
|
||||
fn id(&self) -> &'static str {
|
||||
BACKEND_ID
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &'static str {
|
||||
"Folder"
|
||||
}
|
||||
|
||||
fn endpoint_label(&self) -> &'static str {
|
||||
"Folder"
|
||||
}
|
||||
|
||||
fn endpoint_placeholder(&self) -> &'static str {
|
||||
"/home/you/Pictures"
|
||||
}
|
||||
|
||||
fn sign_in(&self) -> SignIn {
|
||||
SignIn::EndpointOnly
|
||||
}
|
||||
|
||||
/// Check the directory before an account is written for it.
|
||||
///
|
||||
/// A typo here would otherwise be stored, skip the launch screen on the
|
||||
/// next start, and surface as a scan that finds nothing — which reads as
|
||||
/// a broken library rather than a wrong path. The messages say what to fix.
|
||||
fn normalise_endpoint(&self, input: &str) -> Result<String, String> {
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("Choose the folder your photographs are in.".into());
|
||||
}
|
||||
|
||||
// `~` is what a person types and what a shell would have expanded;
|
||||
// nothing expands it here, so a stored `~/Pictures` becomes a
|
||||
// directory literally named `~`.
|
||||
let expanded = match trimmed.strip_prefix("~/") {
|
||||
Some(rest) => match std::env::var_os("HOME") {
|
||||
Some(home) => PathBuf::from(home).join(rest),
|
||||
None => return Err("No home directory to expand ~ against.".into()),
|
||||
},
|
||||
None => PathBuf::from(trimmed),
|
||||
};
|
||||
|
||||
if !expanded.is_absolute() {
|
||||
return Err("Give the full path to the folder, starting at /.".into());
|
||||
}
|
||||
if !expanded.exists() {
|
||||
return Err(format!("No folder at {}.", expanded.display()));
|
||||
}
|
||||
if !expanded.is_dir() {
|
||||
return Err(format!("{} is a file, not a folder.", expanded.display()));
|
||||
}
|
||||
|
||||
// Resolved so a library reached through a symlink or a `..` is stored
|
||||
// under one name. Two spellings of one folder would otherwise be two
|
||||
// accounts with two catalogs indexing the same photographs.
|
||||
let canonical = expanded
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("Cannot read {}: {e}", expanded.display()))?;
|
||||
|
||||
Ok(canonical.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
fn account_for(&self, endpoint: &str) -> Result<Account, RemoteError> {
|
||||
Ok(Account::new(BACKEND_ID, endpoint))
|
||||
}
|
||||
|
||||
fn connect(&self, conn: &Connection) -> Result<Box<dyn RemoteBackend>, RemoteError> {
|
||||
Ok(Box::new(FolderBackend::new(&conn.account.endpoint)?))
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-NC-13 | FR-NC-4
|
||||
/// A library rooted at a directory.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FolderBackend {
|
||||
root: PathBuf,
|
||||
caps: Capabilities,
|
||||
}
|
||||
|
||||
impl FolderBackend {
|
||||
/// Open the folder at `root`.
|
||||
///
|
||||
/// The directory must exist now. It may stop existing later — a drive
|
||||
/// unplugged, a mount dropped — and that surfaces per-operation as
|
||||
/// [`RemoteError::Network`], which is what puts the app into offline mode
|
||||
/// and leaves the catalog readable, exactly as a dead server does.
|
||||
pub fn new(root: impl Into<PathBuf>) -> Result<Self, RemoteError> {
|
||||
let root = root.into();
|
||||
if !root.is_dir() {
|
||||
return Err(RemoteError::Configuration(format!(
|
||||
"{} is not a folder",
|
||||
root.display()
|
||||
)));
|
||||
}
|
||||
Ok(Self {
|
||||
root,
|
||||
caps: Capabilities {
|
||||
// A directory's mtime describes its own entry list and nothing
|
||||
// below it, so there is no propagation to exploit; the engine
|
||||
// walks and compares per entry.
|
||||
change_detection: ChangeDetection::LocalEtags,
|
||||
// A path hash does not survive a rename. See the module docs
|
||||
// for why the inode is not used instead.
|
||||
stable_ids: false,
|
||||
range_reads: true,
|
||||
// Not a protocol with a message size limit; a write is a write.
|
||||
chunked_upload: None,
|
||||
bulk_upload: false,
|
||||
conditional_write: true,
|
||||
server_previews: ServerPreviews::None,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pub fn root(&self) -> &Path {
|
||||
&self.root
|
||||
}
|
||||
|
||||
/// The local path for a remote path, refusing anything that escapes.
|
||||
///
|
||||
/// The guard is not theoretical. A `RemotePath` is built from strings that
|
||||
/// reach us from a catalog written by another device and from filenames on
|
||||
/// the remote itself, and this backend resolves them against a real
|
||||
/// filesystem with the user's own permissions. `../../.ssh/id_ed25519` is
|
||||
/// a legal path segment; without this it would be a legal *read*.
|
||||
fn resolve(&self, path: &RemotePath) -> Result<PathBuf, RemoteError> {
|
||||
let rel = Path::new(path.as_str());
|
||||
for component in rel.components() {
|
||||
match component {
|
||||
Component::Normal(_) => {}
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
|
||||
return Err(RemoteError::Configuration(format!(
|
||||
"{path} leaves the library folder"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(self.root.join(rel))
|
||||
}
|
||||
|
||||
/// The local path a [`RemoteId`] names.
|
||||
///
|
||||
/// A stable id here is a hash and nothing can be resolved from it, exactly
|
||||
/// as a Nextcloud `oc:fileid` names no WebDAV endpoint. Callers hold the
|
||||
/// path alongside it in the catalog and pass that.
|
||||
fn resolve_id(&self, id: &RemoteId) -> Result<PathBuf, RemoteError> {
|
||||
match id {
|
||||
RemoteId::Path(p) => self.resolve(p),
|
||||
RemoteId::Stable(_) => Err(RemoteError::Unsupported(
|
||||
"a folder cannot be addressed by id; use RemoteId::Path",
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a filesystem operation off the async worker that asked for it.
|
||||
///
|
||||
/// See the module docs: a stalled network mount must not take the caller's
|
||||
/// runtime with it.
|
||||
async fn blocking<T, F>(f: F) -> Result<T, RemoteError>
|
||||
where
|
||||
F: FnOnce() -> Result<T, RemoteError> + Send + 'static,
|
||||
T: Send + 'static,
|
||||
{
|
||||
match tokio::task::spawn_blocking(f).await {
|
||||
Ok(r) => r,
|
||||
// The only way a blocking task fails to produce a result is a panic
|
||||
// inside it, which is a bug here rather than a condition the caller
|
||||
// can act on — but crashing the worker over it would lose a whole
|
||||
// scan, so it is reported like any other failure.
|
||||
Err(e) => Err(RemoteError::Protocol(format!("folder task failed: {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Map an IO failure to the error the engine already knows how to handle.
|
||||
///
|
||||
/// The classification is the point. [`RemoteError::indicates_offline`] drives
|
||||
/// offline mode, so a vanished mount must reach it as `Network` — that is
|
||||
/// precisely the "the library is unreachable, keep working from the catalog"
|
||||
/// case — while a permissions problem must not, because going offline over one
|
||||
/// forbidden file would hide a fixable problem behind a network banner.
|
||||
fn map_io(e: std::io::Error, what: &str) -> RemoteError {
|
||||
use std::io::ErrorKind as K;
|
||||
match e.kind() {
|
||||
K::NotFound => RemoteError::NotFound(what.to_string()),
|
||||
K::PermissionDenied => RemoteError::PermissionDenied,
|
||||
K::AlreadyExists => RemoteError::PreconditionFailed,
|
||||
// ENOSPC and friends. Quota is what the engine calls "no room".
|
||||
K::StorageFull | K::QuotaExceeded | K::FileTooLarge => RemoteError::QuotaExceeded,
|
||||
// A dropped mount answers ESTALE/EIO/ENOTCONN, and the honest reading
|
||||
// is the same as a dead server: the library cannot be reached now, and
|
||||
// may be again shortly.
|
||||
K::HostUnreachable
|
||||
| K::NetworkUnreachable
|
||||
| K::NetworkDown
|
||||
| K::ConnectionAborted
|
||||
| K::ConnectionReset
|
||||
| K::NotConnected
|
||||
| K::BrokenPipe
|
||||
| K::TimedOut => RemoteError::Network(format!("{what}: {e}")),
|
||||
_ => RemoteError::Protocol(format!("{what}: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The identity of a file, from its path relative to the library root.
|
||||
///
|
||||
/// FNV-1a rather than `DefaultHasher`, whose output is explicitly unstable
|
||||
/// between Rust releases: this value is written into the catalog and into the
|
||||
/// thumbnail index, and must mean the same thing after a toolchain upgrade as
|
||||
/// it did before one.
|
||||
fn identity(path: &RemotePath) -> u64 {
|
||||
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
for b in path.as_str().as_bytes() {
|
||||
h ^= *b as u64;
|
||||
h = h.wrapping_mul(0x0000_0100_0000_01b3);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// A file's validator: its size and modification time.
|
||||
///
|
||||
/// The pair, not either alone. An mtime with one-second granularity — which is
|
||||
/// what some filesystems and most network mounts report — cannot distinguish
|
||||
/// two writes in the same second, and a size alone cannot see an edit that
|
||||
/// preserved it. Together they miss only a same-second write of identical
|
||||
/// length, which for a photograph is a rewrite of the same frame.
|
||||
fn validator_of(meta: &std::fs::Metadata) -> Validator {
|
||||
let (secs, nanos) = meta
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| (d.as_secs(), d.subsec_nanos()))
|
||||
.unwrap_or((0, 0));
|
||||
Validator::new(format!("{:x}-{:x}.{:x}", meta.len(), secs, nanos))
|
||||
}
|
||||
|
||||
fn modified_secs(meta: &std::fs::Metadata) -> Option<i64> {
|
||||
meta.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs() as i64)
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RemoteBackend for FolderBackend {
|
||||
fn capabilities(&self) -> &Capabilities {
|
||||
&self.caps
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
"Folder"
|
||||
}
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
dir: &RemotePath,
|
||||
_since: Option<&Validator>,
|
||||
) -> Result<Vec<RemoteEntry>, RemoteError> {
|
||||
let local = self.resolve(dir)?;
|
||||
let dir = dir.clone();
|
||||
blocking(move || {
|
||||
let read =
|
||||
std::fs::read_dir(&local).map_err(|e| map_io(e, &local.display().to_string()))?;
|
||||
|
||||
let mut out = Vec::new();
|
||||
for entry in read {
|
||||
let entry = match entry {
|
||||
Ok(e) => e,
|
||||
// One unreadable entry must not fail the listing: a
|
||||
// scan of a real library meets a broken symlink or a
|
||||
// file being written, and abandoning the whole
|
||||
// directory over it loses every photograph beside it.
|
||||
Err(e) => {
|
||||
log::debug!("skipping an entry in {}: {e}", local.display());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let name = entry.file_name();
|
||||
let Some(name) = name.to_str() else {
|
||||
// A name that is not UTF-8 cannot round-trip through a
|
||||
// `RemotePath`, and quietly mangling it would produce a
|
||||
// path that addresses a different file — or none.
|
||||
log::warn!("skipping a non-UTF-8 name in {}", local.display());
|
||||
continue;
|
||||
};
|
||||
|
||||
// `metadata`, not `symlink_metadata`: a symlinked shoot
|
||||
// folder is a normal way to assemble a library, and the
|
||||
// scan's depth limit is what stops a loop.
|
||||
let meta = match entry.metadata() {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
log::debug!("skipping {name}: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let path = dir.join(name);
|
||||
out.push(RemoteEntry {
|
||||
id: RemoteId::Stable(identity(&path)),
|
||||
kind: if meta.is_dir() {
|
||||
EntryKind::Directory
|
||||
} else {
|
||||
EntryKind::File
|
||||
},
|
||||
validator: validator_of(&meta),
|
||||
size: meta.len(),
|
||||
modified: modified_secs(&meta),
|
||||
// No renderer behind a folder; previews are extracted
|
||||
// locally from the file itself.
|
||||
has_preview: false,
|
||||
path,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Not offered.
|
||||
///
|
||||
/// A directory's mtime changes when its own entries are added or removed
|
||||
/// and at no other time, so it cannot answer the question this method
|
||||
/// exists for — "did anything below here change?". Returning it anyway
|
||||
/// would let a future caller prune a subtree whose contents had been
|
||||
/// edited, and hide those edits for as long as the folder list held still.
|
||||
async fn dir_validator(&self, _dir: &RemotePath) -> Result<Validator, RemoteError> {
|
||||
Err(RemoteError::Unsupported(
|
||||
"a folder's mtime does not propagate; use per-entry validators",
|
||||
))
|
||||
}
|
||||
|
||||
async fn delta(&self, _cursor: &Cursor) -> Result<(Vec<RemoteChange>, Cursor), RemoteError> {
|
||||
Err(RemoteError::Unsupported("a folder keeps no change feed"))
|
||||
}
|
||||
|
||||
async fn get(&self, id: &RemoteId, range: Option<Range<u64>>) -> Result<Vec<u8>, RemoteError> {
|
||||
let local = self.resolve_id(id)?;
|
||||
blocking(move || {
|
||||
let what = local.display().to_string();
|
||||
let mut file = std::fs::File::open(&local).map_err(|e| map_io(e, &what))?;
|
||||
|
||||
let Some(r) = range else {
|
||||
let mut buf = Vec::new();
|
||||
file.read_to_end(&mut buf).map_err(|e| map_io(e, &what))?;
|
||||
return Ok(buf);
|
||||
};
|
||||
|
||||
// A short read at the end of the file is not an error: the header
|
||||
// extractor asks for a fixed window and the file may be smaller
|
||||
// than it, which is the ordinary case for a small JPEG.
|
||||
file.seek(SeekFrom::Start(r.start))
|
||||
.map_err(|e| map_io(e, &what))?;
|
||||
let want = r.end.saturating_sub(r.start);
|
||||
let mut buf = Vec::new();
|
||||
file.take(want)
|
||||
.read_to_end(&mut buf)
|
||||
.map_err(|e| map_io(e, &what))?;
|
||||
Ok(buf)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn put(
|
||||
&self,
|
||||
path: &RemotePath,
|
||||
body: Vec<u8>,
|
||||
precond: Option<Precondition>,
|
||||
) -> Result<Validator, RemoteError> {
|
||||
let local = self.resolve(path)?;
|
||||
blocking(move || {
|
||||
let what = local.display().to_string();
|
||||
if let Some(parent) = local.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| map_io(e, &parent.display().to_string()))?;
|
||||
}
|
||||
|
||||
match &precond {
|
||||
// Genuinely atomic: `O_CREAT | O_EXCL` is one syscall, so two
|
||||
// devices racing to create a sidecar cannot both win.
|
||||
Some(Precondition::IfAbsent) => {
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&local)
|
||||
.map_err(|e| map_io(e, &what))?;
|
||||
f.write_all(&body).map_err(|e| map_io(e, &what))?;
|
||||
f.sync_all().map_err(|e| map_io(e, &what))?;
|
||||
let meta = f.metadata().map_err(|e| map_io(e, &what))?;
|
||||
return Ok(validator_of(&meta));
|
||||
}
|
||||
// Compare, then swap. A POSIX filesystem has no compare-and-
|
||||
// swap, so this narrows the window to the microseconds between
|
||||
// the `stat` and the `rename` rather than closing it. That is
|
||||
// still far tighter than the fallback the engine uses when a
|
||||
// backend declares no conditional write at all — comparing
|
||||
// revision counters *inside* the sidecar, which spans a whole
|
||||
// read-modify-write — which is why the capability is declared
|
||||
// rather than refused.
|
||||
Some(Precondition::IfMatch(expected)) => {
|
||||
let meta = std::fs::metadata(&local).map_err(|e| map_io(e, &what))?;
|
||||
if &validator_of(&meta) != expected {
|
||||
return Err(RemoteError::PreconditionFailed);
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
|
||||
// Write beside the destination and rename over it, so a reader
|
||||
// never sees a half-written sidecar and an interrupted write
|
||||
// cannot destroy the file it was replacing. Beside, not in
|
||||
// `/tmp`: a rename across filesystems is not atomic, and on
|
||||
// Android `/tmp` is a different one.
|
||||
let tmp = local.with_extension(format!(
|
||||
"{}.darkroom-tmp",
|
||||
local.extension().and_then(|e| e.to_str()).unwrap_or("")
|
||||
));
|
||||
let write = (|| -> Result<(), RemoteError> {
|
||||
let mut f = std::fs::File::create(&tmp).map_err(|e| map_io(e, &what))?;
|
||||
f.write_all(&body).map_err(|e| map_io(e, &what))?;
|
||||
f.sync_all().map_err(|e| map_io(e, &what))
|
||||
})();
|
||||
if let Err(e) = write {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(e);
|
||||
}
|
||||
if let Err(e) = std::fs::rename(&tmp, &local) {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(map_io(e, &what));
|
||||
}
|
||||
|
||||
let meta = std::fs::metadata(&local).map_err(|e| map_io(e, &what))?;
|
||||
Ok(validator_of(&meta))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Delete a file, or an empty directory.
|
||||
///
|
||||
/// **Not recursive, unlike WebDAV's `DELETE` on a collection.** The
|
||||
/// divergence is deliberate: a folder library is the user's own
|
||||
/// photographs on their own disk, with no server-side trash behind it, so
|
||||
/// a caller that passed the wrong path would have no way back. Nothing in
|
||||
/// the engine deletes a directory — the soft delete is a
|
||||
/// [`move_to`](RemoteBackend::move_to) into the trash folder — so refusing
|
||||
/// costs nothing and the guard is free.
|
||||
async fn delete(
|
||||
&self,
|
||||
id: &RemoteId,
|
||||
precond: Option<Precondition>,
|
||||
) -> Result<(), RemoteError> {
|
||||
let local = self.resolve_id(id)?;
|
||||
blocking(move || {
|
||||
let what = local.display().to_string();
|
||||
let meta = std::fs::symlink_metadata(&local).map_err(|e| map_io(e, &what))?;
|
||||
|
||||
match &precond {
|
||||
Some(Precondition::IfMatch(expected)) => {
|
||||
if &validator_of(&meta) != expected {
|
||||
return Err(RemoteError::PreconditionFailed);
|
||||
}
|
||||
}
|
||||
// "Delete only if nothing is there" is not a thing to ask of a
|
||||
// delete; something is there or the `stat` above already
|
||||
// failed.
|
||||
Some(Precondition::IfAbsent) => {
|
||||
return Err(RemoteError::Unsupported(
|
||||
"IfAbsent is not meaningful on a delete",
|
||||
))
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
|
||||
if meta.is_dir() {
|
||||
std::fs::remove_dir(&local).map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::DirectoryNotEmpty {
|
||||
RemoteError::Configuration(format!(
|
||||
"{what} is not empty; a folder library will not delete a tree"
|
||||
))
|
||||
} else {
|
||||
map_io(e, &what)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
std::fs::remove_file(&local).map_err(|e| map_io(e, &what))
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn move_to(&self, from: &RemoteId, to: &RemotePath) -> Result<(), RemoteError> {
|
||||
let src = self.resolve_id(from)?;
|
||||
let dst = self.resolve(to)?;
|
||||
blocking(move || {
|
||||
let what = dst.display().to_string();
|
||||
// Parents first: the trash folder does not exist until the first
|
||||
// photograph is trashed, and the trait promises this creates it.
|
||||
if let Some(parent) = dst.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| map_io(e, &parent.display().to_string()))?;
|
||||
}
|
||||
|
||||
match std::fs::rename(&src, &dst) {
|
||||
Ok(()) => Ok(()),
|
||||
// EXDEV. Both paths are inside one library root, so this
|
||||
// needs a root that spans a mount point — a shoot folder
|
||||
// that is its own mount, which is an ordinary way to attach
|
||||
// an archive drive. Copy and unlink rather than refusing:
|
||||
// the identity a rename would have preserved is a path hash
|
||||
// here, and it changes either way.
|
||||
Err(e) if e.raw_os_error() == Some(18) => {
|
||||
std::fs::copy(&src, &dst).map_err(|e| map_io(e, &what))?;
|
||||
std::fs::remove_file(&src).map_err(|e| {
|
||||
// The copy landed. Leaving the original is a
|
||||
// duplicate, which the next scan will show; losing
|
||||
// the copy would be worse.
|
||||
let _ = std::fs::remove_file(&dst);
|
||||
map_io(e, &src.display().to_string())
|
||||
})
|
||||
}
|
||||
Err(e) => Err(map_io(e, &what)),
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn create_dir(&self, path: &RemotePath) -> Result<(), RemoteError> {
|
||||
let local = self.resolve(path)?;
|
||||
blocking(move || {
|
||||
// `create_dir_all` makes parents and succeeds on one that already
|
||||
// exists, which is exactly the contract.
|
||||
std::fs::create_dir_all(&local).map_err(|e| map_io(e, &local.display().to_string()))
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,540 @@
|
||||
//! Behaviour of the folder connector, against real directories.
|
||||
//!
|
||||
//! No mocks: the whole point of this backend is what a filesystem actually
|
||||
//! does, and a double would only assert what this file assumes.
|
||||
|
||||
use super::*;
|
||||
use dr_sync::{scan, RemoteBackend};
|
||||
use dr_types::FormatFilter;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// A throwaway library root.
|
||||
///
|
||||
/// Under the system temp directory, named for the test, and cleared first so a
|
||||
/// crashed run cannot leave state that makes the next one pass.
|
||||
struct Tmp(PathBuf);
|
||||
|
||||
impl Tmp {
|
||||
fn new(name: &str) -> Self {
|
||||
let d = std::env::temp_dir().join(format!("dr-folder-test-{name}"));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
Tmp(d)
|
||||
}
|
||||
|
||||
fn file(&self, rel: &str, body: &[u8]) -> &Self {
|
||||
let p = self.0.join(rel);
|
||||
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
|
||||
std::fs::write(p, body).unwrap();
|
||||
self
|
||||
}
|
||||
|
||||
fn backend(&self) -> FolderBackend {
|
||||
FolderBackend::new(&self.0).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Tmp {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn names(entries: &[RemoteEntry]) -> Vec<String> {
|
||||
let mut v: Vec<String> = entries.iter().map(|e| e.path.name().to_string()).collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
// --- opening --------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn a_missing_folder_is_a_configuration_error_not_a_network_one() {
|
||||
// It must not put the app into offline mode: nothing was unreachable, the
|
||||
// account names somewhere that is not a folder.
|
||||
let err = FolderBackend::new("/definitely/not/here").unwrap_err();
|
||||
assert!(matches!(err, RemoteError::Configuration(_)), "{err:?}");
|
||||
assert!(!err.indicates_offline());
|
||||
}
|
||||
|
||||
// --- listing --------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn listing_reports_files_and_directories() {
|
||||
let t = Tmp::new("list");
|
||||
t.file("a.CR2", b"raw").file("sub/b.CR2", b"raw");
|
||||
let b = t.backend();
|
||||
|
||||
let root = b.list(&RemotePath::root(), None).await.unwrap();
|
||||
assert_eq!(names(&root), vec!["a.CR2", "sub"]);
|
||||
|
||||
let kinds: HashMap<_, _> = root
|
||||
.iter()
|
||||
.map(|e| (e.path.name().to_string(), e.kind))
|
||||
.collect();
|
||||
assert_eq!(kinds["a.CR2"], EntryKind::File);
|
||||
assert_eq!(kinds["sub"], EntryKind::Directory);
|
||||
|
||||
let sub = b.list(&RemotePath::new("sub"), None).await.unwrap();
|
||||
assert_eq!(names(&sub), vec!["b.CR2"]);
|
||||
// Paths are rooted at the library, not at the filesystem.
|
||||
assert_eq!(sub[0].path.as_str(), "sub/b.CR2");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_listing_carries_the_size_a_scan_needs() {
|
||||
let t = Tmp::new("size");
|
||||
t.file("a.CR2", &[7u8; 1234]);
|
||||
let e = &t.backend().list(&RemotePath::root(), None).await.unwrap()[0];
|
||||
assert_eq!(e.size, 1234);
|
||||
assert!(e.modified.is_some());
|
||||
// Nothing behind a folder renders anything.
|
||||
assert!(!e.has_preview);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn listing_a_missing_directory_is_not_found() {
|
||||
let t = Tmp::new("missing");
|
||||
let e = t
|
||||
.backend()
|
||||
.list(&RemotePath::new("nope"), None)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(e, RemoteError::NotFound(_)), "{e:?}");
|
||||
}
|
||||
|
||||
// --- identity and validators ---------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn identity_is_stable_across_an_edit_but_not_across_a_rename() {
|
||||
// The catalog keys thumbnails and faces on this id, so editing a file must
|
||||
// not orphan its thumbnail. A rename is a different photograph as far as
|
||||
// this backend can tell, which `Capabilities::stable_ids` reports.
|
||||
let t = Tmp::new("identity");
|
||||
t.file("a.CR2", b"one");
|
||||
let b = t.backend();
|
||||
|
||||
let before = b.list(&RemotePath::root(), None).await.unwrap()[0]
|
||||
.id
|
||||
.clone();
|
||||
t.file("a.CR2", b"two-different-length");
|
||||
let after = b.list(&RemotePath::root(), None).await.unwrap()[0]
|
||||
.id
|
||||
.clone();
|
||||
assert_eq!(before, after, "an edit is not a new photograph");
|
||||
|
||||
std::fs::rename(t.0.join("a.CR2"), t.0.join("b.CR2")).unwrap();
|
||||
let renamed = b.list(&RemotePath::root(), None).await.unwrap()[0]
|
||||
.id
|
||||
.clone();
|
||||
assert_ne!(before, renamed);
|
||||
assert!(!b.capabilities().stable_ids, "and the capability says so");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn two_libraries_agree_on_the_identity_of_the_same_photograph() {
|
||||
// Two devices mounting one share must key the thumbnail index the same
|
||||
// way, or each re-derives what the other already stored. This is why the
|
||||
// id is a path hash and not an inode.
|
||||
let a = Tmp::new("id-a");
|
||||
let b = Tmp::new("id-b");
|
||||
a.file("2026/x.CR2", b"one");
|
||||
b.file("2026/x.CR2", b"quite different bytes");
|
||||
|
||||
let ida = a
|
||||
.backend()
|
||||
.list(&RemotePath::new("2026"), None)
|
||||
.await
|
||||
.unwrap()[0]
|
||||
.id
|
||||
.clone();
|
||||
let idb = b
|
||||
.backend()
|
||||
.list(&RemotePath::new("2026"), None)
|
||||
.await
|
||||
.unwrap()[0]
|
||||
.id
|
||||
.clone();
|
||||
assert_eq!(ida, idb);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_validator_changes_when_the_content_does() {
|
||||
let t = Tmp::new("validator");
|
||||
t.file("a.CR2", b"one");
|
||||
let b = t.backend();
|
||||
let before = b.list(&RemotePath::root(), None).await.unwrap()[0]
|
||||
.validator
|
||||
.clone();
|
||||
|
||||
// A different length, so this holds on a filesystem with one-second mtime
|
||||
// granularity as well as on one with nanoseconds.
|
||||
t.file("a.CR2", b"a rather longer body");
|
||||
let after = b.list(&RemotePath::root(), None).await.unwrap()[0]
|
||||
.validator
|
||||
.clone();
|
||||
assert_ne!(before, after);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_folder_does_not_pretend_to_prune() {
|
||||
// Answering with the directory's own mtime would let a caller skip a
|
||||
// subtree whose files had been edited, hiding those edits indefinitely.
|
||||
let t = Tmp::new("prune");
|
||||
let b = t.backend();
|
||||
assert!(matches!(
|
||||
b.dir_validator(&RemotePath::root()).await,
|
||||
Err(RemoteError::Unsupported(_))
|
||||
));
|
||||
assert_eq!(
|
||||
b.capabilities().change_detection,
|
||||
ChangeDetection::LocalEtags
|
||||
);
|
||||
}
|
||||
|
||||
// --- reading --------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_whole_file_and_a_range_both_read() {
|
||||
let t = Tmp::new("get");
|
||||
t.file("a.CR2", b"0123456789");
|
||||
let b = t.backend();
|
||||
let id = RemoteId::Path(RemotePath::new("a.CR2"));
|
||||
|
||||
assert_eq!(b.get(&id, None).await.unwrap(), b"0123456789");
|
||||
assert_eq!(b.get(&id, Some(2..5)).await.unwrap(), b"234");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_range_past_the_end_returns_what_is_there() {
|
||||
// The header extractor asks for a fixed window; a small JPEG is shorter
|
||||
// than it, and failing would make every small file undatable.
|
||||
let t = Tmp::new("shortrange");
|
||||
t.file("a.JPG", b"abc");
|
||||
let got = t
|
||||
.backend()
|
||||
.get(&RemoteId::Path(RemotePath::new("a.JPG")), Some(0..65536))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(got, b"abc");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_bare_identity_cannot_address_a_file() {
|
||||
// Same contract as the Nextcloud connector: the id says *which*
|
||||
// photograph, the path says *where*. Callers hold both.
|
||||
let t = Tmp::new("byid");
|
||||
t.file("a.CR2", b"x");
|
||||
let e = t
|
||||
.backend()
|
||||
.get(&RemoteId::Stable(1), None)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(e, RemoteError::Unsupported(_)), "{e:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn nothing_reachable_from_a_remote_path_escapes_the_library() {
|
||||
// A `RemotePath` is built from names on the remote and from a catalog
|
||||
// another device wrote. Resolving one against a real filesystem with the
|
||||
// user's own permissions makes `..` a read of anything they own.
|
||||
let t = Tmp::new("escape");
|
||||
let b = t.backend();
|
||||
for attempt in ["../../../etc/passwd", "sub/../../outside"] {
|
||||
let e = b
|
||||
.get(&RemoteId::Path(RemotePath::new(attempt)), None)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(e, RemoteError::Configuration(_)),
|
||||
"{attempt} was not refused: {e:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- writing --------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_write_creates_the_folders_it_needs() {
|
||||
let t = Tmp::new("put");
|
||||
let b = t.backend();
|
||||
b.put(&RemotePath::new("2026/03/a.xmp"), b"<x/>".to_vec(), None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(std::fs::read(t.0.join("2026/03/a.xmp")).unwrap(), b"<x/>");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_write_leaves_no_temporary_behind() {
|
||||
// The rename-into-place is invisible from outside, and must stay that way:
|
||||
// a stray `.darkroom-tmp` in a shoot folder would be listed by the scan.
|
||||
let t = Tmp::new("puttmp");
|
||||
let b = t.backend();
|
||||
b.put(&RemotePath::new("a.xmp"), b"x".to_vec(), None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
names(&b.list(&RemotePath::root(), None).await.unwrap()),
|
||||
vec!["a.xmp"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_overwrite_replaces_rather_than_appends() {
|
||||
let t = Tmp::new("overwrite");
|
||||
t.file("a.xmp", b"the older and much longer body");
|
||||
let b = t.backend();
|
||||
b.put(&RemotePath::new("a.xmp"), b"new".to_vec(), None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(std::fs::read(t.0.join("a.xmp")).unwrap(), b"new");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn if_absent_creates_once_and_refuses_after() {
|
||||
let t = Tmp::new("ifabsent");
|
||||
let b = t.backend();
|
||||
let p = RemotePath::new("a.xmp");
|
||||
|
||||
b.put(&p, b"first".to_vec(), Some(Precondition::IfAbsent))
|
||||
.await
|
||||
.unwrap();
|
||||
let e = b
|
||||
.put(&p, b"second".to_vec(), Some(Precondition::IfAbsent))
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(e, RemoteError::PreconditionFailed), "{e:?}");
|
||||
assert_eq!(std::fs::read(t.0.join("a.xmp")).unwrap(), b"first");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn if_match_writes_on_the_expected_version_and_refuses_a_stale_one() {
|
||||
// The sidecar conflict path (ARCH §8.5): a failure here means another
|
||||
// device wrote first, and triggers a merge rather than an overwrite.
|
||||
let t = Tmp::new("ifmatch");
|
||||
t.file("a.xmp", b"one");
|
||||
let b = t.backend();
|
||||
let p = RemotePath::new("a.xmp");
|
||||
|
||||
let current = b.list(&RemotePath::root(), None).await.unwrap()[0]
|
||||
.validator
|
||||
.clone();
|
||||
let after = b
|
||||
.put(
|
||||
&p,
|
||||
b"two".to_vec(),
|
||||
Some(Precondition::IfMatch(current.clone())),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_ne!(after, current);
|
||||
|
||||
let e = b
|
||||
.put(&p, b"three".to_vec(), Some(Precondition::IfMatch(current)))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(e, RemoteError::PreconditionFailed), "{e:?}");
|
||||
assert_eq!(std::fs::read(t.0.join("a.xmp")).unwrap(), b"two");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_validator_a_write_returns_is_the_one_a_listing_reports() {
|
||||
// Otherwise the next conditional write fails against a file nobody else
|
||||
// touched, and every sidecar update becomes a spurious conflict.
|
||||
let t = Tmp::new("putvalidator");
|
||||
let b = t.backend();
|
||||
let p = RemotePath::new("a.xmp");
|
||||
let written = b.put(&p, b"body".to_vec(), None).await.unwrap();
|
||||
let listed = b.list(&RemotePath::root(), None).await.unwrap()[0]
|
||||
.validator
|
||||
.clone();
|
||||
assert_eq!(written, listed);
|
||||
}
|
||||
|
||||
// --- moving and deleting --------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_move_creates_the_trash_folder_it_needs() {
|
||||
// The soft delete (FR-CAT-15): the trash does not exist until the first
|
||||
// photograph goes into it, and the trait promises the move makes it.
|
||||
let t = Tmp::new("move");
|
||||
t.file("a.CR2", b"raw");
|
||||
let b = t.backend();
|
||||
|
||||
b.move_to(
|
||||
&RemoteId::Path(RemotePath::new("a.CR2")),
|
||||
&RemotePath::new(".darkroom-trash/a.CR2"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!t.0.join("a.CR2").exists());
|
||||
assert_eq!(
|
||||
std::fs::read(t.0.join(".darkroom-trash/a.CR2")).unwrap(),
|
||||
b"raw"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deleting_a_file_removes_it() {
|
||||
let t = Tmp::new("delete");
|
||||
t.file("a.CR2", b"raw");
|
||||
let b = t.backend();
|
||||
b.delete(&RemoteId::Path(RemotePath::new("a.CR2")), None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!t.0.join("a.CR2").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deleting_refuses_to_take_a_tree_with_it() {
|
||||
// Deliberately unlike WebDAV. There is no server-side trash behind a local
|
||||
// folder, so a caller with a wrong path would have no way back.
|
||||
let t = Tmp::new("deletetree");
|
||||
t.file("shoot/a.CR2", b"raw");
|
||||
let e = t
|
||||
.backend()
|
||||
.delete(&RemoteId::Path(RemotePath::new("shoot")), None)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(e, RemoteError::Configuration(_)), "{e:?}");
|
||||
assert!(t.0.join("shoot/a.CR2").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_conditional_delete_refuses_a_file_that_changed() {
|
||||
let t = Tmp::new("deletecond");
|
||||
t.file("a.CR2", b"raw");
|
||||
let b = t.backend();
|
||||
let stale = Validator::new("0-0.0");
|
||||
let e = b
|
||||
.delete(
|
||||
&RemoteId::Path(RemotePath::new("a.CR2")),
|
||||
Some(Precondition::IfMatch(stale)),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(e, RemoteError::PreconditionFailed), "{e:?}");
|
||||
assert!(t.0.join("a.CR2").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn creating_a_directory_twice_succeeds() {
|
||||
// Callers use this to guarantee a destination, not to claim they made it.
|
||||
let t = Tmp::new("mkdir");
|
||||
let b = t.backend();
|
||||
let p = RemotePath::new("2026/03");
|
||||
b.create_dir(&p).await.unwrap();
|
||||
b.create_dir(&p).await.unwrap();
|
||||
assert!(t.0.join("2026/03").is_dir());
|
||||
}
|
||||
|
||||
// --- driven by the engine -------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_scan_engine_walks_a_folder_library() {
|
||||
// The claim this whole crate makes: the engine written for one backend
|
||||
// drives another with no change. Nothing below is folder-specific.
|
||||
let t = Tmp::new("scan");
|
||||
t.file("2026/03/a.CR2", b"raw")
|
||||
.file("2026/03/b.JPG", b"jpeg")
|
||||
.file("2026/04/c.CR2", b"raw")
|
||||
.file("2026/notes.txt", b"text")
|
||||
.file(".darkroom-trash/deleted.CR2", b"raw");
|
||||
|
||||
let result = scan(
|
||||
&t.backend(),
|
||||
&RemotePath::root(),
|
||||
&FormatFilter::from_formats([dr_types::Format::Cr2]),
|
||||
&HashMap::new(),
|
||||
|_| {},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let found: Vec<&str> = result.images.iter().map(|e| e.path.as_str()).collect();
|
||||
// The filter picked the RAWs; the trash was skipped, or the soft delete
|
||||
// would undo itself on the next scan.
|
||||
assert_eq!(found, vec!["2026/03/a.CR2", "2026/04/c.CR2"]);
|
||||
assert_eq!(result.progress.directories_pruned, 0, "nothing to prune");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_upload_lands_where_the_engine_places_it() {
|
||||
let t = Tmp::new("upload");
|
||||
let b = t.backend();
|
||||
let placed = dr_sync::upload_original(
|
||||
&b,
|
||||
&RemotePath::root(),
|
||||
&["2026".to_string(), "03".to_string()],
|
||||
"a.CR2",
|
||||
b"raw".to_vec(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(placed.path().as_str(), "2026/03/a.CR2");
|
||||
assert_eq!(std::fs::read(t.0.join("2026/03/a.CR2")).unwrap(), b"raw");
|
||||
}
|
||||
|
||||
// --- the provider ---------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn an_endpoint_is_checked_before_an_account_is_written_for_it() {
|
||||
let t = Tmp::new("provider");
|
||||
let p = FolderProvider;
|
||||
|
||||
assert!(p.normalise_endpoint(" ").is_err(), "empty");
|
||||
assert!(p.normalise_endpoint("Pictures").is_err(), "relative");
|
||||
assert!(p.normalise_endpoint("/no/such/place").is_err(), "missing");
|
||||
|
||||
t.file("a.CR2", b"x");
|
||||
assert!(
|
||||
p.normalise_endpoint(&t.0.join("a.CR2").to_string_lossy())
|
||||
.is_err(),
|
||||
"a file is not a library"
|
||||
);
|
||||
|
||||
let ok = p.normalise_endpoint(&t.0.to_string_lossy()).unwrap();
|
||||
assert_eq!(PathBuf::from(&ok), t.0.canonicalize().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_spellings_of_one_folder_become_one_account() {
|
||||
// Otherwise the same photographs are indexed twice, into two catalogs.
|
||||
let t = Tmp::new("canonical");
|
||||
t.file("sub/a.CR2", b"x");
|
||||
let p = FolderProvider;
|
||||
let direct = p
|
||||
.normalise_endpoint(&t.0.join("sub").to_string_lossy())
|
||||
.unwrap();
|
||||
let roundabout = p
|
||||
.normalise_endpoint(&t.0.join("sub/../sub").to_string_lossy())
|
||||
.unwrap();
|
||||
assert_eq!(direct, roundabout);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_account_needs_no_credential() {
|
||||
let p = FolderProvider;
|
||||
assert_eq!(p.sign_in(), SignIn::EndpointOnly);
|
||||
assert!(!p.sign_in().needs_secret());
|
||||
|
||||
let account = p.account_for("/mnt/photos").unwrap();
|
||||
assert_eq!(account.backend, BACKEND_ID);
|
||||
assert_eq!(account.endpoint, "/mnt/photos");
|
||||
assert!(account.login.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_registry_opens_a_folder_account() {
|
||||
// End to end through the abstraction: an account, a registry, a backend —
|
||||
// with nothing in between naming this crate.
|
||||
let t = Tmp::new("registry");
|
||||
let mut registry = dr_sync::BackendRegistry::new();
|
||||
registry.register(std::sync::Arc::new(FolderProvider));
|
||||
|
||||
let account = Account::new(BACKEND_ID, t.0.to_string_lossy());
|
||||
let backend = registry.connect(&Connection::new(account, None)).unwrap();
|
||||
assert_eq!(backend.name(), "Folder");
|
||||
}
|
||||
@@ -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}");
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
//! Nextcloud connector — the only [`RemoteBackend`] implementation.
|
||||
//! Nextcloud connector.
|
||||
//!
|
||||
//! One of two [`RemoteBackend`] implementations, registered through
|
||||
//! [`NextcloudProvider`]. What an *account* is no longer lives here — that is
|
||||
//! [`dr_sync::Account`], which has no server in it — so this crate is the
|
||||
//! protocol and nothing else.
|
||||
//!
|
||||
//! Hand-rolled over `reqwest` rather than built on a WebDAV crate (D7). No
|
||||
//! mature Nextcloud crate exists, and the operations that matter here are
|
||||
@@ -16,11 +21,11 @@ use dr_sync::{
|
||||
pub mod auth;
|
||||
pub mod desktop_client;
|
||||
mod propfind;
|
||||
pub mod session;
|
||||
pub mod provider;
|
||||
|
||||
pub use auth::{AppCredentials, LoginFlow};
|
||||
pub use desktop_client::DesktopClient;
|
||||
pub use session::{Session, SessionError, SessionStore};
|
||||
pub use provider::NextcloudProvider;
|
||||
|
||||
/// Chunk sizes Nextcloud's chunked upload v2 accepts.
|
||||
const CHUNKS: ChunkConstraints = ChunkConstraints {
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
// TRACES: FR-NC-12 | FR-NC-1
|
||||
//! Registering Nextcloud as a storage backend.
|
||||
//!
|
||||
//! The account model this connector used to own now lives in
|
||||
//! [`dr_sync::account`], where it has no server in it. What is left here is
|
||||
//! the part that genuinely is Nextcloud: an endpoint is an HTTPS URL, an
|
||||
//! account is established through Login Flow v2, and the credential is an app
|
||||
//! password.
|
||||
//!
|
||||
//! Nothing above `dr_ui::remote` refers to this type.
|
||||
|
||||
use dr_sync::{
|
||||
Account, BackendProvider, Connection, RemoteBackend, RemoteError, SignIn, LEGACY_BACKEND,
|
||||
};
|
||||
|
||||
use crate::{AppCredentials, NextcloudBackend};
|
||||
|
||||
/// The id written to [`Account::backend`] for a Nextcloud account.
|
||||
///
|
||||
/// The same string [`dr_sync::LEGACY_BACKEND`] freezes, because every account
|
||||
/// configured before there was a choice is one of these and must keep the
|
||||
/// catalog directory it already has.
|
||||
pub const BACKEND_ID: &str = LEGACY_BACKEND;
|
||||
|
||||
/// Registers the Nextcloud connector.
|
||||
pub struct NextcloudProvider;
|
||||
|
||||
impl NextcloudProvider {
|
||||
/// The account a completed login flow describes.
|
||||
///
|
||||
/// `user_id` is the DAV path segment, which is not always the login name:
|
||||
/// a login can be an email address while the user id is something else,
|
||||
/// and building `/remote.php/dav/files/<login>/` from the wrong one 404s
|
||||
/// every request.
|
||||
pub fn account_from(creds: &AppCredentials, user_id: impl Into<String>) -> Account {
|
||||
Account::new(BACKEND_ID, creds.server.trim_end_matches('/'))
|
||||
.with_login(creds.login_name.clone(), user_id)
|
||||
}
|
||||
|
||||
/// The credentials a stored account plus its secret amount to.
|
||||
///
|
||||
/// [`AppCredentials`] stays the connector's own type rather than becoming
|
||||
/// something general: an app password, an OAuth token and a bucket key
|
||||
/// pair have no useful common shape, and inventing one would produce a
|
||||
/// wrong answer confidently. The general form is [`Connection`]; this is
|
||||
/// the translation into what one protocol needs.
|
||||
pub fn credentials(conn: &Connection) -> Result<AppCredentials, RemoteError> {
|
||||
Ok(AppCredentials {
|
||||
server: conn.account.endpoint.clone(),
|
||||
login_name: conn.account.login.clone(),
|
||||
app_password: conn.require_secret()?.expose().to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl BackendProvider for NextcloudProvider {
|
||||
fn id(&self) -> &'static str {
|
||||
BACKEND_ID
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &'static str {
|
||||
"Nextcloud"
|
||||
}
|
||||
|
||||
fn endpoint_label(&self) -> &'static str {
|
||||
"Server"
|
||||
}
|
||||
|
||||
fn endpoint_placeholder(&self) -> &'static str {
|
||||
"https://cloud.example.com"
|
||||
}
|
||||
|
||||
fn sign_in(&self) -> SignIn {
|
||||
SignIn::Browser
|
||||
}
|
||||
|
||||
/// Normalise a server address typed by hand.
|
||||
///
|
||||
/// 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.
|
||||
fn normalise_endpoint(&self, input: &str) -> Result<String, String> {
|
||||
let s = input.trim().trim_end_matches('/');
|
||||
if s.is_empty() {
|
||||
return Err("Enter the address of your Nextcloud server.".into());
|
||||
}
|
||||
if s.starts_with("https://") {
|
||||
Ok(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.
|
||||
Ok(format!("https://{rest}"))
|
||||
} else {
|
||||
Ok(format!("https://{s}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn connect(&self, conn: &Connection) -> Result<Box<dyn RemoteBackend>, RemoteError> {
|
||||
let creds = Self::credentials(conn)?;
|
||||
Ok(Box::new(NextcloudBackend::new(
|
||||
&creds,
|
||||
&conn.account.user_id,
|
||||
)?))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn creds() -> AppCredentials {
|
||||
AppCredentials {
|
||||
server: "https://cloud.example/".into(),
|
||||
login_name: "duncan@example.com".into(),
|
||||
app_password: "token".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_address_typed_by_hand_becomes_an_https_url() {
|
||||
let p = NextcloudProvider;
|
||||
assert_eq!(
|
||||
p.normalise_endpoint("cloud.example.com/").unwrap(),
|
||||
"https://cloud.example.com"
|
||||
);
|
||||
// Upgraded, never accepted: NFR-SEC-3.
|
||||
assert_eq!(
|
||||
p.normalise_endpoint("http://cloud.example.com").unwrap(),
|
||||
"https://cloud.example.com"
|
||||
);
|
||||
assert!(p.normalise_endpoint(" ").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_account_keeps_the_dav_user_id_apart_from_the_login() {
|
||||
// A login can be an email address while the user id is something
|
||||
// else; building the DAV path from the wrong one 404s everything.
|
||||
let a = NextcloudProvider::account_from(&creds(), "duncan");
|
||||
assert_eq!(a.login, "duncan@example.com");
|
||||
assert_eq!(a.user_id, "duncan");
|
||||
assert_eq!(a.endpoint, "https://cloud.example");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nextcloud_account_keeps_its_historical_catalog_directory() {
|
||||
// Frozen: this names the directory holding the catalog, the thumbnail
|
||||
// shards and un-uploaded sidecars.
|
||||
let a = NextcloudProvider::account_from(&creds(), "duncan");
|
||||
assert_eq!(a.namespace(), "cloud-example-duncan");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connecting_without_a_credential_is_unauthenticated_not_a_crash() {
|
||||
// A cleared keyring or a revoked app password arrives here as an
|
||||
// account with no secret. The caller re-runs the login flow.
|
||||
let account = NextcloudProvider::account_from(&creds(), "duncan");
|
||||
match NextcloudProvider.connect(&Connection::new(account, None)) {
|
||||
Err(RemoteError::Unauthenticated) => {}
|
||||
Err(e) => panic!("wrong error: {e:?}"),
|
||||
Ok(b) => panic!("connected without a credential as {}", b.name()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stored_account_and_its_secret_rebuild_the_credentials() {
|
||||
let account = NextcloudProvider::account_from(&creds(), "duncan");
|
||||
let conn = Connection::new(account, Some(dr_sync::Secret::new("token")));
|
||||
let rebuilt = NextcloudProvider::credentials(&conn).unwrap();
|
||||
assert_eq!(rebuilt.server, "https://cloud.example");
|
||||
assert_eq!(rebuilt.login_name, "duncan@example.com");
|
||||
assert_eq!(rebuilt.app_password, "token");
|
||||
}
|
||||
}
|
||||
@@ -1,451 +0,0 @@
|
||||
//! Account sessions — logging in once and staying logged in.
|
||||
//!
|
||||
//! Splits deliberately in two:
|
||||
//!
|
||||
//! - **Credentials** go to platform secure storage (FR-NC-2). Never the
|
||||
//! catalog, never a file, never a log line.
|
||||
//! - **Everything else** — server, login, chosen root, format filter — is
|
||||
//! ordinary configuration, safe to write as plain JSON.
|
||||
//!
|
||||
//! That split is what lets the app show "signed in as duncan, watching
|
||||
//! /PhotosRaw" before it has touched the keyring, and re-authenticate cleanly
|
||||
//! if the credential has been revoked server-side.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use dr_plat::{SecretError, SecretRef, SecretStore};
|
||||
use dr_sync::RemoteError;
|
||||
use dr_types::{Format, FormatFilter};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::AppCredentials;
|
||||
|
||||
/// Where configuration is written, when the platform has told us.
|
||||
///
|
||||
/// Android has no `$HOME` and no XDG directories, so the guess below resolves
|
||||
/// to a path the app cannot write. Nothing failed loudly: the session list went
|
||||
/// to a doomed path, so credentials survived only as long as the process did and
|
||||
/// backgrounding the app lost the account (ARCH §6.9 — no core API may assume a
|
||||
/// filesystem path on Android).
|
||||
///
|
||||
/// The platform layer sets this once at startup, before any store is opened.
|
||||
static DATA_DIR: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
|
||||
|
||||
/// TRACES: FR-NC-2
|
||||
/// Declare the per-app directory configuration belongs in.
|
||||
///
|
||||
/// Call before opening any store; later calls are ignored rather than racing.
|
||||
/// On Android this is `AndroidApp::internal_data_path`, which is private to the
|
||||
/// app and survives being backgrounded. Desktop needs no call — the XDG
|
||||
/// fallback is correct there.
|
||||
pub fn set_data_dir(dir: PathBuf) {
|
||||
let _ = DATA_DIR.set(dir);
|
||||
}
|
||||
|
||||
/// The directory configuration lives in.
|
||||
fn config_dir() -> PathBuf {
|
||||
if let Some(d) = DATA_DIR.get() {
|
||||
return d.clone();
|
||||
}
|
||||
std::env::var_os("XDG_CONFIG_HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".config"))
|
||||
.join("darkroom")
|
||||
}
|
||||
|
||||
/// A configured account, minus its credential.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Session {
|
||||
pub server: String,
|
||||
pub login: String,
|
||||
/// The DAV path segment, which may differ from `login` — a login can be
|
||||
/// an email address while the user id is something else.
|
||||
pub user_id: String,
|
||||
/// The folder chosen as the library root. Empty means the account root.
|
||||
#[serde(default)]
|
||||
pub root: String,
|
||||
/// Which formats the scan looks for (the tick-boxes).
|
||||
#[serde(default)]
|
||||
pub formats: Vec<String>,
|
||||
/// Unix seconds of the last completed scan, for display.
|
||||
#[serde(default)]
|
||||
pub last_scan: Option<i64>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn new(creds: &AppCredentials, user_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
server: creds.server.trim_end_matches('/').to_string(),
|
||||
login: creds.login_name.clone(),
|
||||
user_id: user_id.into(),
|
||||
root: String::new(),
|
||||
formats: Vec::new(),
|
||||
last_scan: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The stored format selection, defaulting to every supported format.
|
||||
///
|
||||
/// An unconfigured session must find everything rather than nothing.
|
||||
pub fn format_filter(&self) -> FormatFilter {
|
||||
if self.formats.is_empty() {
|
||||
FormatFilter::all()
|
||||
} else {
|
||||
FormatFilter::from_formats(
|
||||
self.formats
|
||||
.iter()
|
||||
.filter_map(|s| Format::from_extension(&s.to_ascii_lowercase())),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_format_filter(&mut self, filter: &FormatFilter) {
|
||||
self.formats = filter
|
||||
.iter()
|
||||
.map(|f| format!("{f:?}").to_lowercase())
|
||||
.collect();
|
||||
}
|
||||
|
||||
/// Where this session's credential lives.
|
||||
pub fn secret_ref(&self) -> SecretRef {
|
||||
SecretRef::app_password(&self.server, &self.login)
|
||||
}
|
||||
|
||||
/// A short description for the UI.
|
||||
pub fn describe(&self) -> String {
|
||||
let host = self
|
||||
.server
|
||||
.trim_start_matches("https://")
|
||||
.trim_start_matches("http://");
|
||||
if self.root.is_empty() {
|
||||
format!("{} on {host}", self.login)
|
||||
} else {
|
||||
format!("{} on {host}/{}", self.login, self.root)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-NC-1 | FR-NC-2 | M-1 | M-2
|
||||
/// Loads and saves sessions, keeping credentials in secure storage.
|
||||
pub struct SessionStore {
|
||||
config_path: PathBuf,
|
||||
secrets: Box<dyn SecretStore>,
|
||||
}
|
||||
|
||||
/// What is written to disk. Versioned so a format change is a migration
|
||||
/// rather than a parse failure.
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
struct ConfigFile {
|
||||
#[serde(default = "one")]
|
||||
version: u32,
|
||||
#[serde(default)]
|
||||
sessions: Vec<Session>,
|
||||
}
|
||||
|
||||
fn one() -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
impl SessionStore {
|
||||
/// Open the store at the platform config location.
|
||||
///
|
||||
/// Linux: `$XDG_CONFIG_HOME/darkroom/sessions.json`, falling back to
|
||||
/// `~/.config` (FR-PLAT-LIN-1).
|
||||
pub fn open(secrets: Box<dyn SecretStore>) -> Self {
|
||||
Self::open_at(config_dir().join("sessions.json"), secrets)
|
||||
}
|
||||
|
||||
/// Open at an explicit path — used by tests, and by anything wanting a
|
||||
/// non-default config location.
|
||||
/// Where configuration lives, for callers that need to sit files beside it.
|
||||
pub fn data_dir() -> PathBuf {
|
||||
config_dir()
|
||||
}
|
||||
|
||||
pub fn open_at(config_path: PathBuf, secrets: Box<dyn SecretStore>) -> Self {
|
||||
Self {
|
||||
config_path,
|
||||
secrets,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn config_path(&self) -> &Path {
|
||||
&self.config_path
|
||||
}
|
||||
|
||||
/// Whether credentials can be remembered at all.
|
||||
///
|
||||
/// Where false the UI should say sign-in will not persist, rather than
|
||||
/// letting the user discover it next launch.
|
||||
pub fn can_remember(&self) -> bool {
|
||||
self.secrets.is_available()
|
||||
}
|
||||
|
||||
/// Every configured session. Missing or unreadable config yields an empty
|
||||
/// list rather than an error — a first run is not a failure.
|
||||
pub fn list(&self) -> Vec<Session> {
|
||||
self.read_config().sessions
|
||||
}
|
||||
|
||||
/// The most recently configured session, if any.
|
||||
pub fn current(&self) -> Option<Session> {
|
||||
self.read_config().sessions.into_iter().next_back()
|
||||
}
|
||||
|
||||
/// Persist a session and its credential.
|
||||
///
|
||||
/// The credential goes to secure storage first: if that fails there is no
|
||||
/// point recording a session that cannot authenticate.
|
||||
pub fn save(&self, session: &Session, creds: &AppCredentials) -> Result<(), SessionError> {
|
||||
self.secrets
|
||||
.store(&session.secret_ref(), &creds.app_password)?;
|
||||
|
||||
let mut config = self.read_config();
|
||||
config
|
||||
.sessions
|
||||
.retain(|s| !(s.server == session.server && s.login == session.login));
|
||||
config.sessions.push(session.clone());
|
||||
self.write_config(&config)
|
||||
}
|
||||
|
||||
/// Update a session's settings, leaving its credential untouched.
|
||||
pub fn update(&self, session: &Session) -> Result<(), SessionError> {
|
||||
let mut config = self.read_config();
|
||||
match config
|
||||
.sessions
|
||||
.iter_mut()
|
||||
.find(|s| s.server == session.server && s.login == session.login)
|
||||
{
|
||||
Some(existing) => *existing = session.clone(),
|
||||
None => config.sessions.push(session.clone()),
|
||||
}
|
||||
self.write_config(&config)
|
||||
}
|
||||
|
||||
/// Rebuild credentials for a session from secure storage.
|
||||
///
|
||||
/// [`SecretError::NotFound`] means the credential was revoked or the
|
||||
/// keyring was cleared — the caller re-runs the login flow.
|
||||
pub fn credentials(&self, session: &Session) -> Result<AppCredentials, SessionError> {
|
||||
let password = self.secrets.retrieve(&session.secret_ref())?;
|
||||
Ok(AppCredentials {
|
||||
server: session.server.clone(),
|
||||
login_name: session.login.clone(),
|
||||
app_password: password,
|
||||
})
|
||||
}
|
||||
|
||||
/// Forget a session and delete its credential.
|
||||
///
|
||||
/// The credential is removed even if the config write fails, so a logout
|
||||
/// never leaves a usable secret behind.
|
||||
pub fn forget(&self, session: &Session) -> Result<(), SessionError> {
|
||||
let deleted = self.secrets.delete(&session.secret_ref());
|
||||
|
||||
let mut config = self.read_config();
|
||||
config
|
||||
.sessions
|
||||
.retain(|s| !(s.server == session.server && s.login == session.login));
|
||||
let written = self.write_config(&config);
|
||||
|
||||
deleted?;
|
||||
written
|
||||
}
|
||||
|
||||
fn read_config(&self) -> ConfigFile {
|
||||
std::fs::read_to_string(&self.config_path)
|
||||
.ok()
|
||||
.and_then(|t| serde_json::from_str(&t).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn write_config(&self, config: &ConfigFile) -> Result<(), SessionError> {
|
||||
if let Some(parent) = self.config_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let json = serde_json::to_string_pretty(config)?;
|
||||
|
||||
// Write and rename, so an interrupted save cannot truncate an
|
||||
// existing config.
|
||||
let tmp = self.config_path.with_extension("tmp");
|
||||
std::fs::write(&tmp, json)?;
|
||||
std::fs::rename(&tmp, &self.config_path)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SessionError {
|
||||
#[error("secure storage: {0}")]
|
||||
Secret(#[from] SecretError),
|
||||
|
||||
#[error("config io: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("config format: {0}")]
|
||||
Serde(#[from] serde_json::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
Remote(#[from] RemoteError),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use dr_plat::EphemeralSecretStore;
|
||||
|
||||
fn creds() -> AppCredentials {
|
||||
AppCredentials {
|
||||
server: "https://cloud.example/".into(),
|
||||
login_name: "duncan".into(),
|
||||
app_password: "secret-token".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn store_in(dir: &Path) -> SessionStore {
|
||||
SessionStore::open_at(
|
||||
dir.join("sessions.json"),
|
||||
Box::new(EphemeralSecretStore::new()),
|
||||
)
|
||||
}
|
||||
|
||||
fn tmpdir(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("darkroom-test-{name}"));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_session_survives_reopening() {
|
||||
let dir = tmpdir("survives");
|
||||
let secrets = Box::new(EphemeralSecretStore::new());
|
||||
|
||||
// Same secret store instance, as a real process would have.
|
||||
let store = SessionStore::open_at(dir.join("sessions.json"), secrets);
|
||||
let mut s = Session::new(&creds(), "duncan");
|
||||
s.root = "PhotosRaw".into();
|
||||
store.save(&s, &creds()).unwrap();
|
||||
|
||||
let reloaded = store.current().expect("session persisted");
|
||||
assert_eq!(reloaded.login, "duncan");
|
||||
assert_eq!(reloaded.root, "PhotosRaw");
|
||||
// Trailing slash normalised, so URLs built from it are consistent.
|
||||
assert_eq!(reloaded.server, "https://cloud.example");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_credential_never_reaches_the_config_file() {
|
||||
// NFR-SEC-2: the whole point of the split.
|
||||
let dir = tmpdir("nocreds");
|
||||
let store = store_in(&dir);
|
||||
let s = Session::new(&creds(), "duncan");
|
||||
store.save(&s, &creds()).unwrap();
|
||||
|
||||
let text = std::fs::read_to_string(dir.join("sessions.json")).unwrap();
|
||||
assert!(!text.contains("secret-token"), "credential leaked to disk");
|
||||
assert!(text.contains("duncan"), "session metadata should be there");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credentials_round_trip_through_secure_storage() {
|
||||
let dir = tmpdir("roundtrip");
|
||||
let store = store_in(&dir);
|
||||
let s = Session::new(&creds(), "duncan");
|
||||
store.save(&s, &creds()).unwrap();
|
||||
|
||||
let got = store.credentials(&s).unwrap();
|
||||
assert_eq!(got.app_password, "secret-token");
|
||||
assert_eq!(got.login_name, "duncan");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forgetting_removes_both_halves() {
|
||||
let dir = tmpdir("forget");
|
||||
let store = store_in(&dir);
|
||||
let s = Session::new(&creds(), "duncan");
|
||||
store.save(&s, &creds()).unwrap();
|
||||
|
||||
store.forget(&s).unwrap();
|
||||
assert!(store.current().is_none());
|
||||
assert!(matches!(
|
||||
store.credentials(&s),
|
||||
Err(SessionError::Secret(SecretError::NotFound))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_the_same_account_twice_does_not_duplicate_it() {
|
||||
let dir = tmpdir("dedupe");
|
||||
let store = store_in(&dir);
|
||||
let mut s = Session::new(&creds(), "duncan");
|
||||
store.save(&s, &creds()).unwrap();
|
||||
s.root = "Photos".into();
|
||||
store.save(&s, &creds()).unwrap();
|
||||
|
||||
assert_eq!(store.list().len(), 1);
|
||||
assert_eq!(store.current().unwrap().root, "Photos");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_config_is_a_first_run_not_an_error() {
|
||||
let dir = tmpdir("firstrun");
|
||||
let store = store_in(&dir);
|
||||
assert!(store.list().is_empty());
|
||||
assert!(store.current().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_corrupt_config_does_not_prevent_starting() {
|
||||
// Better to present a first-run state than to refuse to launch.
|
||||
let dir = tmpdir("corrupt");
|
||||
std::fs::write(dir.join("sessions.json"), "{ not json").unwrap();
|
||||
let store = store_in(&dir);
|
||||
assert!(store.list().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_selection_round_trips() {
|
||||
let dir = tmpdir("formats");
|
||||
let store = store_in(&dir);
|
||||
let mut s = Session::new(&creds(), "duncan");
|
||||
s.set_format_filter(&FormatFilter::from_formats([Format::Cr2, Format::Dng]));
|
||||
store.save(&s, &creds()).unwrap();
|
||||
|
||||
let f = store.current().unwrap().format_filter();
|
||||
assert!(f.allows(Format::Cr2));
|
||||
assert!(f.allows(Format::Dng));
|
||||
assert!(!f.allows(Format::Nef));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unset_filter_means_every_format() {
|
||||
// Never "no formats", which would silently find nothing.
|
||||
let s = Session::new(&creds(), "duncan");
|
||||
let f = s.format_filter();
|
||||
assert!(f.allows(Format::Cr2));
|
||||
assert!(f.allows(Format::Jpeg));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn describe_is_readable_and_hides_the_scheme() {
|
||||
let mut s = Session::new(&creds(), "duncan");
|
||||
assert_eq!(s.describe(), "duncan on cloud.example");
|
||||
s.root = "PhotosRaw".into();
|
||||
assert_eq!(s.describe(), "duncan on cloud.example/PhotosRaw");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn updating_settings_leaves_the_credential_alone() {
|
||||
let dir = tmpdir("update");
|
||||
let store = store_in(&dir);
|
||||
let mut s = Session::new(&creds(), "duncan");
|
||||
store.save(&s, &creds()).unwrap();
|
||||
|
||||
s.root = "Elsewhere".into();
|
||||
store.update(&s).unwrap();
|
||||
|
||||
assert_eq!(store.current().unwrap().root, "Elsewhere");
|
||||
assert_eq!(store.credentials(&s).unwrap().app_password, "secret-token");
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,12 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
dr-types.workspace = true
|
||||
# Accounts keep their credential in platform secure storage, never in the
|
||||
# config file they are otherwise written to (NFR-SEC-2).
|
||||
dr-plat.workspace = true
|
||||
async-trait.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
|
||||
@@ -0,0 +1,804 @@
|
||||
// TRACES: FR-NC-12 | FR-NC-2
|
||||
//! What a configured library *is*, with no connector in it.
|
||||
//!
|
||||
//! Before this existed, "an account" meant a Nextcloud server URL, a login
|
||||
//! name and a DAV user id, and that shape reached every layer above:
|
||||
//! `dr-ui` stored it, keyed its caches off it, threaded it through a dozen
|
||||
//! worker threads and handed it to a constructor named after one product.
|
||||
//! [`RemoteBackend`](crate::RemoteBackend) was abstract; everything that
|
||||
//! *reached* a backend was not, so a second connector had nowhere to live.
|
||||
//!
|
||||
//! An [`Account`] is what remains once the product is taken out: somewhere a
|
||||
//! library lives ([`endpoint`](Account::endpoint)), a folder inside it
|
||||
//! ([`root`](Account::root)), and the settings the scan needs. What an
|
||||
//! endpoint means is the connector's business — a URL for Nextcloud, a
|
||||
//! directory for a plain folder, a bucket for whatever comes next.
|
||||
//!
|
||||
//! # The split that has to survive
|
||||
//!
|
||||
//! Credentials go to platform secure storage (FR-NC-2). Never the catalog,
|
||||
//! never a file, never a log line. Everything else is ordinary configuration
|
||||
//! written as plain JSON. That split is what lets the app show "signed in as
|
||||
//! duncan, watching /PhotosRaw" before it has touched the keyring — and it is
|
||||
//! why a [`Connection`] carries the two halves separately rather than as one
|
||||
//! blob.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use dr_plat::{SecretError, SecretRef, SecretStore};
|
||||
use dr_types::{Format, FormatFilter};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::RemoteError;
|
||||
|
||||
/// The connector every account had before there was a choice.
|
||||
///
|
||||
/// Named here, in connector-neutral code, for exactly one reason:
|
||||
/// [`Account::namespace`] must keep producing the same string for these
|
||||
/// accounts as the hard-coded Nextcloud version did. That string is a
|
||||
/// directory name holding a catalog, thumbnail shards, un-uploaded sidecars
|
||||
/// and an export outbox. Changing it does not lose that data, it *abandons*
|
||||
/// it — silently, as an upgrade — and costs a full rescan of the library on
|
||||
/// top.
|
||||
///
|
||||
/// Nothing else in this crate branches on a connector's identity, and nothing
|
||||
/// else should.
|
||||
pub const LEGACY_BACKEND: &str = "nextcloud";
|
||||
|
||||
/// Where configuration is written, when the platform has told us.
|
||||
///
|
||||
/// Android has no `$HOME` and no XDG directories, so the guess below resolves
|
||||
/// to a path the app cannot write. Nothing failed loudly: the account list went
|
||||
/// to a doomed path, so credentials survived only as long as the process did and
|
||||
/// backgrounding the app lost the account (ARCH §6.9 — no core API may assume a
|
||||
/// filesystem path on Android).
|
||||
///
|
||||
/// The platform layer sets this once at startup, before any store is opened.
|
||||
static DATA_DIR: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
|
||||
|
||||
/// TRACES: FR-NC-2
|
||||
/// Declare the per-app directory configuration belongs in.
|
||||
///
|
||||
/// Call before opening any store; later calls are ignored rather than racing.
|
||||
/// On Android this is `AndroidApp::internal_data_path`, which is private to the
|
||||
/// app and survives being backgrounded. Desktop needs no call — the XDG
|
||||
/// fallback is correct there.
|
||||
pub fn set_data_dir(dir: PathBuf) {
|
||||
let _ = DATA_DIR.set(dir);
|
||||
}
|
||||
|
||||
/// The directory configuration lives in.
|
||||
pub fn config_dir() -> PathBuf {
|
||||
if let Some(d) = DATA_DIR.get() {
|
||||
return d.clone();
|
||||
}
|
||||
std::env::var_os("XDG_CONFIG_HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".config"))
|
||||
.join("darkroom")
|
||||
}
|
||||
|
||||
/// TRACES: FR-NC-12
|
||||
/// A configured library, minus its credential.
|
||||
///
|
||||
/// Every field but [`backend`](Self::backend) is interpreted by the connector
|
||||
/// that owns it. Code above this layer reads them for display and for cache
|
||||
/// keys and never for meaning.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Account {
|
||||
/// Which connector serves this library, as
|
||||
/// [`BackendProvider::id`](crate::BackendProvider::id).
|
||||
///
|
||||
/// Defaulted rather than required, because every account written before
|
||||
/// there was a choice omits it and every one of them is a Nextcloud
|
||||
/// account. A missing field here must load, not fail — a config the app
|
||||
/// refuses to parse is an account the user has to set up again.
|
||||
#[serde(default = "legacy_backend")]
|
||||
pub backend: String,
|
||||
|
||||
/// Where the library lives, in whatever form the connector addresses:
|
||||
/// `https://cloud.example` for Nextcloud, `/mnt/photos` for a folder.
|
||||
///
|
||||
/// Stored under its historical name so existing configuration loads
|
||||
/// unchanged.
|
||||
#[serde(rename = "server")]
|
||||
pub endpoint: String,
|
||||
|
||||
/// Who we are, where that means anything. Empty for connectors with no
|
||||
/// notion of a user — it is shown, and used to key the credential.
|
||||
#[serde(default)]
|
||||
pub login: String,
|
||||
|
||||
/// A connector-defined sub-address. Nextcloud's DAV path segment, which
|
||||
/// may differ from `login` because a login can be an email address while
|
||||
/// the user id is something else. Empty where the connector has no use
|
||||
/// for one.
|
||||
#[serde(default)]
|
||||
pub user_id: String,
|
||||
|
||||
/// The folder chosen as the library root, relative to the endpoint. Empty
|
||||
/// means the endpoint itself.
|
||||
#[serde(default)]
|
||||
pub root: String,
|
||||
|
||||
/// Which formats the scan looks for (the tick-boxes).
|
||||
#[serde(default)]
|
||||
pub formats: Vec<String>,
|
||||
|
||||
/// Unix seconds of the last completed scan, for display.
|
||||
#[serde(default)]
|
||||
pub last_scan: Option<i64>,
|
||||
}
|
||||
|
||||
fn legacy_backend() -> String {
|
||||
LEGACY_BACKEND.to_string()
|
||||
}
|
||||
|
||||
impl Account {
|
||||
/// A bare account for `backend` at `endpoint`, with nothing chosen yet.
|
||||
pub fn new(backend: impl Into<String>, endpoint: impl Into<String>) -> Self {
|
||||
Self {
|
||||
backend: backend.into(),
|
||||
endpoint: endpoint.into(),
|
||||
login: String::new(),
|
||||
user_id: String::new(),
|
||||
root: String::new(),
|
||||
formats: Vec::new(),
|
||||
last_scan: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_login(mut self, login: impl Into<String>, user_id: impl Into<String>) -> Self {
|
||||
self.login = login.into();
|
||||
self.user_id = user_id.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Whether two records name the same account.
|
||||
///
|
||||
/// The identity the store deduplicates on. Endpoint and login together,
|
||||
/// because one server can hold two accounts and one machine can hold two
|
||||
/// folders — but the *same* pair twice is the same library reconfigured,
|
||||
/// not a second one.
|
||||
pub fn is_same_as(&self, other: &Account) -> bool {
|
||||
self.backend == other.backend
|
||||
&& self.endpoint == other.endpoint
|
||||
&& self.login == other.login
|
||||
}
|
||||
|
||||
/// The stored format selection, defaulting to every supported format.
|
||||
///
|
||||
/// An unconfigured account must find everything rather than nothing.
|
||||
pub fn format_filter(&self) -> FormatFilter {
|
||||
if self.formats.is_empty() {
|
||||
FormatFilter::all()
|
||||
} else {
|
||||
FormatFilter::from_formats(
|
||||
self.formats
|
||||
.iter()
|
||||
.filter_map(|s| Format::from_extension(&s.to_ascii_lowercase())),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_format_filter(&mut self, filter: &FormatFilter) {
|
||||
self.formats = filter
|
||||
.iter()
|
||||
.map(|f| format!("{f:?}").to_lowercase())
|
||||
.collect();
|
||||
}
|
||||
|
||||
/// Where this account's credential lives, for connectors that need one.
|
||||
pub fn secret_ref(&self) -> SecretRef {
|
||||
SecretRef::app_password(&self.endpoint, &self.login)
|
||||
}
|
||||
|
||||
/// A short description for the UI.
|
||||
///
|
||||
/// Reads for both shapes without asking the connector: "duncan on
|
||||
/// cloud.example/PhotosRaw" where there is a login, and just the location
|
||||
/// where there is not — a folder library has no user to name, and
|
||||
/// inventing one ("(local) on /mnt/photos") would be worse than saying
|
||||
/// where it is.
|
||||
pub fn describe(&self) -> String {
|
||||
let place = self
|
||||
.endpoint
|
||||
.trim_start_matches("https://")
|
||||
.trim_start_matches("http://");
|
||||
let place = if self.root.is_empty() {
|
||||
place.to_string()
|
||||
} else {
|
||||
format!("{}/{}", place.trim_end_matches('/'), self.root)
|
||||
};
|
||||
if self.login.is_empty() {
|
||||
place
|
||||
} else {
|
||||
format!("{} on {place}", self.login)
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-NC-10 | NFR-R1
|
||||
/// The directory name this account's local data hangs off.
|
||||
///
|
||||
/// Not a display string and not stable across a change of endpoint: it is
|
||||
/// the key for the catalog, the thumbnail shards, the sidecar spool and
|
||||
/// the export outbox. Two accounts must never collide here — one would
|
||||
/// index the other's library — and one account must produce the same
|
||||
/// answer on every launch, forever, or its data is abandoned in place.
|
||||
///
|
||||
/// The Nextcloud form is reproduced byte for byte from what
|
||||
/// `catalog_path` computed before accounts were multi-backend
|
||||
/// ([`LEGACY_BACKEND`]). Everything else is prefixed by its connector, so
|
||||
/// a folder library at `/srv/photos` and a hypothetical S3 bucket of the
|
||||
/// same name cannot land in one directory.
|
||||
pub fn namespace(&self) -> String {
|
||||
let slug = slugify(
|
||||
self.endpoint
|
||||
.trim_start_matches("https://")
|
||||
.trim_start_matches("http://"),
|
||||
);
|
||||
|
||||
if self.backend == LEGACY_BACKEND {
|
||||
// Frozen. See LEGACY_BACKEND.
|
||||
return format!("{slug}-{}", self.user_id);
|
||||
}
|
||||
|
||||
let tail = if self.user_id.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("-{}", slugify(&self.user_id))
|
||||
};
|
||||
let name = format!("{}-{slug}{tail}", slugify(&self.backend));
|
||||
shorten(&name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything that is not `[A-Za-z0-9]`, flattened to `-`.
|
||||
///
|
||||
/// Not an escape and not reversible: the result names a directory, and the
|
||||
/// only property it needs is that it is a legal filename on every platform
|
||||
/// the app runs on.
|
||||
fn slugify(s: &str) -> String {
|
||||
s.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Cap a namespace at a length every filesystem accepts.
|
||||
///
|
||||
/// A folder endpoint is an absolute path and can be far longer than a server
|
||||
/// URL — deep enough to exceed the 255-byte component limit on ext4 and APFS
|
||||
/// alike, at which point creating the catalog directory fails and the library
|
||||
/// cannot be opened at all. Truncating alone would make two deep paths under
|
||||
/// one parent collide, so the discarded tail is replaced by a hash of the
|
||||
/// whole.
|
||||
fn shorten(name: &str) -> String {
|
||||
const MAX: usize = 96;
|
||||
if name.len() <= MAX {
|
||||
return name.to_string();
|
||||
}
|
||||
let head: String = name.chars().take(MAX - 17).collect();
|
||||
format!("{head}-{:016x}", fnv1a64(name.as_bytes()))
|
||||
}
|
||||
|
||||
/// FNV-1a, 64-bit.
|
||||
///
|
||||
/// Written out rather than taken from `DefaultHasher`, whose output is
|
||||
/// explicitly not stable between Rust releases. This one keys a directory that
|
||||
/// must be found again after a toolchain upgrade.
|
||||
fn fnv1a64(bytes: &[u8]) -> u64 {
|
||||
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
for b in bytes {
|
||||
h ^= *b as u64;
|
||||
h = h.wrapping_mul(0x0000_0100_0000_01b3);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// TRACES: FR-NC-2 | NFR-SEC-2
|
||||
/// A credential, kept out of logs by construction.
|
||||
///
|
||||
/// The inner string is reachable only through [`expose`](Secret::expose), so
|
||||
/// the ways a secret leaks — a `{:?}` on a struct that happens to contain one,
|
||||
/// a `Display` in an error message — do not compile into a leak. NFR-SEC-2 is
|
||||
/// the requirement; this is the part of it that a reviewer cannot forget to
|
||||
/// apply.
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct Secret(String);
|
||||
|
||||
impl Secret {
|
||||
pub fn new(value: impl Into<String>) -> Self {
|
||||
Secret(value.into())
|
||||
}
|
||||
|
||||
/// The credential itself. Every call site is a place to check.
|
||||
pub fn expose(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Secret {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("Secret(***)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything needed to open a backend, in one movable value.
|
||||
///
|
||||
/// Workers run on their own threads and each one needs its own way in, so this
|
||||
/// is `Clone` and owns what it holds. It replaced a pair of arguments —
|
||||
/// credentials and a user id — that had to be threaded together through
|
||||
/// fifteen functions and could be passed in the wrong order.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Connection {
|
||||
pub account: Account,
|
||||
/// `None` where the connector needs no credential, which is the ordinary
|
||||
/// state of a folder library rather than a failure to load one.
|
||||
pub secret: Option<Secret>,
|
||||
}
|
||||
|
||||
impl Connection {
|
||||
pub fn new(account: Account, secret: Option<Secret>) -> Self {
|
||||
Self { account, secret }
|
||||
}
|
||||
|
||||
/// The credential, or [`RemoteError::Unauthenticated`].
|
||||
///
|
||||
/// For connectors that require one: turning the absence into the error the
|
||||
/// caller already handles saves every implementation writing the same
|
||||
/// `ok_or`.
|
||||
pub fn require_secret(&self) -> Result<&Secret, RemoteError> {
|
||||
self.secret.as_ref().ok_or(RemoteError::Unauthenticated)
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-NC-1 | FR-NC-2 | M-1 | M-2
|
||||
/// Loads and saves accounts, keeping credentials in secure storage.
|
||||
pub struct AccountStore {
|
||||
config_path: PathBuf,
|
||||
secrets: Box<dyn SecretStore>,
|
||||
}
|
||||
|
||||
/// What is written to disk. Versioned so a format change is a migration
|
||||
/// rather than a parse failure.
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
struct ConfigFile {
|
||||
#[serde(default = "one")]
|
||||
version: u32,
|
||||
/// Named `sessions` on disk because that is what it has always been
|
||||
/// called there, and renaming the key would orphan every existing config.
|
||||
#[serde(default)]
|
||||
sessions: Vec<Account>,
|
||||
}
|
||||
|
||||
fn one() -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
impl AccountStore {
|
||||
/// Open the store at the platform config location.
|
||||
///
|
||||
/// Linux: `$XDG_CONFIG_HOME/darkroom/sessions.json`, falling back to
|
||||
/// `~/.config` (FR-PLAT-LIN-1).
|
||||
pub fn open(secrets: Box<dyn SecretStore>) -> Self {
|
||||
Self::open_at(config_dir().join("sessions.json"), secrets)
|
||||
}
|
||||
|
||||
/// Where configuration lives, for callers that need to sit files beside it.
|
||||
pub fn data_dir() -> PathBuf {
|
||||
config_dir()
|
||||
}
|
||||
|
||||
/// Open at an explicit path — used by tests, and by anything wanting a
|
||||
/// non-default config location.
|
||||
pub fn open_at(config_path: PathBuf, secrets: Box<dyn SecretStore>) -> Self {
|
||||
Self {
|
||||
config_path,
|
||||
secrets,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn config_path(&self) -> &Path {
|
||||
&self.config_path
|
||||
}
|
||||
|
||||
/// Whether credentials can be remembered at all.
|
||||
///
|
||||
/// Where false the UI should say sign-in will not persist, rather than
|
||||
/// letting the user discover it next launch.
|
||||
pub fn can_remember(&self) -> bool {
|
||||
self.secrets.is_available()
|
||||
}
|
||||
|
||||
/// Every configured account. Missing or unreadable config yields an empty
|
||||
/// list rather than an error — a first run is not a failure.
|
||||
pub fn list(&self) -> Vec<Account> {
|
||||
self.read_config().sessions
|
||||
}
|
||||
|
||||
/// The most recently configured account, if any.
|
||||
pub fn current(&self) -> Option<Account> {
|
||||
self.read_config().sessions.into_iter().next_back()
|
||||
}
|
||||
|
||||
/// Persist an account and its credential.
|
||||
///
|
||||
/// The credential goes to secure storage first: if that fails there is no
|
||||
/// point recording an account that cannot authenticate. `None` is the
|
||||
/// ordinary case for a connector that needs no credential, and stores
|
||||
/// nothing rather than an empty secret.
|
||||
pub fn save(&self, account: &Account, secret: Option<&Secret>) -> Result<(), AccountError> {
|
||||
if let Some(s) = secret {
|
||||
self.secrets.store(&account.secret_ref(), s.expose())?;
|
||||
}
|
||||
|
||||
let mut config = self.read_config();
|
||||
config.sessions.retain(|a| !a.is_same_as(account));
|
||||
config.sessions.push(account.clone());
|
||||
self.write_config(&config)
|
||||
}
|
||||
|
||||
/// Update an account's settings, leaving its credential untouched.
|
||||
pub fn update(&self, account: &Account) -> Result<(), AccountError> {
|
||||
let mut config = self.read_config();
|
||||
match config.sessions.iter_mut().find(|a| a.is_same_as(account)) {
|
||||
Some(existing) => *existing = account.clone(),
|
||||
None => config.sessions.push(account.clone()),
|
||||
}
|
||||
self.write_config(&config)
|
||||
}
|
||||
|
||||
/// Rebuild a connection for an account, fetching its credential.
|
||||
///
|
||||
/// `needs_secret` is the connector's answer, passed in rather than
|
||||
/// inferred: an account with an empty login might be a folder library or
|
||||
/// might be a broken Nextcloud record, and guessing turns the second into
|
||||
/// a silent unauthenticated connection instead of an error the user can
|
||||
/// act on.
|
||||
///
|
||||
/// [`SecretError::NotFound`] means the credential was revoked or the
|
||||
/// keyring was cleared — the caller re-runs the sign-in.
|
||||
pub fn connection(
|
||||
&self,
|
||||
account: &Account,
|
||||
needs_secret: bool,
|
||||
) -> Result<Connection, AccountError> {
|
||||
let secret = if needs_secret {
|
||||
Some(Secret::new(self.secrets.retrieve(&account.secret_ref())?))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(Connection::new(account.clone(), secret))
|
||||
}
|
||||
|
||||
/// Forget an account and delete its credential.
|
||||
///
|
||||
/// The credential is removed even if the config write fails, so a logout
|
||||
/// never leaves a usable secret behind. A connector that stores none
|
||||
/// reports [`SecretError::NotFound`], which is not a failure to forget.
|
||||
pub fn forget(&self, account: &Account) -> Result<(), AccountError> {
|
||||
let deleted = match self.secrets.delete(&account.secret_ref()) {
|
||||
Err(SecretError::NotFound) => Ok(()),
|
||||
other => other,
|
||||
};
|
||||
|
||||
let mut config = self.read_config();
|
||||
config.sessions.retain(|a| !a.is_same_as(account));
|
||||
let written = self.write_config(&config);
|
||||
|
||||
deleted?;
|
||||
written
|
||||
}
|
||||
|
||||
fn read_config(&self) -> ConfigFile {
|
||||
std::fs::read_to_string(&self.config_path)
|
||||
.ok()
|
||||
.and_then(|t| serde_json::from_str(&t).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn write_config(&self, config: &ConfigFile) -> Result<(), AccountError> {
|
||||
if let Some(parent) = self.config_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let json = serde_json::to_string_pretty(config)?;
|
||||
|
||||
// Write and rename, so an interrupted save cannot truncate an
|
||||
// existing config.
|
||||
let tmp = self.config_path.with_extension("tmp");
|
||||
std::fs::write(&tmp, json)?;
|
||||
std::fs::rename(&tmp, &self.config_path)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AccountError {
|
||||
#[error("secure storage: {0}")]
|
||||
Secret(#[from] SecretError),
|
||||
|
||||
#[error("config io: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("config format: {0}")]
|
||||
Serde(#[from] serde_json::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
Remote(#[from] RemoteError),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use dr_plat::EphemeralSecretStore;
|
||||
|
||||
fn nextcloud() -> Account {
|
||||
Account::new(LEGACY_BACKEND, "https://cloud.example").with_login("duncan", "duncan")
|
||||
}
|
||||
|
||||
fn folder() -> Account {
|
||||
Account::new("folder", "/mnt/photos")
|
||||
}
|
||||
|
||||
fn store_in(dir: &Path) -> AccountStore {
|
||||
AccountStore::open_at(
|
||||
dir.join("sessions.json"),
|
||||
Box::new(EphemeralSecretStore::new()),
|
||||
)
|
||||
}
|
||||
|
||||
fn tmpdir(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("darkroom-account-test-{name}"));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_account_survives_reopening() {
|
||||
let dir = tmpdir("survives");
|
||||
let store = store_in(&dir);
|
||||
let mut a = nextcloud();
|
||||
a.root = "PhotosRaw".into();
|
||||
store.save(&a, Some(&Secret::new("token"))).unwrap();
|
||||
|
||||
let reloaded = store.current().expect("account persisted");
|
||||
assert_eq!(reloaded.login, "duncan");
|
||||
assert_eq!(reloaded.root, "PhotosRaw");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_credential_never_reaches_the_config_file() {
|
||||
// NFR-SEC-2: the whole point of the split.
|
||||
let dir = tmpdir("nocreds");
|
||||
let store = store_in(&dir);
|
||||
store
|
||||
.save(&nextcloud(), Some(&Secret::new("secret-token")))
|
||||
.unwrap();
|
||||
|
||||
let text = std::fs::read_to_string(dir.join("sessions.json")).unwrap();
|
||||
assert!(!text.contains("secret-token"), "credential leaked to disk");
|
||||
assert!(text.contains("duncan"), "account metadata should be there");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_secret_does_not_print_itself() {
|
||||
// The leak this closes is indirect: a `{:?}` on any struct holding a
|
||||
// connection used to print the app password.
|
||||
let c = Connection::new(nextcloud(), Some(Secret::new("hunter2")));
|
||||
let printed = format!("{c:?}");
|
||||
assert!(!printed.contains("hunter2"), "credential leaked to a log");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credentials_round_trip_through_secure_storage() {
|
||||
let dir = tmpdir("roundtrip");
|
||||
let store = store_in(&dir);
|
||||
let a = nextcloud();
|
||||
store.save(&a, Some(&Secret::new("secret-token"))).unwrap();
|
||||
|
||||
let conn = store.connection(&a, true).unwrap();
|
||||
assert_eq!(conn.require_secret().unwrap().expose(), "secret-token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_credentialless_account_connects_without_touching_the_keyring() {
|
||||
// A folder library must open on a machine with no secrets daemon at
|
||||
// all — asking for a credential it does not have would fail the one
|
||||
// backend that needs nothing.
|
||||
let dir = tmpdir("nosecret");
|
||||
let store = store_in(&dir);
|
||||
let a = folder();
|
||||
store.save(&a, None).unwrap();
|
||||
|
||||
let conn = store.connection(&a, false).unwrap();
|
||||
assert!(conn.secret.is_none());
|
||||
assert!(matches!(
|
||||
conn.require_secret(),
|
||||
Err(RemoteError::Unauthenticated)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forgetting_removes_both_halves() {
|
||||
let dir = tmpdir("forget");
|
||||
let store = store_in(&dir);
|
||||
let a = nextcloud();
|
||||
store.save(&a, Some(&Secret::new("token"))).unwrap();
|
||||
|
||||
store.forget(&a).unwrap();
|
||||
assert!(store.current().is_none());
|
||||
assert!(matches!(
|
||||
store.connection(&a, true),
|
||||
Err(AccountError::Secret(SecretError::NotFound))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forgetting_a_credentialless_account_is_not_an_error() {
|
||||
// There is no secret to delete, and reporting the absence as a failure
|
||||
// would leave a folder library that cannot be signed out of.
|
||||
let dir = tmpdir("forget-folder");
|
||||
let store = store_in(&dir);
|
||||
let a = folder();
|
||||
store.save(&a, None).unwrap();
|
||||
store.forget(&a).unwrap();
|
||||
assert!(store.current().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_backends_at_the_same_endpoint_are_two_accounts() {
|
||||
let dir = tmpdir("twobackends");
|
||||
let store = store_in(&dir);
|
||||
store.save(&Account::new("folder", "/mnt/p"), None).unwrap();
|
||||
store.save(&Account::new("webdav", "/mnt/p"), None).unwrap();
|
||||
assert_eq!(store.list().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_the_same_account_twice_does_not_duplicate_it() {
|
||||
let dir = tmpdir("dedupe");
|
||||
let store = store_in(&dir);
|
||||
let mut a = nextcloud();
|
||||
store.save(&a, Some(&Secret::new("token"))).unwrap();
|
||||
a.root = "Photos".into();
|
||||
store.save(&a, Some(&Secret::new("token"))).unwrap();
|
||||
|
||||
assert_eq!(store.list().len(), 1);
|
||||
assert_eq!(store.current().unwrap().root, "Photos");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_config_is_a_first_run_not_an_error() {
|
||||
let dir = tmpdir("firstrun");
|
||||
let store = store_in(&dir);
|
||||
assert!(store.list().is_empty());
|
||||
assert!(store.current().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_corrupt_config_does_not_prevent_starting() {
|
||||
// Better to present a first-run state than to refuse to launch.
|
||||
let dir = tmpdir("corrupt");
|
||||
std::fs::write(dir.join("sessions.json"), "{ not json").unwrap();
|
||||
let store = store_in(&dir);
|
||||
assert!(store.list().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_config_written_before_backends_existed_still_loads() {
|
||||
// The upgrade path. Every account written by an earlier version omits
|
||||
// `backend`, and refusing to parse one would make an upgrade look
|
||||
// like a signed-out app with a library that has to be set up again.
|
||||
let dir = tmpdir("legacy");
|
||||
std::fs::write(
|
||||
dir.join("sessions.json"),
|
||||
r#"{"version":1,"sessions":[{"server":"https://cloud.example",
|
||||
"login":"duncan","user_id":"duncan","root":"PhotosRaw",
|
||||
"formats":[],"last_scan":null}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let a = store_in(&dir).current().expect("legacy account loads");
|
||||
assert_eq!(a.backend, LEGACY_BACKEND);
|
||||
assert_eq!(a.endpoint, "https://cloud.example");
|
||||
assert_eq!(a.root, "PhotosRaw");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_legacy_account_keeps_the_directory_its_data_is_already_in() {
|
||||
// Frozen deliberately: this string names the directory holding the
|
||||
// catalog, the thumbnail shards and un-uploaded sidecars. A change
|
||||
// here abandons all three and forces a full rescan.
|
||||
let a = Account::new(LEGACY_BACKEND, "https://cloud.example.com").with_login("d", "duncan");
|
||||
assert_eq!(a.namespace(), "cloud-example-com-duncan");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_backend_cannot_collide_with_a_legacy_one() {
|
||||
let ns = Account::new("folder", "/mnt/photos").namespace();
|
||||
assert!(ns.starts_with("folder-"), "{ns}");
|
||||
assert_ne!(ns, Account::new(LEGACY_BACKEND, "/mnt/photos").namespace());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_folders_never_share_a_directory() {
|
||||
// Two libraries in one catalog would index each other's images.
|
||||
assert_ne!(
|
||||
Account::new("folder", "/mnt/photos/2025").namespace(),
|
||||
Account::new("folder", "/mnt/photos/2026").namespace()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_very_deep_folder_still_yields_a_legal_directory_name() {
|
||||
// Past 255 bytes the catalog directory cannot be created at all, and
|
||||
// the library simply fails to open.
|
||||
let deep = format!("/{}", vec!["a-rather-long-folder-name"; 40].join("/"));
|
||||
let a = Account::new("folder", &deep);
|
||||
let ns = a.namespace();
|
||||
assert!(ns.len() <= 96, "{} chars", ns.len());
|
||||
|
||||
// Truncation alone would make these two the same directory.
|
||||
let b = Account::new("folder", format!("{deep}/second"));
|
||||
assert_ne!(ns, b.namespace());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn describe_reads_for_an_account_with_no_user() {
|
||||
// A folder library has nobody to name; "(none) on /mnt/photos" would
|
||||
// be worse than saying where it is.
|
||||
let mut a = folder();
|
||||
assert_eq!(a.describe(), "/mnt/photos");
|
||||
a.root = "2026".into();
|
||||
assert_eq!(a.describe(), "/mnt/photos/2026");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn describe_is_readable_and_hides_the_scheme() {
|
||||
let mut a = nextcloud();
|
||||
assert_eq!(a.describe(), "duncan on cloud.example");
|
||||
a.root = "PhotosRaw".into();
|
||||
assert_eq!(a.describe(), "duncan on cloud.example/PhotosRaw");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_selection_round_trips() {
|
||||
let mut a = nextcloud();
|
||||
a.set_format_filter(&FormatFilter::from_formats([Format::Cr2, Format::Dng]));
|
||||
let f = a.format_filter();
|
||||
assert!(f.allows(Format::Cr2));
|
||||
assert!(f.allows(Format::Dng));
|
||||
assert!(!f.allows(Format::Nef));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unset_filter_means_every_format() {
|
||||
// Never "no formats", which would silently find nothing.
|
||||
let f = nextcloud().format_filter();
|
||||
assert!(f.allows(Format::Cr2));
|
||||
assert!(f.allows(Format::Jpeg));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn updating_settings_leaves_the_credential_alone() {
|
||||
let dir = tmpdir("update");
|
||||
let store = store_in(&dir);
|
||||
let mut a = nextcloud();
|
||||
store.save(&a, Some(&Secret::new("secret-token"))).unwrap();
|
||||
|
||||
a.root = "Elsewhere".into();
|
||||
store.update(&a).unwrap();
|
||||
|
||||
assert_eq!(store.current().unwrap().root, "Elsewhere");
|
||||
assert_eq!(
|
||||
store
|
||||
.connection(&a, true)
|
||||
.unwrap()
|
||||
.require_secret()
|
||||
.unwrap()
|
||||
.expose(),
|
||||
"secret-token"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,21 @@ pub enum RemoteError {
|
||||
#[error("operation unsupported by this backend: {0}")]
|
||||
Unsupported(&'static str),
|
||||
|
||||
/// The account is configured wrongly, or for a backend this build has no
|
||||
/// connector for.
|
||||
///
|
||||
/// **Not a network failure and not an auth failure**, which is why it is
|
||||
/// its own variant. A folder library whose directory has been unmounted,
|
||||
/// or an account naming a backend a cut-down build was not compiled with,
|
||||
/// produces a request that never leaves the process — reporting either as
|
||||
/// `Network` would put the app into offline mode and tell the user their
|
||||
/// connection is down, and reporting them as `AuthFailed` would send them
|
||||
/// to re-enter a credential that is fine. The message names what is wrong
|
||||
/// with the configuration, because that is the only thing that will fix
|
||||
/// it.
|
||||
#[error("account misconfigured: {0}")]
|
||||
Configuration(String),
|
||||
|
||||
/// A conditional write failed: the remote changed underneath us. Triggers
|
||||
/// the sidecar merge path (ARCH §8.5).
|
||||
#[error("precondition failed — remote was modified")]
|
||||
|
||||
+12
-3
@@ -1,9 +1,14 @@
|
||||
//! 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.
|
||||
//! 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
|
||||
//!
|
||||
@@ -20,15 +25,19 @@ 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::{
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
// TRACES: FR-NC-12
|
||||
//! How a connector announces itself.
|
||||
//!
|
||||
//! [`RemoteBackend`] says what a backend can *do* once it is open.
|
||||
//! [`BackendProvider`] says everything the application needs before that: what
|
||||
//! to call it, what a library location looks like, whether signing in involves
|
||||
//! a browser, and how to turn a stored [`Account`] into a live backend.
|
||||
//!
|
||||
//! Together they are the whole contract. Adding a storage layer is:
|
||||
//!
|
||||
//! 1. implement [`RemoteBackend`] over your protocol,
|
||||
//! 2. implement [`BackendProvider`] beside it,
|
||||
//! 3. register it in `dr_ui::remote`.
|
||||
//!
|
||||
//! Nothing above that module names a connector, so nothing above it changes.
|
||||
//!
|
||||
//! # Why sign-in is a shape rather than a method
|
||||
//!
|
||||
//! It would be tidier for a provider to expose `async fn sign_in()` and let
|
||||
//! the launch screen await it. It would also be wrong: Nextcloud's Login Flow
|
||||
//! v2 is a browser handshake the user completes elsewhere while the app polls,
|
||||
//! so it is not one call, it does not finish on our schedule, and the screen
|
||||
//! has to render a URL and a waiting state in the middle of it. A folder needs
|
||||
//! none of that. [`SignIn`] names which of those two shapes the screen must
|
||||
//! draw, and the flow itself stays where its protocol is.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{Account, Connection, RemoteBackend, RemoteError};
|
||||
|
||||
/// What establishing an account involves.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SignIn {
|
||||
/// A handshake the user completes outside the app, yielding a credential
|
||||
/// the app then stores. Nextcloud's Login Flow v2.
|
||||
///
|
||||
/// The connector drives it; the launch screen only shows the waiting
|
||||
/// state, because what happens in the middle is protocol-specific.
|
||||
Browser,
|
||||
|
||||
/// The endpoint is the whole account. Nothing to authenticate, nothing to
|
||||
/// store in the keyring, no waiting state to draw — a local folder.
|
||||
EndpointOnly,
|
||||
}
|
||||
|
||||
impl SignIn {
|
||||
/// Whether an account of this shape has a credential in secure storage.
|
||||
pub fn needs_secret(self) -> bool {
|
||||
matches!(self, SignIn::Browser)
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-NC-12
|
||||
/// A storage connector, described well enough to configure without naming it.
|
||||
///
|
||||
/// Implementations are held in an [`Arc`] inside a [`BackendRegistry`] and
|
||||
/// must be usable from any thread: the launch screen reads them on the UI
|
||||
/// thread and workers open connections from them on their own.
|
||||
pub trait BackendProvider: Send + Sync {
|
||||
/// The stable identifier written to [`Account::backend`].
|
||||
///
|
||||
/// **It is on-disk configuration.** Changing it after anyone has an
|
||||
/// account orphans that account, so pick it once.
|
||||
fn id(&self) -> &'static str;
|
||||
|
||||
/// What to call this in the interface. "Nextcloud", "Folder".
|
||||
fn display_name(&self) -> &'static str;
|
||||
|
||||
/// What to label the endpoint field: "Server address", "Folder".
|
||||
fn endpoint_label(&self) -> &'static str;
|
||||
|
||||
/// An example endpoint, for the empty field.
|
||||
fn endpoint_placeholder(&self) -> &'static str;
|
||||
|
||||
/// How an account of this kind is established.
|
||||
fn sign_in(&self) -> SignIn;
|
||||
|
||||
/// Turn what the user typed into the form that gets stored.
|
||||
///
|
||||
/// Two jobs, and the second is the important one: this is where a bad
|
||||
/// endpoint is *rejected*, before an account is written for a library that
|
||||
/// does not exist. The error is shown to the user, so it says what is
|
||||
/// wrong rather than naming a type.
|
||||
fn normalise_endpoint(&self, input: &str) -> Result<String, String>;
|
||||
|
||||
/// Build an account from a normalised endpoint alone.
|
||||
///
|
||||
/// Only meaningful for [`SignIn::EndpointOnly`]; a browser flow produces
|
||||
/// its account from what the handshake returned, so the default here
|
||||
/// refuses rather than inventing one.
|
||||
fn account_for(&self, endpoint: &str) -> Result<Account, RemoteError> {
|
||||
let _ = endpoint;
|
||||
Err(RemoteError::Unsupported(
|
||||
"this backend establishes an account through its sign-in flow",
|
||||
))
|
||||
}
|
||||
|
||||
/// Open a live backend.
|
||||
///
|
||||
/// Cheap and synchronous: it validates configuration and constructs a
|
||||
/// client, and does not talk to the remote. Workers call it per task, so
|
||||
/// anything expensive here is paid over and over.
|
||||
fn connect(&self, conn: &Connection) -> Result<Box<dyn RemoteBackend>, RemoteError>;
|
||||
}
|
||||
|
||||
/// TRACES: FR-NC-12 | FR-NC-13
|
||||
/// The connectors this build has.
|
||||
///
|
||||
/// One instance is built at startup and consulted by everything that needs a
|
||||
/// backend. The registry is the *only* thing that knows connectors exist,
|
||||
/// which is what keeps the layers above free of them.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct BackendRegistry {
|
||||
providers: Vec<Arc<dyn BackendProvider>>,
|
||||
}
|
||||
|
||||
impl BackendRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Add a connector.
|
||||
///
|
||||
/// Later registrations of an id replace earlier ones, so a build can
|
||||
/// substitute a connector — a test double for a real server — without the
|
||||
/// registry needing to know it happened.
|
||||
pub fn register(&mut self, provider: Arc<dyn BackendProvider>) -> &mut Self {
|
||||
let id = provider.id();
|
||||
self.providers.retain(|p| p.id() != id);
|
||||
self.providers.push(provider);
|
||||
self
|
||||
}
|
||||
|
||||
/// The connector for an id.
|
||||
pub fn get(&self, id: &str) -> Option<&Arc<dyn BackendProvider>> {
|
||||
self.providers.iter().find(|p| p.id() == id)
|
||||
}
|
||||
|
||||
/// The connector an account names, or a message naming the account's.
|
||||
///
|
||||
/// The error case is real rather than defensive: a configuration file can
|
||||
/// outlive the build that wrote it, and a user moving between a full
|
||||
/// desktop build and a cut-down one will have accounts this binary cannot
|
||||
/// serve. Saying which backend is missing is the difference between that
|
||||
/// and "could not open library".
|
||||
pub fn for_account(&self, account: &Account) -> Result<&Arc<dyn BackendProvider>, RemoteError> {
|
||||
self.get(&account.backend).ok_or_else(|| {
|
||||
RemoteError::Configuration(format!(
|
||||
"no storage backend named {:?} in this build",
|
||||
account.backend
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// Open the backend an account is configured for.
|
||||
pub fn connect(&self, conn: &Connection) -> Result<Box<dyn RemoteBackend>, RemoteError> {
|
||||
self.for_account(&conn.account)?.connect(conn)
|
||||
}
|
||||
|
||||
/// Every connector, in registration order. What the launch screen offers.
|
||||
pub fn providers(&self) -> &[Arc<dyn BackendProvider>] {
|
||||
&self.providers
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for BackendRegistry {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("BackendRegistry")
|
||||
.field(
|
||||
"providers",
|
||||
&self.providers.iter().map(|p| p.id()).collect::<Vec<_>>(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct Stub(&'static str);
|
||||
|
||||
impl BackendProvider for Stub {
|
||||
fn id(&self) -> &'static str {
|
||||
self.0
|
||||
}
|
||||
fn display_name(&self) -> &'static str {
|
||||
"Stub"
|
||||
}
|
||||
fn endpoint_label(&self) -> &'static str {
|
||||
"Where"
|
||||
}
|
||||
fn endpoint_placeholder(&self) -> &'static str {
|
||||
"somewhere"
|
||||
}
|
||||
fn sign_in(&self) -> SignIn {
|
||||
SignIn::EndpointOnly
|
||||
}
|
||||
fn normalise_endpoint(&self, input: &str) -> Result<String, String> {
|
||||
if input.trim().is_empty() {
|
||||
Err("say where the library is".into())
|
||||
} else {
|
||||
Ok(input.trim().to_string())
|
||||
}
|
||||
}
|
||||
fn connect(&self, _conn: &Connection) -> Result<Box<dyn RemoteBackend>, RemoteError> {
|
||||
Err(RemoteError::Unsupported("stub"))
|
||||
}
|
||||
}
|
||||
|
||||
fn registry() -> BackendRegistry {
|
||||
let mut r = BackendRegistry::new();
|
||||
r.register(Arc::new(Stub("alpha")));
|
||||
r.register(Arc::new(Stub("beta")));
|
||||
r
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_registered_backend_is_found_by_id() {
|
||||
assert_eq!(registry().get("beta").map(|p| p.id()), Some("beta"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registering_an_id_twice_replaces_rather_than_shadows() {
|
||||
let mut r = registry();
|
||||
r.register(Arc::new(Stub("alpha")));
|
||||
assert_eq!(r.providers().len(), 2, "{r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_account_for_a_missing_backend_says_which_one() {
|
||||
// A config can outlive the build that wrote it. "could not open
|
||||
// library" would send the user to check their server.
|
||||
let account = Account::new("s3", "bucket");
|
||||
let err = match registry().for_account(&account) {
|
||||
Err(e) => e.to_string(),
|
||||
Ok(p) => panic!("a backend this build has no connector for: {}", p.id()),
|
||||
};
|
||||
assert!(err.contains("s3"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_endpoint_only_backend_needs_no_credential() {
|
||||
assert!(!SignIn::EndpointOnly.needs_secret());
|
||||
assert!(SignIn::Browser.needs_secret());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_browser_backend_refuses_to_invent_an_account() {
|
||||
// Building one from an endpoint would skip the handshake and store an
|
||||
// account with no credential, which fails later and further away.
|
||||
struct Interactive;
|
||||
impl BackendProvider for Interactive {
|
||||
fn id(&self) -> &'static str {
|
||||
"i"
|
||||
}
|
||||
fn display_name(&self) -> &'static str {
|
||||
"I"
|
||||
}
|
||||
fn endpoint_label(&self) -> &'static str {
|
||||
"Server"
|
||||
}
|
||||
fn endpoint_placeholder(&self) -> &'static str {
|
||||
""
|
||||
}
|
||||
fn sign_in(&self) -> SignIn {
|
||||
SignIn::Browser
|
||||
}
|
||||
fn normalise_endpoint(&self, i: &str) -> Result<String, String> {
|
||||
Ok(i.into())
|
||||
}
|
||||
fn connect(&self, _: &Connection) -> Result<Box<dyn RemoteBackend>, RemoteError> {
|
||||
Err(RemoteError::Unsupported("stub"))
|
||||
}
|
||||
}
|
||||
assert!(Interactive.account_for("https://x").is_err());
|
||||
}
|
||||
}
|
||||
@@ -407,10 +407,9 @@ impl Default for ExportSettings {
|
||||
/// [`create_dir`](../../dr_sync/trait.RemoteBackend.html) and behaves
|
||||
/// identically on both platforms.
|
||||
///
|
||||
/// It is also where the photographs already are. A library that lives on
|
||||
/// Nextcloud and exports to a phone's local storage has put the output
|
||||
/// somewhere the user's other devices cannot see, which is rarely what was
|
||||
/// meant.
|
||||
/// It is also where the photographs already are. A library that lives on a
|
||||
/// server and exports to a phone's local storage has put the output somewhere
|
||||
/// the user's other devices cannot see, which is rarely what was meant.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ExportTarget {
|
||||
@@ -425,7 +424,11 @@ pub enum ExportTarget {
|
||||
/// destination was filled in. It is excluded from [`Self::available`]
|
||||
/// rather than offered and then failing.
|
||||
Device,
|
||||
/// A folder on the connected account, created if absent.
|
||||
/// A folder in the connected library, created if absent.
|
||||
///
|
||||
/// Named for where it goes rather than for what is behind it: the library
|
||||
/// may be a Nextcloud account or a folder on a mount, and the export
|
||||
/// behaves identically either way.
|
||||
Remote,
|
||||
}
|
||||
|
||||
@@ -466,7 +469,9 @@ impl ExportTarget {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Device => "This device",
|
||||
Self::Remote => "Nextcloud",
|
||||
// Not the connector's name: the library may be a server or a
|
||||
// folder, and the setting means the same thing for both.
|
||||
Self::Remote => "The library",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user