Review finding: `--http` parsed whatever address it was given and bound it. The door is the plaintext MCP service with a static bearer token; plan 3.9 and the P9a model put a TLS proxy or tunnel in front of a loopback listener, and no decision authorises exposing the books unencrypted. `--http 0.0.0.0:7411` would have done exactly that. `local::tunnel_address` now accepts only a loopback address (127.0.0.0/8 or ::1; `127.0.0.1:7411` stays the default) and refuses anything else with a message naming loopback and why. `server` refuses while parsing, before it takes the lock or binds a socket; `serve` refuses before it starts a host, so nothing is left half-open. The bind-failure behaviour for a loopback address is unchanged (said, not fatal, 3.9), as is the unit's hardening. The test `the_tunnel_door_binds_loopback_only` refuses 0.0.0.0, a public sample address and [::] through `server`, checks no socket or host was left behind, refuses through `serve`, then starts a loopback listener and reaches `books.start` with the bearer. No accounting code, report or figure changes.
3476 lines
137 KiB
Rust
3476 lines
137 KiB
Rust
//! `wanderbooks server`: every book in the data root, served on local
|
|
//! sockets (plan 3.1, 3.9). The host starts it; nothing else should.
|
|
//!
|
|
//! ```text
|
|
//! GOAL: one process, the books' sole writer, serves every book in the
|
|
//! data root to this account alone, over sockets in the root's
|
|
//! private `run/` folder, and lets go of every book cleanly.
|
|
//! SOURCE: plan/served.md 3.1 (the server's sockets; the admin socket
|
|
//! creates and adopts books, mints tokens, and can't read books),
|
|
//! 3.3 (bearer tokens, scopes), 3.4 (data
|
|
//! layout, run/books.lock), 3.9 (local channels, peer checks,
|
|
//! closing: "no new requests, the one in progress finishes, every
|
|
//! writer lock is released"), D-21 and D-23 (no service: the host
|
|
//! starts and stops this process).
|
|
//! INVARIANTS:
|
|
//! 1. One server per root: it holds `run/server.lock` from before it
|
|
//! opens anything until it exits; a second server waits for the lock
|
|
//! (an old server still finishing after its host died), touching
|
|
//! nothing, and exits if told to close first. The sockets are
|
|
//! replaced only under that lock.
|
|
//! 2. Only this account: a connection to either socket whose peer is
|
|
//! another user ID is dropped before a byte is read.
|
|
//! 3. Every book is opened and verified (rebuilding a drifted database)
|
|
//! before the server says it is ready; one that won't open is
|
|
//! reported with why, on its route and in the list, and the others
|
|
//! are served.
|
|
//! 4. Each book has one store, shared by every session: one writer.
|
|
//! 5. The ready line (stdout, once) comes only after both sockets are
|
|
//! bound and every book has been tried.
|
|
//! 6. Closing (stdin reaches its end, or SIGTERM, SIGINT or SIGHUP): new
|
|
//! requests are refused, every request in progress finishes (admin
|
|
//! ones included), then the sessions end, the sockets are removed,
|
|
//! and the process exits 0, releasing every lock.
|
|
//! 7. Nothing this process sends a client names the data root.
|
|
//! 8. An MCP request reaches a book only with a bearer token that is
|
|
//! live for that book (auth/principals.jsonl; refused 401 without
|
|
//! one, 403 with another book's), and a session only with the token
|
|
//! that opened it (403). A request refused at the door is recorded
|
|
//! in that book's access log; the token rides with the request to
|
|
//! the book's tools, which check its scopes (access.rs). A session is
|
|
//! bound to its token when opened, and forgotten when it ends, is
|
|
//! found gone, or its token is revoked or replaced; past MAX_SESSIONS
|
|
//! bindings a new session is refused (an initialize), never a binding
|
|
//! dropped. Tokens are made, listed, checked (by a
|
|
//! SHA-256, never a text) and revoked only through the admin socket.
|
|
//! 9. A file's bytes come in only by `PUT /b/{id}/u/{upload}` and go
|
|
//! out only by `GET /b/{id}/a/{attachment}` (a stored file) and `GET
|
|
//! /b/{id}/x/{export}` (a file the books made, D-29) (plan 4.7), each
|
|
//! through the same door as 8 and counted for the close like any
|
|
//! request: an upload only from the token that began it, a download
|
|
//! only with books:read, an export only to the token it was made for
|
|
//! (whose tool passed its own scope check; no other is asked). Each,
|
|
//! refused at the door or not, is recorded in the book's access log
|
|
//! as `upload`, `download` or `export` with its id, and the
|
|
//! bytes (kept, or read) and their record are one task that runs to
|
|
//! its end if the client hangs up (a process killed in that task
|
|
//! excepted); bytes whose record can't be written aren't kept, and
|
|
//! the books stop. Lapsed uploads and exports are swept every
|
|
//! SWEEP_EVERY. Nothing comes in or goes out once the books stopped.
|
|
//! ```
|
|
|
|
use crate::admin::{self, Request, Response};
|
|
use crate::server::{BooksServer, Isolation, Served, SharedStore};
|
|
use ledger_store::Store;
|
|
use ledger_store::access::{Access, AccessLog, Outcome, Principal};
|
|
use ledger_store::auth::{Principals, Scope, Token};
|
|
use ledger_store::root::{BookId, Root, RootError};
|
|
use rmcp::transport::streamable_http_server::{
|
|
StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
|
|
};
|
|
use std::collections::BTreeMap;
|
|
use std::os::fd::AsRawFd;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
use std::sync::{Arc, Mutex, RwLock};
|
|
use std::time::{Duration, Instant};
|
|
use tokio_util::sync::CancellationToken;
|
|
use tokio_util::task::TaskTracker;
|
|
|
|
/// The server's MCP socket, in the root's `run/`.
|
|
pub const MCP_SOCKET: &str = "mcp.sock";
|
|
/// The server's admin socket, in the root's `run/`.
|
|
pub const ADMIN_SOCKET: &str = "admin.sock";
|
|
/// Held by the one server for a root (INVARIANT 1).
|
|
pub const SERVER_LOCK: &str = "server.lock";
|
|
/// What the server prints on stdout when it is ready (INVARIANT 5).
|
|
pub const READY: &str = "ready";
|
|
|
|
/// How large an MCP request may be (plan 3.2): files come only through
|
|
/// uploads (`/b/{book}/u/{upload}`), never in a request (D-46).
|
|
pub const MAX_BODY: usize = 1024 * 1024;
|
|
|
|
/// How long the admin socket waits for a request to arrive.
|
|
const ADMIN_READ_TIMEOUT: Duration = Duration::from_secs(30);
|
|
|
|
use ledger_store::root::THE_ROOT;
|
|
|
|
/// The header on a request refused before it could run (books that
|
|
/// didn't open, books that don't exist): a client knows it didn't run.
|
|
pub const REFUSED_HEADER: &str = "x-wanderbooks-refused";
|
|
|
|
/// How often each book's lapsed uploads are swept.
|
|
const SWEEP_EVERY: Duration = Duration::from_secs(10);
|
|
/// How long the books stay unwritten before they are backed up.
|
|
const BACKUP_QUIET: Duration = Duration::from_secs(30);
|
|
/// How often the books are looked at for a backup due.
|
|
const BACKUP_LOOK: Duration = Duration::from_secs(2);
|
|
/// How often each book is looked at for a drill due.
|
|
const DRILL_LOOK: Duration = Duration::from_secs(3600);
|
|
/// How long after books are first seen open their first drill is looked
|
|
/// for (a drill that fell due while they were closed): long enough that a
|
|
/// backup on opening comes first.
|
|
const DRILL_FIRST: Duration = Duration::from_secs(60);
|
|
/// The most the last backup at a close may take.
|
|
const BACKUP_AT_CLOSE: Duration = Duration::from_secs(60);
|
|
|
|
type Service = StreamableHttpService<BooksServer, LocalSessionManager>;
|
|
|
|
/// A book the server holds.
|
|
#[derive(Clone)]
|
|
enum Book {
|
|
/// Open and served.
|
|
Open {
|
|
/// Its MCP service (boxed: the other variant is a line of text).
|
|
service: Box<Service>,
|
|
/// Its store, closed when the server closes (INVARIANT 6).
|
|
store: Arc<SharedStore>,
|
|
/// Its access log, where requests refused at the door are
|
|
/// recorded too (access.rs).
|
|
access: Arc<AccessLog>,
|
|
/// Its files on their way in (`/b/{id}/u/{upload}`).
|
|
uploads: ledger_store::uploads::Uploads,
|
|
/// The files it made for agents (`/b/{id}/x/{export}`).
|
|
exports: ledger_store::exports::Exports,
|
|
/// Its backups (backup.rs).
|
|
backups: Arc<crate::backup::Backups>,
|
|
/// The format upgrade it waits for, if any (plan 3.9, D-39): read
|
|
/// from its journal as it opened, and cleared by the upgrade (only
|
|
/// the upgrade moves an open book's format).
|
|
waiting: Arc<Mutex<Option<ledger_store::FormatUpgrade>>>,
|
|
/// Its projection database's file, and other schemas' beside it,
|
|
/// by name, as it opened (`wanderbooks doctor` says them).
|
|
database: (String, Vec<String>),
|
|
},
|
|
/// Not opened, with why (INVARIANT 3).
|
|
Failed(String),
|
|
}
|
|
|
|
impl Book {
|
|
/// Why it isn't open, if it isn't.
|
|
fn problem(&self) -> Option<String> {
|
|
match self {
|
|
Book::Open { .. } => None,
|
|
Book::Failed(why) => Some(why.clone()),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Every book the server holds, and what serving them needs.
|
|
struct Books {
|
|
root: Root,
|
|
held: RwLock<BTreeMap<String, Book>>,
|
|
/// Ends every MCP session (INVARIANT 6, last).
|
|
sessions: CancellationToken,
|
|
/// Set when the server starts closing: new requests are refused.
|
|
closing: CancellationToken,
|
|
/// MCP requests in progress (not counting event streams).
|
|
in_flight: AtomicUsize,
|
|
/// When the last MCP request arrived (milliseconds since the server
|
|
/// started): closing lingers until none has for a while.
|
|
last_arrival: std::sync::atomic::AtomicU64,
|
|
/// When the server started, for `last_arrival`.
|
|
started: std::time::Instant,
|
|
/// Woken when one finishes.
|
|
finished: tokio::sync::Notify,
|
|
/// The agents' tokens (auth/principals.jsonl), or why they can't be
|
|
/// read: then every MCP request is refused (INVARIANT 8).
|
|
principals: RwLock<Result<Principals, String>>,
|
|
/// Which token opened each MCP session: a session is used only with
|
|
/// the token that opened it (INVARIANT 8).
|
|
session_tokens: Mutex<BTreeMap<String, String>>,
|
|
/// How long the books stay unwritten before a backup is made
|
|
/// (`--backup-quiet`, for tests; else BACKUP_QUIET).
|
|
backup_quiet: Duration,
|
|
/// How long after books are first seen open a drill is first looked
|
|
/// for (`--drill-first`, for tests; else DRILL_FIRST).
|
|
drill_first: Duration,
|
|
/// Held through a restore: one at a time.
|
|
restoring: Mutex<()>,
|
|
/// How these books are kept from agents (plan 3.8, P9a): what
|
|
/// `books.start` reports (`--isolation`).
|
|
isolation: Isolation,
|
|
}
|
|
|
|
impl Books {
|
|
/// Take the root's path out of a message (INVARIANT 7).
|
|
fn hide(&self, text: impl Into<String>) -> String {
|
|
let mut text = text.into();
|
|
let path = self.root.path();
|
|
let real = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
|
|
for p in [path, real.as_path()] {
|
|
text = text.replace(&p.display().to_string(), THE_ROOT);
|
|
}
|
|
text
|
|
}
|
|
|
|
/// Change or read the principals; a log that failed to write is read
|
|
/// again before anything else is done with it (chain.rs INVARIANT 2).
|
|
fn with_principals(
|
|
&self,
|
|
f: impl FnOnce(&mut Principals) -> Result<Response, ledger_store::auth::AuthError>,
|
|
) -> Response {
|
|
let mut guard = self.principals.write().unwrap_or_else(|e| e.into_inner());
|
|
let p = match guard.as_mut() {
|
|
Ok(p) => p,
|
|
Err(why) => return Response::Refused(why.clone()),
|
|
};
|
|
match f(p) {
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
if matches!(e, ledger_store::auth::AuthError::Log(_)) {
|
|
*guard = self.load_principals();
|
|
}
|
|
Response::Refused(self.hide(e.to_string()))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Forget the sessions a token opened (it was revoked or replaced:
|
|
/// they can't be used again, and won't be ended by it).
|
|
fn forget_sessions_of(&self, token_id: &str) {
|
|
self.session_tokens
|
|
.lock()
|
|
.unwrap_or_else(|e| e.into_inner())
|
|
.retain(|_, owner| owner != token_id);
|
|
}
|
|
|
|
/// Read the principals from the root's `auth/`.
|
|
fn load_principals(&self) -> Result<Principals, String> {
|
|
let dir = self.root.auth_dir().map_err(|e| e.to_string())?;
|
|
Principals::open(&dir).map_err(|e| self.hide(e.to_string()))
|
|
}
|
|
|
|
/// The live token whose text a request carries as its bearer, or why
|
|
/// it is refused (INVARIANT 8): the code for the access log and the
|
|
/// words for the client.
|
|
fn bearer(&self, headers: &axum::http::HeaderMap) -> Result<Token, (&'static str, String)> {
|
|
// The scheme's name in any case (RFC 9110 11.1).
|
|
let presented = headers
|
|
.get(axum::http::header::AUTHORIZATION)
|
|
.and_then(|v| v.to_str().ok())
|
|
.and_then(|v| v.split_once(' '))
|
|
.filter(|(scheme, _)| scheme.eq_ignore_ascii_case("bearer"))
|
|
.map(|(_, token)| token.trim());
|
|
let Some(presented) = presented else {
|
|
return Err((
|
|
crate::access::UNAUTHORIZED,
|
|
"these books need a token, and this request carried none: the human makes one with `wanderbooks token create` (if one was made, `wanderbooks token check` says whether this computer's keyring still gives it)"
|
|
.into(),
|
|
));
|
|
};
|
|
let guard = self.principals.read().unwrap_or_else(|e| e.into_inner());
|
|
match guard.as_ref() {
|
|
Err(why) => Err((crate::access::UNAUTHORIZED, why.clone())),
|
|
Ok(p) => p.check(presented).cloned().ok_or((
|
|
crate::access::UNAUTHORIZED,
|
|
"that token isn't one these books know, or it was revoked".into(),
|
|
)),
|
|
}
|
|
}
|
|
|
|
/// Open the book `id` from disk: served, or failed with why.
|
|
fn open(&self, id: &BookId) -> Book {
|
|
let opened = match Store::open(&self.root.book_dir(id)) {
|
|
Ok(store) => self.serve(store),
|
|
Err(ledger_store::StoreError::Locked(_)) => {
|
|
Err("another program has these books open".to_string())
|
|
}
|
|
Err(e) => Err(self.hide(e.to_string())),
|
|
};
|
|
opened.unwrap_or_else(|why| {
|
|
eprintln!("wanderbooks server: the books {id} were not opened: {why}");
|
|
Book::Failed(why)
|
|
})
|
|
}
|
|
|
|
/// Verify an open store and build its service (INVARIANTS 3, 4).
|
|
fn serve(&self, mut store: Store) -> Result<Book, String> {
|
|
let report = store
|
|
.verify_and_heal()
|
|
.map_err(|e| format!("they don't verify: {}", self.hide(e.to_string())))?;
|
|
if let Some(why) = &report.healed {
|
|
eprintln!(
|
|
"wanderbooks server: rebuilt a database from its journal ({})",
|
|
self.hide(why.clone())
|
|
);
|
|
}
|
|
let waiting = Arc::new(Mutex::new(store.journal_format().upgrade()));
|
|
let database = (store.database_file(), store.other_databases());
|
|
let served = Served::open_with(store, self.isolation).map_err(|e| self.hide(e))?;
|
|
let config = StreamableHttpServerConfig::default()
|
|
.with_json_response(true)
|
|
.with_legacy_session_mode(true)
|
|
.with_max_request_body_bytes(MAX_BODY)
|
|
.enforce_origin_validation()
|
|
.with_cancellation_token(self.sessions.child_token());
|
|
let parts = served.clone();
|
|
// Every session's server is made the one way (server.rs Served).
|
|
let service = StreamableHttpService::new(
|
|
move || Ok(BooksServer::new(&parts)),
|
|
Arc::new(sessions()),
|
|
config,
|
|
);
|
|
let Served {
|
|
store: shared,
|
|
access,
|
|
uploads,
|
|
exports,
|
|
backups,
|
|
..
|
|
} = served;
|
|
Ok(Book::Open {
|
|
service: Box::new(service),
|
|
store: shared,
|
|
access,
|
|
uploads,
|
|
exports,
|
|
backups,
|
|
waiting,
|
|
database,
|
|
})
|
|
}
|
|
|
|
/// Every open book's store.
|
|
fn stores(&self) -> Vec<Arc<SharedStore>> {
|
|
self.held()
|
|
.values()
|
|
.filter_map(|b| match b {
|
|
Book::Open { store, .. } => Some(store.clone()),
|
|
Book::Failed(_) => None,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn held(&self) -> std::sync::RwLockReadGuard<'_, BTreeMap<String, Book>> {
|
|
self.held.read().unwrap_or_else(|e| e.into_inner())
|
|
}
|
|
|
|
fn hold(&self, id: &BookId, book: Book) {
|
|
self.held
|
|
.write()
|
|
.unwrap_or_else(|e| e.into_inner())
|
|
.insert(id.to_string(), book);
|
|
}
|
|
|
|
/// Answer an admin request (blocking: it runs on its own thread).
|
|
fn admin(&self, request: Request) -> Response {
|
|
let refused = |e: RootError| Response::Refused(self.hide(e.to_string()));
|
|
match request {
|
|
Request::List => Response::Books(
|
|
self.held()
|
|
.iter()
|
|
.map(|(id, book)| admin::BookStatus {
|
|
id: id.clone(),
|
|
open: matches!(book, Book::Open { .. }),
|
|
problem: match book {
|
|
Book::Open { .. } => None,
|
|
Book::Failed(why) => Some(why.clone()),
|
|
},
|
|
format_upgrade: match book {
|
|
Book::Open { waiting, .. } => waiting
|
|
.lock()
|
|
.unwrap_or_else(|e| e.into_inner())
|
|
.as_ref()
|
|
.map(admin::FormatWaiting::from),
|
|
Book::Failed(_) => None,
|
|
},
|
|
database: match book {
|
|
Book::Open { database, .. } => Some(database.0.clone()),
|
|
Book::Failed(_) => None,
|
|
},
|
|
other_databases: match book {
|
|
Book::Open { database, .. } => database.1.clone(),
|
|
Book::Failed(_) => Vec::new(),
|
|
},
|
|
})
|
|
.collect(),
|
|
),
|
|
Request::TokenCreate {
|
|
book,
|
|
name,
|
|
scopes,
|
|
replace,
|
|
} => {
|
|
let book = match BookId::parse(&book) {
|
|
Ok(id) => id,
|
|
Err(e) => return refused(e),
|
|
};
|
|
if !self.held().contains_key(book.as_str()) {
|
|
return Response::Refused(format!(
|
|
"there are no books called {book}: `wanderbooks books list` lists them"
|
|
));
|
|
}
|
|
let mut wanted = std::collections::BTreeSet::new();
|
|
for s in &scopes {
|
|
match Scope::parse(s) {
|
|
Some(scope) => {
|
|
wanted.insert(scope);
|
|
}
|
|
None => {
|
|
return Response::Refused(format!(
|
|
"{:?} isn't a scope: use books:read, books:write, books:post or books:admin",
|
|
s.chars().take(64).collect::<String>()
|
|
));
|
|
}
|
|
}
|
|
}
|
|
let mut replaced: Option<String> = None;
|
|
let answer = self.with_principals(|p| {
|
|
replaced = p
|
|
.tokens()
|
|
.iter()
|
|
.find(|t| t.book == book.as_str() && t.name == name)
|
|
.map(|t| t.id.clone())
|
|
.filter(|_| replace);
|
|
p.create(&book, &name, wanted, replace)
|
|
.map(|(token, text)| Response::TokenCreated {
|
|
token: admin::TokenInfo::from(&token),
|
|
text: admin::TokenText(text.expose().to_string()),
|
|
})
|
|
});
|
|
if let (Response::TokenCreated { .. }, Some(old)) = (&answer, &replaced) {
|
|
self.forget_sessions_of(old);
|
|
}
|
|
answer
|
|
}
|
|
Request::TokenList => self.with_principals(|p| {
|
|
Ok(Response::Tokens(
|
|
p.tokens().iter().map(admin::TokenInfo::from).collect(),
|
|
))
|
|
}),
|
|
Request::TokenCheck { book, name, sha256 } => self.with_principals(|p| {
|
|
let live = p.tokens().iter().find(|t| t.book == book && t.name == name);
|
|
// A hash, not a secret: compared as it is.
|
|
let matches = live.is_some_and(|t| t.sha256.eq_ignore_ascii_case(&sha256));
|
|
Ok(Response::TokenChecked {
|
|
live: live.map(admin::TokenInfo::from),
|
|
matches,
|
|
})
|
|
}),
|
|
Request::TokenRevoke { book, name, id } => {
|
|
let book = match BookId::parse(&book) {
|
|
Ok(id) => id,
|
|
Err(e) => return refused(e),
|
|
};
|
|
let answer = self.with_principals(|p| {
|
|
p.revoke_if(&book, &name, id.as_deref())
|
|
.map(|t| Response::TokenRevoked(admin::TokenInfo::from(&t)))
|
|
});
|
|
if let Response::TokenRevoked(t) = &answer {
|
|
self.forget_sessions_of(&t.id);
|
|
}
|
|
answer
|
|
}
|
|
Request::Create { id, currency } => {
|
|
let id = match BookId::parse(&id) {
|
|
Ok(id) => id,
|
|
Err(e) => return refused(e),
|
|
};
|
|
let Ok(currency) = ledger_core::Currency::new(currency.trim()) else {
|
|
return Response::Refused(
|
|
"that isn't a currency: give its three-letter ISO code, such as USD".into(),
|
|
);
|
|
};
|
|
if self.held().contains_key(id.as_str()) {
|
|
return refused(RootError::Exists(id.to_string()));
|
|
}
|
|
match self.root.create_book(&id, currency) {
|
|
Ok(store) => {
|
|
// Made: kept whether or not it opens (INVARIANT 3).
|
|
let book = self.serve(store).unwrap_or_else(Book::Failed);
|
|
let problem = book.problem();
|
|
self.hold(&id, book);
|
|
Response::Created {
|
|
id: id.to_string(),
|
|
problem,
|
|
}
|
|
}
|
|
Err(e) => refused(e),
|
|
}
|
|
}
|
|
Request::Restore {
|
|
id,
|
|
source,
|
|
replace,
|
|
confirmed,
|
|
expect_head,
|
|
} => self.restore(id, source, replace, confirmed, expect_head),
|
|
Request::UpgradeFormat { id, passphrase } => self.upgrade_format(id, &passphrase),
|
|
Request::BackUpNow { id, new_disk } => {
|
|
let id = match BookId::parse(&id) {
|
|
Ok(id) => id,
|
|
Err(e) => return refused(e),
|
|
};
|
|
let backups = match self.held().get(id.as_str()) {
|
|
Some(Book::Open { backups, .. }) => backups.clone(),
|
|
Some(Book::Failed(why)) => {
|
|
return Response::Refused(format!("those books didn't open: {why}"));
|
|
}
|
|
None => {
|
|
return Response::Refused(format!(
|
|
"there are no books called {:?}",
|
|
id.as_str()
|
|
));
|
|
}
|
|
};
|
|
let rt = tokio::runtime::Handle::current();
|
|
match rt.block_on(backups.run_with("asked", new_disk)) {
|
|
Ok(status) => Response::BackedUp {
|
|
head_seq: status.head_seq,
|
|
targets: status
|
|
.targets
|
|
.into_iter()
|
|
.map(|t| admin::BackedUpTarget {
|
|
target: t.target,
|
|
at: t.at,
|
|
through_seq: t.through_seq,
|
|
problem: t.last_error,
|
|
})
|
|
.collect(),
|
|
},
|
|
Err(why) => Response::Refused(self.hide(why)),
|
|
}
|
|
}
|
|
Request::Adopt {
|
|
id,
|
|
source,
|
|
settings_toml,
|
|
config_json,
|
|
vault_dir,
|
|
vault_key,
|
|
confirmed,
|
|
expect_head,
|
|
skip_unknown,
|
|
} => {
|
|
let id = match BookId::parse(&id) {
|
|
Ok(id) => id,
|
|
Err(e) => return refused(e),
|
|
};
|
|
if self.held().contains_key(id.as_str()) {
|
|
return refused(RootError::Exists(id.to_string()));
|
|
}
|
|
let found = match self.root.adopt_preview(&source) {
|
|
Ok(found) => found,
|
|
Err(e) => return refused(e),
|
|
};
|
|
if vault_dir.is_some() != vault_key.is_some() {
|
|
return Response::Refused(
|
|
"a vault comes with its key: give both the vault folder and its key file, or neither"
|
|
.into(),
|
|
);
|
|
}
|
|
if confirmed && expect_head.is_none() {
|
|
// admin INVARIANT 4: a confirm names what it confirms.
|
|
return Response::Refused(
|
|
"an adoption is confirmed against the preview's head: preview first".into(),
|
|
);
|
|
}
|
|
let import = match (&settings_toml, found.keeps_settings) {
|
|
(Some(toml), false) => {
|
|
// The key the file names matters only with a vault
|
|
// to open.
|
|
let key = vault_dir.as_ref().and(vault_key.as_deref());
|
|
match settings_to_move(
|
|
toml,
|
|
config_json.as_deref(),
|
|
found.currency.code(),
|
|
vault_dir.is_some(),
|
|
key,
|
|
) {
|
|
Ok(import) => Some(import),
|
|
Err(why) => return Response::Refused(why),
|
|
}
|
|
}
|
|
(None, false) => {
|
|
return Response::Refused(
|
|
"these books keep no settings of their own yet: give their settings file (--toml), and its settings are moved in as they are adopted"
|
|
.into(),
|
|
);
|
|
}
|
|
_ => None,
|
|
};
|
|
let vault_records = match (&vault_dir, &vault_key) {
|
|
(Some(dir), Some(key)) => match vault_opens(dir, key) {
|
|
Ok(n) => n,
|
|
Err(why) => return Response::Refused(why),
|
|
},
|
|
_ => 0,
|
|
};
|
|
if !confirmed {
|
|
let i = import.unwrap_or_default();
|
|
return Response::Preview(admin::AdoptPreview {
|
|
source_head: found.head,
|
|
keeps_settings: found.keeps_settings,
|
|
settings: i
|
|
.changes
|
|
.iter()
|
|
.map(|c| admin::SettingChange {
|
|
key: c.key.clone(),
|
|
value: c.value.clone(),
|
|
})
|
|
.collect(),
|
|
notes: i.notes,
|
|
unknown: i.unknown,
|
|
comments: i.comments,
|
|
vault_records,
|
|
});
|
|
}
|
|
// admin INVARIANT 5: nothing dropped unseen.
|
|
if let Some(i) = &import
|
|
&& !i.unknown.is_empty()
|
|
&& !skip_unknown
|
|
{
|
|
return Response::Refused(format!(
|
|
"the settings file has entries the books don't know, which wouldn't move in: {}. Take them out, or adopt with --skip-unknown to leave them behind",
|
|
i.unknown.join(", ")
|
|
));
|
|
}
|
|
let options = ledger_store::root::AdoptOptions {
|
|
settings: match import {
|
|
Some(i) => {
|
|
let source = moved_in_at_adoption(&i.changes);
|
|
Some((i.changes, source))
|
|
}
|
|
// Given for books that keep their own: passed on so
|
|
// the report says they weren't used.
|
|
None if settings_toml.is_some() => {
|
|
Some((Vec::new(), moved_in_at_adoption(&[])))
|
|
}
|
|
None => None,
|
|
},
|
|
vault_dir,
|
|
vault_key,
|
|
expect_head,
|
|
};
|
|
match self.root.adopt(&source, &id, &options) {
|
|
Ok(r) => {
|
|
// Adopted: kept whether or not it opens.
|
|
let book = self.open(&id);
|
|
let problem = book.problem();
|
|
self.hold(&id, book);
|
|
Response::Adopted(admin::Adopted {
|
|
id: r.id.to_string(),
|
|
source_head: r.source_head,
|
|
head: r.head,
|
|
blob_files: r.blob_files,
|
|
blobs_verified: r.blobs_verified,
|
|
unnamed_blobs: r.unnamed_blobs,
|
|
skipped_files: r.skipped_files,
|
|
vault_files: r.vault_files,
|
|
key_copied: r.key_copied,
|
|
settings_imported: r.settings_imported,
|
|
settings_ignored: r.settings_ignored,
|
|
source_lock_created: r.source_lock_created,
|
|
format_upgraded: r.format_upgraded,
|
|
problem,
|
|
})
|
|
}
|
|
Err(e) => refused(e),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Books {
|
|
/// The owner's go-ahead for a format upgrade (plan 3.9, D-39; admin
|
|
/// INVARIANT 7): the owner passphrase checked (Argon2id, as the human
|
|
/// pages check it) before anything is done; then a verified backup
|
|
/// with the keys and the upgrade, under the books' lock
|
|
/// (backup::Backups::upgrade_format). Books already in this version's
|
|
/// format: nothing is done, and the answer says so.
|
|
fn upgrade_format(&self, id: String, passphrase: &admin::Passphrase) -> Response {
|
|
let id = match BookId::parse(&id) {
|
|
Ok(id) => id,
|
|
Err(e) => return Response::Refused(self.hide(e.to_string())),
|
|
};
|
|
let (backups, waiting) = match self.held().get(id.as_str()) {
|
|
Some(Book::Open {
|
|
backups, waiting, ..
|
|
}) => (backups.clone(), waiting.clone()),
|
|
Some(Book::Failed(why)) => {
|
|
return Response::Refused(format!("those books didn't open: {why}"));
|
|
}
|
|
None => {
|
|
return Response::Refused(format!(
|
|
"there are no books called {:?}: `wanderbooks books list` lists them",
|
|
id.as_str()
|
|
));
|
|
}
|
|
};
|
|
let auth = match self.root.auth_dir() {
|
|
Ok(dir) => dir,
|
|
Err(e) => return Response::Refused(self.hide(e.to_string())),
|
|
};
|
|
match ledger_store::passphrase::verify(&auth, &passphrase.0) {
|
|
Ok(true) => {}
|
|
Ok(false) => {
|
|
return Response::Refused(
|
|
"that isn't the owner passphrase: nothing was changed".into(),
|
|
);
|
|
}
|
|
Err(e) => {
|
|
return Response::Refused(format!(
|
|
"{}: nothing was changed",
|
|
self.hide(e.to_string())
|
|
));
|
|
}
|
|
}
|
|
let rt = tokio::runtime::Handle::current();
|
|
match rt.block_on(backups.upgrade_format()) {
|
|
Ok(done) => {
|
|
// The books are in this version's format now (or were).
|
|
*waiting.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
|
if let Some(up) = &done.upgrade {
|
|
eprintln!(
|
|
"wanderbooks server: the books {id} were upgraded to this version's journal format ({}), after a verified backup",
|
|
up.adds().join(", ")
|
|
);
|
|
}
|
|
Response::FormatUpgraded(admin::FormatUpgraded {
|
|
id: id.to_string(),
|
|
added: done.upgrade.map(|u| u.adds()).unwrap_or_default(),
|
|
backup: done.backup.map(|b| admin::UpgradeBackup {
|
|
commit: b.commit,
|
|
head: b.head,
|
|
}),
|
|
head: done.head,
|
|
})
|
|
}
|
|
Err(why) => Response::Refused(self.hide(why)),
|
|
}
|
|
}
|
|
|
|
/// Restore a backup (admin INVARIANT 4; ledger_store::root INVARIANT
|
|
/// 7): a preview, or the books restored and opened. Books it replaces
|
|
/// are let go first, and opened again if the restore fails.
|
|
fn restore(
|
|
&self,
|
|
id: String,
|
|
source: String,
|
|
replace: bool,
|
|
confirmed: bool,
|
|
expect_head: Option<(u64, String)>,
|
|
) -> Response {
|
|
// admin INVARIANT 3: never the root, nor the address given.
|
|
let said = |text: String| {
|
|
let text = if source.chars().count() > 1 {
|
|
text.replace(&source, "the backup given")
|
|
} else {
|
|
text
|
|
};
|
|
Response::Refused(self.hide(text))
|
|
};
|
|
let id = match BookId::parse(&id) {
|
|
Ok(id) => id,
|
|
Err(e) => return said(e.to_string()),
|
|
};
|
|
let _one = self.restoring.lock().unwrap_or_else(|e| e.into_inner());
|
|
let git = match ledger_store::backup::Git::find() {
|
|
Ok(git) => git,
|
|
Err(e) => return said(e.to_string()),
|
|
};
|
|
// A preview only reports (whether books of that id exist among
|
|
// what it says): the restore itself is refused over books, or
|
|
// replacing none.
|
|
if !confirmed {
|
|
return match self.root.restore_preview(&git, &source, &id) {
|
|
Ok(p) => Response::RestorePreview(admin::RestorePreview {
|
|
head: p.head,
|
|
blobs: p.blobs,
|
|
vault_files: p.vault_files,
|
|
exists: p.exists,
|
|
origin: p.origin,
|
|
}),
|
|
Err(e) => said(e.to_string()),
|
|
};
|
|
}
|
|
let Some(expect_head) = expect_head else {
|
|
return said("a restore is confirmed against the preview's head: preview first".into());
|
|
};
|
|
let held = self.held().contains_key(id.as_str());
|
|
if replace && !held {
|
|
return said(format!(
|
|
"there are no books called {:?} to replace: nothing was restored",
|
|
id.as_str()
|
|
));
|
|
}
|
|
if !replace && held {
|
|
return said(format!(
|
|
"there are already books called {:?}: restore them under another id (--id), or replace them (--replace; they are kept in legacy/)",
|
|
id.as_str()
|
|
));
|
|
}
|
|
// The books replaced are let go (their writer lock released),
|
|
// their backups idle until the restore is done: none of theirs
|
|
// reaches a target after the backup is read.
|
|
let old = if replace {
|
|
self.held
|
|
.write()
|
|
.unwrap_or_else(|e| e.into_inner())
|
|
.remove(id.as_str())
|
|
} else {
|
|
None
|
|
};
|
|
let old_backups = match &old {
|
|
Some(Book::Open { backups, .. }) => Some(backups.clone()),
|
|
_ => None,
|
|
};
|
|
let rt = tokio::runtime::Handle::current();
|
|
// Its requests end first (a books.back_up_now among them waits for
|
|
// a run of the loop's), then its last run.
|
|
if let Some(Book::Open { store, .. }) = &old {
|
|
rt.block_on(store.let_go());
|
|
}
|
|
let _paused = old_backups.as_ref().map(|b| rt.block_on(b.pause()));
|
|
match self.root.restore(&git, &source, &id, &expect_head, replace) {
|
|
Ok(r) => {
|
|
// Restored: kept whether or not it opens; its backups told
|
|
// whose books these were (backup.rs INVARIANTS 6, 7).
|
|
let book = self.open(&id);
|
|
let problem = book.problem();
|
|
self.hold(&id, book);
|
|
Response::Restored(admin::Restored {
|
|
id: r.id.to_string(),
|
|
head: r.head,
|
|
blobs_verified: r.blobs_verified,
|
|
vault_files: r.vault_files,
|
|
replaced: r.replaced,
|
|
origin: r.origin,
|
|
problem,
|
|
})
|
|
}
|
|
Err(e) => {
|
|
// The books it would have replaced, let go above, open
|
|
// again (only this restore runs, so nothing else holds them).
|
|
if replace && !self.held().contains_key(id.as_str()) {
|
|
let book = self.open(&id);
|
|
self.hold(&id, book);
|
|
}
|
|
said(e.to_string())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The settings a settings file moves in at adoption, checked: they
|
|
/// parse, they apply, and the vault key file it names (which the books
|
|
/// don't keep) is the one given.
|
|
fn settings_to_move(
|
|
toml: &str,
|
|
config_json: Option<&str>,
|
|
currency: &str,
|
|
with_vault: bool,
|
|
vault_key: Option<&Path>,
|
|
) -> Result<ledger_config::codec::Import, String> {
|
|
let import = ledger_config::codec::import(
|
|
toml,
|
|
config_json,
|
|
&ledger_core::settings::Settings::default(),
|
|
currency,
|
|
)
|
|
.map_err(|e| format!("the settings file can't be moved in: {e}"))?;
|
|
let mut after = ledger_core::settings::Settings::default();
|
|
after
|
|
.apply(
|
|
&import.changes,
|
|
&moved_in_at_adoption(&import.changes),
|
|
u64::MAX,
|
|
)
|
|
.map_err(|e| format!("the settings file can't be moved in: {e}"))?;
|
|
if let Some(named) = import.vault_key_file.as_ref().filter(|_| with_vault) {
|
|
let same = vault_key.is_some_and(|given| {
|
|
match (std::fs::canonicalize(named), std::fs::canonicalize(given)) {
|
|
(Ok(a), Ok(b)) => a == b,
|
|
_ => false,
|
|
}
|
|
});
|
|
if !same {
|
|
return Err(
|
|
"the settings file names the TIN vault's key file ([vault] key_file), which the books don't keep: adopt with --vault-key naming that same file, or the vault wouldn't open"
|
|
.into(),
|
|
);
|
|
}
|
|
}
|
|
Ok(import)
|
|
}
|
|
|
|
/// Check that `key` opens every record in the vault folder `dir`; how
|
|
/// many there are.
|
|
fn vault_opens(dir: &Path, key: &Path) -> Result<usize, String> {
|
|
let vault = ledger_vault::Vault::open(dir, key, false).map_err(|_| {
|
|
"the vault key given isn't one this vault can use (64 hex digits): nothing was adopted"
|
|
.to_string()
|
|
})?;
|
|
let entries = std::fs::read_dir(dir)
|
|
.map_err(|e| format!("the vault folder given can't be read: {}", e.kind()))?;
|
|
let mut records = 0;
|
|
for entry in entries {
|
|
let entry = entry.map_err(|e| format!("the vault folder can't be read: {}", e.kind()))?;
|
|
let name = entry.file_name().to_string_lossy().into_owned();
|
|
let Some(stem) = name.strip_suffix(".bin") else {
|
|
continue;
|
|
};
|
|
let opened = match stem.split_once('-') {
|
|
Some(("tin", n)) => n.parse().ok().map(|n| vault.tin(n).map(|_| ())),
|
|
Some(("w9", n)) => n.parse().ok().map(|n| vault.w9(n).map(|_| ())),
|
|
_ => None,
|
|
};
|
|
match opened {
|
|
Some(Ok(())) => records += 1,
|
|
Some(Err(_)) => {
|
|
return Err(
|
|
"the vault key given doesn't open the vault's records: give the key they were sealed with; nothing was adopted"
|
|
.into(),
|
|
);
|
|
}
|
|
None => {}
|
|
}
|
|
}
|
|
Ok(records)
|
|
}
|
|
|
|
/// How long a session may go without a message before it ends: far
|
|
/// longer than any call (rmcp's idle timer doesn't count a call in
|
|
/// progress, so a call longer than it would lose its answer while its work
|
|
/// went on). It only bounds sessions whose client vanished without ending
|
|
/// them; the bridge ends its own, even when signalled.
|
|
const SESSION_IDLE: Duration = Duration::from_secs(24 * 60 * 60);
|
|
|
|
/// The book's MCP sessions (see SESSION_IDLE).
|
|
fn sessions() -> LocalSessionManager {
|
|
let mut sessions = LocalSessionManager::default();
|
|
sessions.session_config.keep_alive = Some(SESSION_IDLE);
|
|
sessions
|
|
}
|
|
|
|
/// Who and when, for settings moved in from a file at adoption: dated in
|
|
/// the company's time zone when the file sets one.
|
|
fn moved_in_at_adoption(
|
|
changes: &[ledger_core::settings::Change],
|
|
) -> ledger_core::settings::Source {
|
|
let zone = changes
|
|
.iter()
|
|
.find(|c| c.key == "company.timezone")
|
|
.and_then(|c| c.value.as_ref())
|
|
.and_then(|v| v.as_str())
|
|
.map(str::to_string);
|
|
ledger_core::settings::Source {
|
|
decided_by: "the settings file".into(),
|
|
decided_on: ledger_core::settings::today_in(zone.as_deref()),
|
|
citation: None,
|
|
note: Some(
|
|
"moved in by the owner when the books were adopted (wanderbooks books adopt)".into(),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// The user ID at the other end of a Unix socket.
|
|
pub fn peer_uid(fd: &impl AsRawFd) -> std::io::Result<u32> {
|
|
let fd = fd.as_raw_fd();
|
|
#[cfg(any(target_os = "linux", target_os = "android"))]
|
|
{
|
|
let mut cred = libc::ucred {
|
|
pid: 0,
|
|
uid: 0,
|
|
gid: 0,
|
|
};
|
|
let mut len = std::mem::size_of::<libc::ucred>() as libc::socklen_t;
|
|
// SAFETY: `cred` and `len` are valid for writes of the sizes given.
|
|
let rc = unsafe {
|
|
libc::getsockopt(
|
|
fd,
|
|
libc::SOL_SOCKET,
|
|
libc::SO_PEERCRED,
|
|
(&mut cred as *mut libc::ucred).cast(),
|
|
&mut len,
|
|
)
|
|
};
|
|
if rc != 0 {
|
|
return Err(std::io::Error::last_os_error());
|
|
}
|
|
if len as usize != std::mem::size_of::<libc::ucred>() {
|
|
return Err(std::io::Error::other(
|
|
"the peer's credentials came back short",
|
|
));
|
|
}
|
|
Ok(cred.uid)
|
|
}
|
|
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
|
{
|
|
let mut uid: libc::uid_t = 0;
|
|
let mut gid: libc::gid_t = 0;
|
|
// SAFETY: `uid` and `gid` are valid for writes.
|
|
if unsafe { libc::getpeereid(fd, &mut uid, &mut gid) } != 0 {
|
|
return Err(std::io::Error::last_os_error());
|
|
}
|
|
Ok(uid)
|
|
}
|
|
}
|
|
|
|
/// The process at the other end of a Unix socket, where the system says
|
|
/// (Linux); `None` elsewhere or if it can't be read.
|
|
pub fn peer_pid(fd: &impl AsRawFd) -> Option<u32> {
|
|
#[cfg(any(target_os = "linux", target_os = "android"))]
|
|
{
|
|
let mut cred = libc::ucred {
|
|
pid: 0,
|
|
uid: 0,
|
|
gid: 0,
|
|
};
|
|
let mut len = std::mem::size_of::<libc::ucred>() as libc::socklen_t;
|
|
// SAFETY: `cred` and `len` are valid for writes of the sizes given.
|
|
let rc = unsafe {
|
|
libc::getsockopt(
|
|
fd.as_raw_fd(),
|
|
libc::SOL_SOCKET,
|
|
libc::SO_PEERCRED,
|
|
(&mut cred as *mut libc::ucred).cast(),
|
|
&mut len,
|
|
)
|
|
};
|
|
(rc == 0 && len as usize == std::mem::size_of::<libc::ucred>() && cred.pid > 0)
|
|
.then_some(cred.pid as u32)
|
|
}
|
|
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
|
{
|
|
let _ = fd;
|
|
None
|
|
}
|
|
}
|
|
|
|
/// This process's user ID.
|
|
fn own_uid() -> u32 {
|
|
// SAFETY: geteuid has no preconditions and can't fail.
|
|
unsafe { libc::geteuid() }
|
|
}
|
|
|
|
/// Whether the other end of a socket is this account (INVARIANT 2).
|
|
pub fn peer_is_us(fd: &impl AsRawFd) -> bool {
|
|
peer_uid(fd).is_ok_and(|uid| uid == own_uid())
|
|
}
|
|
|
|
/// The client's check of the server (plan 3.9: both ends check).
|
|
pub(crate) fn check_peer_std(stream: &std::os::unix::net::UnixStream) -> Result<(), String> {
|
|
if peer_is_us(stream) {
|
|
Ok(())
|
|
} else {
|
|
Err("the books server's socket belongs to another account: not used".into())
|
|
}
|
|
}
|
|
|
|
/// A Unix listener that hands on only this account's connections
|
|
/// (INVARIANT 2).
|
|
struct OwnerOnly(tokio::net::UnixListener);
|
|
|
|
impl OwnerOnly {
|
|
async fn next(&mut self) -> tokio::net::UnixStream {
|
|
loop {
|
|
match self.0.accept().await {
|
|
Ok((stream, _)) if peer_is_us(&stream) => return stream,
|
|
Ok((stream, _)) => {
|
|
eprintln!("wanderbooks server: refused a connection from another account");
|
|
drop(stream);
|
|
}
|
|
Err(e) => {
|
|
// Out of descriptors and the like: wait, as axum does,
|
|
// rather than spin. A peer that hung up is no matter.
|
|
if !matches!(
|
|
e.kind(),
|
|
std::io::ErrorKind::ConnectionAborted
|
|
| std::io::ErrorKind::ConnectionReset
|
|
| std::io::ErrorKind::ConnectionRefused
|
|
) {
|
|
eprintln!("wanderbooks server: accept failed: {e}");
|
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl axum::serve::Listener for OwnerOnly {
|
|
type Io = tokio::net::UnixStream;
|
|
type Addr = tokio::net::unix::SocketAddr;
|
|
|
|
async fn accept(&mut self) -> (Self::Io, Self::Addr) {
|
|
loop {
|
|
let stream = self.next().await;
|
|
if let Ok(addr) = stream.peer_addr() {
|
|
return (stream, addr);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn local_addr(&self) -> std::io::Result<Self::Addr> {
|
|
self.0.local_addr()
|
|
}
|
|
}
|
|
|
|
/// Counts an MCP request in progress, from its arrival until its answer's
|
|
/// body is dropped (sent whole, or its client gone): it rides in that
|
|
/// body, which a session fills later. Whether a tool is still running is
|
|
/// counted by its store (SharedStore::close), not here.
|
|
struct InFlight(Arc<Books>);
|
|
|
|
impl InFlight {
|
|
fn new(books: &Arc<Books>) -> Self {
|
|
books.in_flight.fetch_add(1, Ordering::SeqCst);
|
|
Self(books.clone())
|
|
}
|
|
}
|
|
|
|
impl Drop for InFlight {
|
|
fn drop(&mut self) {
|
|
self.0.in_flight.fetch_sub(1, Ordering::SeqCst);
|
|
self.0.finished.notify_waiters();
|
|
}
|
|
}
|
|
|
|
/// The MCP session header.
|
|
const SESSION_HEADER: &str = "mcp-session-id";
|
|
|
|
/// The most sessions open at once (each bound to its token): past it a
|
|
/// new session is refused until one ends, never a binding forgotten
|
|
/// (INVARIANT 8; review of slice 4: clearing them let a flood unbind
|
|
/// live sessions).
|
|
const MAX_SESSIONS: usize = 100_000;
|
|
|
|
/// What a request asks for, as the access log names it: an MCP request
|
|
/// (action `connect`, its method), an upload (`upload`, its id) or a
|
|
/// download (`download`, the attachment's id).
|
|
struct Asked {
|
|
action: &'static str,
|
|
subject: String,
|
|
}
|
|
|
|
impl Asked {
|
|
fn connect(method: &axum::http::Method) -> Self {
|
|
Self {
|
|
action: "connect",
|
|
subject: method.as_str().to_string(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A request refused at the door: recorded in the book's access log
|
|
/// (access.rs INVARIANT 3), then answered with `status` and `why`.
|
|
async fn refused_at_door(
|
|
book: &Book,
|
|
principal: Principal,
|
|
asked: &Asked,
|
|
code: &'static str,
|
|
status: axum::http::StatusCode,
|
|
why: String,
|
|
) -> axum::response::Response {
|
|
use axum::response::IntoResponse;
|
|
if let Book::Open { access, store, .. } = book {
|
|
let access = access.clone();
|
|
let record = Access {
|
|
principal,
|
|
client: None,
|
|
request_id: None,
|
|
action: asked.action.into(),
|
|
// Masked, then cut, by the log itself (access.rs INVARIANT 2).
|
|
subject: asked.subject.clone(),
|
|
args_digest: None,
|
|
result: Outcome::Refused(code.into()),
|
|
head_after: None,
|
|
};
|
|
let written = tokio::task::spawn_blocking(move || access.record(&record)).await;
|
|
if !matches!(written, Ok(Ok(_))) {
|
|
store.stop();
|
|
return (
|
|
axum::http::StatusCode::SERVICE_UNAVAILABLE,
|
|
[(REFUSED_HEADER, "not-recorded")],
|
|
crate::access::NOT_RECORDED,
|
|
)
|
|
.into_response();
|
|
}
|
|
}
|
|
let mut response = (status, [(REFUSED_HEADER, code)], why).into_response();
|
|
if status == axum::http::StatusCode::UNAUTHORIZED {
|
|
response.headers_mut().insert(
|
|
axum::http::header::WWW_AUTHENTICATE,
|
|
axum::http::HeaderValue::from_static("Bearer realm=\"wanderbooks\""),
|
|
);
|
|
}
|
|
response
|
|
}
|
|
|
|
/// The door to `book` (INVARIANT 8): the books held open, the server not
|
|
/// closing, and a live token for those books; else the answer refusing
|
|
/// the request (recorded in the books' access log, where there is one).
|
|
async fn door(
|
|
books: &Arc<Books>,
|
|
book: &str,
|
|
asked: &Asked,
|
|
headers: &axum::http::HeaderMap,
|
|
) -> Result<(Book, Token), Box<axum::response::Response>> {
|
|
use axum::http::StatusCode;
|
|
use axum::response::IntoResponse;
|
|
let closing = books.closing.is_cancelled();
|
|
let found = books.held().get(book).cloned();
|
|
let held = match found {
|
|
Some(Book::Failed(why)) if !closing => {
|
|
return Err((
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
[(REFUSED_HEADER, "not-open")],
|
|
format!("the books {book} didn't open: {why}"),
|
|
)
|
|
.into_response()
|
|
.into());
|
|
}
|
|
None if !closing => {
|
|
return Err((
|
|
StatusCode::NOT_FOUND,
|
|
[(REFUSED_HEADER, "no-such-books")],
|
|
format!(
|
|
"there are no books called {:?}: `wanderbooks books list` lists them",
|
|
book.chars().take(64).collect::<String>()
|
|
),
|
|
)
|
|
.into_response()
|
|
.into());
|
|
}
|
|
Some(b @ Book::Open { .. }) => b,
|
|
_ => {
|
|
return Err((
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"the books are closing: this request wasn't run",
|
|
)
|
|
.into_response()
|
|
.into());
|
|
}
|
|
};
|
|
let token = books.bearer(headers);
|
|
let principal = |t: &Token| Principal {
|
|
id: t.id.clone(),
|
|
kind: "agent".into(),
|
|
label: t.name.clone(),
|
|
};
|
|
if closing {
|
|
let who = token
|
|
.as_ref()
|
|
.map_or_else(|_| Principal::unknown(), principal);
|
|
return Err(refused_at_door(
|
|
&held,
|
|
who,
|
|
asked,
|
|
crate::access::CLOSING,
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"the books are closing: this request wasn't run".into(),
|
|
)
|
|
.await
|
|
.into());
|
|
}
|
|
let token = match token {
|
|
Ok(t) => t,
|
|
Err((code, why)) => {
|
|
return Err(refused_at_door(
|
|
&held,
|
|
Principal::unknown(),
|
|
asked,
|
|
code,
|
|
StatusCode::UNAUTHORIZED,
|
|
why,
|
|
)
|
|
.await
|
|
.into());
|
|
}
|
|
};
|
|
if token.book != book {
|
|
return Err(refused_at_door(
|
|
&held,
|
|
principal(&token),
|
|
asked,
|
|
"wrong_books",
|
|
StatusCode::FORBIDDEN,
|
|
format!("this token is for the books {}, not {book}", token.book),
|
|
)
|
|
.await
|
|
.into());
|
|
}
|
|
Ok((held, token))
|
|
}
|
|
|
|
/// `PUT /b/{book}/u/{upload}`: the bytes of an upload begun with
|
|
/// `books.begin_upload`, from the token that began it (INVARIANT 9; plan
|
|
/// 4.7): at most the upload's limit, kept only if exactly the size and
|
|
/// SHA-256 declared, once; recorded in the access log (action `upload`).
|
|
async fn upload(
|
|
axum::extract::State(books): axum::extract::State<Arc<Books>>,
|
|
axum::extract::Path((book, id)): axum::extract::Path<(String, String)>,
|
|
request: axum::extract::Request,
|
|
) -> axum::response::Response {
|
|
use axum::http::StatusCode;
|
|
use axum::response::IntoResponse;
|
|
use ledger_store::uploads::UploadError;
|
|
let _counted = InFlight::new(&books);
|
|
books
|
|
.last_arrival
|
|
.store(books.started.elapsed().as_millis() as u64, Ordering::SeqCst);
|
|
let asked = Asked {
|
|
action: "upload",
|
|
subject: id.clone(),
|
|
};
|
|
let (held, token) = match door(&books, &book, &asked, request.headers()).await {
|
|
Ok(passed) => passed,
|
|
Err(refused) => {
|
|
// What was sent is read and dropped (up to the most an upload
|
|
// can be: only this account reaches the socket), so the client
|
|
// gets the refusal, not a connection cut mid-send.
|
|
use futures_util::StreamExt;
|
|
let mut rest = request.into_body().into_data_stream();
|
|
let mut read = 0u64;
|
|
while let Some(Ok(chunk)) = rest.next().await {
|
|
read += chunk.len() as u64;
|
|
if read > ledger_store::uploads::MAX_UPLOAD {
|
|
break;
|
|
}
|
|
}
|
|
return *refused;
|
|
}
|
|
};
|
|
let Book::Open {
|
|
access,
|
|
uploads,
|
|
store,
|
|
..
|
|
} = &held
|
|
else {
|
|
unreachable!("the door lets only open books through")
|
|
};
|
|
// Streamed to the disk as it comes, never held whole; `receive` reads
|
|
// no more than the size declared and one byte. The bytes and their
|
|
// record are one blocking task, which runs to its end even if the
|
|
// client hangs up: never bytes kept without their record, and a close
|
|
// waits for both (the count goes with them).
|
|
use futures_util::TryStreamExt;
|
|
let stream = request
|
|
.into_body()
|
|
.into_data_stream()
|
|
.map_err(std::io::Error::other);
|
|
let mut body = tokio_util::io::SyncIoBridge::new(tokio_util::io::StreamReader::new(stream));
|
|
let (uploads, access, stopped) = (uploads.clone(), access.clone(), store.is_stopped());
|
|
let task = tokio::task::spawn_blocking(move || {
|
|
let _counted = _counted;
|
|
// The books server stopped after an internal error: nothing more
|
|
// comes in (SharedStore INVARIANT 2).
|
|
let received = if stopped {
|
|
Err(UploadError::Bad(crate::server::STOPPED.into()))
|
|
} else {
|
|
uploads.receive(&id, &token.id, &mut body, chrono::Utc::now().timestamp())
|
|
};
|
|
// Refused: the rest of what was sent (up to the most an upload
|
|
// can be) is read and dropped, so the client gets the answer, not
|
|
// a connection cut while it was still sending.
|
|
if received.is_err() {
|
|
use std::io::Read;
|
|
let rest = ledger_store::uploads::MAX_UPLOAD + 1;
|
|
let _ = std::io::copy(&mut (&mut body).take(rest), &mut std::io::sink());
|
|
}
|
|
let (status, outcome, text) = match &received {
|
|
Ok(g) => (
|
|
StatusCode::OK,
|
|
Outcome::Ok,
|
|
serde_json::json!({"received": g.id, "size": g.size, "sha256": g.sha256})
|
|
.to_string(),
|
|
),
|
|
Err(e) => {
|
|
let (status, code) = match e {
|
|
_ if stopped => (StatusCode::SERVICE_UNAVAILABLE, "stopped"),
|
|
UploadError::Unknown(_) => (StatusCode::NOT_FOUND, "no_such_upload"),
|
|
UploadError::NotYours => (StatusCode::FORBIDDEN, "not_yours"),
|
|
UploadError::AlreadySent => (StatusCode::CONFLICT, "already_sent"),
|
|
UploadError::InUse => (StatusCode::CONFLICT, "in_use"),
|
|
UploadError::WrongSize { .. }
|
|
| UploadError::TooMuch { .. }
|
|
| UploadError::WrongHash => (StatusCode::BAD_REQUEST, "not_as_declared"),
|
|
_ => (StatusCode::INTERNAL_SERVER_ERROR, "failed"),
|
|
};
|
|
(status, Outcome::Refused(code.into()), e.to_string())
|
|
}
|
|
};
|
|
let record = Access {
|
|
principal: Principal {
|
|
id: token.id.clone(),
|
|
kind: "agent".into(),
|
|
label: token.name.clone(),
|
|
},
|
|
client: None,
|
|
request_id: None,
|
|
action: "upload".into(),
|
|
subject: id.clone(),
|
|
args_digest: None,
|
|
result: outcome.clone(),
|
|
head_after: None,
|
|
};
|
|
let recorded = access.record(&record).is_ok();
|
|
if !recorded && received.is_ok() {
|
|
// Never bytes kept without their record: sent again later.
|
|
uploads.discard(&id);
|
|
}
|
|
(status, outcome, text, recorded)
|
|
});
|
|
let Ok((status, outcome, text, true)) = task.await else {
|
|
store.stop();
|
|
return (
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
[(REFUSED_HEADER, "not-recorded")],
|
|
crate::access::NOT_RECORDED,
|
|
)
|
|
.into_response();
|
|
};
|
|
match outcome {
|
|
Outcome::Refused(code) => (status, [(REFUSED_HEADER, code)], text).into_response(),
|
|
_ => (status, text).into_response(),
|
|
}
|
|
}
|
|
|
|
/// `GET /b/{book}/a/{attachment}`: a stored file's bytes, for a token
|
|
/// with books:read (INVARIANT 9; what `wanderbooks download` asks for);
|
|
/// recorded in the access log (action `download`).
|
|
async fn download(
|
|
axum::extract::State(books): axum::extract::State<Arc<Books>>,
|
|
axum::extract::Path((book, attachment)): axum::extract::Path<(String, String)>,
|
|
request: axum::extract::Request,
|
|
) -> axum::response::Response {
|
|
fetch(books, book, Wanted::Attachment(attachment), request).await
|
|
}
|
|
|
|
/// `GET /b/{book}/x/{export}`: a file the books made for an agent
|
|
/// (exports.rs; D-29), only to the token it was made for (INVARIANT 9);
|
|
/// recorded in the access log (action `export`).
|
|
async fn export(
|
|
axum::extract::State(books): axum::extract::State<Arc<Books>>,
|
|
axum::extract::Path((book, id)): axum::extract::Path<(String, String)>,
|
|
request: axum::extract::Request,
|
|
) -> axum::response::Response {
|
|
fetch(books, book, Wanted::Export(id), request).await
|
|
}
|
|
|
|
/// What a GET fetches.
|
|
enum Wanted {
|
|
/// A stored receipt or document, by its id.
|
|
Attachment(String),
|
|
/// A file the books made, by its id.
|
|
Export(String),
|
|
}
|
|
|
|
/// A download or an export: the door, the read and its record, the
|
|
/// headers (INVARIANT 9).
|
|
async fn fetch(
|
|
books: Arc<Books>,
|
|
book: String,
|
|
wanted: Wanted,
|
|
request: axum::extract::Request,
|
|
) -> axum::response::Response {
|
|
use axum::http::StatusCode;
|
|
use axum::response::IntoResponse;
|
|
// GET only (axum hands a HEAD to a GET route): a HEAD would read the
|
|
// file and record a download that sent nothing.
|
|
if request.method() != axum::http::Method::GET {
|
|
return (
|
|
StatusCode::METHOD_NOT_ALLOWED,
|
|
[(axum::http::header::ALLOW, "GET")],
|
|
"a download is a GET",
|
|
)
|
|
.into_response();
|
|
}
|
|
let _counted = InFlight::new(&books);
|
|
books
|
|
.last_arrival
|
|
.store(books.started.elapsed().as_millis() as u64, Ordering::SeqCst);
|
|
let (action, subject) = match &wanted {
|
|
Wanted::Attachment(id) => ("download", id.clone()),
|
|
Wanted::Export(id) => ("export", id.clone()),
|
|
};
|
|
let asked = Asked {
|
|
action,
|
|
subject: subject.clone(),
|
|
};
|
|
let (held, token) = match door(&books, &book, &asked, request.headers()).await {
|
|
Ok(passed) => passed,
|
|
Err(refused) => return *refused,
|
|
};
|
|
let Book::Open {
|
|
access,
|
|
store,
|
|
exports,
|
|
..
|
|
} = &held
|
|
else {
|
|
unreachable!("the door lets only open books through")
|
|
};
|
|
// The read and its record are one blocking task, run to its end even
|
|
// if the client hangs up, and counted until then.
|
|
let (access, store_for, exports) = (access.clone(), store.clone(), exports.clone());
|
|
let task = tokio::task::spawn_blocking(move || {
|
|
let _counted = _counted;
|
|
use ledger_store::exports::ExportError;
|
|
let not_found = |code, what: &str| {
|
|
(
|
|
StatusCode::NOT_FOUND,
|
|
code,
|
|
format!("there is no such {what}"),
|
|
)
|
|
};
|
|
let found = if store_for.is_stopped() {
|
|
// Stopped after an internal error: as for uploads.
|
|
Err((
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"stopped",
|
|
crate::server::STOPPED.to_string(),
|
|
))
|
|
// An export is bound to the token it was made for, whose tool
|
|
// passed its own scope check; a stored file needs books:read.
|
|
} else if matches!(wanted, Wanted::Attachment(_)) && !token.scopes.contains(&Scope::Read) {
|
|
Err((
|
|
StatusCode::FORBIDDEN,
|
|
"insufficient_scope",
|
|
format!(
|
|
"this agent's token can't do that: a download needs books:read, and its token ({:?}) hasn't it",
|
|
token.name
|
|
),
|
|
))
|
|
} else {
|
|
match &wanted {
|
|
Wanted::Export(id) => {
|
|
match exports.get(id, &token.id, chrono::Utc::now().timestamp()) {
|
|
Ok((x, bytes)) => Ok((x.file_name, x.mime, bytes)),
|
|
Err(e @ ExportError::Unknown(_)) => {
|
|
Err((StatusCode::NOT_FOUND, "no_such_export", e.to_string()))
|
|
}
|
|
Err(e @ ExportError::NotYours) => {
|
|
Err((StatusCode::FORBIDDEN, "not_yours", e.to_string()))
|
|
}
|
|
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, "failed", e.to_string())),
|
|
}
|
|
}
|
|
// An id as the books write it (no sign, no leading zeros),
|
|
// so the log holds no alias of it.
|
|
Wanted::Attachment(a) => {
|
|
match a.parse::<u64>().ok().filter(|n| n.to_string() == *a) {
|
|
None => Err(not_found("no_such_attachment", "attachment")),
|
|
Some(id) => match store_for.attachment_file(id) {
|
|
Ok(Some(file)) => Ok(file),
|
|
Ok(None) => Err(not_found("no_such_attachment", "attachment")),
|
|
// The stored file damaged: not a missing one.
|
|
Err(why) => Err((StatusCode::INTERNAL_SERVER_ERROR, "failed", why)),
|
|
},
|
|
}
|
|
}
|
|
}
|
|
};
|
|
let record = Access {
|
|
principal: Principal {
|
|
id: token.id.clone(),
|
|
kind: "agent".into(),
|
|
label: token.name.clone(),
|
|
},
|
|
client: None,
|
|
request_id: None,
|
|
action: action.into(),
|
|
subject,
|
|
args_digest: None,
|
|
result: match &found {
|
|
Ok(_) => Outcome::Ok,
|
|
Err((_, code, _)) => Outcome::Refused((*code).into()),
|
|
},
|
|
head_after: None,
|
|
};
|
|
let recorded = access.record(&record).is_ok();
|
|
(found, recorded)
|
|
});
|
|
let Ok((found, true)) = task.await else {
|
|
store.stop();
|
|
return (
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
[(REFUSED_HEADER, "not-recorded")],
|
|
crate::access::NOT_RECORDED,
|
|
)
|
|
.into_response();
|
|
};
|
|
match found {
|
|
Ok((name, mime, bytes)) => {
|
|
let digest = format!(
|
|
"sha-256=:{}:",
|
|
base64::Engine::encode(
|
|
&base64::engine::general_purpose::STANDARD,
|
|
<sha2::Sha256 as sha2::Digest>::digest(&bytes)
|
|
)
|
|
);
|
|
let mime = axum::http::HeaderValue::from_str(&mime).unwrap_or(
|
|
axum::http::HeaderValue::from_static("application/octet-stream"),
|
|
);
|
|
(
|
|
StatusCode::OK,
|
|
[
|
|
(axum::http::header::CONTENT_TYPE, mime),
|
|
(
|
|
axum::http::header::CONTENT_DISPOSITION,
|
|
axum::http::HeaderValue::from_str(&disposition(&name))
|
|
.expect("only visible ASCII"),
|
|
),
|
|
(
|
|
axum::http::header::X_CONTENT_TYPE_OPTIONS,
|
|
axum::http::HeaderValue::from_static("nosniff"),
|
|
),
|
|
(
|
|
axum::http::HeaderName::from_static(DIGEST_HEADER),
|
|
axum::http::HeaderValue::from_str(&digest).expect("base64 is ASCII"),
|
|
),
|
|
],
|
|
bytes,
|
|
)
|
|
.into_response()
|
|
}
|
|
Err((status, code, why)) => (status, [(REFUSED_HEADER, code)], why).into_response(),
|
|
}
|
|
}
|
|
|
|
/// The header a download's SHA-256 rides in (RFC 9530 Repr-Digest), so
|
|
/// `wanderbooks download` keeps only what was stored.
|
|
pub const DIGEST_HEADER: &str = "repr-digest";
|
|
|
|
/// A download's Content-Disposition (RFC 6266): its name in ASCII, every
|
|
/// other character `_`, and whole in UTF-8 as `filename*` (RFC 8187).
|
|
fn disposition(name: &str) -> String {
|
|
let plain: String = name
|
|
.chars()
|
|
.map(|c| {
|
|
if (c.is_ascii_graphic() || c == ' ') && c != '"' && c != '\\' {
|
|
c
|
|
} else {
|
|
'_'
|
|
}
|
|
})
|
|
.collect();
|
|
let mut encoded = String::new();
|
|
for b in name.bytes() {
|
|
if b.is_ascii_alphanumeric() || b"!#$&+-.^_`|~".contains(&b) {
|
|
encoded.push(b as char);
|
|
} else {
|
|
encoded.push_str(&format!("%{b:02X}"));
|
|
}
|
|
}
|
|
format!("attachment; filename=\"{plain}\"; filename*=UTF-8''{encoded}")
|
|
}
|
|
|
|
/// `/b/{book}/mcp`: the book's MCP service, for a request carrying a token
|
|
/// for that book (INVARIANT 8).
|
|
async fn mcp(
|
|
axum::extract::State(books): axum::extract::State<Arc<Books>>,
|
|
axum::extract::Path(book): axum::extract::Path<String>,
|
|
mut request: axum::extract::Request,
|
|
) -> axum::response::Response {
|
|
use axum::http::StatusCode;
|
|
use tower::ServiceExt;
|
|
// Counted before the closing check, so closing never misses it.
|
|
let counted = (request.method() != axum::http::Method::GET).then(|| InFlight::new(&books));
|
|
books
|
|
.last_arrival
|
|
.store(books.started.elapsed().as_millis() as u64, Ordering::SeqCst);
|
|
let method = request.method().clone();
|
|
let (held, token) = match door(&books, &book, &Asked::connect(&method), request.headers()).await
|
|
{
|
|
Ok(passed) => passed,
|
|
Err(refused) => return *refused,
|
|
};
|
|
let Book::Open { service, .. } = &held else {
|
|
unreachable!("the door lets only open books through")
|
|
};
|
|
let service = (**service).clone();
|
|
let principal = |t: &Token| Principal {
|
|
id: t.id.clone(),
|
|
kind: "agent".into(),
|
|
label: t.name.clone(),
|
|
};
|
|
let session = request
|
|
.headers()
|
|
.get(SESSION_HEADER)
|
|
.and_then(|v| v.to_str().ok())
|
|
.map(str::to_string);
|
|
// A session is bound when it is opened (below): one bound to another
|
|
// token is refused; one never bound isn't one this server opened, and
|
|
// is left to the MCP service, which doesn't know it either (and says
|
|
// so, which a client takes as "open a new one").
|
|
let (elsewhere, full) = {
|
|
let bound = books
|
|
.session_tokens
|
|
.lock()
|
|
.unwrap_or_else(|e| e.into_inner());
|
|
let elsewhere = session
|
|
.as_ref()
|
|
.and_then(|sid| bound.get(sid))
|
|
.is_some_and(|owner| *owner != token.id);
|
|
(elsewhere, session.is_none() && bound.len() >= MAX_SESSIONS)
|
|
};
|
|
// The cap is on opening sessions: a request opens one only if it is an
|
|
// initialize (a 2026-07-28 call without a session opens none).
|
|
let full = full && {
|
|
let (parts, body) = request.into_parts();
|
|
let bytes = axum::body::to_bytes(body, MAX_BODY)
|
|
.await
|
|
.unwrap_or_default();
|
|
let opens = serde_json::from_slice::<serde_json::Value>(&bytes)
|
|
.ok()
|
|
.is_some_and(|m| m.get("method").and_then(|v| v.as_str()) == Some("initialize"));
|
|
request = axum::extract::Request::from_parts(parts, axum::body::Body::from(bytes));
|
|
opens
|
|
};
|
|
if full {
|
|
return refused_at_door(
|
|
&held,
|
|
principal(&token),
|
|
&Asked::connect(&method),
|
|
"too_many_sessions",
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"the books server has too many sessions open: end some, then try again".into(),
|
|
)
|
|
.await;
|
|
}
|
|
if elsewhere {
|
|
return refused_at_door(
|
|
&held,
|
|
principal(&token),
|
|
&Asked::connect(&method),
|
|
"session_token",
|
|
StatusCode::FORBIDDEN,
|
|
"this session was opened with another token".into(),
|
|
)
|
|
.await;
|
|
}
|
|
let token_id = token.id.clone();
|
|
request
|
|
.extensions_mut()
|
|
.insert(crate::access::Caller { token });
|
|
let response = match service.oneshot(request).await {
|
|
Ok(response) => response,
|
|
Err(never) => match never {},
|
|
};
|
|
// A session the answer opened is bound to the token that opened it;
|
|
// one ended, or found gone, is forgotten.
|
|
{
|
|
let mut bound = books
|
|
.session_tokens
|
|
.lock()
|
|
.unwrap_or_else(|e| e.into_inner());
|
|
if session.is_none()
|
|
&& let Some(sid) = response
|
|
.headers()
|
|
.get(SESSION_HEADER)
|
|
.and_then(|v| v.to_str().ok())
|
|
{
|
|
bound.insert(sid.to_string(), token_id);
|
|
}
|
|
let ended = method == axum::http::Method::DELETE && response.status().is_success();
|
|
let gone = response.status() == StatusCode::NOT_FOUND;
|
|
if (ended || gone)
|
|
&& let Some(sid) = &session
|
|
{
|
|
bound.remove(sid);
|
|
}
|
|
}
|
|
response.map(|body| {
|
|
use http_body_util::BodyExt;
|
|
axum::body::Body::new(body.map_frame(move |frame| {
|
|
let _held = &counted;
|
|
frame
|
|
}))
|
|
})
|
|
}
|
|
|
|
/// Anything but `/b/{book}/mcp`.
|
|
async fn not_found() -> (axum::http::StatusCode, &'static str) {
|
|
(axum::http::StatusCode::NOT_FOUND, "not found")
|
|
}
|
|
|
|
/// One admin connection: one request, one answer (admin INVARIANT 1).
|
|
async fn serve_admin(stream: tokio::net::UnixStream, books: Arc<Books>) {
|
|
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt};
|
|
let (read, mut write) = stream.into_split();
|
|
let mut reader = tokio::io::BufReader::new(read.take(admin::MAX_LINE as u64 + 1));
|
|
let mut line = String::new();
|
|
// A request still arriving when closing begins is turned away.
|
|
let read = tokio::select! {
|
|
read = tokio::time::timeout(ADMIN_READ_TIMEOUT, reader.read_line(&mut line)) => Some(read),
|
|
() = books.closing.cancelled() => None,
|
|
};
|
|
let answer = match read {
|
|
None => Response::Refused("the books are closing: nothing was done".into()),
|
|
Some(Err(_)) => Response::Refused("no request arrived in time".into()),
|
|
Some(Ok(Err(e))) => {
|
|
Response::Refused(format!("the request couldn't be read: {}", e.kind()))
|
|
}
|
|
Some(Ok(Ok(_))) if !line.ends_with('\n') => {
|
|
Response::Refused("the request is too long, or cut off".into())
|
|
}
|
|
Some(Ok(Ok(_))) => match serde_json::from_str::<admin::Envelope>(&line) {
|
|
Err(e) => Response::Refused(format!("the request isn't understood: {e}")),
|
|
Ok(envelope) if !admin::accepts(envelope.proto) => Response::Refused(
|
|
"this command is from another version of wanderbooks than the books server: \
|
|
close the books (`wanderbooks close`) and try again"
|
|
.into(),
|
|
),
|
|
Ok(_) if books.closing.is_cancelled() => {
|
|
Response::Refused("the books are closing: nothing was done".into())
|
|
}
|
|
Ok(envelope) => {
|
|
let books = books.clone();
|
|
tokio::task::spawn_blocking(move || books.admin(envelope.request))
|
|
.await
|
|
.unwrap_or_else(|_| {
|
|
Response::Refused(
|
|
"the books server hit an internal error doing that".into(),
|
|
)
|
|
})
|
|
}
|
|
},
|
|
};
|
|
let mut text = serde_json::to_string(&answer).unwrap_or_else(|_| {
|
|
r#"{"refused":"the books server couldn't write its answer"}"#.to_string()
|
|
});
|
|
text.push('\n');
|
|
let _ = write.write_all(text.as_bytes()).await;
|
|
let _ = write.shutdown().await;
|
|
}
|
|
|
|
/// Bind a socket in `run/`, replacing a stale one (INVARIANT 1: only the
|
|
/// server holding the lock gets here).
|
|
fn bind(run: &Path, name: &str) -> std::io::Result<tokio::net::UnixListener> {
|
|
let path = run.join(name);
|
|
match std::fs::remove_file(&path) {
|
|
Ok(()) => {}
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
|
Err(e) => return Err(e),
|
|
}
|
|
let short = ledger_store::root::SocketPath::new(run, name)?;
|
|
let listener = tokio::net::UnixListener::bind(short.path())?;
|
|
use std::os::unix::fs::PermissionsExt;
|
|
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
|
|
Ok(listener)
|
|
}
|
|
|
|
/// The address `--http` takes: loopback only (plan 3.9, P9a).
|
|
///
|
|
/// The tunnel's door is the same plaintext MCP service with a static
|
|
/// bearer token; a TLS proxy or tunnel runs in front of it on the same
|
|
/// machine. Any other address would put the books' plaintext door on the
|
|
/// network, which 3.9 and P9a do not allow, so it is refused rather than
|
|
/// bound (or warned about).
|
|
pub fn tunnel_address(text: &str) -> Result<std::net::SocketAddr, String> {
|
|
let addr: std::net::SocketAddr = text
|
|
.parse()
|
|
.map_err(|e| format!("--http takes an address like 127.0.0.1:7411: {e}"))?;
|
|
if !addr.ip().is_loopback() {
|
|
return Err(format!(
|
|
"--http is loopback only (127.0.0.1:7411 or [::1]:7411), not {addr}: this door is plaintext, so the TLS proxy or tunnel belongs in front of loopback"
|
|
));
|
|
}
|
|
Ok(addr)
|
|
}
|
|
|
|
fn print_usage(prog: &str) {
|
|
eprintln!("usage: {prog} [--root DIR] [--isolation LEVEL] [--http [ADDR]]");
|
|
eprintln!(" The books server for the data root: started by the host, not by hand.");
|
|
eprintln!(" It runs until its standard input ends, or SIGTERM, SIGINT or SIGHUP.");
|
|
eprintln!(" --isolation same_user | enforced: what books.start says of how these books");
|
|
eprintln!(" are kept from agents (enforced on the owner's own machine: P9a).");
|
|
eprintln!(" --http [ADDR]: also serve the MCP door on loopback only (default");
|
|
eprintln!(" 127.0.0.1:7411), for a TLS proxy or a tunnel in front of it. Off unless");
|
|
eprintln!(" given; any other address is refused.");
|
|
}
|
|
|
|
/// `wanderbooks server`: serve every book in the data root until told to
|
|
/// close (INVARIANT 6).
|
|
pub fn run(
|
|
prog: &str,
|
|
args: impl IntoIterator<Item = String>,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
let mut root: Option<PathBuf> = None;
|
|
let mut backup_quiet = BACKUP_QUIET;
|
|
let mut drill_first = DRILL_FIRST;
|
|
let mut isolation = Isolation::SameUser;
|
|
let mut tunnel: Option<std::net::SocketAddr> = None;
|
|
let mut args = args.into_iter().peekable();
|
|
while let Some(arg) = args.next() {
|
|
match arg.as_str() {
|
|
"--root" => root = Some(PathBuf::from(args.next().ok_or("--root needs a folder")?)),
|
|
// How the books are kept from agents (plan 3.8, P9a): the
|
|
// system unit on the owner's own machine passes `enforced`;
|
|
// the server never guesses.
|
|
"--isolation" => {
|
|
let value = args
|
|
.next()
|
|
.ok_or("--isolation needs same_user or enforced")?;
|
|
isolation = Isolation::parse(&value)?;
|
|
}
|
|
// The tunnel's door (plan 3.9, P9a): the same MCP service on
|
|
// loopback, for a TLS proxy or tunnel in front of it. Off
|
|
// unless asked for; the bearer token still guards every call.
|
|
"--http" => {
|
|
let addr = match args.peek() {
|
|
Some(v) if !v.starts_with('-') => args.next().unwrap_or_default(),
|
|
_ => "127.0.0.1:7411".to_string(),
|
|
};
|
|
tunnel = Some(tunnel_address(&addr)?);
|
|
}
|
|
// How long the books stay unwritten before a backup (tests).
|
|
"--backup-quiet" => {
|
|
let ms: u64 = args
|
|
.next()
|
|
.and_then(|v| v.parse().ok())
|
|
.ok_or("--backup-quiet needs milliseconds")?;
|
|
backup_quiet = Duration::from_millis(ms);
|
|
}
|
|
// How soon after books open a drill is first looked for (tests).
|
|
"--drill-first" => {
|
|
let ms: u64 = args
|
|
.next()
|
|
.and_then(|v| v.parse().ok())
|
|
.ok_or("--drill-first needs milliseconds")?;
|
|
drill_first = Duration::from_millis(ms);
|
|
}
|
|
"--help" | "-h" => {
|
|
print_usage(prog);
|
|
return Ok(());
|
|
}
|
|
other => {
|
|
eprintln!("unknown argument: {other}");
|
|
print_usage(prog);
|
|
return Err(Box::new(crate::Exit(2)));
|
|
}
|
|
}
|
|
}
|
|
let root = match Root::find(root.as_deref()) {
|
|
Ok(root) => root,
|
|
Err(e) => {
|
|
eprintln!("wanderbooks server: {e}");
|
|
return Err(Box::new(crate::Exit(2)));
|
|
}
|
|
};
|
|
let run = root.run_dir().map_err(|e| e.to_string())?;
|
|
// INVARIANT 1: the lock first, before anything else is opened.
|
|
let lock_path = run.join(SERVER_LOCK);
|
|
let lock = std::fs::OpenOptions::new()
|
|
.create(true)
|
|
.truncate(false)
|
|
.write(true)
|
|
.open(&lock_path)
|
|
.map_err(|e| format!("can't open the server lock: {}", e.kind()))?;
|
|
let rt = tokio::runtime::Builder::new_multi_thread()
|
|
.enable_all()
|
|
.build()?;
|
|
// INVARIANT 6: told to close at any point (a long wait or rebuild
|
|
// included), the server closes cleanly as soon as it can.
|
|
let stops = Stops::default();
|
|
{
|
|
let _in_runtime = rt.enter();
|
|
watch_for_close(&stops)?;
|
|
}
|
|
// INVARIANT 1: a server still finishing (its host gone) has the lock;
|
|
// this one waits for it, touching nothing, until told to close.
|
|
let mut said = false;
|
|
loop {
|
|
match lock.try_lock() {
|
|
Ok(()) => break,
|
|
Err(std::fs::TryLockError::WouldBlock) => {
|
|
if stops.close.is_cancelled() {
|
|
return Ok(());
|
|
}
|
|
if !said {
|
|
eprintln!(
|
|
"wanderbooks server: another books server has these books open; waiting for it to finish"
|
|
);
|
|
said = true;
|
|
}
|
|
std::thread::sleep(Duration::from_millis(100));
|
|
}
|
|
Err(std::fs::TryLockError::Error(e)) => {
|
|
return Err(format!("can't take the server lock: {}", e.kind()).into());
|
|
}
|
|
}
|
|
}
|
|
{
|
|
// Only once it is ours: a refused server touches nothing.
|
|
use std::os::unix::fs::PermissionsExt;
|
|
std::fs::set_permissions(&lock_path, std::fs::Permissions::from_mode(0o600))
|
|
.map_err(|e| format!("can't make the server lock private: {}", e.kind()))?;
|
|
}
|
|
let books = Arc::new(Books {
|
|
root: root.clone(),
|
|
held: RwLock::new(BTreeMap::new()),
|
|
sessions: CancellationToken::new(),
|
|
closing: CancellationToken::new(),
|
|
in_flight: AtomicUsize::new(0),
|
|
finished: tokio::sync::Notify::new(),
|
|
last_arrival: std::sync::atomic::AtomicU64::new(0),
|
|
started: std::time::Instant::now(),
|
|
principals: RwLock::new(Err(String::new())),
|
|
session_tokens: Mutex::new(BTreeMap::new()),
|
|
backup_quiet,
|
|
drill_first,
|
|
restoring: Mutex::new(()),
|
|
isolation,
|
|
});
|
|
{
|
|
let loaded = books.load_principals();
|
|
if let Err(why) = &loaded {
|
|
eprintln!("wanderbooks server: agents' tokens can't be read: {why}");
|
|
}
|
|
*books.principals.write().unwrap_or_else(|e| e.into_inner()) = loaded;
|
|
}
|
|
// INVARIANT 3: every book tried before ready.
|
|
for id in root.list_books().map_err(|e| e.to_string())? {
|
|
let book = books.open(&id);
|
|
books.hold(&id, book);
|
|
}
|
|
let served = if stops.close.is_cancelled() {
|
|
eprintln!("wanderbooks server: told to close while opening: closed");
|
|
Ok(())
|
|
} else {
|
|
rt.block_on(serve(books.clone(), &run, &stops, tunnel))
|
|
};
|
|
// No request is running now (serve waited for each); the stores go
|
|
// before the lock, and the process exit would release both.
|
|
books
|
|
.held
|
|
.write()
|
|
.unwrap_or_else(|e| e.into_inner())
|
|
.clear();
|
|
rt.shutdown_timeout(Duration::from_secs(1));
|
|
for name in [MCP_SOCKET, ADMIN_SOCKET] {
|
|
let _ = std::fs::remove_file(run.join(name));
|
|
}
|
|
drop(lock);
|
|
served
|
|
}
|
|
|
|
/// How long, once every running request has finished, the server still
|
|
/// waits for answers on their way out (or requests still arriving).
|
|
const ANSWER_GRACE: Duration = Duration::from_secs(10);
|
|
/// How long the door stays open, refusing, after the last request came.
|
|
const LINGER: Duration = Duration::from_millis(500);
|
|
/// The longest it stays open so.
|
|
const LINGER_MAX: Duration = Duration::from_secs(3);
|
|
/// How long the HTTP side then has to end before it is dropped.
|
|
const HTTP_GRACE: Duration = Duration::from_secs(5);
|
|
|
|
/// Being told to close: once (`close`), and again (`hurry`: stop waiting
|
|
/// for answers on their way out; never for a request still running).
|
|
#[derive(Default)]
|
|
struct Stops {
|
|
close: CancellationToken,
|
|
hurry: CancellationToken,
|
|
}
|
|
|
|
/// Bind, say ready, serve, and close (INVARIANTS 5, 6).
|
|
async fn serve(
|
|
books: Arc<Books>,
|
|
run: &Path,
|
|
stops: &Stops,
|
|
tunnel: Option<std::net::SocketAddr>,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
let mcp_listener = OwnerOnly(bind(run, MCP_SOCKET)?);
|
|
let mut admin_listener = OwnerOnly(bind(run, ADMIN_SOCKET)?);
|
|
// The tunnel's door (plan 3.9, P9a): the same service on 127.0.0.1,
|
|
// for the TLS proxy or tunnel in front of it. Bound before ready, so
|
|
// what starts beside the server finds it; a bind that fails is said
|
|
// and is not fatal (3.9: the books' own sockets still serve).
|
|
let tunnel_listener = match tunnel {
|
|
Some(addr) => match tokio::net::TcpListener::bind(addr).await {
|
|
Ok(listener) => Some(listener),
|
|
Err(e) => {
|
|
eprintln!(
|
|
"wanderbooks server: the tunnel's door at {addr} can't be bound ({e}); serving the local sockets only"
|
|
);
|
|
None
|
|
}
|
|
},
|
|
None => None,
|
|
};
|
|
let stop = stops.close.clone();
|
|
{
|
|
use std::io::Write;
|
|
let mut out = std::io::stdout().lock();
|
|
writeln!(out, "{READY}")?;
|
|
out.flush()?;
|
|
}
|
|
let held: Vec<String> = books.held().keys().cloned().collect();
|
|
eprintln!("wanderbooks server: ready, holding {} books", held.len());
|
|
// Every open book is backed up when it opens, once its writing has
|
|
// stopped for a while, and again after a failed run (backup.rs).
|
|
let (backing, backup_stop) = (books.clone(), stop.clone());
|
|
let backup_loop = tokio::spawn(async move { keep_backed_up(backing, backup_stop).await });
|
|
// Every open book's lapsed uploads go within SWEEP_EVERY of lapsing,
|
|
// whatever else the server is doing (uploads.rs INVARIANT 3).
|
|
let (sweeping, sweep_stop) = (books.clone(), stop.clone());
|
|
tokio::spawn(async move {
|
|
let mut tick = tokio::time::interval(SWEEP_EVERY);
|
|
loop {
|
|
tokio::select! {
|
|
() = sweep_stop.cancelled() => break,
|
|
_ = tick.tick() => {
|
|
let held: Vec<_> = sweeping
|
|
.held()
|
|
.values()
|
|
.filter_map(|b| match b {
|
|
Book::Open { uploads, exports, .. } => {
|
|
Some((uploads.clone(), exports.clone()))
|
|
}
|
|
Book::Failed(_) => None,
|
|
})
|
|
.collect();
|
|
let now = chrono::Utc::now().timestamp();
|
|
let _ = tokio::task::spawn_blocking(move || {
|
|
for (u, x) in &held {
|
|
u.sweep(now);
|
|
x.sweep(now);
|
|
}
|
|
})
|
|
.await;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
let app = axum::Router::new()
|
|
.route("/b/{book}/mcp", axum::routing::any(mcp))
|
|
.route("/b/{book}/u/{upload}", axum::routing::put(upload))
|
|
.route("/b/{book}/a/{attachment}", axum::routing::get(download))
|
|
.route("/b/{book}/x/{export}", axum::routing::get(export))
|
|
.fallback(not_found)
|
|
.with_state(books.clone());
|
|
// The HTTP side keeps answering (refusing, once closing) until the
|
|
// close is done: a client is never cut off mid-request by it.
|
|
let shut = CancellationToken::new();
|
|
let graceful = shut.clone();
|
|
let tunnel_app = app.clone();
|
|
let mut http = tokio::spawn(async move {
|
|
axum::serve(mcp_listener, app)
|
|
.with_graceful_shutdown(async move { graceful.cancelled().await })
|
|
.await
|
|
});
|
|
let tunnel_http = tunnel_listener.map(|listener| {
|
|
let app = tunnel_app;
|
|
let graceful = shut.clone();
|
|
tokio::spawn(async move {
|
|
axum::serve(listener, app)
|
|
.with_graceful_shutdown(async move { graceful.cancelled().await })
|
|
.await
|
|
})
|
|
});
|
|
let admin_jobs = TaskTracker::new();
|
|
loop {
|
|
tokio::select! {
|
|
_ = stop.cancelled() => break,
|
|
stream = admin_listener.next() => {
|
|
admin_jobs.spawn(serve_admin(stream, books.clone()));
|
|
}
|
|
}
|
|
}
|
|
drop(admin_listener);
|
|
eprintln!("wanderbooks server: closing");
|
|
// INVARIANT 6. New requests are refused from here on.
|
|
books.closing.cancel();
|
|
// Admin work under way (a create, an adoption) finishes.
|
|
admin_jobs.close();
|
|
admin_jobs.wait().await;
|
|
// Every request already running finishes its work, however long it
|
|
// takes and whether or not its client is still there: a store is
|
|
// never let go of mid-write (SharedStore INVARIANT 3).
|
|
for store in books.stores() {
|
|
store.close().await;
|
|
}
|
|
// A last backup of what was written since the last one, for a while
|
|
// at most: the books are closing, nothing more is written. The loop
|
|
// stops at its next step (`stop`); a run of its still under way
|
|
// finishes first (each book runs one at a time).
|
|
drop(backup_loop);
|
|
let held: Vec<Arc<crate::backup::Backups>> = books
|
|
.held()
|
|
.values()
|
|
.filter_map(|b| match b {
|
|
Book::Open { backups, .. } => Some(backups.clone()),
|
|
Book::Failed(_) => None,
|
|
})
|
|
.collect();
|
|
for b in held {
|
|
let _ = tokio::time::timeout(BACKUP_AT_CLOSE, b.run("close")).await;
|
|
}
|
|
// Answers on their way out are sent, for a while: a client sending
|
|
// a request it never finishes doesn't hold the books open.
|
|
let answers = async {
|
|
loop {
|
|
let notified = books.finished.notified();
|
|
if books.in_flight.load(Ordering::SeqCst) == 0 {
|
|
break;
|
|
}
|
|
notified.await;
|
|
}
|
|
};
|
|
tokio::select! {
|
|
() = answers => {}
|
|
() = tokio::time::sleep(ANSWER_GRACE) => {
|
|
eprintln!("wanderbooks server: a client didn't finish its request; closing without it");
|
|
}
|
|
() = stops.hurry.cancelled() => {}
|
|
}
|
|
books.sessions.cancel();
|
|
// A call that set out just before its client heard of the close still
|
|
// meets a refusal, never a connection cut mid-way (which it couldn't
|
|
// tell from a call that ran): the door stays open, refusing, until
|
|
// none has come for LINGER.
|
|
let linger = async {
|
|
let deadline = tokio::time::Instant::now() + LINGER_MAX;
|
|
loop {
|
|
let since = (books.started.elapsed().as_millis() as u64)
|
|
.saturating_sub(books.last_arrival.load(Ordering::SeqCst));
|
|
let quiet = Duration::from_millis(since);
|
|
if quiet >= LINGER || tokio::time::Instant::now() >= deadline {
|
|
break;
|
|
}
|
|
tokio::time::sleep(LINGER - quiet).await;
|
|
}
|
|
};
|
|
tokio::select! {
|
|
() = linger => {}
|
|
() = stops.hurry.cancelled() => {}
|
|
}
|
|
shut.cancel();
|
|
match tokio::time::timeout(HTTP_GRACE, &mut http).await {
|
|
Ok(Ok(Ok(()))) => {}
|
|
Ok(Ok(Err(e))) => eprintln!("wanderbooks server: {e}"),
|
|
Ok(Err(e)) => eprintln!("wanderbooks server: {e}"),
|
|
Err(_) => http.abort(),
|
|
}
|
|
if let Some(mut tunnel) = tunnel_http {
|
|
match tokio::time::timeout(HTTP_GRACE, &mut tunnel).await {
|
|
Ok(Ok(Ok(()))) => {}
|
|
Ok(Ok(Err(e))) => eprintln!("wanderbooks server: {e}"),
|
|
Ok(Err(e)) => eprintln!("wanderbooks server: {e}"),
|
|
Err(_) => tunnel.abort(),
|
|
}
|
|
}
|
|
eprintln!("wanderbooks server: closed");
|
|
Ok(())
|
|
}
|
|
|
|
/// Close when standard input ends (the host let go, or died) or on
|
|
/// SIGTERM, SIGINT or SIGHUP; hurry on the next signal. Runs inside the
|
|
/// runtime, before the books are opened.
|
|
fn watch_for_close(stops: &Stops) -> std::io::Result<()> {
|
|
use tokio::signal::unix::{SignalKind, signal};
|
|
let on_eof = stops.close.clone();
|
|
// A plain thread: a blocked read must never hold up the runtime's end.
|
|
std::thread::Builder::new()
|
|
.name("stdin-watch".into())
|
|
.spawn(move || {
|
|
use std::io::Read;
|
|
let mut sink = [0u8; 256];
|
|
let mut stdin = std::io::stdin();
|
|
loop {
|
|
match stdin.read(&mut sink) {
|
|
Ok(0) => break,
|
|
Ok(_) => {}
|
|
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
|
|
Err(_) => break,
|
|
}
|
|
}
|
|
on_eof.cancel();
|
|
})?;
|
|
for kind in [
|
|
SignalKind::terminate(),
|
|
SignalKind::interrupt(),
|
|
SignalKind::hangup(),
|
|
] {
|
|
let mut signals = signal(kind)?;
|
|
let (close, hurry) = (stops.close.clone(), stops.hurry.clone());
|
|
tokio::spawn(async move {
|
|
while signals.recv().await.is_some() {
|
|
if close.is_cancelled() {
|
|
hurry.cancel();
|
|
} else {
|
|
close.cancel();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// What the backup loop knows of one open book.
|
|
struct Seen {
|
|
/// Its backups, by identity: books restored over others are new.
|
|
backups: Arc<crate::backup::Backups>,
|
|
watch: Watch,
|
|
}
|
|
|
|
/// One open book's backups, as the loop has watched them.
|
|
#[derive(Debug, Clone, Copy)]
|
|
struct Watch {
|
|
/// Its head's seq when last looked at, and when that changed.
|
|
seq: u64,
|
|
moved_at: Instant,
|
|
/// The seq last committed and offered to the targets.
|
|
backed: Option<u64>,
|
|
/// When the last run's commit failed, if it did: nothing runs again
|
|
/// for RETRY_AFTER.
|
|
failed_at: Option<Instant>,
|
|
/// When the last run left a target failing, if it did: it is tried
|
|
/// again RETRY_AFTER on, written to or not.
|
|
target_failed_at: Option<Instant>,
|
|
/// When a drill is next looked for.
|
|
drill_next: Instant,
|
|
}
|
|
|
|
impl Watch {
|
|
/// Books first seen open now, at `seq`: a drill is first looked for
|
|
/// `drill_first` from now.
|
|
fn new(seq: u64, now: Instant, drill_first: Duration) -> Self {
|
|
Watch {
|
|
seq,
|
|
moved_at: now,
|
|
backed: None,
|
|
failed_at: None,
|
|
target_failed_at: None,
|
|
drill_next: now + drill_first,
|
|
}
|
|
}
|
|
|
|
/// A run through `seq` came to `outcome` at `now`: None when its
|
|
/// commit failed, else whether every target holds it.
|
|
fn ran(&mut self, seq: u64, outcome: Option<bool>, now: Instant) {
|
|
match outcome {
|
|
None => self.failed_at = Some(now),
|
|
Some(all) => {
|
|
self.backed = Some(seq);
|
|
self.failed_at = None;
|
|
self.target_failed_at = (!all).then_some(now);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Why a backup is due now, if one is: the books just seen open; or
|
|
/// written and quiet for `quiet` since (not yet backed up); or a target
|
|
/// left failing RETRY_AFTER ago. Nothing within RETRY_AFTER of a failed
|
|
/// commit.
|
|
fn backup_due(
|
|
seen: Option<&Watch>,
|
|
seq: u64,
|
|
now: Instant,
|
|
quiet: Duration,
|
|
) -> Option<&'static str> {
|
|
let Some(s) = seen else {
|
|
return Some("open");
|
|
};
|
|
let after =
|
|
|t: Option<Instant>| t.is_some_and(|f| now.duration_since(f) >= crate::backup::RETRY_AFTER);
|
|
if s.failed_at.is_some() && !after(s.failed_at) {
|
|
return None;
|
|
}
|
|
let quiet_now = if s.seq == seq {
|
|
now.duration_since(s.moved_at) >= quiet
|
|
} else {
|
|
quiet.is_zero()
|
|
};
|
|
if s.backed != Some(seq) && quiet_now {
|
|
return Some("write");
|
|
}
|
|
after(s.target_failed_at).then_some("retry")
|
|
}
|
|
|
|
/// Whether a run's every target holds the books: a target paused for
|
|
/// these books (restored as a copy of others) is no failure to try again.
|
|
fn every_target_held(status: &crate::backup::Status) -> bool {
|
|
status
|
|
.targets
|
|
.iter()
|
|
.all(|t| t.paused || t.last_error.is_none())
|
|
}
|
|
|
|
/// Whether to look for a drill due now (then again DRILL_LOOK on).
|
|
fn drill_look_due(seen: &Watch, now: Instant) -> bool {
|
|
now >= seen.drill_next
|
|
}
|
|
|
|
/// Back every open book up (backup.rs) whenever [`backup_due`] says, and
|
|
/// drill its backups when a drill is due (looked for every DRILL_LOOK).
|
|
/// Until `stop`.
|
|
async fn keep_backed_up(books: Arc<Books>, stop: CancellationToken) {
|
|
let mut seen: BTreeMap<String, Seen> = BTreeMap::new();
|
|
let mut tick = tokio::time::interval(BACKUP_LOOK);
|
|
loop {
|
|
tokio::select! {
|
|
() = stop.cancelled() => return,
|
|
_ = tick.tick() => {}
|
|
}
|
|
let open: Vec<(String, Arc<crate::backup::Backups>)> = books
|
|
.held()
|
|
.iter()
|
|
.filter_map(|(id, b)| match b {
|
|
Book::Open { backups, .. } => Some((id.clone(), backups.clone())),
|
|
Book::Failed(_) => None,
|
|
})
|
|
.collect();
|
|
seen.retain(|id, _| open.iter().any(|(o, _)| o == id));
|
|
for (id, backups) in open {
|
|
if stop.is_cancelled() {
|
|
return;
|
|
}
|
|
let Some((seq, _)) = backups.head().await else {
|
|
continue;
|
|
};
|
|
let now = Instant::now();
|
|
// Books restored over others are seen anew.
|
|
if seen
|
|
.get(&id)
|
|
.is_some_and(|s| !Arc::ptr_eq(&s.backups, &backups))
|
|
{
|
|
seen.remove(&id);
|
|
}
|
|
let due = backup_due(
|
|
seen.get(&id).map(|s| &s.watch),
|
|
seq,
|
|
now,
|
|
books.backup_quiet,
|
|
);
|
|
let s = &mut seen
|
|
.entry(id)
|
|
.or_insert(Seen {
|
|
backups: backups.clone(),
|
|
watch: Watch::new(seq, now, books.drill_first),
|
|
})
|
|
.watch;
|
|
if s.seq != seq {
|
|
s.seq = seq;
|
|
s.moved_at = now;
|
|
}
|
|
if let Some(why) = due {
|
|
let outcome = backups
|
|
.run(why)
|
|
.await
|
|
.ok()
|
|
.map(|status| every_target_held(&status));
|
|
s.ran(seq, outcome, now);
|
|
}
|
|
if stop.is_cancelled() {
|
|
return;
|
|
}
|
|
// Drills (backup.rs INVARIANT 5).
|
|
if drill_look_due(s, now) {
|
|
s.drill_next = now + DRILL_LOOK;
|
|
if backups.drill_due().await {
|
|
backups.drill().await;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn a_backup_is_due_on_open_then_once_quiet_after_a_write() {
|
|
let t0 = Instant::now();
|
|
let quiet = Duration::from_secs(30);
|
|
let s = |seq, moved: u64, backed, failed: Option<u64>| Watch {
|
|
seq,
|
|
moved_at: t0 + Duration::from_secs(moved),
|
|
backed,
|
|
failed_at: failed.map(|f| t0 + Duration::from_secs(f)),
|
|
target_failed_at: None,
|
|
drill_next: t0,
|
|
};
|
|
let at = |secs: u64| t0 + Duration::from_secs(secs);
|
|
// First seen: at once.
|
|
assert_eq!(backup_due(None, 5, t0, quiet), Some("open"));
|
|
// Backed up through the head: nothing.
|
|
assert_eq!(
|
|
backup_due(Some(&s(5, 0, Some(5), None)), 5, at(100), quiet),
|
|
None
|
|
);
|
|
// Written: not until it has been quiet `quiet`.
|
|
assert_eq!(
|
|
backup_due(Some(&s(5, 0, Some(5), None)), 6, at(1), quiet),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
backup_due(Some(&s(6, 10, Some(5), None)), 6, at(39), quiet),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
backup_due(Some(&s(6, 10, Some(5), None)), 6, at(40), quiet),
|
|
Some("write")
|
|
);
|
|
// No quiet asked for: at once.
|
|
assert_eq!(
|
|
backup_due(Some(&s(5, 0, Some(5), None)), 6, at(1), Duration::ZERO),
|
|
Some("write")
|
|
);
|
|
// A failure waits RETRY_AFTER.
|
|
let retry = crate::backup::RETRY_AFTER.as_secs();
|
|
let failed = s(6, 10, Some(5), Some(50));
|
|
assert_eq!(
|
|
backup_due(Some(&failed), 6, at(50 + retry - 1), quiet),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
backup_due(Some(&failed), 6, at(50 + retry), quiet),
|
|
Some("write")
|
|
);
|
|
// A drill is first looked for `drill_first` after the books are
|
|
// seen open, never at once.
|
|
let first = Duration::from_secs(60);
|
|
let w = Watch::new(1, t0, first);
|
|
assert!(!drill_look_due(&w, t0));
|
|
assert!(!drill_look_due(&w, t0 + first - Duration::from_millis(1)));
|
|
assert!(drill_look_due(&w, t0 + first));
|
|
assert_eq!((w.backed, w.failed_at, w.seq), (None, None, 1));
|
|
// A run's outcome: its commit failed (nothing again for a
|
|
// while), or made and some target failing (tried again later,
|
|
// while writes still go to the others), or all well.
|
|
let mut w = Watch::new(1, t0, first);
|
|
w.ran(4, None, at(5));
|
|
assert_eq!(
|
|
(w.backed, w.failed_at, w.target_failed_at),
|
|
(None, Some(at(5)), None)
|
|
);
|
|
w.ran(4, Some(false), at(9));
|
|
assert_eq!(
|
|
(w.backed, w.failed_at, w.target_failed_at),
|
|
(Some(4), None, Some(at(9)))
|
|
);
|
|
w.seq = 5;
|
|
w.moved_at = at(10);
|
|
assert_eq!(backup_due(Some(&w), 5, at(10 + 30), quiet), Some("write"));
|
|
w.backed = Some(5);
|
|
assert_eq!(backup_due(Some(&w), 5, at(9 + retry - 1), quiet), None);
|
|
assert_eq!(backup_due(Some(&w), 5, at(9 + retry), quiet), Some("retry"));
|
|
// A paused target is no failure; another's error is.
|
|
let target = |paused: bool, error: Option<&str>| crate::backup::TargetStatus {
|
|
target: "repository".into(),
|
|
at: "x".into(),
|
|
last_ok_at: None,
|
|
through_seq: None,
|
|
stale: false,
|
|
last_error: error.map(str::to_string),
|
|
last_drill_at: None,
|
|
drill_error: None,
|
|
paused,
|
|
};
|
|
let status = |targets| crate::backup::Status {
|
|
head_seq: 5,
|
|
targets,
|
|
note: None,
|
|
};
|
|
assert!(every_target_held(&status(vec![
|
|
target(true, Some("paused")),
|
|
target(false, None)
|
|
])));
|
|
assert!(!every_target_held(&status(vec![
|
|
target(false, Some("no")),
|
|
target(false, None)
|
|
])));
|
|
assert!(every_target_held(&status(vec![])));
|
|
w.ran(5, Some(true), at(9 + retry));
|
|
assert_eq!((w.failed_at, w.target_failed_at), (None, None));
|
|
assert_eq!(backup_due(Some(&w), 5, at(9 + retry * 3), quiet), None);
|
|
}
|
|
|
|
#[test]
|
|
fn a_socket_pair_is_this_account_at_both_ends() {
|
|
let (a, b) = std::os::unix::net::UnixStream::pair().unwrap();
|
|
assert_eq!(peer_uid(&a).unwrap(), own_uid());
|
|
assert!(peer_is_us(&b));
|
|
assert!(check_peer_std(&a).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn a_socket_pair_names_this_process() {
|
|
let (a, _b) = std::os::unix::net::UnixStream::pair().unwrap();
|
|
if cfg!(target_os = "linux") {
|
|
assert_eq!(peer_pid(&a), Some(std::process::id()));
|
|
}
|
|
let file = std::fs::File::open("/dev/null").unwrap();
|
|
assert_eq!(peer_pid(&file), None);
|
|
}
|
|
|
|
#[test]
|
|
fn a_descriptor_that_isnt_a_socket_is_never_taken_for_us() {
|
|
let file = std::fs::File::open("/dev/null").unwrap();
|
|
assert!(peer_uid(&file).is_err());
|
|
assert!(!peer_is_us(&file));
|
|
}
|
|
|
|
/// A socket with no other end: the system answers for it (Linux: no
|
|
/// process, no user), and that answer names neither this process nor
|
|
/// this account; the client's check refuses it.
|
|
#[test]
|
|
fn a_socket_with_no_other_end_names_no_process_and_isnt_us() {
|
|
use std::os::fd::FromRawFd;
|
|
// SAFETY: socket(2) has no preconditions; the descriptor is checked
|
|
// and then owned by the stream alone.
|
|
let fd = unsafe { libc::socket(libc::AF_UNIX, libc::SOCK_STREAM | libc::SOCK_CLOEXEC, 0) };
|
|
assert!(fd >= 0);
|
|
// SAFETY: a fresh descriptor of a Unix stream socket, owned by none.
|
|
let lone = unsafe { std::os::unix::net::UnixStream::from_raw_fd(fd) };
|
|
assert_eq!(peer_pid(&lone), None);
|
|
assert!(!peer_is_us(&lone));
|
|
assert_eq!(
|
|
check_peer_std(&lone).unwrap_err(),
|
|
"the books server's socket belongs to another account: not used"
|
|
);
|
|
}
|
|
|
|
/// A session ends only after a day without a message (SESSION_IDLE),
|
|
/// never rmcp's few minutes: a call longer than that keeps its answer.
|
|
#[test]
|
|
fn a_session_ends_only_after_a_day_without_a_message() {
|
|
assert_eq!(
|
|
sessions().session_config.keep_alive,
|
|
Some(Duration::from_secs(86_400))
|
|
);
|
|
}
|
|
|
|
/// Settings moved in at adoption are dated in the time zone the file
|
|
/// sets (company.timezone), not by any other entry's text. The two
|
|
/// zones are 26 hours apart, so their dates always differ.
|
|
#[test]
|
|
fn settings_moved_in_are_dated_in_the_files_own_time_zone() {
|
|
let change = |key: &str, value: &str| ledger_core::settings::Change {
|
|
key: key.into(),
|
|
value: Some(serde_json::json!(value)),
|
|
};
|
|
let changes = [
|
|
change("company.name", "Etc/GMT+12"),
|
|
change("company.timezone", "Pacific/Kiritimati"),
|
|
];
|
|
let source = moved_in_at_adoption(&changes);
|
|
assert_eq!(
|
|
source.decided_on,
|
|
ledger_core::settings::today_in(Some("Pacific/Kiritimati"))
|
|
);
|
|
assert_ne!(
|
|
source.decided_on,
|
|
ledger_core::settings::today_in(Some("Etc/GMT+12"))
|
|
);
|
|
assert_eq!(source.decided_by, "the settings file");
|
|
}
|
|
|
|
/// A server's books in a fresh root under `home`, as `run` holds them,
|
|
/// without its sockets: each test drives the handlers itself.
|
|
fn books_in(home: &Path) -> Arc<Books> {
|
|
let (root, _) = Root::setup(home, Some(&home.join("root")), None).unwrap();
|
|
let books = Arc::new(Books {
|
|
root,
|
|
held: RwLock::new(BTreeMap::new()),
|
|
sessions: CancellationToken::new(),
|
|
closing: CancellationToken::new(),
|
|
in_flight: AtomicUsize::new(0),
|
|
finished: tokio::sync::Notify::new(),
|
|
last_arrival: std::sync::atomic::AtomicU64::new(0),
|
|
started: Instant::now(),
|
|
principals: RwLock::new(Err(String::new())),
|
|
session_tokens: Mutex::new(BTreeMap::new()),
|
|
backup_quiet: BACKUP_QUIET,
|
|
drill_first: DRILL_FIRST,
|
|
restoring: Mutex::new(()),
|
|
isolation: Isolation::SameUser,
|
|
});
|
|
let loaded = books.load_principals();
|
|
*books.principals.write().unwrap() = loaded;
|
|
books
|
|
}
|
|
|
|
/// Make the books `id` through the admin request.
|
|
fn create(books: &Books, id: &str) {
|
|
match books.admin(Request::Create {
|
|
id: id.into(),
|
|
currency: "USD".into(),
|
|
}) {
|
|
Response::Created { problem: None, .. } => {}
|
|
other => panic!("{other:?}"),
|
|
}
|
|
}
|
|
|
|
/// A token for `book` named `name` with every scope (made, or made
|
|
/// again): its id and its text.
|
|
fn token(books: &Books, book: &str, name: &str) -> (String, String) {
|
|
match books.admin(Request::TokenCreate {
|
|
book: book.into(),
|
|
name: name.into(),
|
|
scopes: ["read", "write", "post", "admin"]
|
|
.map(String::from)
|
|
.to_vec(),
|
|
replace: true,
|
|
}) {
|
|
Response::TokenCreated { token, text } => (token.id, text.0),
|
|
other => panic!("{other:?}"),
|
|
}
|
|
}
|
|
|
|
/// The sessions bound, by id.
|
|
fn bound(books: &Books) -> Vec<String> {
|
|
books
|
|
.session_tokens
|
|
.lock()
|
|
.unwrap()
|
|
.keys()
|
|
.cloned()
|
|
.collect()
|
|
}
|
|
|
|
/// The status, the refusal code and the text of an answer.
|
|
async fn answer(r: axum::response::Response) -> (u16, Option<String>, String) {
|
|
let code = r
|
|
.headers()
|
|
.get(REFUSED_HEADER)
|
|
.map(|v| v.to_str().unwrap().to_string());
|
|
let status = r.status().as_u16();
|
|
let body = axum::body::to_bytes(r.into_body(), usize::MAX)
|
|
.await
|
|
.unwrap();
|
|
(status, code, String::from_utf8_lossy(&body).into_owned())
|
|
}
|
|
|
|
/// A PUT of `body` for upload `id` of the books `acme`.
|
|
fn put(id: &str, bearer: Option<&str>, body: axum::body::Body) -> axum::extract::Request {
|
|
let mut r = axum::http::Request::builder()
|
|
.method("PUT")
|
|
.uri(format!("http://localhost/b/acme/u/{id}"));
|
|
if let Some(b) = bearer {
|
|
r = r.header("authorization", format!("Bearer {b}"));
|
|
}
|
|
r.body(body).unwrap()
|
|
}
|
|
|
|
/// Send a PUT to the upload route as the router would.
|
|
async fn send_upload(
|
|
books: &Arc<Books>,
|
|
id: &str,
|
|
bearer: Option<&str>,
|
|
body: axum::body::Body,
|
|
) -> axum::response::Response {
|
|
upload(
|
|
axum::extract::State(books.clone()),
|
|
axum::extract::Path(("acme".to_string(), id.to_string())),
|
|
put(id, bearer, body),
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// A request body that arrives as the test sends it, one chunk at a
|
|
/// time (the sender's `reserve` returns once the last chunk was taken).
|
|
fn fed() -> (tokio::sync::mpsc::Sender<Vec<u8>>, axum::body::Body) {
|
|
let (tx, rx) = tokio::sync::mpsc::channel::<Vec<u8>>(1);
|
|
let stream = futures_util::stream::unfold(rx, |mut rx| async move {
|
|
rx.recv()
|
|
.await
|
|
.map(|b| (Ok::<_, std::io::Error>(axum::body::Bytes::from(b)), rx))
|
|
});
|
|
(tx, axum::body::Body::from_stream(stream))
|
|
}
|
|
|
|
/// The upload's grant, for `token_id`, of `bytes`.
|
|
fn grant(books: &Books, token_id: &str, bytes: &[u8]) -> ledger_store::uploads::Grant {
|
|
let held = books.held();
|
|
let Some(Book::Open { uploads, .. }) = held.get("acme") else {
|
|
panic!("acme isn't open");
|
|
};
|
|
uploads
|
|
.grant(
|
|
token_id,
|
|
&ledger_store::journal::sha256_hex(bytes),
|
|
bytes.len() as u64,
|
|
"application/pdf",
|
|
"upload.pdf",
|
|
"document",
|
|
chrono::Utc::now().timestamp(),
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
/// The last access record of `acme`: its action and its outcome.
|
|
fn last_access(books: &Books) -> (String, Outcome) {
|
|
let dir = books.root.book_dir(&BookId::parse("acme").unwrap());
|
|
let (records, _) = ledger_store::access::read(&dir).unwrap();
|
|
let last = records.last().unwrap();
|
|
(last.access.action.clone(), last.access.result.clone())
|
|
}
|
|
|
|
#[test]
|
|
fn a_book_that_didnt_open_says_why_and_one_open_says_nothing() {
|
|
let why = "its access log can't be used".to_string();
|
|
assert_eq!(Book::Failed(why.clone()).problem(), Some(why));
|
|
let home = tempfile::tempdir().unwrap();
|
|
let books = books_in(home.path());
|
|
create(&books, "acme");
|
|
assert_eq!(books.held()["acme"].problem(), None);
|
|
}
|
|
|
|
/// INVARIANT 6: once closing, every request is refused as closing,
|
|
/// even one for books that didn't open or don't exist (until then
|
|
/// told so), so a client knows the server is going, not that its
|
|
/// books are gone.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn once_closing_every_request_is_refused_as_closing() {
|
|
let home = tempfile::tempdir().unwrap();
|
|
let books = books_in(home.path());
|
|
create(&books, "acme");
|
|
books.hold(
|
|
&BookId::parse("broken").unwrap(),
|
|
Book::Failed("its access log can't be used".into()),
|
|
);
|
|
let asked = Asked::connect(&axum::http::Method::POST);
|
|
let headers = axum::http::HeaderMap::new();
|
|
let refused = |books: Arc<Books>, book: &'static str| {
|
|
let (asked, headers) = (&asked, &headers);
|
|
async move {
|
|
match door(&books, book, asked, headers).await {
|
|
Ok(_) => panic!("{book}: let through"),
|
|
Err(r) => answer(*r).await,
|
|
}
|
|
}
|
|
};
|
|
let (status, code, text) = refused(books.clone(), "broken").await;
|
|
assert_eq!((status, code.as_deref()), (503, Some("not-open")), "{text}");
|
|
let (status, code, text) = refused(books.clone(), "nobody").await;
|
|
assert_eq!(
|
|
(status, code.as_deref()),
|
|
(404, Some("no-such-books")),
|
|
"{text}"
|
|
);
|
|
books.closing.cancel();
|
|
for book in ["broken", "nobody"] {
|
|
let (status, code, text) = refused(books.clone(), book).await;
|
|
assert_eq!((status, code), (503, None), "{book}: {text}");
|
|
assert_eq!(text, "the books are closing: this request wasn't run");
|
|
}
|
|
let (status, code, text) = refused(books.clone(), "acme").await;
|
|
assert_eq!(
|
|
(status, code.as_deref()),
|
|
(503, Some(crate::access::CLOSING))
|
|
);
|
|
assert_eq!(text, "the books are closing: this request wasn't run");
|
|
}
|
|
|
|
/// INVARIANT 9: nothing comes in once the books stopped (SharedStore
|
|
/// INVARIANT 2): an upload is refused as stopped, and recorded so.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn an_upload_to_books_that_stopped_is_refused_as_stopped() {
|
|
let home = tempfile::tempdir().unwrap();
|
|
let books = books_in(home.path());
|
|
create(&books, "acme");
|
|
let (id, text) = token(&books, "acme", "agent");
|
|
let bytes = b"%PDF-1.4 stopped".to_vec();
|
|
let g = grant(&books, &id, &bytes);
|
|
match books.held().get("acme") {
|
|
Some(Book::Open { store, .. }) => store.stop(),
|
|
_ => panic!("acme isn't open"),
|
|
}
|
|
let r = send_upload(&books, &g.id, Some(&text), bytes.into()).await;
|
|
let (status, code, said) = answer(r).await;
|
|
assert_eq!((status, code.as_deref()), (503, Some("stopped")), "{said}");
|
|
assert!(said.contains("stopped taking requests"), "{said}");
|
|
assert_eq!(
|
|
last_access(&books),
|
|
("upload".to_string(), Outcome::Refused("stopped".into()))
|
|
);
|
|
}
|
|
|
|
/// INVARIANT 9: an upload is received from one request at a time;
|
|
/// a second sent while the first is still arriving is refused as in
|
|
/// use, and the first is kept.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn an_upload_still_arriving_is_refused_to_a_second_sender() {
|
|
let home = tempfile::tempdir().unwrap();
|
|
let books = books_in(home.path());
|
|
create(&books, "acme");
|
|
let (id, text) = token(&books, "acme", "agent");
|
|
let bytes: Vec<u8> = b"%PDF-1.4 "
|
|
.iter()
|
|
.copied()
|
|
.chain((0..2_000_000u32).map(|i| (i % 251) as u8))
|
|
.collect();
|
|
let g = grant(&books, &id, &bytes);
|
|
let (tx, body) = fed();
|
|
let first = tokio::spawn({
|
|
let (books, id, text) = (books.clone(), g.id.clone(), text.clone());
|
|
async move { send_upload(&books, &id, Some(&text), body).await }
|
|
});
|
|
tx.send(bytes[..1_000_000].to_vec()).await.unwrap();
|
|
// Arriving: its part is on the disk.
|
|
let part = books
|
|
.root
|
|
.book_dir(&BookId::parse("acme").unwrap())
|
|
.join(ledger_store::uploads::UPLOADS_DIR)
|
|
.join(format!("{}.part", g.id));
|
|
let deadline = Instant::now() + Duration::from_secs(30);
|
|
while !part.exists() {
|
|
assert!(Instant::now() < deadline, "the first upload never began");
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
}
|
|
let second = send_upload(&books, &g.id, Some(&text), bytes.clone().into()).await;
|
|
let (status, code, said) = answer(second).await;
|
|
assert_eq!((status, code.as_deref()), (409, Some("in_use")), "{said}");
|
|
assert_eq!(
|
|
last_access(&books),
|
|
("upload".to_string(), Outcome::Refused("in_use".into()))
|
|
);
|
|
tx.send(bytes[1_000_000..].to_vec()).await.unwrap();
|
|
drop(tx);
|
|
let (status, _, said) = answer(first.await.unwrap()).await;
|
|
assert_eq!(status, 200, "{said}");
|
|
}
|
|
|
|
/// INVARIANT 9: a refused upload's bytes are read and dropped up to
|
|
/// the most an upload can be, so a client sending no more than that
|
|
/// is never cut off mid-send, and no further, so one that never stops
|
|
/// sending is still answered: refused at the door (no token) and past
|
|
/// it (no such upload) alike.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn a_refused_upload_is_read_up_to_the_most_an_upload_can_be() {
|
|
let home = tempfile::tempdir().unwrap();
|
|
let books = books_in(home.path());
|
|
create(&books, "acme");
|
|
let (_, text) = token(&books, "acme", "agent");
|
|
let max = ledger_store::uploads::MAX_UPLOAD as usize;
|
|
let mib = 1024 * 1024;
|
|
assert_eq!(max % mib, 0);
|
|
for (bearer, status, code) in [
|
|
(None, 401, crate::access::UNAUTHORIZED),
|
|
(Some(text.clone()), 404, "no_such_upload"),
|
|
] {
|
|
let (tx, body) = fed();
|
|
let answered = tokio::spawn({
|
|
let books = books.clone();
|
|
async move {
|
|
let id = "up_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
|
send_upload(&books, id, bearer.as_deref(), body).await
|
|
}
|
|
});
|
|
for _ in 0..max / mib {
|
|
tx.send(vec![b'x'; mib]).await.unwrap();
|
|
}
|
|
// Every byte up to the most taken, and a moment to act on it:
|
|
// still reading.
|
|
let more = tx.reserve().await.unwrap();
|
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
|
assert!(
|
|
!answered.is_finished(),
|
|
"{status}: answered before the most an upload can be was read"
|
|
);
|
|
// One byte past it: answered, though the body never ends.
|
|
more.send(vec![b'x']);
|
|
let r = tokio::time::timeout(Duration::from_secs(60), answered)
|
|
.await
|
|
.unwrap_or_else(|_| panic!("{status}: never answered"))
|
|
.unwrap();
|
|
let (got, refused, said) = answer(r).await;
|
|
assert_eq!((got, refused.as_deref()), (status, Some(code)), "{said}");
|
|
drop(tx);
|
|
}
|
|
}
|
|
|
|
/// INVARIANT 8: a token revoked or replaced lets go of the sessions it
|
|
/// opened, and only those: not another book's token of its name, nor
|
|
/// its own book's token of another name.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn a_replaced_or_revoked_token_lets_go_of_its_own_sessions_alone() {
|
|
let home = tempfile::tempdir().unwrap();
|
|
let books = books_in(home.path());
|
|
create(&books, "acme");
|
|
create(&books, "beta");
|
|
// Oldest first: another book's token of the same name, then one of
|
|
// the same book with another name, then the one replaced.
|
|
let (beta, _) = token(&books, "beta", "agent");
|
|
let (other, _) = token(&books, "acme", "other");
|
|
let (agent, _) = token(&books, "acme", "agent");
|
|
{
|
|
let mut bound = books.session_tokens.lock().unwrap();
|
|
for (sid, owner) in [("s-beta", &beta), ("s-other", &other), ("s-agent", &agent)] {
|
|
bound.insert(sid.into(), owner.clone());
|
|
}
|
|
}
|
|
token(&books, "acme", "agent");
|
|
assert_eq!(bound(&books), ["s-beta", "s-other"]);
|
|
match books.admin(Request::TokenRevoke {
|
|
book: "beta".into(),
|
|
name: "agent".into(),
|
|
id: None,
|
|
}) {
|
|
Response::TokenRevoked(t) => assert_eq!(t.id, beta),
|
|
other => panic!("{other:?}"),
|
|
}
|
|
assert_eq!(bound(&books), ["s-other"]);
|
|
}
|
|
|
|
/// A token check answers by the SHA-256 asked, for the books and name
|
|
/// asked alone.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn a_token_check_says_whether_the_text_is_the_live_one() {
|
|
let home = tempfile::tempdir().unwrap();
|
|
let books = books_in(home.path());
|
|
create(&books, "acme");
|
|
let (old_id, old) = token(&books, "acme", "agent");
|
|
let (new_id, new) = token(&books, "acme", "agent");
|
|
assert_ne!(old_id, new_id);
|
|
let check = |book: &str, name: &str, text: &str| match books.admin(Request::TokenCheck {
|
|
book: book.into(),
|
|
name: name.into(),
|
|
sha256: ledger_store::journal::sha256_hex(text.as_bytes()),
|
|
}) {
|
|
Response::TokenChecked { live, matches } => (live.map(|t| t.id), matches),
|
|
other => panic!("{other:?}"),
|
|
};
|
|
assert_eq!(check("acme", "agent", &new), (Some(new_id.clone()), true));
|
|
assert_eq!(check("acme", "agent", &old), (Some(new_id), false));
|
|
assert_eq!(check("acme", "other", &new), (None, false));
|
|
assert_eq!(check("beta", "agent", &new), (None, false));
|
|
}
|
|
|
|
/// An MCP request to `acme` with `bearer`, on `session` if given.
|
|
fn mcp_request(
|
|
method: axum::http::Method,
|
|
bearer: &str,
|
|
session: Option<&str>,
|
|
body: serde_json::Value,
|
|
) -> axum::extract::Request {
|
|
let mut r = axum::http::Request::builder()
|
|
.method(method)
|
|
.uri("http://localhost/b/acme/mcp")
|
|
.header("host", "localhost")
|
|
.header("content-type", "application/json")
|
|
.header("accept", "application/json, text/event-stream")
|
|
.header("authorization", format!("Bearer {bearer}"));
|
|
if let Some(sid) = session {
|
|
r = r
|
|
.header(SESSION_HEADER, sid)
|
|
.header("mcp-protocol-version", "2025-06-18");
|
|
}
|
|
r.body(axum::body::Body::from(body.to_string())).unwrap()
|
|
}
|
|
|
|
async fn send_mcp(
|
|
books: &Arc<Books>,
|
|
request: axum::extract::Request,
|
|
) -> axum::response::Response {
|
|
mcp(
|
|
axum::extract::State(books.clone()),
|
|
axum::extract::Path("acme".to_string()),
|
|
request,
|
|
)
|
|
.await
|
|
}
|
|
|
|
fn initialize() -> serde_json::Value {
|
|
serde_json::json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
|
|
"protocolVersion": "2025-06-18", "capabilities": {},
|
|
"clientInfo": {"name": "test", "version": "0"}}})
|
|
}
|
|
|
|
/// INVARIANT 8: a session stays bound to the token that opened it
|
|
/// through every request it serves, and is forgotten when it ends.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn a_session_stays_bound_to_its_token_until_it_ends() {
|
|
use axum::http::Method;
|
|
let home = tempfile::tempdir().unwrap();
|
|
let books = books_in(home.path());
|
|
create(&books, "acme");
|
|
let (agent_id, agent) = token(&books, "acme", "agent");
|
|
let (_, other) = token(&books, "acme", "other");
|
|
let r = send_mcp(
|
|
&books,
|
|
mcp_request(Method::POST, &agent, None, initialize()),
|
|
)
|
|
.await;
|
|
let sid = r.headers()[SESSION_HEADER].to_str().unwrap().to_string();
|
|
let (status, _, said) = answer(r).await;
|
|
assert_eq!(status, 200, "{said}");
|
|
let initialized =
|
|
serde_json::json!({"jsonrpc": "2.0", "method": "notifications/initialized"});
|
|
let list = serde_json::json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"});
|
|
for body in [initialized, list.clone()] {
|
|
let r = send_mcp(&books, mcp_request(Method::POST, &agent, Some(&sid), body)).await;
|
|
let (status, _, said) = answer(r).await;
|
|
assert!((200..300).contains(&status), "{status}: {said}");
|
|
assert_eq!(
|
|
books.session_tokens.lock().unwrap().get(&sid),
|
|
Some(&agent_id)
|
|
);
|
|
}
|
|
let r = send_mcp(&books, mcp_request(Method::POST, &other, Some(&sid), list)).await;
|
|
let (status, code, _) = answer(r).await;
|
|
assert_eq!((status, code.as_deref()), (403, Some("session_token")));
|
|
// Ended: forgotten.
|
|
let r = send_mcp(
|
|
&books,
|
|
mcp_request(Method::DELETE, &agent, Some(&sid), serde_json::json!({})),
|
|
)
|
|
.await;
|
|
let (status, _, said) = answer(r).await;
|
|
assert!((200..300).contains(&status), "{status}: {said}");
|
|
assert!(bound(&books).is_empty());
|
|
}
|
|
|
|
/// INVARIANT 8: past MAX_SESSIONS bindings only a request that opens
|
|
/// a session (an initialize) is refused; any other still reaches the
|
|
/// MCP service.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn past_the_most_sessions_only_opening_another_is_refused() {
|
|
use axum::http::Method;
|
|
let home = tempfile::tempdir().unwrap();
|
|
let books = books_in(home.path());
|
|
create(&books, "acme");
|
|
let (agent_id, agent) = token(&books, "acme", "agent");
|
|
{
|
|
let mut bound = books.session_tokens.lock().unwrap();
|
|
for n in 0..MAX_SESSIONS {
|
|
bound.insert(format!("s-{n}"), agent_id.clone());
|
|
}
|
|
}
|
|
let r = send_mcp(
|
|
&books,
|
|
mcp_request(Method::POST, &agent, None, initialize()),
|
|
)
|
|
.await;
|
|
let (status, code, said) = answer(r).await;
|
|
assert_eq!(
|
|
(status, code.as_deref()),
|
|
(503, Some("too_many_sessions")),
|
|
"{said}"
|
|
);
|
|
let list = serde_json::json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"});
|
|
let r = send_mcp(&books, mcp_request(Method::POST, &agent, None, list)).await;
|
|
let (status, code, said) = answer(r).await;
|
|
assert_ne!(
|
|
code.as_deref(),
|
|
Some("too_many_sessions"),
|
|
"{status}: {said}"
|
|
);
|
|
assert_eq!(bound(&books).len(), MAX_SESSIONS);
|
|
}
|
|
|
|
/// The owner passphrase the upgrade tests set.
|
|
const PASS: &str = "correct horse battery";
|
|
|
|
/// Books `id` as an older version wrote them (journal format 1), with
|
|
/// a chart, an entry and a vault key, held by the server.
|
|
fn old_books(books: &Books, id: &str) -> PathBuf {
|
|
let id = BookId::parse(id).unwrap();
|
|
let dir = books.root.book_dir(&id);
|
|
std::fs::create_dir(&dir).unwrap();
|
|
std::fs::set_permissions(&dir, std::os::unix::fs::PermissionsExt::from_mode(0o700))
|
|
.unwrap();
|
|
let mut s =
|
|
Store::create_in_format(&dir, ledger_core::Currency::USD, &Default::default()).unwrap();
|
|
let cash = s
|
|
.add_account("1000", "Cash", ledger_core::AccountType::Asset, None, false)
|
|
.unwrap();
|
|
let cap = s
|
|
.add_account(
|
|
"3000",
|
|
"Capital",
|
|
ledger_core::AccountType::Equity,
|
|
None,
|
|
false,
|
|
)
|
|
.unwrap();
|
|
let e = s
|
|
.create_entry(chrono::NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(), "In")
|
|
.unwrap();
|
|
let m =
|
|
ledger_core::Money::new(rust_decimal::Decimal::from(500), ledger_core::Currency::USD);
|
|
s.add_line(e, cash, ledger_core::Side::Debit, m).unwrap();
|
|
s.add_line(e, cap, ledger_core::Side::Credit, m).unwrap();
|
|
s.post_entry(e).unwrap();
|
|
drop(s);
|
|
let keys = dir.join(ledger_store::root::KEYS_DIR);
|
|
std::fs::create_dir(&keys).unwrap();
|
|
std::fs::write(
|
|
keys.join(ledger_store::root::LEGACY_KEY_FILE),
|
|
"ab".repeat(32),
|
|
)
|
|
.unwrap();
|
|
books.hold(&id, books.open(&id));
|
|
dir
|
|
}
|
|
|
|
/// The books `id` as listed.
|
|
fn listed(books: &Books, id: &str) -> admin::BookStatus {
|
|
match books.admin(Request::List) {
|
|
Response::Books(list) => list.into_iter().find(|b| b.id == id).unwrap(),
|
|
other => panic!("{other:?}"),
|
|
}
|
|
}
|
|
|
|
/// Ask for the upgrade of `id` with `pass`, off the runtime (as the
|
|
/// admin socket does).
|
|
async fn upgrade(books: &Arc<Books>, id: &str, pass: &str) -> Response {
|
|
let (books, id, pass) = (books.clone(), id.to_string(), pass.to_string());
|
|
tokio::task::spawn_blocking(move || {
|
|
books.admin(Request::UpgradeFormat {
|
|
id,
|
|
passphrase: admin::Passphrase(pass),
|
|
})
|
|
})
|
|
.await
|
|
.unwrap()
|
|
}
|
|
|
|
/// git, on the books' own archive: what it said.
|
|
fn archive_git(dir: &Path, args: &[&str]) -> String {
|
|
let out = std::process::Command::new("git")
|
|
.arg(format!("--git-dir={}", dir.join("archive.git").display()))
|
|
.args(args)
|
|
.output()
|
|
.unwrap();
|
|
assert!(out.status.success(), "{out:?}");
|
|
String::from_utf8(out.stdout).unwrap()
|
|
}
|
|
|
|
/// plan/served.md 3.9's `format_upgrade_waits_for_consent`, the
|
|
/// server's part: books an older version wrote are served on their old
|
|
/// format and listed as waiting; the go-ahead is refused without the
|
|
/// owner passphrase (checked before anything is done: no backup, no
|
|
/// line), and the books stay served as they were; with it, a backup of
|
|
/// the books as they were, their keys in it, is committed and proven,
|
|
/// then the whole format is declared in one line; asked again, nothing.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn format_upgrade_waits_for_consent() {
|
|
let home = tempfile::tempdir().unwrap();
|
|
let books = books_in(home.path());
|
|
let dir = old_books(&books, "acme");
|
|
let journal = || std::fs::read(dir.join(ledger_store::JOURNAL_FILE)).unwrap();
|
|
let before = journal();
|
|
let waiting = listed(&books, "acme").format_upgrade.expect("it waits");
|
|
// The database file it uses: this version's schema's own.
|
|
assert_eq!(
|
|
listed(&books, "acme").database.as_deref(),
|
|
Some(ledger_store::db_file().as_str())
|
|
);
|
|
// Format 1 waits for this version's whole format: format 2 and
|
|
// every feature it knows.
|
|
let adds = ledger_store::JournalFormat::default()
|
|
.upgrade()
|
|
.unwrap()
|
|
.adds();
|
|
assert_eq!(adds[0], "journal format 2");
|
|
assert_eq!(waiting.format, 1);
|
|
assert_eq!(waiting.adds, adds);
|
|
// No passphrase set yet, then a wrong one, then none: refused,
|
|
// nothing done.
|
|
match upgrade(&books, "acme", PASS).await {
|
|
Response::Refused(why) => {
|
|
assert!(why.contains("no owner passphrase is set yet"), "{why}");
|
|
assert!(why.contains("nothing was changed"), "{why}");
|
|
}
|
|
other => panic!("{other:?}"),
|
|
}
|
|
let auth = books.root.auth_dir().unwrap();
|
|
ledger_store::passphrase::set(&auth, PASS, None).unwrap();
|
|
for wrong in ["not the passphrase at all", ""] {
|
|
match upgrade(&books, "acme", wrong).await {
|
|
Response::Refused(why) => {
|
|
assert_eq!(why, "that isn't the owner passphrase: nothing was changed")
|
|
}
|
|
other => panic!("{other:?}"),
|
|
}
|
|
}
|
|
assert_eq!(journal(), before);
|
|
assert!(
|
|
!dir.join("archive.git").exists(),
|
|
"no backup before consent"
|
|
);
|
|
assert!(listed(&books, "acme").format_upgrade.is_some());
|
|
assert_eq!(books.held()["acme"].problem(), None, "still served");
|
|
// Books that don't exist, or didn't open.
|
|
assert!(matches!(
|
|
upgrade(&books, "nobody", PASS).await,
|
|
Response::Refused(_)
|
|
));
|
|
// The go-ahead.
|
|
let done = match upgrade(&books, "acme", PASS).await {
|
|
Response::FormatUpgraded(u) => u,
|
|
other => panic!("{other:?}"),
|
|
};
|
|
assert_eq!(done.id, "acme");
|
|
assert_eq!(done.added, adds);
|
|
let backup = done.backup.clone().expect("backed up first");
|
|
let after = journal();
|
|
assert!(after.starts_with(&before));
|
|
let added: Vec<serde_json::Value> = String::from_utf8(after[before.len()..].to_vec())
|
|
.unwrap()
|
|
.lines()
|
|
.map(|l| serde_json::from_str(l).unwrap())
|
|
.collect();
|
|
assert_eq!(added.len(), 1, "one line");
|
|
assert_eq!(added[0]["type"], "journal.format_set");
|
|
let c = ledger_store::format::current();
|
|
assert_eq!(
|
|
added[0]["payload"],
|
|
serde_json::json!({"format": c.format, "features": c.features})
|
|
);
|
|
assert_eq!(done.head.0, backup.head.0 + 1);
|
|
// The backup: the books as they were, keys and all, the latest
|
|
// commit of their archive.
|
|
assert_eq!(
|
|
archive_git(&dir, &["rev-parse", "HEAD"]).trim(),
|
|
backup.commit
|
|
);
|
|
assert_eq!(
|
|
archive_git(&dir, &["show", &format!("{}:journal.jsonl", backup.commit)]).as_bytes(),
|
|
before.as_slice()
|
|
);
|
|
assert_eq!(
|
|
archive_git(
|
|
&dir,
|
|
&["show", &format!("{}:keys/legacy.key", backup.commit)]
|
|
),
|
|
"ab".repeat(32)
|
|
);
|
|
// Both on the backup record.
|
|
let record =
|
|
std::fs::read_to_string(ledger_store::ops::ops_dir(&dir).join(crate::backup::LOG_FILE))
|
|
.unwrap();
|
|
assert!(record.contains(r#""kind":"format_upgrade""#), "{record}");
|
|
assert!(record.contains(&backup.commit), "{record}");
|
|
assert!(
|
|
!ledger_store::ops::ops_dir(&dir)
|
|
.join("upgrade-check")
|
|
.exists()
|
|
);
|
|
// Waiting for nothing now; asked again, nothing is done.
|
|
assert_eq!(listed(&books, "acme").format_upgrade, None);
|
|
match upgrade(&books, "acme", PASS).await {
|
|
Response::FormatUpgraded(u) => {
|
|
assert!(u.added.is_empty() && u.backup.is_none(), "{u:?}");
|
|
}
|
|
other => panic!("{other:?}"),
|
|
}
|
|
assert_eq!(journal(), after);
|
|
// Books this version made wait for nothing, and asking for their
|
|
// upgrade changes nothing of them: not even a backup repository.
|
|
create(&books, "beta");
|
|
assert_eq!(listed(&books, "beta").format_upgrade, None);
|
|
let beta = books.root.book_dir(&BookId::parse("beta").unwrap());
|
|
match upgrade(&books, "beta", PASS).await {
|
|
Response::FormatUpgraded(u) => {
|
|
assert!(u.added.is_empty() && u.backup.is_none(), "{u:?}");
|
|
}
|
|
other => panic!("{other:?}"),
|
|
}
|
|
assert!(!beta.join("archive.git").exists(), "nothing was changed");
|
|
}
|
|
|
|
/// The backup is trusted only once proven, and only the one just made:
|
|
/// an archive whose head names a branch other than the one a backup's
|
|
/// clone takes (a damaged archive) takes the new commit where no
|
|
/// clone finds it, so what is proven isn't what was made: nothing is
|
|
/// upgraded, and it is recorded. Once the archive is right, its latest
|
|
/// commit (which already holds the books) is proven and the upgrade
|
|
/// made.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn no_upgrade_unless_the_backup_just_made_is_proven() {
|
|
let home = tempfile::tempdir().unwrap();
|
|
let books = books_in(home.path());
|
|
let dir = old_books(&books, "acme");
|
|
ledger_store::passphrase::set(&books.root.auth_dir().unwrap(), PASS, None).unwrap();
|
|
let store = match &books.held()["acme"] {
|
|
Book::Open { store, .. } => store.clone(),
|
|
Book::Failed(why) => panic!("{why}"),
|
|
};
|
|
let (d, s) = (dir.clone(), store.clone());
|
|
let first = tokio::task::spawn_blocking(move || {
|
|
let git = ledger_store::backup::Git::find().unwrap();
|
|
let head = s.with_store(|s| s.head()).unwrap();
|
|
ledger_store::backup::commit(&git, &d, "acme", head).unwrap()
|
|
})
|
|
.await
|
|
.unwrap()
|
|
.expect("a first backup");
|
|
archive_git(&dir, &["symbolic-ref", "HEAD", "refs/heads/elsewhere"]);
|
|
// A second later: the commit made now, of the same files, isn't
|
|
// byte for byte the first (git dates commits to the second).
|
|
tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
|
|
let before = std::fs::read(dir.join(ledger_store::JOURNAL_FILE)).unwrap();
|
|
match upgrade(&books, "acme", PASS).await {
|
|
Response::Refused(why) => {
|
|
assert!(why.contains("didn't prove whole"), "{why}");
|
|
assert!(why.contains("nothing was upgraded"), "{why}");
|
|
}
|
|
other => panic!("{other:?}"),
|
|
}
|
|
assert_eq!(
|
|
std::fs::read(dir.join(ledger_store::JOURNAL_FILE)).unwrap(),
|
|
before
|
|
);
|
|
assert!(listed(&books, "acme").format_upgrade.is_some());
|
|
let record = || {
|
|
std::fs::read_to_string(ledger_store::ops::ops_dir(&dir).join(crate::backup::LOG_FILE))
|
|
.unwrap()
|
|
};
|
|
assert!(
|
|
record().contains(r#""why":"format_upgrade""#),
|
|
"the commit made: {}",
|
|
record()
|
|
);
|
|
assert!(record().contains(r#""ok":false"#), "{}", record());
|
|
// The archive put right: its latest commit holds the books.
|
|
archive_git(&dir, &["symbolic-ref", "HEAD", "refs/heads/main"]);
|
|
match upgrade(&books, "acme", PASS).await {
|
|
Response::FormatUpgraded(u) => {
|
|
let b = u.backup.clone().expect("proven");
|
|
assert_eq!(b.commit, first.commit, "{u:?}");
|
|
assert_eq!(b.head, first.head);
|
|
assert_eq!(u.head.0, first.head.0 + 1);
|
|
}
|
|
other => panic!("{other:?}"),
|
|
}
|
|
assert_eq!(listed(&books, "acme").format_upgrade, None);
|
|
}
|
|
|
|
/// A backup that can't be made upgrades nothing: the books stay on
|
|
/// their old format, served and waiting, and the failure is recorded.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn no_upgrade_without_a_backup() {
|
|
let home = tempfile::tempdir().unwrap();
|
|
let books = books_in(home.path());
|
|
let dir = old_books(&books, "acme");
|
|
ledger_store::passphrase::set(&books.root.auth_dir().unwrap(), PASS, None).unwrap();
|
|
// A file no book keeps: the backup refuses it.
|
|
std::fs::write(dir.join(".gitattributes"), "* -text\n").unwrap();
|
|
let before = std::fs::read(dir.join(ledger_store::JOURNAL_FILE)).unwrap();
|
|
match upgrade(&books, "acme", PASS).await {
|
|
Response::Refused(why) => {
|
|
assert!(why.contains("couldn't be made"), "{why}");
|
|
assert!(why.contains("nothing was upgraded"), "{why}");
|
|
assert!(!why.contains(&home.path().display().to_string()), "{why}");
|
|
}
|
|
other => panic!("{other:?}"),
|
|
}
|
|
assert_eq!(
|
|
std::fs::read(dir.join(ledger_store::JOURNAL_FILE)).unwrap(),
|
|
before
|
|
);
|
|
assert!(listed(&books, "acme").format_upgrade.is_some());
|
|
let record =
|
|
std::fs::read_to_string(ledger_store::ops::ops_dir(&dir).join(crate::backup::LOG_FILE))
|
|
.unwrap();
|
|
assert!(
|
|
record.contains(r#""kind":"format_upgrade","ok":false"#)
|
|
|| (record.contains(r#""kind":"format_upgrade""#)
|
|
&& record.contains(r#""ok":false"#)),
|
|
"{record}"
|
|
);
|
|
}
|
|
}
|