982 lines
35 KiB
Rust
982 lines
35 KiB
Rust
//! 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, adopt and
|
|
//! restore books, to make, list and revoke agents' tokens, and how the
|
|
//! host hands it the owner's go-ahead for a format upgrade.
|
|
//!
|
|
//! ```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 and restoring are two steps: without `confirmed` the
|
|
//! answer is a preview, and nothing is written; `confirmed: true`
|
|
//! adopts (restores), and only books (a backup) 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): a check (TokenCheck) asks by the text's SHA-256.
|
|
//! 7. A format upgrade is asked with the owner passphrase, which the
|
|
//! server checks before anything is done (plan 3.9, D-39); no answer
|
|
//! carries it, and Debug never shows it.
|
|
//! ```
|
|
|
|
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<String>,
|
|
/// `config.json`'s text, for its receipt policy.
|
|
#[serde(default)]
|
|
config_json: Option<String>,
|
|
/// The source's TIN vault folder.
|
|
#[serde(default)]
|
|
vault_dir: Option<PathBuf>,
|
|
/// The v1 vault key file.
|
|
#[serde(default)]
|
|
vault_key: Option<PathBuf>,
|
|
/// 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,
|
|
},
|
|
/// Restore a backup (plan 3.7): as new books, or replacing books
|
|
/// (which are moved into `legacy/`, kept).
|
|
Restore {
|
|
/// The books' id.
|
|
id: String,
|
|
/// The backup: a git repository's address, or its folder
|
|
/// (`<book>.git` in the backup folder).
|
|
source: String,
|
|
/// Replace the books called `id`.
|
|
#[serde(default)]
|
|
replace: bool,
|
|
/// Restore (true), or only preview it.
|
|
#[serde(default)]
|
|
confirmed: bool,
|
|
/// The head the preview read: restored only if still so.
|
|
#[serde(default)]
|
|
expect_head: Option<(u64, String)>,
|
|
},
|
|
/// Back books up now (plan 3.7): to every target their settings name.
|
|
BackUpNow {
|
|
/// The books.
|
|
id: String,
|
|
/// The backup folder's disk is new: begin these books' backup
|
|
/// there again.
|
|
#[serde(default)]
|
|
new_disk: 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<String>,
|
|
/// End a token of the same name in the same step.
|
|
#[serde(default)]
|
|
replace: bool,
|
|
},
|
|
/// The live tokens.
|
|
TokenList,
|
|
/// Whether a token kept elsewhere (this computer's keyring) is the
|
|
/// live one of its books and name: asked by its SHA-256 alone, never
|
|
/// its text (INVARIANT 6).
|
|
TokenCheck {
|
|
/// Its books.
|
|
book: String,
|
|
/// Its name.
|
|
name: String,
|
|
/// The hex SHA-256 of the text kept.
|
|
sha256: String,
|
|
},
|
|
/// The owner's go-ahead for the format upgrade of books waiting for
|
|
/// one (plan 3.9, D-39): a verified backup with the keys, then this
|
|
/// version's journal format declared. The host sends it, for the
|
|
/// lease socket's `consent_format_upgrade`.
|
|
UpgradeFormat {
|
|
/// The books.
|
|
id: String,
|
|
/// The owner passphrase, checked by the server (INVARIANT 7).
|
|
passphrase: Passphrase,
|
|
},
|
|
/// Read one attachment's text now and journal it (plan P6 step 2):
|
|
/// the backfill, one attachment at a time, so a long books dir can be
|
|
/// resumed.
|
|
Extract {
|
|
/// The books.
|
|
id: String,
|
|
/// The attachment to read.
|
|
attachment_id: u64,
|
|
},
|
|
/// 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<String>,
|
|
},
|
|
}
|
|
|
|
/// 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(…)")
|
|
}
|
|
}
|
|
|
|
/// The owner passphrase in a request (and in the lease socket's consent,
|
|
/// which the host hands on as this): Debug never shows it (INVARIANT 7),
|
|
/// and it is wiped from memory when dropped, as the command line wipes
|
|
/// what is typed.
|
|
#[derive(Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(transparent)]
|
|
pub struct Passphrase(pub String);
|
|
|
|
impl std::fmt::Debug for Passphrase {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.write_str("Passphrase(…)")
|
|
}
|
|
}
|
|
|
|
impl Drop for Passphrase {
|
|
fn drop(&mut self) {
|
|
zeroize::Zeroize::zeroize(&mut self.0);
|
|
}
|
|
}
|
|
|
|
/// A format upgrade books wait for (plan 3.9, D-39): written by an older
|
|
/// version, they open on their old format until the owner says go.
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct FormatWaiting {
|
|
/// The journal format they are in.
|
|
pub format: u32,
|
|
/// The features they declared.
|
|
pub features: Vec<String>,
|
|
/// What the upgrade adds, in words ("journal format 2").
|
|
pub adds: Vec<String>,
|
|
}
|
|
|
|
impl From<&ledger_store::FormatUpgrade> for FormatWaiting {
|
|
fn from(u: &ledger_store::FormatUpgrade) -> Self {
|
|
Self {
|
|
format: u.from.format,
|
|
features: u.from.features.clone(),
|
|
adds: u.adds(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// What a format upgrade did.
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct FormatUpgraded {
|
|
/// The books.
|
|
pub id: String,
|
|
/// What it added, in words; empty when the books were already in this
|
|
/// version's format (nothing was done).
|
|
pub added: Vec<String>,
|
|
/// The verified backup made first: the books as they were.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub backup: Option<UpgradeBackup>,
|
|
/// The books' head now.
|
|
pub head: (u64, String),
|
|
}
|
|
|
|
/// The backup a format upgrade made first, proven to hold the books as
|
|
/// they were, their vault and keys too.
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct UpgradeBackup {
|
|
/// Its commit in the books' own backup repository.
|
|
pub commit: String,
|
|
/// The head it holds.
|
|
pub head: (u64, String),
|
|
}
|
|
|
|
/// 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<String>,
|
|
/// 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<String>,
|
|
/// The format upgrade it waits for, if it does.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub format_upgrade: Option<FormatWaiting>,
|
|
/// The projection database's file in use (a name, no folder), if it
|
|
/// opened (ledger_store::db_file_for).
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub database: Option<String>,
|
|
/// Other schemas' projection files beside it, left as they are for the
|
|
/// versions that wrote them (never deleted).
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub other_databases: Vec<String>,
|
|
}
|
|
|
|
/// 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<serde_json::Value>,
|
|
}
|
|
|
|
/// 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<SettingChange>,
|
|
/// What the settings file says that isn't a books setting.
|
|
pub notes: Vec<String>,
|
|
/// Entries in it the books don't know (not moved in).
|
|
pub unknown: Vec<String>,
|
|
/// The settings file's comments, in order (they aren't settings: keep
|
|
/// what matters as a convention or a decision).
|
|
pub comments: Vec<String>,
|
|
/// 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,
|
|
/// The copy was moved to this version's journal format (the source's
|
|
/// is older, and keeps its own).
|
|
#[serde(default)]
|
|
pub format_upgraded: 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<String>,
|
|
}
|
|
|
|
/// What a restore would bring in.
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct RestorePreview {
|
|
/// The backup's head, proven.
|
|
pub head: (u64, String),
|
|
/// Stored files in it, each re-hashed.
|
|
pub blobs: u64,
|
|
/// TIN vault records in it.
|
|
pub vault_files: u64,
|
|
/// Whether books of that id exist now.
|
|
pub exists: bool,
|
|
/// The id of the books it holds, as they were backed up.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub origin: Option<String>,
|
|
}
|
|
|
|
/// What a restore did.
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct Restored {
|
|
/// The books' id.
|
|
pub id: String,
|
|
/// Their head: the backup's.
|
|
pub head: (u64, String),
|
|
/// Stored files the books name, re-hashed through the store.
|
|
pub blobs_verified: u64,
|
|
/// TIN vault records restored.
|
|
pub vault_files: u64,
|
|
/// The `legacy/` folder the replaced books were moved to.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub replaced: Option<String>,
|
|
/// The id of the books it held, as they were backed up.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub origin: Option<String>,
|
|
/// Why the restored books didn't open, if they didn't (they are kept).
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub problem: Option<String>,
|
|
}
|
|
|
|
/// How one backup target took a backup now.
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct BackedUpTarget {
|
|
/// "repository" or "folder".
|
|
pub target: String,
|
|
/// Where (no path in the data root).
|
|
pub at: String,
|
|
/// The journal seq it holds now, if any.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub through_seq: Option<u64>,
|
|
/// Why it holds no backup of the latest, if it doesn't.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub problem: Option<String>,
|
|
}
|
|
|
|
/// 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<BookStatus>),
|
|
/// 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<String>,
|
|
},
|
|
/// What an adoption would do.
|
|
Preview(AdoptPreview),
|
|
/// Books adopted and open.
|
|
Adopted(Adopted),
|
|
/// What a restore would bring in.
|
|
RestorePreview(RestorePreview),
|
|
/// A backup restored, and open.
|
|
Restored(Restored),
|
|
/// Books backed up now: their head, and each target's outcome (none
|
|
/// named: none set up).
|
|
BackedUp {
|
|
/// The books' head seq.
|
|
head_seq: u64,
|
|
/// Each target.
|
|
targets: Vec<BackedUpTarget>,
|
|
},
|
|
/// A token made: its text, shown this once (INVARIANT 6).
|
|
TokenCreated {
|
|
/// The token.
|
|
token: TokenInfo,
|
|
/// Its text.
|
|
text: TokenText,
|
|
},
|
|
/// The live tokens.
|
|
Tokens(Vec<TokenInfo>),
|
|
/// A token ended.
|
|
TokenRevoked(TokenInfo),
|
|
/// What a token check found: the books' live token of that name, if
|
|
/// any, and whether the text asked about is it.
|
|
TokenChecked {
|
|
/// The live token of that books and name.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
live: Option<TokenInfo>,
|
|
/// The text asked about is that live token's.
|
|
matches: bool,
|
|
},
|
|
/// A format upgrade made (or none needed).
|
|
FormatUpgraded(FormatUpgraded),
|
|
/// An attachment's text read and journaled (plan P6 step 2).
|
|
Extracted {
|
|
/// The attachment.
|
|
attachment_id: u64,
|
|
/// "ok", "none" or "failed".
|
|
status: String,
|
|
/// How many pages it has.
|
|
pages: u32,
|
|
/// The text blob's SHA-256, when there is text.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
text_sha256: Option<String>,
|
|
},
|
|
/// 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<Response, CallError> {
|
|
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<std::time::Duration>,
|
|
) -> Result<Response, CallError> {
|
|
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 | Request::TokenCheck { .. } => None,
|
|
Request::Create { .. } | Request::Adopt { .. } | Request::Restore { .. } => {
|
|
Some("`wanderbooks books list` shows the books there are")
|
|
}
|
|
Request::BackUpNow { .. } => {
|
|
Some("`wanderbooks books back-up` again backs up whatever it didn't")
|
|
}
|
|
Request::TokenCreate { .. } | Request::TokenRevoke { .. } => {
|
|
Some("`wanderbooks token list` shows the tokens there are")
|
|
}
|
|
Request::UpgradeFormat { .. } => {
|
|
Some("`wanderbooks books list` says whether the books still wait for it")
|
|
}
|
|
Request::Extract { .. } => Some(
|
|
"`wanderbooks books extract BOOK ATTACHMENT` reads it again; it journals once per extractor version",
|
|
),
|
|
};
|
|
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()));
|
|
}
|
|
// INVARIANT 1: an answer is one line of at most MAX_LINE bytes; a
|
|
// longer one, or one cut off, is refused, even if what came parses.
|
|
if !answer.ends_with('\n') {
|
|
return Err(unknown(
|
|
"the books server's answer is too long, or cut off".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 a_restore_request_is_a_preview_unless_confirmed() {
|
|
let r: Envelope = serde_json::from_value(
|
|
json!({"proto": 1, "op": "restore", "id": "acme", "source": "/backups/acme.git"}),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
r.request,
|
|
Request::Restore {
|
|
id: "acme".into(),
|
|
source: "/backups/acme.git".into(),
|
|
replace: false,
|
|
confirmed: false,
|
|
expect_head: None,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[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::<Envelope>(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()),
|
|
format_upgrade: None,
|
|
database: None,
|
|
other_databases: Vec::new(),
|
|
},
|
|
BookStatus {
|
|
id: "beta".into(),
|
|
open: true,
|
|
problem: None,
|
|
format_upgrade: Some(FormatWaiting {
|
|
format: 1,
|
|
features: Vec::new(),
|
|
adds: vec!["journal format 2".into()],
|
|
}),
|
|
database: Some("wanderbooks.v13.db".into()),
|
|
other_databases: vec!["wanderbooks.db".into()],
|
|
},
|
|
]),
|
|
Response::FormatUpgraded(FormatUpgraded {
|
|
id: "beta".into(),
|
|
added: vec!["journal format 2".into()],
|
|
backup: Some(UpgradeBackup {
|
|
commit: "c".repeat(40),
|
|
head: (7, "ab".into()),
|
|
}),
|
|
head: (8, "cd".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![]),
|
|
Response::RestorePreview(RestorePreview {
|
|
head: (3, "ab".into()),
|
|
blobs: 2,
|
|
vault_files: 1,
|
|
exists: true,
|
|
origin: Some("acme".into()),
|
|
}),
|
|
Response::Restored(Restored {
|
|
id: "acme".into(),
|
|
head: (3, "ab".into()),
|
|
blobs_verified: 2,
|
|
vault_files: 1,
|
|
replaced: Some("acme-20261019-120000".into()),
|
|
origin: Some("acme".into()),
|
|
problem: None,
|
|
}),
|
|
] {
|
|
let text = serde_json::to_string(&r).unwrap();
|
|
assert_eq!(serde_json::from_str::<Response>(&text).unwrap(), r);
|
|
}
|
|
}
|
|
|
|
/// INVARIANT 7: the go-ahead for a format upgrade carries the owner
|
|
/// passphrase as written, and Debug never shows it; a book listed by
|
|
/// an earlier server (no format_upgrade) reads as waiting for none.
|
|
#[test]
|
|
fn an_upgrade_request_carries_the_passphrase_and_never_shows_it() {
|
|
let r: Envelope = serde_json::from_value(json!({"proto": 1, "op": "upgrade_format",
|
|
"id": "acme", "passphrase": "correct horse battery"}))
|
|
.unwrap();
|
|
assert_eq!(
|
|
r.request,
|
|
Request::UpgradeFormat {
|
|
id: "acme".into(),
|
|
passphrase: Passphrase("correct horse battery".into()),
|
|
}
|
|
);
|
|
let shown = format!("{r:?}");
|
|
assert!(!shown.contains("horse"), "{shown}");
|
|
assert!(shown.contains("Passphrase(…)"), "{shown}");
|
|
assert!(
|
|
serde_json::from_value::<Envelope>(
|
|
json!({"proto": 1, "op": "upgrade_format", "id": "acme"})
|
|
)
|
|
.is_err(),
|
|
"an upgrade names the passphrase"
|
|
);
|
|
let listed: BookStatus =
|
|
serde_json::from_value(json!({"id": "acme", "open": true})).unwrap();
|
|
assert_eq!(listed.format_upgrade, None);
|
|
let up = ledger_store::FormatUpgrade {
|
|
from: ledger_store::JournalFormat::default(),
|
|
to: ledger_store::format::current(),
|
|
};
|
|
let waiting = FormatWaiting::from(&up);
|
|
assert_eq!((waiting.format, waiting.adds), (1, up.adds()));
|
|
}
|
|
|
|
#[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}");
|
|
// The text is there, and hidden: never shown as nothing.
|
|
assert!(shown.contains("text: TokenText(…)"), "{shown}");
|
|
let r: Envelope = serde_json::from_value(json!({"proto": 1, "op": "token_list"})).unwrap();
|
|
assert_eq!(r.request, Request::TokenList);
|
|
let r: Envelope = serde_json::from_value(json!({"proto": 1, "op": "token_check",
|
|
"book": "acme", "name": "agent", "sha256": "ab"}))
|
|
.unwrap();
|
|
assert_eq!(
|
|
r.request,
|
|
Request::TokenCheck {
|
|
book: "acme".into(),
|
|
name: "agent".into(),
|
|
sha256: "ab".into()
|
|
}
|
|
);
|
|
assert!(
|
|
serde_json::from_value::<Envelope>(
|
|
json!({"proto": 1, "op": "token_check", "book": "acme", "name": "agent", "text": "wb_x"})
|
|
)
|
|
.is_err(),
|
|
"a check carries no text"
|
|
);
|
|
assert!(
|
|
serde_json::from_value::<Envelope>(
|
|
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"));
|
|
}
|
|
|
|
/// Why an admin call has no answer is what the command line shows.
|
|
#[test]
|
|
fn a_call_error_says_why() {
|
|
assert_eq!(
|
|
CallError::NotSent("the books server isn't running".into()).to_string(),
|
|
"the books server isn't running"
|
|
);
|
|
assert_eq!(
|
|
CallError::Unknown("no answer came".into()).to_string(),
|
|
"no answer came"
|
|
);
|
|
}
|
|
|
|
/// A fake books server on the socket `name` in `dir`: it takes one
|
|
/// request and answers `answer`, byte for byte.
|
|
#[cfg(unix)]
|
|
fn answering(
|
|
dir: &std::path::Path,
|
|
name: &str,
|
|
answer: Vec<u8>,
|
|
) -> (std::path::PathBuf, std::thread::JoinHandle<()>) {
|
|
let socket = dir.join(name);
|
|
let listener = std::os::unix::net::UnixListener::bind(&socket).unwrap();
|
|
let server = std::thread::spawn(move || {
|
|
use std::io::{BufRead, Write};
|
|
let (stream, _) = listener.accept().unwrap();
|
|
let mut request = String::new();
|
|
std::io::BufReader::new(&stream)
|
|
.read_line(&mut request)
|
|
.unwrap();
|
|
// A client that stops reading part way breaks the pipe: fine.
|
|
let _ = (&stream).write_all(&answer);
|
|
});
|
|
(socket, server)
|
|
}
|
|
|
|
/// A refusal padded with spaces to `len` bytes, then its newline.
|
|
fn padded(len: usize) -> Vec<u8> {
|
|
let mut answer = br#"{"refused":"no"}"#.to_vec();
|
|
answer.resize(len, b' ');
|
|
answer.push(b'\n');
|
|
answer
|
|
}
|
|
|
|
/// INVARIANT 1, the command line's side: an answer of 4 MiB, its
|
|
/// newline not counted, is taken whole (settings texts ride in them);
|
|
/// a byte more is refused, as is an answer cut off before its
|
|
/// newline, even when what came parses.
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn an_answer_is_one_line_of_at_most_four_mib() {
|
|
let four_mib = 4 * 1024 * 1024;
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let (socket, server) = answering(dir.path(), "whole.sock", padded(four_mib));
|
|
assert_eq!(
|
|
call(&socket, Request::List),
|
|
Ok(Response::Refused("no".into()))
|
|
);
|
|
server.join().unwrap();
|
|
let too_long_or_cut = Err(CallError::Unknown(
|
|
"the books server's answer is too long, or cut off".into(),
|
|
));
|
|
let (socket, server) = answering(dir.path(), "long.sock", padded(four_mib + 1));
|
|
assert_eq!(call(&socket, Request::List), too_long_or_cut);
|
|
server.join().unwrap();
|
|
let (socket, server) = answering(dir.path(), "cut.sock", br#"{"refused":"no"}"#.into());
|
|
assert_eq!(call(&socket, Request::List), too_long_or_cut);
|
|
server.join().unwrap();
|
|
}
|
|
|
|
/// No server on the socket: the request never left, and the command
|
|
/// line says the server isn't running, whether there is no socket or
|
|
/// one a stopped server left behind.
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn no_server_means_nothing_was_sent() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let not_running = Err(CallError::NotSent("the books server isn't running".into()));
|
|
assert_eq!(
|
|
call(&dir.path().join("admin.sock"), Request::List),
|
|
not_running
|
|
);
|
|
let stale = dir.path().join("stale.sock");
|
|
drop(std::os::unix::net::UnixListener::bind(&stale).unwrap());
|
|
assert_eq!(
|
|
call(
|
|
&stale,
|
|
Request::Create {
|
|
id: "acme".into(),
|
|
currency: "USD".into(),
|
|
}
|
|
),
|
|
not_running
|
|
);
|
|
}
|
|
}
|