The Android job's "Verify minimum API level" step has never verified the minimum API level. It took the first `*.so` anywhere under the target directory, which is a host proc-macro from debug/deps — an x86-64 object built by the runner's gcc, whose .comment section cannot mention Android and so can never contradict the expected value. It now reads the artifact under the target triple, compares against MIN_API parsed from the Dockerfile rather than a second copy of the number, and fails on a mismatch. Both sides are checked non-empty first: two failed parses would otherwise compare equal and pass, which is the same silent success in a new costume. The Android image installs one SDK package per layer and keeps the output. sdkmanager is a JVM program that aborts when it cannot get memory, and the single `> /dev/null` step reported that as a bare "exit code 134" while a retry re-downloaded everything that had already succeeded. tools/ci-local.sh runs all four jobs — desktop, android, layering, traceability — against the host toolchain, which is pinned to the same 1.92.0 CI installs. Its matrix check compares regeneration against the working tree rather than against HEAD: CI starts from a clean checkout, so git's answer is the right one there and reports every local run stale here. The rest is rustfmt across the workspace, and the clippy findings that surfaced once it did: manual_contains in dr-thumbs and collections_ui, a map iterated as pairs for its keys, an index loop over a slice, and two runtime assertions on a constant now made at compile time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
452 lines
15 KiB
Rust
452 lines
15 KiB
Rust
//! 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");
|
|
}
|
|
}
|