//! The admin socket's messages (`run/admin.sock`, plan 3.1, 3.3, 3.9): how //! the command line asks the books server to list, create and adopt books, //! and to make, list and revoke agents' tokens. //! //! ```text //! GOAL: the command line makes and adopts books without ever touching //! the data root itself: the server, its sole writer, does it. //! SOURCE: plan/served.md 3.1 (the CLI "talks to run/admin.sock ... it can //! create or adopt books"), 5.5 (adoption), 3.9 (protocol versions //! N and N-1). //! INVARIANTS: //! 1. One request per connection, one JSON line each way, at most //! MAX_LINE bytes; anything else is refused with a plain reason. //! 2. A request carries its protocol version; the server accepts this //! version and the one before, and refuses others with a message //! that says to restart. //! 3. No answer names the data root or a path in it, nor echoes a path //! the caller gave (which could be the root under another name). //! 4. Adopting is two steps: without `confirmed` the answer is a //! preview, and nothing is written; `confirmed: true` adopts, and //! only books still at the head the preview read (`expect_head`). //! 5. Nothing in a settings file is dropped silently: entries the books //! don't know are refused unless `skip_unknown`, and its notes and //! comments are in the preview. //! 6. A token's text is in exactly one answer, TokenCreated, to the //! request that made it; nothing else carries it (auth.rs //! INVARIANT 1). //! ``` use serde::{Deserialize, Serialize}; use std::path::PathBuf; /// This protocol's version (INVARIANT 2). pub const PROTO: u32 = 1; /// The oldest version this side still accepts (INVARIANT 2: this one and /// the one before; there is none before the first). pub const OLDEST: u32 = 1; /// Whether a request of protocol version `proto` is accepted. pub fn accepts(proto: u32) -> bool { (OLDEST..=PROTO).contains(&proto) } /// The longest line either side sends, its newline not counted /// (INVARIANT 1): settings texts ride in an adopt request. pub const MAX_LINE: usize = 4 * 1024 * 1024; /// A request, with its protocol version. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct Envelope { /// The sender's protocol version. pub proto: u32, /// What is asked. #[serde(flatten)] pub request: Request, } /// What the command line can ask. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)] pub enum Request { /// The books the server holds. List, /// Start new, empty books. Create { /// The new books' id. id: String, /// Their base currency (ISO 4217), fixed for good. currency: String, }, /// Adopt existing books (plan 5.5). Adopt { /// The new books' id. id: String, /// The data dir to adopt (`journal.jsonl`, `blobs/`). source: PathBuf, /// The settings file's text, for books that keep no settings. #[serde(default)] settings_toml: Option, /// `config.json`'s text, for its receipt policy. #[serde(default)] config_json: Option, /// The source's TIN vault folder. #[serde(default)] vault_dir: Option, /// The v1 vault key file. #[serde(default)] vault_key: Option, /// Adopt (true), or only preview what would be moved in. #[serde(default)] confirmed: bool, /// The head the preview read: adopted only if still so. #[serde(default)] expect_head: Option<(u64, String)>, /// Leave out settings-file entries the books don't know. #[serde(default)] skip_unknown: bool, }, /// Make a token for an agent (plan 3.3). TokenCreate { /// The books it opens. book: String, /// Its name, unique among the books' tokens ("agent"). name: String, /// What it may do: books:read, books:write, books:post, /// books:admin. scopes: Vec, /// End a token of the same name in the same step. #[serde(default)] replace: bool, }, /// The live tokens. TokenList, /// End a token. TokenRevoke { /// Its books. book: String, /// Its name. name: String, /// Only if it is still this one (a token just made, taken back). #[serde(default, skip_serializing_if = "Option::is_none")] id: Option, }, } /// A token's text in an answer: Debug never shows it (INVARIANT 6). #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(transparent)] pub struct TokenText(pub String); impl std::fmt::Debug for TokenText { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str("TokenText(…)") } } /// A token, as listed (never its text). #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct TokenInfo { /// Its id. pub id: String, /// The books it opens. pub book: String, /// Its name. pub name: String, /// What it may do. pub scopes: Vec, /// When it was made (UTC). pub created: String, } impl From<&ledger_store::auth::Token> for TokenInfo { fn from(t: &ledger_store::auth::Token) -> Self { Self { id: t.id.clone(), book: t.book.clone(), name: t.name.clone(), scopes: t.scopes.iter().map(|s| s.as_str().to_string()).collect(), created: t.created.clone(), } } } /// One book, as listed. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct BookStatus { /// Its id. pub id: String, /// Whether it is open and served. pub open: bool, /// Why it couldn't be opened, if it couldn't. #[serde(default, skip_serializing_if = "Option::is_none")] pub problem: Option, } /// A setting an adoption would move in. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct SettingChange { /// Its key. pub key: String, /// Its value, as JSON (`None`: the setting is cleared). pub value: Option, } /// What an unconfirmed adoption would do (INVARIANT 4). #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct AdoptPreview { /// The source's last event, `(seq, hash)`, as read now. pub source_head: (u64, String), /// The source already keeps its own settings: none would be moved in. pub keeps_settings: bool, /// The settings that would be moved in. pub settings: Vec, /// What the settings file says that isn't a books setting. pub notes: Vec, /// Entries in it the books don't know (not moved in). pub unknown: Vec, /// The settings file's comments, in order (they aren't settings: keep /// what matters as a convention or a decision). pub comments: Vec, /// The vault's records, each checked to open with the key given. pub vault_records: usize, } /// What an adoption did. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct Adopted { /// The new books' id. pub id: String, /// The source's head, verified before copying. pub source_head: (u64, String), /// The adopted books' head. pub head: (u64, String), /// Stored files copied. pub blob_files: u64, /// Stored files the books name, re-hashed through the store. pub blobs_verified: u64, /// Stored files copied that the books don't name. pub unnamed_blobs: u64, /// Files left behind among the stored ones (unfinished writes, hidden /// files). pub skipped_files: u64, /// Vault records copied. pub vault_files: u64, /// Whether the v1 key was copied. pub key_copied: bool, /// Settings moved in. pub settings_imported: usize, /// Settings were given, but the books keep their own. pub settings_ignored: bool, /// The source had no lock file, so one was made beside its journal. pub source_lock_created: bool, /// Why the adopted books didn't open, if they didn't (they are kept; /// closing the books and opening them again tries again). #[serde(default, skip_serializing_if = "Option::is_none")] pub problem: Option, } /// The server's answer. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case", deny_unknown_fields)] pub enum Response { /// The books held. Books(Vec), /// New books made. Created { /// Their id. id: String, /// Why they didn't open, if they didn't (they are kept). #[serde(default, skip_serializing_if = "Option::is_none")] problem: Option, }, /// What an adoption would do. Preview(AdoptPreview), /// Books adopted and open. Adopted(Adopted), /// A token made: its text, shown this once (INVARIANT 6). TokenCreated { /// The token. token: TokenInfo, /// Its text. text: TokenText, }, /// The live tokens. Tokens(Vec), /// A token ended. TokenRevoked(TokenInfo), /// Refused, with why (no path in the data root: INVARIANT 3). Refused(String), } /// Why an admin call has no answer. #[derive(Clone, Debug, PartialEq, Eq)] pub enum CallError { /// The request never reached the server: nothing was done. NotSent(String), /// It was sent, and no answer came: it may or may not have been done. Unknown(String), } impl std::fmt::Display for CallError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { CallError::NotSent(why) | CallError::Unknown(why) => f.write_str(why), } } } /// Ask the server on `socket` (the root's `run/admin.sock`), waiting as /// long as it takes (a create or an adoption). #[cfg(unix)] pub fn call(socket: &std::path::Path, request: Request) -> Result { call_within(socket, request, None) } /// [`call`], waiting at most `wait` for the answer (a quick look, such /// as `doctor`'s). #[cfg(unix)] pub fn call_within( socket: &std::path::Path, request: Request, wait: Option, ) -> Result { use std::io::{BufRead, Write}; let not_sent = |why: String| CallError::NotSent(why); // A list changes nothing: its only question is whether it answered. let look = match request { Request::List | Request::TokenList => None, Request::Create { .. } | Request::Adopt { .. } => { Some("`wanderbooks books list` shows the books there are") } Request::TokenCreate { .. } | Request::TokenRevoke { .. } => { Some("`wanderbooks token list` shows the tokens there are") } }; let unknown = move |why: String| match look { None => CallError::Unknown(why), Some(look) => CallError::Unknown(format!( "{why}, so it may or may not have been done: {look}" )), }; let quiet = |e: std::io::Error| match e.kind() { std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut => { "the books server isn't answering".to_string() } kind => format!("the books server didn't answer ({kind})"), }; let (dir, name) = ( socket.parent().unwrap_or(std::path::Path::new(".")), socket .file_name() .and_then(|n| n.to_str()) .unwrap_or_default(), ); let short = ledger_store::root::SocketPath::new(dir, name) .map_err(|e| not_sent(format!("can't reach the books server: {e}")))?; let stream = std::os::unix::net::UnixStream::connect(short.path()).map_err(|e| match e.kind() { std::io::ErrorKind::NotFound | std::io::ErrorKind::ConnectionRefused => { not_sent("the books server isn't running".to_string()) } _ => not_sent(format!("can't reach the books server: {}", e.kind())), })?; crate::local::check_peer_std(&stream).map_err(not_sent)?; stream .set_read_timeout(wait) .map_err(|e| not_sent(e.to_string()))?; let mut line = serde_json::to_string(&Envelope { proto: PROTO, request, }) .map_err(|e| not_sent(e.to_string()))?; line.push('\n'); // A write cut part way may still have reached the server whole. (&stream).write_all(line.as_bytes()).map_err(|e| { unknown(format!( "the books server stopped taking the request ({})", e.kind() )) })?; let mut answer = String::new(); std::io::BufReader::new(std::io::Read::take(&stream, MAX_LINE as u64 + 1)) .read_line(&mut answer) .map_err(|e| unknown(quiet(e)))?; if answer.is_empty() { return Err(unknown("the books server stopped before answering".into())); } serde_json::from_str(answer.trim_end()) .map_err(|e| unknown(format!("the books server's answer isn't understood ({e})"))) } #[cfg(test)] mod tests { use super::*; use serde_json::json; #[test] fn requests_are_one_flat_object_with_their_version() { let line = serde_json::to_value(Envelope { proto: PROTO, request: Request::Create { id: "acme".into(), currency: "USD".into(), }, }) .unwrap(); assert_eq!( line, json!({"proto": 1, "op": "create", "id": "acme", "currency": "USD"}) ); let back: Envelope = serde_json::from_value(line).unwrap(); assert_eq!(back.proto, 1); let list: Envelope = serde_json::from_value(json!({"proto": 1, "op": "list"})).unwrap(); assert_eq!(list.request, Request::List); } #[test] fn an_adopt_request_is_a_preview_unless_confirmed() { let r: Envelope = serde_json::from_value( json!({"proto": 1, "op": "adopt", "id": "acme", "source": "/x/books-data"}), ) .unwrap(); match r.request { Request::Adopt { confirmed, settings_toml, vault_dir, expect_head, skip_unknown, .. } => { assert!(!confirmed && !skip_unknown); assert!(settings_toml.is_none() && vault_dir.is_none() && expect_head.is_none()); } other => panic!("{other:?}"), } } #[test] fn unknown_operations_and_fields_are_refused() { for bad in [ json!({"proto": 1, "op": "delete", "id": "acme"}), json!({"proto": 1, "op": "create", "id": "acme", "currency": "USD", "path": "/x"}), json!({"proto": 1}), ] { assert!( serde_json::from_value::(bad.clone()).is_err(), "{bad}" ); } } #[test] fn answers_round_trip() { for r in [ Response::Books(vec![BookStatus { id: "acme".into(), open: false, problem: Some("damaged".into()), }]), Response::Created { id: "acme".into(), problem: None, }, Response::Refused("no".into()), Response::TokenCreated { token: TokenInfo { id: "tok_1".into(), book: "acme".into(), name: "agent".into(), scopes: vec!["books:read".into()], created: "2026-10-19T00:00:00.000Z".into(), }, text: TokenText("wb_00".into()), }, Response::Tokens(vec![]), ] { let text = serde_json::to_string(&r).unwrap(); assert_eq!(serde_json::from_str::(&text).unwrap(), r); } } #[test] fn token_requests_read_as_written() { let r: Envelope = serde_json::from_value(json!({"proto": 1, "op": "token_create", "book": "acme", "name": "agent", "scopes": ["books:read"]})) .unwrap(); assert_eq!( r.request, Request::TokenCreate { book: "acme".into(), name: "agent".into(), scopes: vec!["books:read".into()], replace: false, } ); let shown = format!( "{:?}", Response::TokenCreated { token: TokenInfo { id: "tok_1".into(), book: "acme".into(), name: "agent".into(), scopes: vec![], created: String::new(), }, text: TokenText("wb_secret".into()), } ); assert!(!shown.contains("wb_secret"), "{shown}"); let r: Envelope = serde_json::from_value(json!({"proto": 1, "op": "token_list"})).unwrap(); assert_eq!(r.request, Request::TokenList); assert!( serde_json::from_value::( json!({"proto": 1, "op": "token_revoke", "book": "acme"}) ) .is_err(), "a revoke names the token" ); } #[test] fn a_listed_token_carries_no_text_or_hash() { let dir = tempfile::tempdir().unwrap(); let mut p = ledger_store::auth::Principals::open(dir.path()).unwrap(); let book = ledger_store::root::BookId::parse("acme").unwrap(); let (t, secret) = p .create( &book, "agent", [ledger_store::auth::Scope::Read].into(), false, ) .unwrap(); let text = serde_json::to_string(&Response::Tokens(vec![TokenInfo::from(&t)])).unwrap(); assert!(!text.contains(&secret.expose()[3..])); assert!(!text.contains(&t.sha256)); assert!(text.contains("books:read")); } }