From 5e474cd1ac245cf9b6c2c26d02ad7cedd343bb0f Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 5 Nov 2026 08:36:03 -0700 Subject: [PATCH] feat(mcp): the books door served in-process for fuzzing (P12) The local socket and the tunnel already mount the same four routes; naming that table `router` gives the fuzz targets the very door a client reaches, not a copy of it. `local/fuzz_door` (behind the `fuzzing` feature, off in every shipped build) builds a book, two live tokens and that router, and answers one request through `tower::oneshot`: no socket, no port, no clock. The target's oracle is then the code that enforces it: INVARIANT 8 (a book only behind a live bearer token) is checked by asking `Books::bearer`, not by a second model of the rule that could drift from it. --- crates/ledger-mcp/Cargo.toml | 6 + crates/ledger-mcp/src/local.rs | 24 ++- crates/ledger-mcp/src/local/fuzz_door.rs | 187 +++++++++++++++++++++++ 3 files changed, 210 insertions(+), 7 deletions(-) create mode 100644 crates/ledger-mcp/src/local/fuzz_door.rs diff --git a/crates/ledger-mcp/Cargo.toml b/crates/ledger-mcp/Cargo.toml index 4f4495f..a648a70 100644 --- a/crates/ledger-mcp/Cargo.toml +++ b/crates/ledger-mcp/Cargo.toml @@ -39,12 +39,18 @@ toml = "1" # The vault's TINs, decoded for the filing scan (tin_files.rs), are wiped # from memory when dropped, as the vault's own are. zeroize = "1" +tempfile = { version = "3", optional = true } # The socket's peer credentials (SO_PEERCRED, getpeereid) for local.rs's # same-user check: std has no call for them. [target.'cfg(unix)'.dependencies] libc = "0.2" +[features] +# The door in-process, for fuzz/ (local.rs fuzz_door). Off in every build +# but the fuzz workspace's. +fuzzing = ["dep:tempfile"] + [lints] workspace = true diff --git a/crates/ledger-mcp/src/local.rs b/crates/ledger-mcp/src/local.rs index 56f60a6..7200c34 100644 --- a/crates/ledger-mcp/src/local.rs +++ b/crates/ledger-mcp/src/local.rs @@ -1730,6 +1730,22 @@ async fn mcp( }) } +/// The one router both doors serve (the local socket and the tunnel). +fn router(books: Arc) -> axum::Router { + 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) +} + +/// The door in-process, for the fuzz targets (P12): the same router, fed +/// bytes without a socket. Not built unless asked for. +#[cfg(feature = "fuzzing")] +pub mod fuzz_door; + /// Anything but `/b/{book}/mcp`. async fn not_found() -> (axum::http::StatusCode, &'static str) { (axum::http::StatusCode::NOT_FOUND, "not found") @@ -2080,13 +2096,7 @@ async fn serve( } }); - 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()); + let app = router(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(); diff --git a/crates/ledger-mcp/src/local/fuzz_door.rs b/crates/ledger-mcp/src/local/fuzz_door.rs new file mode 100644 index 0000000..ffa2b34 --- /dev/null +++ b/crates/ledger-mcp/src/local/fuzz_door.rs @@ -0,0 +1,187 @@ +//! The books server's HTTP door, in-process, for the fuzz targets +//! (`fuzz_http_mcp_request`, `fuzz_upload_put`; P12). It is the very router +//! `serve` mounts (`router`), over a real book in a folder of its own, fed +//! requests by `tower::oneshot`: no socket, no port. Behind the `fuzzing` +//! feature, which only the fuzz workspace turns on. +//! +//! Oracle: whatever the bytes, the door answers (no panic, no hang), and +//! never lets in a request without a token live for the book (INVARIANT 8) +//! or bytes that are not the ones declared (INVARIANT 9). + +use super::*; +use tower::ServiceExt; + +/// One book `acme`, two tokens (`bearer`, `other`), and the router. +pub struct FuzzDoor { + rt: tokio::runtime::Runtime, + books: Arc, + app: axum::Router, + bearer: String, + token_id: String, + other: String, + _home: tempfile::TempDir, +} + +impl FuzzDoor { + /// A fresh book in a fresh folder. + pub fn new() -> Self { + let home = tempfile::tempdir().expect("a folder"); + let (root, _) = + Root::setup(home.path(), Some(&home.path().join("root")), None).expect("a data root"); + 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; + match books.admin(Request::Create { + id: "acme".into(), + currency: "USD".into(), + }) { + Response::Created { problem: None, .. } => {} + other => panic!("{other:?}"), + } + let token = |name: &str| match books.admin(Request::TokenCreate { + book: "acme".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:?}"), + }; + let (token_id, bearer) = token("fuzz"); + let (_, other) = token("fuzz-other"); + let app = router(books.clone()); + let rt = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + .expect("a runtime"); + FuzzDoor { + rt, + books, + app, + bearer, + token_id, + other, + _home: home, + } + } + + /// The text of the token that is live for the book. + pub fn bearer(&self) -> &str { + &self.bearer + } + + /// The text of a second live token (the one an upload is not its). + pub fn other_bearer(&self) -> &str { + &self.other + } + + /// An upload granted to the live token: its id, or why not. + pub fn grant(&self, sha256: &str, size: u64) -> Result { + let held = self.books.held(); + let Some(Book::Open { uploads, .. }) = held.get("acme") else { + return Err("acme isn't open".into()); + }; + uploads + .grant( + &self.token_id, + sha256, + size, + "application/pdf", + "fuzz.pdf", + "document", + chrono::Utc::now().timestamp(), + ) + .map(|g| g.id) + .map_err(|e| e.to_string()) + } + + /// Whether the door would take one of these headers as a bearer token + /// live for the book: the same check every request passes, so a target + /// never has to model the rule a second time. + pub fn carries_a_token(&self, headers: &[(Vec, Vec)]) -> bool { + let mut map = axum::http::HeaderMap::new(); + for (name, value) in headers { + let (Ok(name), Ok(value)) = ( + axum::http::HeaderName::from_bytes(name), + axum::http::HeaderValue::from_bytes(value), + ) else { + return false; + }; + map.append(name, value); + } + self.books.bearer(&map).is_ok() + } + + /// Send a request as the router receives it; the status and the body + /// of the answer, or `None` when the pieces do not make an HTTP request + /// at all (a method or header the HTTP types refuse never reaches the + /// door in life either). + pub fn send( + &self, + method: &[u8], + path: &str, + headers: &[(Vec, Vec)], + body: Vec, + ) -> Option<(u16, Vec)> { + let mut request = axum::http::Request::builder() + .method(axum::http::Method::from_bytes(method).ok()?) + .uri( + format!("http://localhost{path}") + .parse::() + .ok()?, + ); + for (name, value) in headers { + request = request.header( + axum::http::HeaderName::from_bytes(name).ok()?, + axum::http::HeaderValue::from_bytes(value).ok()?, + ); + } + let request = request.body(axum::body::Body::from(body)).ok()?; + let app = self.app.clone(); + Some(self.rt.block_on(async move { + let response = app.oneshot(request).await.unwrap_or_else(|e| match e {}); + let status = response.status().as_u16(); + // An event stream never ends on its own: read what comes in a + // moment, as a client that hangs up would. + let bytes = tokio::time::timeout( + Duration::from_millis(200), + axum::body::to_bytes(response.into_body(), 1 << 20), + ) + .await + .ok() + .and_then(Result::ok) + .unwrap_or_default(); + (status, bytes.to_vec()) + })) + } + + /// Sessions the door has bound (they only grow, so the target starts + /// a fresh door now and then). + pub fn sessions(&self) -> usize { + self.books.session_tokens.lock().unwrap().len() + } +} + +impl Default for FuzzDoor { + fn default() -> Self { + Self::new() + } +}