//! 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. //! //! A client that has somewhere better to keep a key passes a [`TokenSeal`], and the //! token is stored sealed. Android does, with a key in the Keystore (family idea //! #5105, practice 12); the desktop does not. 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, pub last_sync_at: Option, /// The linked server's trash-retention window, as it last advertised it. `None` /// until a probe or sync has learned it. pub server_retention_days: Option, } 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, pub last_sync_at: Option, } 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, last_sync_at: s.last_sync_at.clone(), } } } /// 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, last_sync_at, server_retention_days FROM sync_state WHERE id = 1", [], |row| { let cursor: Option = row.get(2)?; Ok(SyncState { last_sync_at: present(row.get(3)?), server_retention_days: row.get(4)?, 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, shares_synced = CASE WHEN ?3 THEN shares_synced ELSE 0 END WHERE id = 1", params![server_url, device_token, keep_cursor], )?; if !keep_cursor { forget_shared_notes(conn)?; } Ok(()) } /// Seals the device token for storage, and opens it again. /// /// Neither method fails loudly: a client's key store can be briefly unavailable, and /// a token sealed on another device can never be opened here. `None` says so, and /// [`set_sealed_link`] and [`open_token`] decide what happens next. pub trait TokenSeal { /// The token sealed for storage, or None when that isn't possible right now. fn seal(&self, token: &str) -> Option; /// The sealed token opened, or None when this device can't open it. fn open(&self, sealed: &str) -> Option; } /// Marks a stored token as sealed. A device token is URL-safe base64, which has no /// colon, so a plain one can't be mistaken for a sealed one. const SEALED: &str = "sealed:"; /// [`set_link`] for a client that seals its token. /// /// When sealing fails, the token is stored as it is and [`open_token`] seals it on /// its next read. Refusing the link instead would leave someone unable to sync /// over a passing key-store error. pub fn set_sealed_link( conn: &Connection, server_url: &str, device_token: &str, seal: &dyn TokenSeal, ) -> rusqlite::Result<()> { let stored = match seal.seal(device_token) { Some(sealed) => format!("{SEALED}{sealed}"), None => { log::warn!("couldn't seal the device token; it is stored as is until the next read"); device_token.to_string() } }; set_link(conn, server_url, &stored) } /// The device token the server expects, from the `stored` one. /// /// - A sealed token is opened. If it won't open, it is dropped, so the app reads as /// unlinked and asks to sign in again. That is what happens when Android restores /// the app's files onto another phone, whose Keystore never held the key. The /// server address and cursor stay, for the sign-in form and the same server. /// - A plain token, stored before tokens were sealed or when sealing failed, is /// sealed in place and returned. pub fn open_token( conn: &Connection, stored: &str, seal: &dyn TokenSeal, ) -> rusqlite::Result> { if let Some(sealed) = stored.strip_prefix(SEALED) { let token = seal.open(sealed); if token.is_none() { log::warn!("the stored device token won't open on this device; sign in again"); store_token(conn, None)?; } return Ok(token); } if let Some(sealed) = seal.seal(stored) { store_token(conn, Some(&format!("{SEALED}{sealed}")))?; } Ok(Some(stored.to_string())) } /// Replace the stored token alone, leaving the server and cursor as they are. fn store_token(conn: &Connection, token: Option<&str>) -> rusqlite::Result<()> { conn.execute( "UPDATE sync_state SET device_token = ?1 WHERE id = 1", params![token], )?; Ok(()) } /// Drop the notes other people shared with the account this device was linked to. /// They were only ever here through that link: kept after it ends, they would sit /// on the board as notes nobody here can edit and nothing would ever update. fn forget_shared_notes(conn: &Connection) -> rusqlite::Result<()> { conn.execute("DELETE FROM notes WHERE permission <> 'owner'", [])?; Ok(()) } /// How much of sharing this device's copy of the feed reflects, kept in /// `sync_state.shares_synced`. Each level changed what the feed says about notes a /// device may already hold, below the cursor it already has. /// /// Shared notes arrive at all (#5175). pub const SHARES: i64 = 1; /// A shared note's pin, archive and position are this account's own (#5176). Before, /// the feed sent the owner's, and a device holding those would keep showing them /// until something next changed the note. pub const SHARED_STATE: i64 = 2; /// Start the change feed over the first time this device pulls at a sharing `level` /// the server offers, so what it already holds is re-read in that level's terms. /// /// Returns whether it restarted. Once per level per link: `set_link` to another /// server and `clear_link` both go back to none. pub fn begin_shares(conn: &Connection, level: i64) -> rusqlite::Result { let restarted = conn.execute( "UPDATE sync_state SET last_cursor = NULL, shares_synced = ?1 WHERE id = 1 AND shares_synced < ?1", params![level], )?; Ok(restarted > 0) } /// 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, last_sync_at = NULL, server_retention_days = NULL, shares_synced = 0 WHERE id = 1", [], )?; forget_shared_notes(conn) } /// Remember the linked server's trash-retention window (0 = it never purges). /// /// Refreshed on every sync rather than only at link time, so changing the setting on /// the server reaches the desktop's Trash countdown on the next cycle instead of /// waiting for someone to re-link. pub fn set_server_retention(conn: &Connection, days: i64) -> rusqlite::Result<()> { conn.execute( "UPDATE sync_state SET server_retention_days = ?1 WHERE id = 1", params![days], )?; Ok(()) } /// The retention window in force on THIS device: the linked server's if we know it, /// otherwise the caller's offline default. A linked device must never enforce or /// advertise its own window over the server's. pub fn effective_retention_days(conn: &Connection, offline_default: i64) -> rusqlite::Result { let state = read(conn)?; if !state.is_linked() { return Ok(offline_default); } // Linked but the server hasn't told us yet (linked by an older build, or no sync // has completed). Fall back to the default rather than claiming "kept forever". Ok(state.server_retention_days.unwrap_or(offline_default)) } /// Stamp a completed sync. The cursor can't stand in for this: it's a revision /// watermark, and it doesn't move at all when a sync correctly finds nothing new — /// so "synced a moment ago, no changes" would be indistinguishable from "never /// synced" without it. pub fn mark_synced(conn: &Connection, when: &str) -> rusqlite::Result<()> { conn.execute( "UPDATE sync_state SET last_sync_at = ?1 WHERE id = 1", params![when], )?; 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")); } /// Reverses the token: enough to tell sealed from plain. `broken` stands in for a /// key store that is unavailable, or a key this device never held. struct Reverse { broken: bool, } impl TokenSeal for Reverse { fn seal(&self, token: &str) -> Option { (!self.broken).then(|| token.chars().rev().collect()) } fn open(&self, sealed: &str) -> Option { (!self.broken).then(|| sealed.chars().rev().collect()) } } const WORKING: Reverse = Reverse { broken: false }; const BROKEN: Reverse = Reverse { broken: true }; fn stored_token(conn: &Connection) -> Option { read(conn).expect("read").device_token } #[test] fn a_sealed_token_is_stored_sealed_and_opens() { let conn = db(); set_sealed_link(&conn, "https://notes.example.com", "tok-1", &WORKING).expect("link"); assert_eq!(stored_token(&conn).as_deref(), Some("sealed:1-kot")); let opened = open_token(&conn, "sealed:1-kot", &WORKING).expect("open"); assert_eq!(opened.as_deref(), Some("tok-1")); } #[test] fn a_plain_token_is_sealed_on_its_next_read() { let conn = db(); set_link(&conn, "https://notes.example.com", "tok-1").expect("link"); let opened = open_token(&conn, "tok-1", &WORKING).expect("open"); assert_eq!(opened.as_deref(), Some("tok-1")); assert_eq!(stored_token(&conn).as_deref(), Some("sealed:1-kot")); } #[test] fn a_token_that_cant_be_sealed_is_kept_plain_and_still_works() { let conn = db(); set_sealed_link(&conn, "https://notes.example.com", "tok-1", &BROKEN).expect("link"); assert_eq!(stored_token(&conn).as_deref(), Some("tok-1")); let opened = open_token(&conn, "tok-1", &BROKEN).expect("open"); assert_eq!(opened.as_deref(), Some("tok-1")); } #[test] fn a_token_that_wont_open_here_is_dropped_but_the_server_is_kept() { let conn = db(); set_sealed_link(&conn, "https://notes.example.com", "tok-1", &WORKING).expect("link"); let opened = open_token(&conn, "sealed:1-kot", &BROKEN).expect("open"); assert_eq!(opened, None); let state = read(&conn).expect("read"); assert!(!state.is_linked()); assert_eq!(state.device_token, None); assert_eq!( state.server_url.as_deref(), Some("https://notes.example.com") ); } #[test] fn an_unlinked_device_uses_its_own_retention_window() { let conn = db(); assert_eq!(effective_retention_days(&conn, 30).expect("read"), 30); } #[test] fn a_linked_device_adopts_the_servers_window() { // Including 0 — a server that keeps trash forever must not have this device // showing a 30-day countdown that will never fire. let conn = db(); set_link(&conn, "https://notes.example.com", "tok-1").expect("link"); set_server_retention(&conn, 0).expect("retention"); assert_eq!(effective_retention_days(&conn, 30).expect("read"), 0); set_server_retention(&conn, 90).expect("retention"); assert_eq!(effective_retention_days(&conn, 30).expect("read"), 90); } #[test] fn a_linked_device_that_hasnt_heard_yet_falls_back() { // Linked by an older build, or no cycle has completed. The default is a // safer guess than "forever", which would promise a note is being kept. let conn = db(); set_link(&conn, "https://notes.example.com", "tok-1").expect("link"); assert_eq!(effective_retention_days(&conn, 30).expect("read"), 30); } #[test] fn unlinking_forgets_the_servers_window() { let conn = db(); set_link(&conn, "https://notes.example.com", "tok-1").expect("link"); set_server_retention(&conn, 90).expect("retention"); clear_link(&conn).expect("unlink"); assert_eq!(read(&conn).expect("read").server_retention_days, None); assert_eq!(effective_retention_days(&conn, 30).expect("read"), 30); } #[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()); } fn seed(conn: &Connection, id: &str, permission: &str) { conn.execute( "INSERT INTO notes (id, body, created_at, updated_at, permission) VALUES (?1, 'x', '2026-01-01', '2026-01-01', ?2)", params![id, permission], ) .expect("seed"); } fn note_ids(conn: &Connection) -> Vec { let mut stmt = conn.prepare("SELECT id FROM notes ORDER BY id").unwrap(); let rows = stmt.query_map([], |r| r.get(0)).unwrap(); rows.collect::>().unwrap() } #[test] fn unlinking_drops_the_notes_others_shared_and_keeps_our_own() { let conn = db(); set_link(&conn, "https://a.example.com", "tok-1").expect("link"); seed(&conn, "mine", "owner"); seed(&conn, "theirs", "view"); seed(&conn, "editable", "edit"); // Re-linking the same server keeps everything. set_link(&conn, "https://a.example.com", "tok-2").expect("relink"); assert_eq!(note_ids(&conn), ["editable", "mine", "theirs"]); clear_link(&conn).expect("unlink"); assert_eq!(note_ids(&conn), ["mine"]); } #[test] fn the_first_pull_with_shares_starts_over_once_per_link() { let conn = db(); set_link(&conn, "https://a.example.com", "tok-1").expect("link"); set_cursor(&conn, 500).expect("cursor"); assert!(begin_shares(&conn, SHARES).expect("begin")); assert_eq!(read(&conn).expect("read").last_cursor, 0); set_cursor(&conn, 600).expect("cursor"); assert!( !begin_shares(&conn, SHARES).expect("again"), "only the first time" ); assert_eq!(read(&conn).expect("read").last_cursor, 600); // A server offering more of sharing starts it over once more, and only once. assert!(begin_shares(&conn, SHARED_STATE).expect("a newer server")); assert_eq!(read(&conn).expect("read").last_cursor, 0); set_cursor(&conn, 700).expect("cursor"); assert!(!begin_shares(&conn, SHARED_STATE).expect("again")); assert!(!begin_shares(&conn, SHARES).expect("an older level")); assert_eq!(read(&conn).expect("read").last_cursor, 700); // Another server is a fresh start, shares included. set_link(&conn, "https://b.example.com", "tok-2").expect("relink"); assert!(begin_shares(&conn, SHARES).expect("new server")); } #[test] fn unlink_clears_the_last_sync_stamp() { // Otherwise a freshly-linked server would claim it synced at a time that // belonged to a different one. let conn = db(); set_link(&conn, "https://a.example.com", "tok-1").expect("link"); mark_synced(&conn, "2026-07-26T04:00:00.000Z").expect("stamp"); assert!(read(&conn).expect("read").last_sync_at.is_some()); clear_link(&conn).expect("unlink"); assert!(read(&conn).expect("read").last_sync_at.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}"); } }