// 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 = 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 { 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, /// Unix seconds of the last completed scan, for display. #[serde(default)] pub last_scan: Option, } 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, endpoint: impl Into) -> 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, user_id: impl Into) -> 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) -> 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, } impl Connection { pub fn new(account: Account, secret: Option) -> 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, } /// 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, } 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) -> 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) -> 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 { self.read_config().sessions } /// The most recently configured account, if any. pub fn current(&self) -> Option { 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 { 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 { 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 { 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 { Ok(i.into()) } fn upgrade_endpoint(&self, stored: &str) -> Option { stored .strip_prefix("http://") .map(|rest| format!("https://{rest}")) } fn connect(&self, _: &Connection) -> Result, 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" ); } }