Four macro-argument splits, applied verbatim from run 2895's cargo fmt --check diff. No logic change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
246 lines
8.4 KiB
Rust
246 lines
8.4 KiB
Rust
//! The link record: which server this app is paired with, the device token that
|
|
//! authenticates to it, and how far it has consumed that server's change feed.
|
|
//!
|
|
//! One row, enforced by `CHECK (id = 1)` and seeded during migration, so every
|
|
//! operation here is an UPDATE — there is no create-or-missing case to handle.
|
|
//!
|
|
//! The token lives in the app-data SQLite file rather than an OS keyring on purpose:
|
|
//! the `keyring` crate needs libsecret/DBus on Linux, which adds a C dependency to a
|
|
//! binary that has to cross-compile, and fails outright on headless or minimal-WM
|
|
//! setups. Protecting the database file is the portable trade.
|
|
|
|
use rusqlite::{params, Connection};
|
|
use serde::Serialize;
|
|
|
|
/// The full link record, token included. Internal to the Rust side.
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
pub struct SyncState {
|
|
pub server_url: Option<String>,
|
|
pub device_token: Option<String>,
|
|
pub last_cursor: i64,
|
|
}
|
|
|
|
impl SyncState {
|
|
/// Linked means BOTH a server and a credential for it. Either one alone is a
|
|
/// half-written link that nothing can act on, so it must not read as linked.
|
|
pub fn is_linked(&self) -> bool {
|
|
self.server_url.is_some() && self.device_token.is_some()
|
|
}
|
|
}
|
|
|
|
/// What the UI is allowed to see.
|
|
///
|
|
/// Deliberately has no `device_token` field: this crosses into the webview, and a
|
|
/// long-lived bearer token has no business being reachable from page scripts.
|
|
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
|
pub struct Status {
|
|
pub linked: bool,
|
|
pub server_url: Option<String>,
|
|
pub last_cursor: i64,
|
|
}
|
|
|
|
impl From<&SyncState> for Status {
|
|
fn from(s: &SyncState) -> Self {
|
|
Status {
|
|
linked: s.is_linked(),
|
|
server_url: s.server_url.clone(),
|
|
last_cursor: s.last_cursor,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Treat a blank string as absent, so a half-cleared row can't masquerade as linked.
|
|
fn present(value: Option<String>) -> Option<String> {
|
|
value.filter(|s| !s.trim().is_empty())
|
|
}
|
|
|
|
pub fn read(conn: &Connection) -> rusqlite::Result<SyncState> {
|
|
conn.query_row(
|
|
"SELECT server_url, device_token, last_cursor FROM sync_state WHERE id = 1",
|
|
[],
|
|
|row| {
|
|
let cursor: Option<String> = row.get(2)?;
|
|
Ok(SyncState {
|
|
server_url: present(row.get(0)?),
|
|
device_token: present(row.get(1)?),
|
|
// Stored TEXT (schema) but used as an integer watermark. Absent or
|
|
// unparseable means "start from the beginning" — always the safe
|
|
// reading, because a redundant full sync costs time, never data,
|
|
// whereas a too-high cursor silently skips changes.
|
|
last_cursor: cursor.and_then(|c| c.trim().parse().ok()).unwrap_or(0),
|
|
})
|
|
},
|
|
)
|
|
}
|
|
|
|
/// Record a link.
|
|
///
|
|
/// Resets the change-feed cursor whenever the server differs from the one previously
|
|
/// linked. A cursor is only meaningful against the server that issued it; carrying
|
|
/// one across would silently skip every change on the new server below that
|
|
/// watermark — data loss that looks like a successful sync. Re-linking the SAME
|
|
/// server (after a token refresh, say) keeps the cursor, so a routine re-auth doesn't
|
|
/// force a full re-download.
|
|
pub fn set_link(conn: &Connection, server_url: &str, device_token: &str) -> rusqlite::Result<()> {
|
|
let keep_cursor = read(conn)?.server_url.as_deref() == Some(server_url);
|
|
conn.execute(
|
|
"UPDATE sync_state
|
|
SET server_url = ?1,
|
|
device_token = ?2,
|
|
last_cursor = CASE WHEN ?3 THEN last_cursor ELSE NULL END
|
|
WHERE id = 1",
|
|
params![server_url, device_token, keep_cursor],
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Forget the server entirely.
|
|
///
|
|
/// Clears the cursor as well as the credentials: a cursor left behind would, on the
|
|
/// next link, be interpreted against a server that never issued it.
|
|
pub fn clear_link(conn: &Connection) -> rusqlite::Result<()> {
|
|
conn.execute(
|
|
"UPDATE sync_state
|
|
SET server_url = NULL, device_token = NULL, last_cursor = NULL
|
|
WHERE id = 1",
|
|
[],
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Advance the consumed-change watermark. Called by the pull loop (M10.7b) only
|
|
/// after a page has been fully applied.
|
|
pub fn set_cursor(conn: &Connection, cursor: i64) -> rusqlite::Result<()> {
|
|
conn.execute(
|
|
"UPDATE sync_state SET last_cursor = ?1 WHERE id = 1",
|
|
params![cursor.to_string()],
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn status(conn: &Connection) -> rusqlite::Result<Status> {
|
|
Ok(Status::from(&read(conn)?))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::local::schema;
|
|
|
|
fn db() -> Connection {
|
|
let conn = Connection::open_in_memory().expect("in-memory db");
|
|
schema::migrate(&conn).expect("migrate");
|
|
conn
|
|
}
|
|
|
|
#[test]
|
|
fn fresh_store_is_unlinked() {
|
|
let conn = db();
|
|
let state = read(&conn).expect("read");
|
|
assert_eq!(state, SyncState::default());
|
|
assert!(!state.is_linked());
|
|
assert_eq!(state.last_cursor, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn link_round_trips() {
|
|
let conn = db();
|
|
set_link(&conn, "https://notes.example.com", "tok-1").expect("link");
|
|
let state = read(&conn).expect("read");
|
|
assert!(state.is_linked());
|
|
assert_eq!(
|
|
state.server_url.as_deref(),
|
|
Some("https://notes.example.com")
|
|
);
|
|
assert_eq!(state.device_token.as_deref(), Some("tok-1"));
|
|
}
|
|
|
|
#[test]
|
|
fn relinking_the_same_server_keeps_the_cursor() {
|
|
let conn = db();
|
|
set_link(&conn, "https://a.example.com", "tok-1").expect("link");
|
|
set_cursor(&conn, 4242).expect("cursor");
|
|
// e.g. the token was revoked and the user re-authenticated.
|
|
set_link(&conn, "https://a.example.com", "tok-2").expect("relink");
|
|
let state = read(&conn).expect("read");
|
|
assert_eq!(
|
|
state.last_cursor, 4242,
|
|
"a re-auth shouldn't force a full re-sync"
|
|
);
|
|
assert_eq!(state.device_token.as_deref(), Some("tok-2"));
|
|
}
|
|
|
|
#[test]
|
|
fn linking_a_different_server_resets_the_cursor() {
|
|
let conn = db();
|
|
set_link(&conn, "https://a.example.com", "tok-1").expect("link");
|
|
set_cursor(&conn, 4242).expect("cursor");
|
|
set_link(&conn, "https://b.example.com", "tok-2").expect("relink");
|
|
assert_eq!(
|
|
read(&conn).expect("read").last_cursor,
|
|
0,
|
|
"a cursor from another server would skip everything below it"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unlink_clears_the_cursor_too() {
|
|
let conn = db();
|
|
set_link(&conn, "https://a.example.com", "tok-1").expect("link");
|
|
set_cursor(&conn, 99).expect("cursor");
|
|
clear_link(&conn).expect("unlink");
|
|
let state = read(&conn).expect("read");
|
|
assert!(!state.is_linked());
|
|
assert_eq!(state.last_cursor, 0);
|
|
assert!(state.server_url.is_none());
|
|
assert!(state.device_token.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn half_written_link_is_not_linked() {
|
|
let conn = db();
|
|
conn.execute(
|
|
"UPDATE sync_state SET server_url = 'https://a.example.com' WHERE id = 1",
|
|
[],
|
|
)
|
|
.expect("partial write");
|
|
assert!(!read(&conn).expect("read").is_linked());
|
|
}
|
|
|
|
#[test]
|
|
fn blank_strings_count_as_absent() {
|
|
let conn = db();
|
|
conn.execute(
|
|
"UPDATE sync_state SET server_url = ' ', device_token = '' WHERE id = 1",
|
|
[],
|
|
)
|
|
.expect("blank write");
|
|
let state = read(&conn).expect("read");
|
|
assert!(!state.is_linked());
|
|
assert!(state.server_url.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn unparseable_cursor_falls_back_to_a_full_sync() {
|
|
let conn = db();
|
|
conn.execute(
|
|
"UPDATE sync_state SET last_cursor = 'garbage' WHERE id = 1",
|
|
[],
|
|
)
|
|
.expect("bad cursor");
|
|
assert_eq!(read(&conn).expect("read").last_cursor, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn status_never_carries_the_token() {
|
|
let conn = db();
|
|
set_link(&conn, "https://a.example.com", "super-secret").expect("link");
|
|
let json = serde_json::to_string(&status(&conn).expect("status")).expect("serialize");
|
|
assert!(
|
|
!json.contains("super-secret"),
|
|
"token leaked to the webview: {json}"
|
|
);
|
|
assert!(json.contains("\"linked\":true"), "got {json}");
|
|
}
|
|
}
|