Linking a device is one flow in the core: sync::link

The desktop's sync_link and the ffi's link_with_password/link_with_token were
the same steps written out twice: probe, refuse an incompatible server before
any credential is sent, log in or verify a pasted token, keep the link, and adopt
the server's trash retention. link::authenticate(url, Credential) does the
network half and link::store(conn, ..., seal) keeps it, sealed when the client
has a seal. Each client now only reads its input and picks its seal.

The desktop checks for a missing email/password before probing rather than after.
Same error, sooner.

DRY pass #2, batch 1, F2 (#5372).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 14:16:53 -04:00
co-authored by Claude Opus 5.5
parent 70274347af
commit 91c47245ab
4 changed files with 139 additions and 68 deletions
+90
View File
@@ -0,0 +1,90 @@
//! Linking a device to a server.
//!
//! One flow for every client. The desktop and Android each wrote it out, the same
//! steps in the same order; what differs between them is only how the token is
//! kept (Android seals it, `state::TokenSeal`) and how the result is reported.
use rusqlite::Connection;
use super::client::{self, Identity};
use super::compat::Compatibility;
use super::state::{self, TokenSeal};
/// How a device proves whose it is.
pub enum Credential<'a> {
/// A password login, which mints a token named `device_name` on the server.
Password {
email: &'a str,
password: &'a str,
device_name: &'a str,
},
/// A device token pasted from the web app, for anyone who would rather not
/// type a password into an app. Verified before it is kept, so a copy/paste
/// slip fails here rather than at the next sync.
Token(&'a str),
}
/// A server that accepted this device, before anything is kept.
pub struct Granted {
pub base_url: String,
pub token: String,
pub identity: Identity,
/// Carried through so a client can warn about a `degraded` server right after
/// linking, instead of staying silent until a feature quietly does nothing.
pub compatibility: Compatibility,
pub retention_days: Option<u32>,
}
/// Ask the server at `url` to accept this device. Nothing is stored.
///
/// The handshake runs FIRST, and an incompatible server is refused before any
/// credential is sent: that is exactly the case where a later failure would be
/// hardest to attribute.
pub async fn authenticate(url: &str, credential: Credential<'_>) -> Result<Granted, String> {
let probe = client::probe(url).await?;
if let Compatibility::Incompatible { reason, .. } = &probe.compatibility {
return Err(reason.clone());
}
let base_url = probe.base_url;
let (token, identity) = match credential {
Credential::Password {
email,
password,
device_name,
} => client::device_login(&base_url, email, password, device_name).await?,
Credential::Token(token) => {
let identity = client::fetch_identity(&base_url, token).await?;
(token.to_string(), identity)
}
};
Ok(Granted {
base_url,
token,
identity,
compatibility: probe.compatibility,
retention_days: probe.server.trash_retention_days,
})
}
/// Keep a link: sealed when the client has a seal, plain when it has none.
///
/// The server's trash-retention window is adopted at the same time, so the Trash
/// view stops counting down against this device's offline default the moment it
/// is no longer the policy in force.
pub fn store(
conn: &Connection,
base_url: &str,
token: &str,
retention_days: Option<u32>,
seal: Option<&dyn TokenSeal>,
) -> rusqlite::Result<()> {
match seal {
Some(seal) => state::set_sealed_link(conn, base_url, token, seal)?,
None => state::set_link(conn, base_url, token)?,
}
if let Some(days) = retention_days {
state::set_server_retention(conn, i64::from(days))?;
}
log::info!("linked to {base_url}");
Ok(())
}
+1
View File
@@ -17,6 +17,7 @@ pub mod blobs;
pub mod client;
pub mod compat;
pub mod engine;
pub mod link;
pub mod pull;
pub mod push;
pub mod sharing;