Benchmarks / CPU and I/O (per commit) (push) Successful in 1m53s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Successful in 45m7s
Build and test / Layer separation (push) Successful in 41s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / windows-image (push) Successful in 2s
Traceability / Requirement traces (push) Successful in 40s
Build and test / Android (aarch64) (push) Successful in 28m49s
Build and test / Windows (x86_64, cross) (push) Successful in 17m9s
Build and test / Publish the release (push) Skipped
Before the previous commit, browser sign-in could store an account as http://, and the client now refuses to send to one. Left alone, such a library would fail to open with a configuration error, so its stored endpoint is rewritten before anything reads it. The endpoint is half of two keys, and both are handled: - The keyring entry is filed under it. Rewriting only the record would strand the app password under the old key and sign the user out, so AccountStore::move_endpoint copies the secret across first, rewrites the record in place (the last record is the one resumed), and deletes the old entry only once nothing refers to it. - namespace() is built from it and names the catalog directory. For an http to https rewrite it does not change, because the namespace strips either scheme. A move that would change it is refused, not performed, so no later rewrite can abandon a catalog either. The rewrite is a new BackendProvider::upgrade_endpoint hook, which does nothing by default, and not a second call to normalise_endpoint. The folder connector's normalise_endpoint canonicalises the path and needs it to exist, so running it on every launch would fail a library on an unplugged disk, or rename one whose path now resolves differently. Only Nextcloud implements the hook. If the move fails (for example, a locked keyring), it is logged, the account is left as it was, and the move is tried again on the next launch. Closes #65.
1047 lines
38 KiB
Rust
1047 lines
38 KiB
Rust
// 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::{BackendRegistry, 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 the platform entry point declared, if it declared one.
|
|
///
|
|
/// Android does; a desktop does not, and resolves through `dr_plat::dirs`
|
|
/// instead. Exposed so a caller wanting the *data* directory can honour the
|
|
/// same declaration without inheriting the config rule as its fallback.
|
|
pub fn declared_data_dir() -> Option<PathBuf> {
|
|
DATA_DIR.get().cloned()
|
|
}
|
|
|
|
/// The directory configuration lives in.
|
|
pub fn config_dir() -> PathBuf {
|
|
if let Some(d) = DATA_DIR.get() {
|
|
return d.clone();
|
|
}
|
|
dr_plat::base_dir(dr_plat::Base::Config)
|
|
}
|
|
|
|
/// 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,
|
|
|
|
/// Whether [`root`](Self::root) has been chosen at all.
|
|
///
|
|
/// An empty `root` is two different things: nothing picked yet, and the
|
|
/// endpoint itself picked on purpose — a user who keeps everything at
|
|
/// the top level, or a folder library, which is its own root. The string
|
|
/// cannot tell them apart, and reading empty as "not chosen" meant the
|
|
/// top level could be confirmed in the picker and still not open. So the
|
|
/// fact is recorded separately. Defaulted, so an account written before
|
|
/// it existed loads as it always did: a non-empty root is chosen by
|
|
/// virtue of being there, and an empty one asks again.
|
|
#[serde(default)]
|
|
pub root_chosen: bool,
|
|
|
|
/// 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(),
|
|
root_chosen: false,
|
|
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
|
|
}
|
|
|
|
/// TRACES: NFR-SEC-3
|
|
/// Rewrite the endpoints an older build stored in a form this one would
|
|
/// not, asking each account's connector
|
|
/// ([`upgrade_endpoint`](crate::BackendProvider::upgrade_endpoint)).
|
|
///
|
|
/// Per account and best effort: one that cannot be moved — its keyring
|
|
/// locked, say — is logged and left as it was, and is tried again on the
|
|
/// next launch, rather than stopping the others or the launch. Returns
|
|
/// the accounts it rewrote.
|
|
pub fn upgrade_endpoints(&self, registry: &BackendRegistry) -> Vec<Account> {
|
|
let mut upgraded = Vec::new();
|
|
for account in self.list() {
|
|
let Ok(provider) = registry.for_account(&account) else {
|
|
continue;
|
|
};
|
|
let Some(endpoint) = provider.upgrade_endpoint(&account.endpoint) else {
|
|
continue;
|
|
};
|
|
if endpoint == account.endpoint {
|
|
continue;
|
|
}
|
|
match self.move_endpoint(&account, &endpoint) {
|
|
Ok(moved) => {
|
|
log::info!("account {} moved to {endpoint}", account.describe());
|
|
upgraded.push(moved);
|
|
}
|
|
Err(e) => log::warn!(
|
|
"account {} could not be moved to {endpoint}: {e}",
|
|
account.describe()
|
|
),
|
|
}
|
|
}
|
|
upgraded
|
|
}
|
|
|
|
/// Move an account to a new endpoint, taking its credential with it.
|
|
///
|
|
/// The endpoint is half of two keys, and both have to be dealt with. The
|
|
/// credential is filed under it ([`Account::secret_ref`]), so rewriting
|
|
/// the record alone would strand the app password under the old key and
|
|
/// sign the user out. And it feeds [`Account::namespace`], so a rewrite
|
|
/// that changed the namespace would abandon the catalog and everything
|
|
/// beside it; that is refused outright rather than left to the caller.
|
|
///
|
|
/// Ordered so an interruption at any step leaves something that works:
|
|
/// the credential is copied before the record names the new key, and the
|
|
/// old copy is deleted only once nothing names the old one.
|
|
fn move_endpoint(&self, account: &Account, endpoint: &str) -> Result<Account, AccountError> {
|
|
let mut moved = account.clone();
|
|
moved.endpoint = endpoint.to_string();
|
|
if moved.namespace() != account.namespace() {
|
|
return Err(AccountError::WouldMoveData {
|
|
from: account.namespace(),
|
|
to: moved.namespace(),
|
|
});
|
|
}
|
|
|
|
let mut config = self.read_config();
|
|
// Already there — the user signed in again at the new address. That
|
|
// record and its credential are the newer, so the old one just goes.
|
|
let duplicate = config.sessions.iter().any(|a| a.is_same_as(&moved));
|
|
|
|
let old_ref = account.secret_ref();
|
|
let secret = match self.secrets.retrieve(&old_ref) {
|
|
Ok(s) => Some(s),
|
|
Err(SecretError::NotFound) => None,
|
|
Err(e) => return Err(e.into()),
|
|
};
|
|
if let (Some(s), false) = (&secret, duplicate) {
|
|
self.secrets.store(&moved.secret_ref(), s)?;
|
|
}
|
|
|
|
if duplicate {
|
|
config.sessions.retain(|a| !a.is_same_as(account));
|
|
} else {
|
|
// In place, not removed and pushed: the last record is the one
|
|
// the next launch resumes.
|
|
for a in config.sessions.iter_mut().filter(|a| a.is_same_as(account)) {
|
|
*a = moved.clone();
|
|
}
|
|
}
|
|
self.write_config(&config)?;
|
|
|
|
if secret.is_some() {
|
|
self.secrets.delete(&old_ref)?;
|
|
}
|
|
Ok(moved)
|
|
}
|
|
|
|
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),
|
|
|
|
/// A change that would give an account a different local data directory,
|
|
/// leaving its catalog and caches behind under the old one.
|
|
#[error("moving the account would leave its local data behind ({from} → {to})")]
|
|
WouldMoveData { from: String, to: String },
|
|
}
|
|
|
|
#[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
|
|
}
|
|
|
|
/// A connector that upgrades `http://` endpoints the way Nextcloud's does,
|
|
/// and every other one not at all.
|
|
struct Upgrading(&'static str);
|
|
impl crate::BackendProvider for Upgrading {
|
|
fn id(&self) -> &'static str {
|
|
self.0
|
|
}
|
|
fn display_name(&self) -> &'static str {
|
|
"U"
|
|
}
|
|
fn endpoint_label(&self) -> &'static str {
|
|
"Server"
|
|
}
|
|
fn endpoint_placeholder(&self) -> &'static str {
|
|
""
|
|
}
|
|
fn sign_in(&self) -> crate::SignIn {
|
|
crate::SignIn::Browser
|
|
}
|
|
fn normalise_endpoint(&self, i: &str) -> Result<String, String> {
|
|
Ok(i.into())
|
|
}
|
|
fn upgrade_endpoint(&self, stored: &str) -> Option<String> {
|
|
stored
|
|
.strip_prefix("http://")
|
|
.map(|rest| format!("https://{rest}"))
|
|
}
|
|
fn connect(&self, _: &Connection) -> Result<Box<dyn crate::RemoteBackend>, RemoteError> {
|
|
Err(RemoteError::Unsupported("stub"))
|
|
}
|
|
}
|
|
|
|
fn upgrading(id: &'static str) -> BackendRegistry {
|
|
let mut r = BackendRegistry::new();
|
|
r.register(std::sync::Arc::new(Upgrading(id)));
|
|
r
|
|
}
|
|
|
|
/// TRACES: NFR-SEC-3
|
|
#[test]
|
|
fn an_http_account_is_upgraded_and_keeps_its_credential_and_data() {
|
|
let dir = tmpdir("upgrade");
|
|
let store = store_in(&dir);
|
|
let old =
|
|
Account::new(LEGACY_BACKEND, "http://cloud.example").with_login("duncan", "duncan");
|
|
store.save(&folder(), None).unwrap();
|
|
store
|
|
.save(&old, Some(&Secret::new("secret-token")))
|
|
.unwrap();
|
|
|
|
let moved = store.upgrade_endpoints(&upgrading(LEGACY_BACKEND));
|
|
assert_eq!(moved.len(), 1);
|
|
|
|
let now = store.current().expect("still the account resumed");
|
|
assert_eq!(now.endpoint, "https://cloud.example");
|
|
assert_eq!(
|
|
now.namespace(),
|
|
old.namespace(),
|
|
"the catalog directory must not move"
|
|
);
|
|
assert_eq!(
|
|
store
|
|
.connection(&now, true)
|
|
.unwrap()
|
|
.require_secret()
|
|
.unwrap()
|
|
.expose(),
|
|
"secret-token",
|
|
"the credential moves with the account"
|
|
);
|
|
assert!(
|
|
store.connection(&old, true).is_err(),
|
|
"nothing is left under the old key"
|
|
);
|
|
assert_eq!(store.list().len(), 2, "the folder library is untouched");
|
|
|
|
// And a second launch has nothing to do.
|
|
assert!(store
|
|
.upgrade_endpoints(&upgrading(LEGACY_BACKEND))
|
|
.is_empty());
|
|
}
|
|
|
|
/// TRACES: NFR-SEC-3
|
|
#[test]
|
|
fn a_move_that_would_change_the_data_directory_is_refused() {
|
|
// A scheme change keeps the namespace, which is what makes the upgrade
|
|
// safe; a host change does not, and would give the library a new,
|
|
// empty data directory. The account is left exactly as it was.
|
|
let dir = tmpdir("upgrade-refused");
|
|
let store = store_in(&dir);
|
|
let old = nextcloud();
|
|
store
|
|
.save(&old, Some(&Secret::new("secret-token")))
|
|
.unwrap();
|
|
|
|
assert!(matches!(
|
|
store.move_endpoint(&old, "https://elsewhere.example"),
|
|
Err(AccountError::WouldMoveData { .. })
|
|
));
|
|
assert_eq!(store.current().unwrap().endpoint, old.endpoint);
|
|
assert!(store.connection(&old, true).is_ok());
|
|
}
|
|
|
|
/// TRACES: NFR-SEC-3
|
|
#[test]
|
|
fn an_upgrade_onto_an_existing_sign_in_keeps_the_newer_one() {
|
|
let dir = tmpdir("upgrade-duplicate");
|
|
let store = store_in(&dir);
|
|
let old =
|
|
Account::new(LEGACY_BACKEND, "http://cloud.example").with_login("duncan", "duncan");
|
|
let new =
|
|
Account::new(LEGACY_BACKEND, "https://cloud.example").with_login("duncan", "duncan");
|
|
store.save(&old, Some(&Secret::new("stale"))).unwrap();
|
|
store.save(&new, Some(&Secret::new("fresh"))).unwrap();
|
|
|
|
store.upgrade_endpoints(&upgrading(LEGACY_BACKEND));
|
|
assert_eq!(store.list().len(), 1);
|
|
assert_eq!(
|
|
store
|
|
.connection(&new, true)
|
|
.unwrap()
|
|
.require_secret()
|
|
.unwrap()
|
|
.expose(),
|
|
"fresh"
|
|
);
|
|
}
|
|
|
|
#[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"
|
|
);
|
|
}
|
|
}
|