//! 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, pub device_token: Option, 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, 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) -> Option { value.filter(|s| !s.trim().is_empty()) } pub fn read(conn: &Connection) -> rusqlite::Result { conn.query_row( "SELECT server_url, device_token, last_cursor FROM sync_state WHERE id = 1", [], |row| { let cursor: Option = 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 { 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}"); } }