Files
inkwell/core/src/sync/state.rs
T
bvandeusenandClaude Opus 5.5 8592b83538
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 4s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Web typecheck and unit tests (push) Successful in 20s
CI & Build / Python tests (push) Successful in 20s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Android / Core and FFI clippy and tests (push) Successful in 1m12s
CI & Build / integration (push) Successful in 1m44s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m17s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m30s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m27s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 9m15s
Android / Build the server image (push) Successful in 1s
android: the device token is stored sealed under a Keystore key
Family idea #5105, practice 12, as the operator chose on 2026-10-08: the token
is encrypted, and Android backup stays on.

The core:
- Adds a TokenSeal trait in sync/state.rs, with set_sealed_link and
  open_token.
- A sealed token is stored as "sealed:<value>".
- A plain token, stored before this change or while sealing failed, is sealed
  in place on its next read.
- A sealed token that won't open is dropped, and the server address and cursor
  are kept, so the app reads as unlinked and asks to sign in again. That is
  what happens after Android restores the app onto another phone.
- The desktop passes no seal and keeps storing the token as before.

The FFI:
- Exports TokenSeal as a uniffi foreign trait (seal_token / open_token, null
  rather than an exception).
- Requires it in Inkwell's constructor, so there is no moment a token could be
  stored unsealed.
- Routes credentials(), unlink() and store_link() through it.

Kotlin:
- KeystoreTokenSeal is AES-GCM under an Android Keystore key, using the
  SealedBox framing from Minstrel's KeystoreSessionVault (Scribe snippet #5025),
  with no new dependency.
- SealedBoxTest checks the framing on the JVM.

allowBackup stays true, and the manifest says why. An unlinked phone's notes
exist only on the phone, and the backup is their one other copy. The backup
carries a token nothing can open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:23:22 -04:00

578 lines
22 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.
//!
//! 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<String>,
pub device_token: Option<String>,
pub last_cursor: i64,
pub last_sync_at: Option<String>,
/// 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<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,
pub last_sync_at: Option<String>,
}
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<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, last_sync_at, server_retention_days
FROM sync_state WHERE id = 1",
[],
|row| {
let cursor: Option<String> = 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<String>;
/// The sealed token opened, or None when this device can't open it.
fn open(&self, sealed: &str) -> Option<String>;
}
/// 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<Option<String>> {
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<bool> {
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<i64> {
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<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"));
}
/// 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<String> {
(!self.broken).then(|| token.chars().rev().collect())
}
fn open(&self, sealed: &str) -> Option<String> {
(!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<String> {
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<String> {
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::<rusqlite::Result<_>>().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}");
}
}