//! Client<->server compatibility handshake. //! //! The 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 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}; use std::net::IpAddr; use std::sync::OnceLock; /// The sync wire protocol this client speaks. /// /// v4: `color` left the note. NOT a floor raise on either side — see the note on /// [`MIN_SERVER_PROTOCOL_VERSION`]. /// v5: files attached here upload, and removed attachments and previews are pushed. /// Additive: both go only to a server advertising `attachment_sync`. /// v6: notes shared with this account, asked for with `shares`. /// v7: this account's own pin, archive and position on a shared note, sent /// only to a server advertising `shared_state`. pub const CLIENT_PROTOCOL_VERSION: u32 = 7; /// The oldest server protocol this client can drive — the symmetric half of the /// server's `min_client_protocol_version`. /// /// STAYS AT 3 through v4. Dropping a field a client sends and expects back is /// breaking (v2 dropped `kind` and `title` and moved both floors), but `color` is not /// expected back: a v3 client reading a v4 server gets `"default"` from serde's /// default and draws the colour it derives locally, and a v3 client PUSHING `color` to /// a v4 server has the key ignored, since the server reads its payload key by key. /// Neither direction errors or loses anything a person can see. pub const MIN_SERVER_PROTOCOL_VERSION: u32 = 3; /// 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", "attachment_sync", "shares", "shared_state", ]; /// The handshake fields of `GET /api/config`. /// /// Every protocol field is optional because a server older than the handshake 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 { pub 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 Inkwell." ), 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 } } } /// Who this client says it is, set once by the host application at startup. /// /// The core can't know this: it is compiled into both the desktop and the Android /// app, and its own crate version is a number no build stamps. The app's build is /// what a reader of the header wants. /// /// So the host names itself. `OnceLock` because identity is fixed for the life of /// the process and a second caller should be ignored rather than race the first. static CLIENT_AGENT: OnceLock = OnceLock::new(); /// Name this client for the servers it talks to — `("inkwell-android", "2026.08.31.1204")`. /// /// Call once at startup, before any sync. Calling twice is not an error and the /// first name wins; not calling it at all is visible in the header rather than /// silently plausible. pub fn set_client_agent(name: &str, version: &str) { let _ = CLIENT_AGENT.set(format!("{name}/{version}")); } /// 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] { // `unidentified/unknown`, never a plausible default: a wrong value in a header // nothing checks would sit unnoticed, while an obviously missing one is caught by // the first person who reads a server log. let agent = CLIENT_AGENT .get() .cloned() .unwrap_or_else(|| "inkwell-unidentified/unknown".to_string()); [ ("X-Inkwell-Client", agent), ("X-Inkwell-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 an Inkwell 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}")) } /// Whether a normalized base URL may carry this device's token, as far as the wire /// goes: true for any `https://` server, and for plain `http://` only when the /// host is private. /// /// Plain HTTP on a home network is fine and stays supported. Across the internet, /// anyone on the path could read the token and act as this device until it is /// revoked. So `http://` is allowed only for addresses that can't be public: /// private, loopback, link-local and CGNAT IPs (CGNAT covers Tailscale, which /// encrypts underneath), single-label names like `nas`, and the LAN suffixes in /// [`PRIVATE_SUFFIXES`]. /// /// Decided from the text, without a DNS lookup, so the answer is the same /// everywhere and can be tested. The cost is split DNS: a real domain that /// resolves to a LAN address is refused over `http://`. Its fix is the one this /// asks for anyway, https or the LAN address. pub fn cleartext_allowed(base_url: &str) -> bool { let Some(rest) = base_url.strip_prefix("http://") else { return true; }; let authority = rest.split(['/', '?', '#']).next().unwrap_or(""); is_private_host(&host_of(authority)) } /// [`cleartext_allowed`] as the error a link or a sync cycle stops with. pub fn refuse_public_cleartext(base_url: &str) -> Result<(), String> { if cleartext_allowed(base_url) { return Ok(()); } Err(format!( "{base_url} is a public address over plain http://, which would send this \ device's sign-in token unencrypted. Use https://, or the server's address \ on your own network." )) } /// Names that only mean something on a private network. const PRIVATE_SUFFIXES: &[&str] = &[ ".local", ".lan", ".home", ".home.arpa", ".internal", ".localdomain", ".localhost", ".ts.net", ]; /// The host of an authority: no userinfo, no port, no IPv6 brackets, lowercased. fn host_of(authority: &str) -> String { let authority = authority.rsplit_once('@').map_or(authority, |(_, h)| h); if let Some(bracketed) = authority.strip_prefix('[') { let inner = bracketed.split(']').next().unwrap_or(""); return inner.to_ascii_lowercase(); } let host = authority.rsplit_once(':').map_or(authority, |(h, _)| h); host.to_ascii_lowercase() } fn is_private_host(host: &str) -> bool { if let Ok(ip) = host.parse::() { return match ip { IpAddr::V4(v4) => { v4.is_private() || v4.is_loopback() || v4.is_link_local() || (v4.octets()[0] == 100 && (v4.octets()[1] & 0xc0) == 64) } IpAddr::V6(v6) => { v6.is_loopback() || (v6.segments()[0] & 0xfe00) == 0xfc00 || (v6.segments()[0] & 0xffc0) == 0xfe80 } }; } let name = host.trim_end_matches('.'); !name.contains('.') || PRIVATE_SUFFIXES.iter().any(|s| name.ends_with(s)) } #[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("Inkwell".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 server older than the handshake: /api/config parses, but carries no protocol block. let info = ServerInfo { site_name: Some("Inkwell".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. // Versions and features come from the constants, not literals: this test is // about unknown FIELDS, and pinned values would fail it whenever the protocol // moves. let features: Vec<&str> = REQUIRED_FEATURES .iter() .chain(OPTIONAL_FEATURES.iter()) .copied() .collect(); let body = format!( r#"{{"site_name":"S","sync_protocol_version":{v}, "min_client_protocol_version":{v}, "sync_features":{f}, "some_future_field":{{"nested":true}}}}"#, v = CLIENT_PROTOCOL_VERSION, f = serde_json::to_string(&features).expect("features"), ); let info: ServerInfo = serde_json::from_str(&body).expect("unknown fields are ignored"); assert_eq!(evaluate(&info), Compatibility::Ok); } #[test] fn client_headers_identify_app_and_protocol() { // Sets the process-wide agent, which is why this test also owns the // assertion about it: a second test calling `set_client_agent` would race // this one for the OnceLock, and whichever lost would see the other's name. // One test, both branches, in order. assert!( client_headers()[0].1.starts_with("inkwell-unidentified/"), "a host that never introduced itself must say so" ); set_client_agent("inkwell-test", "2026.08.31.1204"); let headers = client_headers(); assert_eq!(headers[0].0, "X-Inkwell-Client"); assert_eq!(headers[0].1, "inkwell-test/2026.08.31.1204"); assert_eq!(headers[1].1, CLIENT_PROTOCOL_VERSION.to_string()); // First name wins — a second host cannot rename a running process. set_client_agent("inkwell-impostor", "0"); assert_eq!(client_headers()[0].1, "inkwell-test/2026.08.31.1204"); } #[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); } #[test] fn cleartext_is_allowed_on_a_private_network() { for url in [ "http://192.168.1.10:8000", "http://10.0.0.5", "http://172.20.1.1:5000", "http://127.0.0.1:5000", "http://localhost:5000", "http://nas:5000", "http://inkwell.local", "http://notes.home.arpa", "http://100.101.102.103:5000", "http://[fd00::1]:5000", "http://[::1]", "http://user@192.168.1.10:8000/sub", ] { assert!(cleartext_allowed(url), "{url} should be allowed"); } } #[test] fn cleartext_is_refused_at_a_public_address() { for url in [ "http://notes.example.com", "http://notes.example.com:8080/inkwell", "http://8.8.8.8", "http://100.128.0.1", "http://[2001:db8::1]:5000", "http://NOTES.EXAMPLE.COM", ] { assert!(!cleartext_allowed(url), "{url} should be refused"); } } #[test] fn https_is_always_allowed() { assert!(cleartext_allowed("https://notes.example.com")); assert!(cleartext_allowed("https://8.8.8.8")); } }