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.
This commit is contained in:
swaits committed 2026-11-05 08:36:15 -07:00
1 parent 4b0196e416
commit 5e474cd1ac
3 files changed
+210 -7

No files matched your search

+6
View File
@@ -39,12 +39,18 @@ toml = "1"
# The vault's TINs, decoded for the filing scan (tin_files.rs), are wiped # The vault's TINs, decoded for the filing scan (tin_files.rs), are wiped
# from memory when dropped, as the vault's own are. # from memory when dropped, as the vault's own are.
zeroize = "1" zeroize = "1"
tempfile = { version = "3", optional = true }
# The socket's peer credentials (SO_PEERCRED, getpeereid) for local.rs's # The socket's peer credentials (SO_PEERCRED, getpeereid) for local.rs's
# same-user check: std has no call for them. # same-user check: std has no call for them.
[target.'cfg(unix)'.dependencies] [target.'cfg(unix)'.dependencies]
libc = "0.2" 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] [lints]
workspace = true workspace = true
+17 -7
View File
@@ -1730,6 +1730,22 @@ async fn mcp(
}) })
} }
/// The one router both doors serve (the local socket and the tunnel).
fn router(books: Arc<Books>) -> 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`. /// Anything but `/b/{book}/mcp`.
async fn not_found() -> (axum::http::StatusCode, &'static str) { async fn not_found() -> (axum::http::StatusCode, &'static str) {
(axum::http::StatusCode::NOT_FOUND, "not found") (axum::http::StatusCode::NOT_FOUND, "not found")
@@ -2080,13 +2096,7 @@ async fn serve(
} }
}); });
let app = axum::Router::new() let app = router(books.clone());
.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 // The HTTP side keeps answering (refusing, once closing) until the
// close is done: a client is never cut off mid-request by it. // close is done: a client is never cut off mid-request by it.
let shut = CancellationToken::new(); let shut = CancellationToken::new();
+187
View File
@@ -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<Books>,
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<String, String> {
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<u8>, Vec<u8>)]) -> 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<u8>, Vec<u8>)],
body: Vec<u8>,
) -> Option<(u16, Vec<u8>)> {
let mut request = axum::http::Request::builder()
.method(axum::http::Method::from_bytes(method).ok()?)
.uri(
format!("http://localhost{path}")
.parse::<axum::http::Uri>()
.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()
}
}