Files
inkwell/core/src/sync/compat.rs
T
bvandeusenandClaude Opus 5.5 01b53118b9
Android / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Python tests (push) Successful in 12s
CI & Build / Web typecheck and unit tests (push) Successful in 8s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Android / Core and FFI clippy and tests (push) Successful in 1m3s
CI & Build / integration (push) Successful in 1m37s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m40s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m14s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m3s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m42s
Android / Build the server image (push) Successful in 1s
Core and desktop comments describe the code to a stranger (#6224)
Milestone 546 "Bring the repo to the comment standard (rule 32) and
compose defaults (rule 198)", step #6224 "Comment standard sweep: Rust
core and desktop client".

Doc comments and comments across inkwell-core, the Tauri app and
desktop/packaging now say what the code does and the non-obvious why in
the present tense. Gone: milestone tags (M10.x, M11.3, M13, M304, M315,
M314, milestone 325), issue and Scribe-note numbers, family-idea and
family-rule citations, the run numbers and commit hashes behind past
bugs, and the narration of what used to be (the stable channel before it
held its own bundles, CARGO_PKG_VERSION as the shown version, the
MANIFEST_TAG split, the store's missing tests, the old PKGBUILD). The
schema's migration notes keep what each step does and why, without
"until now". The forge is called the forge rather than Forgejo.

Hazards stay as facts: the dev-rolling tag that must not shadow the
branch, the manifest matched on this build's version, the APK's fixed
name surviving the prune, the LIKE guard in v9, -wal/-shm moving with
the renamed store, panic = abort ending the app.

34 files, comments only: a comment-stripping diff of every changed file
against HEAD is identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:30:37 -04:00

582 lines
22 KiB
Rust

//! 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<String>,
/// The server's release version, for display only — never gate on it.
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub sync_protocol_version: Option<u32>,
#[serde(default)]
pub min_client_protocol_version: Option<u32>,
#[serde(default)]
pub sync_features: Vec<String>,
/// 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<u32>,
}
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<String> {
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<String> },
/// 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<String> = 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<String> {
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::<IpAddr>() {
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(&current_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"));
}
}