//! Client<->server compatibility handshake (M10.6). //! //! The desktop app is local-first: it never *needs* a server. When the user links //! one, this module decides whether the two can actually talk — before a single //! note moves. The sync engine (M10.7) consults it on link and on every sync. //! //! The contract is two integers per side, versioning the WIRE PROTOCOL separately //! from either program's release version: //! //! | | this client | the server advertises | //! |---|---|---| //! | speaks | `CLIENT_PROTOCOL_VERSION` | `sync_protocol_version` | //! | accepts down to | `MIN_SERVER_PROTOCOL_VERSION` | `min_client_protocol_version` | //! //! Each side declaring its own floor is what avoids app<->server lockstep: either //! side can mark a change breaking without the other needing to ship in step. See //! `docs/sync.md` for the policy that governs when those numbers move. use serde::{Deserialize, Serialize}; /// The sync wire protocol this client speaks. pub const CLIENT_PROTOCOL_VERSION: u32 = 1; /// The oldest server protocol this client can drive — the symmetric half of the /// server's `min_client_protocol_version`. pub const MIN_SERVER_PROTOCOL_VERSION: u32 = 1; /// Capabilities without which syncing is meaningless, so their absence BLOCKS the /// link rather than degrading it. pub const REQUIRED_FEATURES: &[&str] = &["notes", "labels"]; /// Capabilities whose absence costs a feature but not the link. Listing these /// explicitly (rather than diffing against whatever the server happens to send) is /// what lets the UI name exactly what the user will be missing. pub const OPTIONAL_FEATURES: &[&str] = &["attachments", "tombstones", "revisions"]; /// The handshake fields of `GET /api/config`. /// /// Every protocol field is optional because a server predating M10.6 simply won't /// send them. That case has to read as "this server is too old to sync", not as a /// parse failure — which would look to the user like they mistyped the URL. #[derive(Debug, Clone, Default, Deserialize, Serialize)] pub struct ServerInfo { #[serde(default)] pub site_name: Option, /// The server's release version, for display only — never gate on it. #[serde(default)] pub version: Option, #[serde(default)] pub sync_protocol_version: Option, #[serde(default)] pub min_client_protocol_version: Option, #[serde(default)] pub sync_features: Vec, /// How long the SERVER keeps a trashed note before purging it (0 = forever). /// Once linked this is the window that actually applies, so the desktop's Trash /// countdown has to come from here rather than from its own offline default. #[serde(default)] pub trash_retention_days: Option, } impl ServerInfo { fn has_feature(&self, name: &str) -> bool { self.sync_features.iter().any(|f| f.as_str() == name) } fn missing(&self, from: &[&str]) -> Vec { from.iter() .copied() .filter(|f| !self.has_feature(f)) .map(String::from) .collect() } } /// The verdict the link/settings UI renders and the sync engine obeys. /// /// Serialized tagged so the frontend can `switch` on `status` directly. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(tag = "status", rename_all = "snake_case")] pub enum Compatibility { /// Full parity — sync everything. Ok, /// Safe to sync, but these named capabilities aren't available here. Degraded { unavailable: Vec }, /// Do not sync. `client_must_update` points the user at the side that can fix /// it, so the message can be actionable instead of just "incompatible". Incompatible { reason: String, client_must_update: bool, }, } fn incompatible(reason: &str, client_must_update: bool) -> Compatibility { Compatibility::Incompatible { reason: reason.to_string(), client_must_update, } } /// Decide whether this client can sync with the described server. /// /// Pure: the transport fetches `ServerInfo`, this decides what it means. Keeping /// the decision free of I/O is what makes every branch below unit-testable, which /// matters because there is no Postgres/live-server lane in CI. pub fn evaluate(info: &ServerInfo) -> Compatibility { // Ordered most-fundamental first, so the user sees the root problem rather than // a downstream symptom of it. let Some(server_proto) = info.sync_protocol_version else { return incompatible( "This server doesn't support device sync — it predates the sync protocol. \ Update the server, then link again.", false, ); }; if server_proto < MIN_SERVER_PROTOCOL_VERSION { return incompatible( &format!( "This server speaks sync protocol v{server_proto}, but this app needs \ at least v{MIN_SERVER_PROTOCOL_VERSION}. Update the server." ), false, ); } // The server's floor is what hard-blocks an old client. Absent => no floor: a // server that advertises a protocol but no minimum accepts anything. let floor = info.min_client_protocol_version.unwrap_or(0); if CLIENT_PROTOCOL_VERSION < floor { return incompatible( &format!( "This server requires client protocol v{floor} or newer; this app \ speaks v{CLIENT_PROTOCOL_VERSION}. Update ThoughtSync." ), true, ); } // A version match still isn't enough: a server can speak the protocol with a // core capability compiled out or disabled. let missing_required = info.missing(REQUIRED_FEATURES); if !missing_required.is_empty() { return incompatible( &format!( "This server is missing sync capabilities this app requires: {}.", missing_required.join(", ") ), false, ); } let unavailable = info.missing(OPTIONAL_FEATURES); if unavailable.is_empty() { Compatibility::Ok } else { Compatibility::Degraded { unavailable } } } /// Headers this client puts on every request to a linked server, so the server can /// log or gate on client identity without a separate handshake round-trip. pub fn client_headers() -> [(&'static str, String); 2] { let agent = format!("thoughtsync-desktop/{}", env!("CARGO_PKG_VERSION")); [ ("X-ThoughtSync-Client", agent), ( "X-ThoughtSync-Protocol", CLIENT_PROTOCOL_VERSION.to_string(), ), ] } /// Turn what a user typed into a base URL we can build request paths on, or `None` /// if there's nothing usable in it. /// /// A bare host gets **`https://`**, never `http://`. Silently downgrading would put /// a long-lived device token on the wire in cleartext because someone omitted five /// characters. Plain HTTP on a trusted LAN stays fully supported — the user just /// has to type `http://` and thereby choose it. pub fn normalize_base_url(raw: &str) -> Option { let trimmed = raw.trim(); if trimmed.is_empty() { return None; } // Resolve the scheme BEFORE touching trailing slashes — stripping them first // turns a bare "https://" into "https:", which then reads as a hostname. let with_scheme = match trimmed.split_once("://") { Some((scheme, rest)) => { // Anything that isn't HTTP(S) (ftp://, file://, a stray "foo://") can't // be a ThoughtSync server; reject rather than fail confusingly later. let scheme = scheme.to_ascii_lowercase(); if scheme != "http" && scheme != "https" { return None; } format!("{scheme}://{rest}") } None => format!("https://{trimmed}"), }; let (scheme, rest) = with_scheme.split_once("://")?; let rest = rest.trim_end_matches('/'); // Reject a scheme with no authority ("https://", "http:///path"). if rest.split(['/', '?', '#']).next().unwrap_or("").is_empty() { return None; } Some(format!("{scheme}://{rest}")) } #[cfg(test)] mod tests { use super::*; /// A server matching this client exactly, which each test then degrades. fn current_server() -> ServerInfo { ServerInfo { site_name: Some("ThoughtSync".into()), version: Some("0.1.0".into()), sync_protocol_version: Some(CLIENT_PROTOCOL_VERSION), min_client_protocol_version: Some(CLIENT_PROTOCOL_VERSION), sync_features: REQUIRED_FEATURES .iter() .chain(OPTIONAL_FEATURES.iter()) .copied() .map(String::from) .collect(), trash_retention_days: Some(30), } } #[test] fn current_server_is_fully_compatible() { assert_eq!(evaluate(¤t_server()), Compatibility::Ok); } #[test] fn server_without_protocol_fields_is_too_old() { // A pre-M10.6 server: /api/config parses, but carries no protocol block. let info = ServerInfo { site_name: Some("ThoughtSync".into()), version: Some("0.0.9".into()), ..Default::default() }; match evaluate(&info) { Compatibility::Incompatible { client_must_update, .. } => assert!(!client_must_update, "the SERVER is the old side here"), other => panic!("expected incompatible, got {other:?}"), } } #[test] fn client_older_than_the_servers_floor_must_update() { let info = ServerInfo { sync_protocol_version: Some(CLIENT_PROTOCOL_VERSION + 5), min_client_protocol_version: Some(CLIENT_PROTOCOL_VERSION + 5), ..current_server() }; match evaluate(&info) { Compatibility::Incompatible { client_must_update, .. } => assert!(client_must_update), other => panic!("expected incompatible, got {other:?}"), } } #[test] fn newer_server_within_our_floor_still_works() { // The whole point of the two-number contract: a server can move ahead // additively without locking out a client that predates the change. let info = ServerInfo { sync_protocol_version: Some(CLIENT_PROTOCOL_VERSION + 3), min_client_protocol_version: Some(CLIENT_PROTOCOL_VERSION), ..current_server() }; assert_eq!(evaluate(&info), Compatibility::Ok); } #[test] fn server_with_no_declared_floor_accepts_us() { let info = ServerInfo { min_client_protocol_version: None, ..current_server() }; assert_eq!(evaluate(&info), Compatibility::Ok); } #[test] fn missing_optional_feature_degrades_rather_than_blocks() { let info = ServerInfo { sync_features: current_server() .sync_features .into_iter() .filter(|f| f.as_str() != "attachments") .collect(), ..current_server() }; assert_eq!( evaluate(&info), Compatibility::Degraded { unavailable: vec!["attachments".to_string()] } ); } #[test] fn missing_required_feature_blocks() { let info = ServerInfo { sync_features: vec!["labels".to_string()], ..current_server() }; match evaluate(&info) { Compatibility::Incompatible { reason, .. } => assert!(reason.contains("notes")), other => panic!("expected incompatible, got {other:?}"), } } #[test] fn version_mismatch_outranks_a_missing_feature() { // Both wrong → report the version, the root cause of the missing feature. let info = ServerInfo { sync_protocol_version: Some(CLIENT_PROTOCOL_VERSION + 2), min_client_protocol_version: Some(CLIENT_PROTOCOL_VERSION + 2), sync_features: vec![], ..current_server() }; match evaluate(&info) { Compatibility::Incompatible { client_must_update, .. } => assert!(client_must_update), other => panic!("expected incompatible, got {other:?}"), } } #[test] fn verdict_serializes_tagged_for_the_frontend() { let verdict = Compatibility::Degraded { unavailable: vec!["attachments".into()], }; let json = serde_json::to_string(&verdict).expect("verdict serializes"); assert!(json.contains("\"status\":\"degraded\""), "got {json}"); } #[test] fn server_info_tolerates_unknown_and_absent_fields() { // Forward compatibility: a NEWER server sending fields we've never heard of // must not break the handshake. let info: ServerInfo = serde_json::from_str( r#"{"site_name":"S","sync_protocol_version":1, "min_client_protocol_version":1, "sync_features":["notes","labels","attachments","tombstones","revisions"], "some_future_field":{"nested":true}}"#, ) .expect("unknown fields are ignored"); assert_eq!(evaluate(&info), Compatibility::Ok); } #[test] fn client_headers_identify_app_and_protocol() { let headers = client_headers(); assert_eq!(headers[0].0, "X-ThoughtSync-Client"); assert!(headers[0].1.starts_with("thoughtsync-desktop/")); assert_eq!(headers[1].1, CLIENT_PROTOCOL_VERSION.to_string()); } #[test] fn base_url_defaults_to_https_and_trims() { assert_eq!( normalize_base_url(" notes.example.com/ "), Some("https://notes.example.com".to_string()) ); assert_eq!( normalize_base_url("https://notes.example.com///"), Some("https://notes.example.com".to_string()) ); } #[test] fn base_url_keeps_an_explicit_http_choice() { // Plain HTTP on a LAN is supported — the user just has to ask for it. assert_eq!( normalize_base_url("http://192.168.1.10:8000"), Some("http://192.168.1.10:8000".to_string()) ); } #[test] fn base_url_rejects_junk() { assert_eq!(normalize_base_url(""), None); assert_eq!(normalize_base_url(" "), None); assert_eq!(normalize_base_url("https://"), None); assert_eq!(normalize_base_url("ftp://files.example.com"), None); } }