Files
wanderbooks/crates/ledger-host/src/lease.rs
T
swaitsandClaude Opus 5.5 3cf359a0cd P2 slice 8: the viewer's menu, setup, no confinement (D-32)
No Landlock, no seccomp, no helper: isolation is "same_user
(advisory)". The lease socket keeps two intents (close, format-upgrade
consent). The viewer holds the books open while it runs and gains a
Books menu: New books, Back up now, Restore (preview, then confirm),
Close, Connect Claude Code / Codex. New commands `wanderbooks books
back-up` and `wanderbooks connect claude|codex`. The scaffold
(`new`/`init` and its templates) is gone; install.sh needs no sudo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-20 11:14:22 -06:00

345 lines
12 KiB
Rust

//! The lease socket's messages (`run/lease.sock`, plan 3.9).
//!
//! ```text
//! GOAL: holders hold the books open, and the owner's few requests reach
//! the server, through one socket that accepts nothing else.
//! SOURCE: plan/served.md 3.9 ("Who holds them", "Three local channels":
//! leases record their kind and PID; the owner intents (as built,
//! D-32: two; the viewer, unconfined, backs up, restores and makes
//! books through the server's admin socket as the command line
//! does);
//! consent carries the owner passphrase (a replacing restore no
//! longer does: D-31, the books it replaces are kept in legacy/);
//! "Closing": the host says why on each lease; protocol versions N
//! and N-1).
//! INVARIANTS:
//! 1. A connection's first line is one Ask; a Hold keeps the
//! connection (the lease lasts until it closes), anything else is
//! answered once and the connection closed.
//! 2. Exactly two intents exist: close and consent_format_upgrade; any
//! other request is refused.
//! 3. consent_format_upgrade carries the owner passphrase; without one
//! it is refused before anything is done.
//! 4. Before the host ends a lease it sends the reason; a lease that
//! ends with no reason means the host is gone.
//! 5. Each side accepts its own protocol version and the one before.
//! 6. Lines are at most MAX_LINE bytes.
//! 7. A first line is exactly its version and one ask: anything more
//! (two asks, an unknown key) is refused, never half-read.
//! ```
use serde::{Deserialize, Serialize};
/// This protocol's version (INVARIANT 5).
pub const PROTO: u32 = 1;
/// The oldest version still accepted (there is none before the first).
pub const OLDEST: u32 = 1;
/// The host's socket, in the root's `run/`.
pub const LEASE_SOCKET: &str = "lease.sock";
/// Held by the one host for a root.
pub const HOST_LOCK: &str = "host.lock";
/// Held while the host restarts; openers wait on it.
pub const UPDATE_LOCK: &str = "update.lock";
/// The longest line either side sends (INVARIANT 6).
pub const MAX_LINE: usize = 64 * 1024;
/// Whether a message of protocol version `proto` is accepted.
pub fn accepts(proto: u32) -> bool {
(OLDEST..=PROTO).contains(&proto)
}
/// What kind of holder a lease is.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Kind {
/// The app (the viewer).
App,
/// An agent session's bridge (`wanderbooks mcp`).
Bridge,
/// A terminal running `wanderbooks serve`.
Serve,
/// A command that needs the server (`wanderbooks books ...`).
Cli,
}
impl Kind {
/// How a person names it.
pub fn label(self) -> &'static str {
match self {
Kind::App => "the app",
Kind::Bridge => "an agent session",
Kind::Serve => "wanderbooks serve",
Kind::Cli => "a wanderbooks command",
}
}
}
/// A connection's first line (INVARIANT 1).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Hello {
/// The sender's protocol version.
pub proto: u32,
/// What it asks.
#[serde(flatten)]
pub ask: Ask,
}
/// What a connection asks.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum Ask {
/// Hold the books open while this connection lasts.
Hold {
/// Who holds them.
kind: Kind,
/// The holder's process.
pid: u32,
/// The book it works on, if one.
#[serde(default)]
book: Option<String>,
},
/// The leases held now (the app and `wanderbooks doctor` list them).
Leases,
/// One of the owner's intents.
Intent(Intent),
}
/// The owner's intents: exactly these two (INVARIANT 2).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum Intent {
/// Close the books now: every lease ends.
Close,
/// Let the server upgrade a book's journal format.
ConsentFormatUpgrade {
/// Which.
book: String,
/// The owner passphrase (INVARIANT 3).
#[serde(default)]
passphrase: Option<String>,
},
}
impl Intent {
/// Refuse an intent that needs the passphrase and has none
/// (INVARIANT 3).
pub fn check_passphrase(&self) -> Result<(), String> {
let missing = |p: &Option<String>| p.as_deref().is_none_or(|p| p.is_empty());
match self {
Intent::ConsentFormatUpgrade { passphrase, .. } if missing(passphrase) => Err(
"consenting to a format upgrade needs the owner passphrase, typed in the app"
.into(),
),
_ => Ok(()),
}
}
}
/// Why the host ends a lease (INVARIANT 4).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Reason {
/// The owner closed the books ("Close the books", `wanderbooks
/// close`): reopen only in a new session.
ClosedByOwner,
/// The host is restarting (an update): hold again once it is back.
Restarting,
/// The host is closing because nothing held it, or its restart or
/// start went wrong (never the owner's close, which is
/// `ClosedByOwner`): hold again, and a new host will answer.
Closing,
/// The books server stopped or couldn't start.
ServerFailed,
}
/// One lease, as listed.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct LeaseInfo {
/// Its number.
pub id: u64,
/// Who holds it.
pub kind: Kind,
/// The holder's process.
pub pid: u32,
/// The book it works on, if one.
#[serde(default)]
pub book: Option<String>,
}
/// What the host sends.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum HostMsg {
/// The lease is held; the books are open or opening.
Held {
/// The lease's number.
lease: u64,
},
/// The books server is ready.
Ready,
/// The lease ends, and why (INVARIANT 4).
Ending {
/// Why.
reason: Reason,
/// More, in plain words (no path).
#[serde(default)]
detail: Option<String>,
},
/// An intent was carried out.
Done {
/// What came of it, in plain words.
#[serde(default)]
detail: Option<String>,
},
/// An intent was sent on, and no answer came back: it may or may not
/// have been carried out, in plain words.
Uncertain(String),
/// The leases held now, and whether the books are open yet.
Leases {
/// The books server is ready.
ready: bool,
/// The leases.
leases: Vec<LeaseInfo>,
},
/// Refused, with why.
Refused(String),
}
/// A connection's first line, read strictly (INVARIANT 7).
pub fn parse_hello(text: &str) -> Result<Hello, String> {
strict_pair(text).and_then(|v| serde_json::from_value(v).map_err(|e| e.to_string()))
}
/// `text` as a JSON object of exactly two keys, `proto` and one more.
pub(crate) fn strict_pair(text: &str) -> Result<serde_json::Value, String> {
let value: serde_json::Value = serde_json::from_str(text).map_err(|e| e.to_string())?;
match value.as_object() {
Some(o) if o.len() == 2 && o.contains_key("proto") => Ok(value),
Some(_) => Err("a message is its protocol version and exactly one request".into()),
None => Err("a message is a JSON object".into()),
}
}
/// One line: the message as JSON and a newline.
pub fn line<T: Serialize>(message: &T) -> String {
let mut text = serde_json::to_string(message).expect("a lease message is plain data");
text.push('\n');
text
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn parse(v: serde_json::Value) -> Result<Hello, serde_json::Error> {
serde_json::from_value(v)
}
#[test]
fn a_hold_names_its_kind_pid_and_book() {
let h = parse(json!({"proto": 1, "hold": {"kind": "bridge", "pid": 7, "book": "acme"}}))
.unwrap();
assert_eq!(
h.ask,
Ask::Hold {
kind: Kind::Bridge,
pid: 7,
book: Some("acme".into())
}
);
assert_eq!(
serde_json::to_value(&h).unwrap(),
json!({"proto": 1, "hold": {"kind": "bridge", "pid": 7, "book": "acme"}})
);
}
#[test]
fn exactly_two_intents_are_understood() {
for intent in [
json!("close"),
json!({"consent_format_upgrade": {"book": "acme", "passphrase": "x"}}),
] {
let h = parse(json!({"proto": 1, "intent": intent})).unwrap();
assert!(matches!(h.ask, Ask::Intent(_)), "{intent}");
}
for bad in [
json!({"proto": 1, "intent": "delete"}),
json!({"proto": 1, "intent": {"restore": {"path": "/x"}}}),
json!({"proto": 1, "intent": {"restore": {"source": "/x", "id": "a"}}}),
json!({"proto": 1, "intent": {"back_up_now": {"book": "acme"}}}),
json!({"proto": 1, "intent": {"new_books": {"id": "acme", "currency": "USD"}}}),
json!({"proto": 1, "intent": {"run": {"command": "sh"}}}),
json!({"proto": 1, "exec": "sh"}),
json!({"proto": 1}),
] {
assert!(parse(bad.clone()).is_err(), "{bad}");
}
}
#[test]
fn a_hello_is_exactly_its_version_and_one_ask() {
assert!(parse_hello(r#"{"proto":1,"leases":null}"#).is_ok());
for bad in [
r#"{"proto":1,"intent":{"back_up_now":{"book":"a"}},"leases":null}"#,
r#"{"proto":1,"leases":null,"extra":1}"#,
r#"{"leases":null,"intent":"close"}"#,
r#"{"proto":1}"#,
r#"[1]"#,
"not json",
] {
assert!(parse_hello(bad).is_err(), "{bad}");
}
}
#[test]
fn only_consent_needs_the_passphrase() {
let consent = |p: Option<&str>| Intent::ConsentFormatUpgrade {
book: "acme".into(),
passphrase: p.map(str::to_string),
};
assert!(consent(None).check_passphrase().is_err());
assert!(consent(Some("")).check_passphrase().is_err());
assert!(consent(Some("pw")).check_passphrase().is_ok());
assert!(Intent::Close.check_passphrase().is_ok());
}
#[test]
fn versions_this_one_and_the_one_before() {
assert!(accepts(PROTO));
assert!(!accepts(PROTO + 1));
assert!(!accepts(0));
}
#[test]
fn host_messages_round_trip_as_one_line() {
for m in [
HostMsg::Held { lease: 3 },
HostMsg::Ready,
HostMsg::Ending {
reason: Reason::Restarting,
detail: None,
},
HostMsg::Done {
detail: Some("closed".into()),
},
HostMsg::Leases {
ready: true,
leases: vec![LeaseInfo {
id: 1,
kind: Kind::Serve,
pid: 9,
book: None,
}],
},
HostMsg::Refused("no".into()),
HostMsg::Uncertain("maybe".into()),
] {
let text = line(&m);
assert!(text.ends_with('\n') && !text[..text.len() - 1].contains('\n'));
assert_eq!(serde_json::from_str::<HostMsg>(text.trim_end()).unwrap(), m);
}
assert_eq!(Kind::Bridge.label(), "an agent session");
}
}