//! 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 = 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, /// Unix seconds of the last completed scan, for display. #[serde(default)] pub last_scan: Option, } impl Session { pub fn new(creds: &AppCredentials, user_id: impl Into) -> 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, } /// 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, } 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) -> 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) -> 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 { self.read_config().sessions } /// The most recently configured session, if any. pub fn current(&self) -> Option { 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 { 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"); } }