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.
293 lines
11 KiB
Rust
293 lines
11 KiB
Rust
// TRACES: FR-NC-12
|
|
//! How a connector announces itself.
|
|
//!
|
|
//! [`RemoteBackend`] says what a backend can *do* once it is open.
|
|
//! [`BackendProvider`] says everything the application needs before that: what
|
|
//! to call it, what a library location looks like, whether signing in involves
|
|
//! a browser, and how to turn a stored [`Account`] into a live backend.
|
|
//!
|
|
//! Together they are the whole contract. Adding a storage layer is:
|
|
//!
|
|
//! 1. implement [`RemoteBackend`] over your protocol,
|
|
//! 2. implement [`BackendProvider`] beside it,
|
|
//! 3. register it in `dr_ui::remote`.
|
|
//!
|
|
//! Nothing above that module names a connector, so nothing above it changes.
|
|
//!
|
|
//! # Why sign-in is a shape rather than a method
|
|
//!
|
|
//! It would be tidier for a provider to expose `async fn sign_in()` and let
|
|
//! the launch screen await it. It would also be wrong: Nextcloud's Login Flow
|
|
//! v2 is a browser handshake the user completes elsewhere while the app polls,
|
|
//! so it is not one call, it does not finish on our schedule, and the screen
|
|
//! has to render a URL and a waiting state in the middle of it. A folder needs
|
|
//! none of that. [`SignIn`] names which of those two shapes the screen must
|
|
//! draw, and the flow itself stays where its protocol is.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use crate::{Account, Connection, RemoteBackend, RemoteError};
|
|
|
|
/// What establishing an account involves.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum SignIn {
|
|
/// A handshake the user completes outside the app, yielding a credential
|
|
/// the app then stores. Nextcloud's Login Flow v2.
|
|
///
|
|
/// The connector drives it; the launch screen only shows the waiting
|
|
/// state, because what happens in the middle is protocol-specific.
|
|
Browser,
|
|
|
|
/// The endpoint is the whole account. Nothing to authenticate, nothing to
|
|
/// store in the keyring, no waiting state to draw — a local folder.
|
|
EndpointOnly,
|
|
}
|
|
|
|
impl SignIn {
|
|
/// Whether an account of this shape has a credential in secure storage.
|
|
pub fn needs_secret(self) -> bool {
|
|
matches!(self, SignIn::Browser)
|
|
}
|
|
}
|
|
|
|
/// TRACES: FR-NC-12
|
|
/// A storage connector, described well enough to configure without naming it.
|
|
///
|
|
/// Implementations are held in an [`Arc`] inside a [`BackendRegistry`] and
|
|
/// must be usable from any thread: the launch screen reads them on the UI
|
|
/// thread and workers open connections from them on their own.
|
|
pub trait BackendProvider: Send + Sync {
|
|
/// The stable identifier written to [`Account::backend`].
|
|
///
|
|
/// **It is on-disk configuration.** Changing it after anyone has an
|
|
/// account orphans that account, so pick it once.
|
|
fn id(&self) -> &'static str;
|
|
|
|
/// What to call this in the interface. "Nextcloud", "Folder".
|
|
fn display_name(&self) -> &'static str;
|
|
|
|
/// What to label the endpoint field: "Server address", "Folder".
|
|
fn endpoint_label(&self) -> &'static str;
|
|
|
|
/// An example endpoint, for the empty field.
|
|
fn endpoint_placeholder(&self) -> &'static str;
|
|
|
|
/// How an account of this kind is established.
|
|
fn sign_in(&self) -> SignIn;
|
|
|
|
/// Turn what the user typed into the form that gets stored.
|
|
///
|
|
/// Two jobs, and the second is the important one: this is where a bad
|
|
/// endpoint is *rejected*, before an account is written for a library that
|
|
/// does not exist. The error is shown to the user, so it says what is
|
|
/// wrong rather than naming a type.
|
|
fn normalise_endpoint(&self, input: &str) -> Result<String, String>;
|
|
|
|
/// The form a *stored* endpoint should take now, where an older build
|
|
/// wrote one this build would not.
|
|
///
|
|
/// Not [`normalise_endpoint`](Self::normalise_endpoint) run again: that
|
|
/// judges what a person typed, and may touch the world to do it — a folder
|
|
/// is canonicalised and must exist — so rerunning it on every launch would
|
|
/// fail a library whose disk is unplugged, or rename one whose path now
|
|
/// resolves differently. This is a pure rewrite of the string, and `None`
|
|
/// means leave it alone, which is the answer for almost every connector.
|
|
fn upgrade_endpoint(&self, stored: &str) -> Option<String> {
|
|
let _ = stored;
|
|
None
|
|
}
|
|
|
|
/// Build an account from a normalised endpoint alone.
|
|
///
|
|
/// Only meaningful for [`SignIn::EndpointOnly`]; a browser flow produces
|
|
/// its account from what the handshake returned, so the default here
|
|
/// refuses rather than inventing one.
|
|
fn account_for(&self, endpoint: &str) -> Result<Account, RemoteError> {
|
|
let _ = endpoint;
|
|
Err(RemoteError::Unsupported(
|
|
"this backend establishes an account through its sign-in flow",
|
|
))
|
|
}
|
|
|
|
/// Open a live backend.
|
|
///
|
|
/// Cheap and synchronous: it validates configuration and constructs a
|
|
/// client, and does not talk to the remote. Workers call it per task, so
|
|
/// anything expensive here is paid over and over.
|
|
fn connect(&self, conn: &Connection) -> Result<Box<dyn RemoteBackend>, RemoteError>;
|
|
}
|
|
|
|
/// TRACES: FR-NC-12 | FR-NC-13
|
|
/// The connectors this build has.
|
|
///
|
|
/// One instance is built at startup and consulted by everything that needs a
|
|
/// backend. The registry is the *only* thing that knows connectors exist,
|
|
/// which is what keeps the layers above free of them.
|
|
#[derive(Clone, Default)]
|
|
pub struct BackendRegistry {
|
|
providers: Vec<Arc<dyn BackendProvider>>,
|
|
}
|
|
|
|
impl BackendRegistry {
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
/// Add a connector.
|
|
///
|
|
/// Later registrations of an id replace earlier ones, so a build can
|
|
/// substitute a connector — a test double for a real server — without the
|
|
/// registry needing to know it happened.
|
|
pub fn register(&mut self, provider: Arc<dyn BackendProvider>) -> &mut Self {
|
|
let id = provider.id();
|
|
self.providers.retain(|p| p.id() != id);
|
|
self.providers.push(provider);
|
|
self
|
|
}
|
|
|
|
/// The connector for an id.
|
|
pub fn get(&self, id: &str) -> Option<&Arc<dyn BackendProvider>> {
|
|
self.providers.iter().find(|p| p.id() == id)
|
|
}
|
|
|
|
/// The connector an account names, or a message naming the account's.
|
|
///
|
|
/// The error case is real rather than defensive: a configuration file can
|
|
/// outlive the build that wrote it, and a user moving between a full
|
|
/// desktop build and a cut-down one will have accounts this binary cannot
|
|
/// serve. Saying which backend is missing is the difference between that
|
|
/// and "could not open library".
|
|
pub fn for_account(&self, account: &Account) -> Result<&Arc<dyn BackendProvider>, RemoteError> {
|
|
self.get(&account.backend).ok_or_else(|| {
|
|
RemoteError::Configuration(format!(
|
|
"no storage backend named {:?} in this build",
|
|
account.backend
|
|
))
|
|
})
|
|
}
|
|
|
|
/// Open the backend an account is configured for.
|
|
pub fn connect(&self, conn: &Connection) -> Result<Box<dyn RemoteBackend>, RemoteError> {
|
|
self.for_account(&conn.account)?.connect(conn)
|
|
}
|
|
|
|
/// Every connector, in registration order. What the launch screen offers.
|
|
pub fn providers(&self) -> &[Arc<dyn BackendProvider>] {
|
|
&self.providers
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Debug for BackendRegistry {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("BackendRegistry")
|
|
.field(
|
|
"providers",
|
|
&self.providers.iter().map(|p| p.id()).collect::<Vec<_>>(),
|
|
)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
struct Stub(&'static str);
|
|
|
|
impl BackendProvider for Stub {
|
|
fn id(&self) -> &'static str {
|
|
self.0
|
|
}
|
|
fn display_name(&self) -> &'static str {
|
|
"Stub"
|
|
}
|
|
fn endpoint_label(&self) -> &'static str {
|
|
"Where"
|
|
}
|
|
fn endpoint_placeholder(&self) -> &'static str {
|
|
"somewhere"
|
|
}
|
|
fn sign_in(&self) -> SignIn {
|
|
SignIn::EndpointOnly
|
|
}
|
|
fn normalise_endpoint(&self, input: &str) -> Result<String, String> {
|
|
if input.trim().is_empty() {
|
|
Err("say where the library is".into())
|
|
} else {
|
|
Ok(input.trim().to_string())
|
|
}
|
|
}
|
|
fn connect(&self, _conn: &Connection) -> Result<Box<dyn RemoteBackend>, RemoteError> {
|
|
Err(RemoteError::Unsupported("stub"))
|
|
}
|
|
}
|
|
|
|
fn registry() -> BackendRegistry {
|
|
let mut r = BackendRegistry::new();
|
|
r.register(Arc::new(Stub("alpha")));
|
|
r.register(Arc::new(Stub("beta")));
|
|
r
|
|
}
|
|
|
|
#[test]
|
|
fn a_registered_backend_is_found_by_id() {
|
|
assert_eq!(registry().get("beta").map(|p| p.id()), Some("beta"));
|
|
}
|
|
|
|
#[test]
|
|
fn registering_an_id_twice_replaces_rather_than_shadows() {
|
|
let mut r = registry();
|
|
r.register(Arc::new(Stub("alpha")));
|
|
assert_eq!(r.providers().len(), 2, "{r:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn an_account_for_a_missing_backend_says_which_one() {
|
|
// A config can outlive the build that wrote it. "could not open
|
|
// library" would send the user to check their server.
|
|
let account = Account::new("s3", "bucket");
|
|
let err = match registry().for_account(&account) {
|
|
Err(e) => e.to_string(),
|
|
Ok(p) => panic!("a backend this build has no connector for: {}", p.id()),
|
|
};
|
|
assert!(err.contains("s3"), "{err}");
|
|
}
|
|
|
|
#[test]
|
|
fn an_endpoint_only_backend_needs_no_credential() {
|
|
assert!(!SignIn::EndpointOnly.needs_secret());
|
|
assert!(SignIn::Browser.needs_secret());
|
|
}
|
|
|
|
#[test]
|
|
fn a_browser_backend_refuses_to_invent_an_account() {
|
|
// Building one from an endpoint would skip the handshake and store an
|
|
// account with no credential, which fails later and further away.
|
|
struct Interactive;
|
|
impl BackendProvider for Interactive {
|
|
fn id(&self) -> &'static str {
|
|
"i"
|
|
}
|
|
fn display_name(&self) -> &'static str {
|
|
"I"
|
|
}
|
|
fn endpoint_label(&self) -> &'static str {
|
|
"Server"
|
|
}
|
|
fn endpoint_placeholder(&self) -> &'static str {
|
|
""
|
|
}
|
|
fn sign_in(&self) -> SignIn {
|
|
SignIn::Browser
|
|
}
|
|
fn normalise_endpoint(&self, i: &str) -> Result<String, String> {
|
|
Ok(i.into())
|
|
}
|
|
fn connect(&self, _: &Connection) -> Result<Box<dyn RemoteBackend>, RemoteError> {
|
|
Err(RemoteError::Unsupported("stub"))
|
|
}
|
|
}
|
|
assert!(Interactive.account_for("https://x").is_err());
|
|
}
|
|
}
|