crates/knut-wasm-host/src/service.rs
//! [`WasmHost`] — the mountable thing: a directory of bytes behind one path
//! prefix, plus the entry redirect.
//!
//! The whole answer is computed by [`WasmHost::respond`], which is a plain
//! synchronous function of `(method, path)`. That is the seam on purpose: the
//! [`hyper::service::Service`] impl below is three lines over it, and a caller on a
//! *different* service trait (tonic's router wants a `tower::Service`, axum wants a
//! handler) writes their own three lines instead of this crate growing a dependency
//! on their framework.
//!
//! ## Traversal
//!
//! The request path is REBUILT from `Component::Normal` segments only. `..`, an
//! absolute path, a Windows prefix, and any dot-file are `404` before anything is
//! opened. The percent-encoded forms never decode here — hyper hands over the raw
//! path and `%2F` is not a separator, so `..%2FCargo.toml` is just a file name that
//! does not exist.
use std::convert::Infallible;
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
use bytes::Bytes;
use hyper::http::{Method, Request, Response, StatusCode};
use crate::bundle::{Bundle, BundleError};
use crate::entry::EntryPoint;
use crate::media::{content_type, known_extensions};
use crate::response::{HostBody, bytes_response, text_response};
/// A bundle directory served under one path prefix, with an entry redirect.
///
/// `Clone` is one `Arc` — cheap, which is what a per-connection service needs.
#[derive(Clone, Debug)]
pub struct WasmHost {
inner: Arc<Inner>,
}
#[derive(Debug)]
struct Inner {
bundle: Result<Bundle, BundleError>,
entry: EntryPoint,
/// `""` (mounted at the root) or `/something` with no trailing slash.
prefix: String,
/// Whether a path naming a file NOT in the spec's required list may be read
/// from disk. Off by default — see [`WasmHost::serving_unlisted_files`].
unlisted: bool,
}
impl WasmHost {
/// Mount `bundle` — or the refusal to serve one — with `entry` as `GET /`.
///
/// A failed load is NOT an error here: the host still mounts, and every page
/// route answers `503` with the whole refusal. The caller's own data routes go
/// on working, which is the difference between "the viewer is missing" and "the
/// server is down".
#[must_use]
pub fn new(bundle: Result<Bundle, BundleError>, entry: EntryPoint) -> Self {
WasmHost {
inner: Arc::new(Inner {
bundle,
entry,
prefix: String::new(),
unlisted: false,
}),
}
}
/// Serve under `prefix` (e.g. `/facet`) instead of at the root. Leading slash
/// added and trailing slashes trimmed, so `facet`, `/facet` and `/facet/` are
/// the same mount.
#[must_use]
pub fn with_prefix(self, prefix: impl AsRef<str>) -> Self {
let p = prefix.as_ref().trim_matches('/');
let prefix = if p.is_empty() {
String::new()
} else {
format!("/{p}")
};
self.map_inner(|mut i| {
i.prefix = prefix;
i
})
}
/// Also serve files that are in the directory but NOT in the spec's required
/// list — for a bundle with a variable subtree (wasm-bindgen `snippets/`).
///
/// Off by default, and deliberately opt-in: it reads from disk **inside the
/// response path** with blocking `std::fs`, where the default host answers
/// every request from the bytes it read once at start-up. Turn it on only if
/// the bundle really has files that cannot be listed up front.
#[must_use]
pub fn serving_unlisted_files(self) -> Self {
self.map_inner(|mut i| {
i.unlisted = true;
i
})
}
fn map_inner(self, f: impl FnOnce(Inner) -> Inner) -> Self {
let inner = match Arc::try_unwrap(self.inner) {
Ok(i) => f(i),
// Cloned already: rebuild rather than mutate someone else's mount.
Err(shared) => f(Inner {
bundle: match &shared.bundle {
Ok(b) => Ok(b.clone()),
Err(e) => Err(e.clone()),
},
entry: shared.entry.clone(),
prefix: shared.prefix.clone(),
unlisted: shared.unlisted,
}),
};
WasmHost {
inner: Arc::new(inner),
}
}
/// The loaded bundle, or the refusal.
pub fn bundle(&self) -> Result<&Bundle, &BundleError> {
self.inner.bundle.as_ref()
}
/// The entry redirect's target.
#[must_use]
pub fn entry(&self) -> &EntryPoint {
&self.inner.entry
}
/// The mount prefix (`""` at the root).
#[must_use]
pub fn prefix(&self) -> &str {
&self.inner.prefix
}
/// Whether there are bytes to serve at all.
#[must_use]
pub fn is_available(&self) -> bool {
self.inner.bundle.is_ok()
}
/// The `503` every page route answers with when there is no bundle: plain text,
/// the WHOLE [`BundleError`]. `None` when the bundle loaded.
#[must_use]
pub fn refusal(&self) -> Option<Response<HostBody>> {
self.inner.bundle.as_ref().err().map(|e| {
text_response(StatusCode::SERVICE_UNAVAILABLE, e.to_string())
})
}
/// The whole answer for one request. Synchronous, allocation-light, and the
/// seam every service-trait impl goes through.
#[must_use]
pub fn respond(&self, method: &Method, path: &str) -> Response<HostBody> {
if method != Method::GET && method != Method::HEAD {
return text_response(
StatusCode::METHOD_NOT_ALLOWED,
"this route serves bundle bytes: GET or HEAD only",
);
}
// No bundle: every path under the mount says WHY, at the first URL a person
// opens, instead of rendering nothing.
if let Some(refusal) = self.refusal() {
return Self::maybe_head(method, refusal);
}
let Some(rest) = self.strip_prefix(path) else {
return Self::maybe_head(method, Self::not_found("outside the bundle mount"));
};
if rest.is_empty() || rest == "/" {
return Self::maybe_head(method, self.inner.entry.redirect());
}
Self::maybe_head(method, self.file(rest))
}
/// The response for one file path relative to the mount (leading `/` included).
#[must_use]
pub fn file(&self, rest: &str) -> Response<HostBody> {
if let Some(refusal) = self.refusal() {
return refusal;
}
let Some(rel) = relative_file(rest) else {
return Self::not_found("not a plain path inside the bundle");
};
// Extension first: an unknown one is refused BEFORE the filesystem is
// touched, so a stray file in the directory can never be served as a guess.
let Some(ct) = content_type(&rel) else {
return Self::not_found(&format!(
"unknown extension — this host serves only: {}",
known_extensions()
));
};
let Ok(bundle) = self.inner.bundle.as_ref() else {
unreachable!("refused above");
};
let Some(name) = rel.to_str() else {
return Self::not_found("not a plain path inside the bundle");
};
if let Some(bytes) = bundle.file(name) {
return bytes_response(StatusCode::OK, ct, bytes.clone());
}
if !self.inner.unlisted {
return Self::not_found("not part of this bundle");
}
match std::fs::read(bundle.dir().join(&rel)) {
Ok(bytes) => bytes_response(StatusCode::OK, ct, Bytes::from(bytes)),
Err(_) => Self::not_found("not part of this bundle"),
}
}
/// `path` with the mount prefix removed, or `None` when it is not under it.
fn strip_prefix<'a>(&self, path: &'a str) -> Option<&'a str> {
if self.inner.prefix.is_empty() {
return Some(path);
}
let rest = path.strip_prefix(self.inner.prefix.as_str())?;
(rest.is_empty() || rest.starts_with('/')).then_some(rest)
}
/// A `404` that says which rule was broken — never a bare status.
fn not_found(why: &str) -> Response<HostBody> {
text_response(StatusCode::NOT_FOUND, format!("no such bundle file: {why}"))
}
/// A `HEAD` gets the headers of the `GET` and none of the body.
fn maybe_head(method: &Method, resp: Response<HostBody>) -> Response<HostBody> {
if method != Method::HEAD {
return resp;
}
let (parts, _) = resp.into_parts();
Response::from_parts(parts, HostBody::new(Bytes::new()))
}
}
/// The file a request path names INSIDE the bundle directory — or `None` when it is
/// anything but a chain of plain, non-dot segments.
///
/// This is the traversal guard, and it works by rebuilding rather than by
/// blocklisting: `Path::components` folds `.` away, and the match then accepts only
/// `Component::Normal`, which leaves no spelling of `..`, of a root, or of a drive
/// prefix that can get through.
#[must_use]
pub fn relative_file(uri_path: &str) -> Option<PathBuf> {
let mut out = PathBuf::new();
let mut any = false;
for component in Path::new(uri_path.trim_start_matches('/')).components() {
match component {
Component::Normal(seg) => {
let s = seg.to_str()?;
// A dot-file is never part of a bundle, and `.git` least of all.
if s.starts_with('.') {
return None;
}
out.push(s);
any = true;
}
_ => return None,
}
}
any.then_some(out)
}
impl<B> hyper::service::Service<Request<B>> for WasmHost {
type Response = Response<HostBody>;
type Error = Infallible;
type Future = std::future::Ready<Result<Self::Response, Self::Error>>;
fn call(&self, req: Request<B>) -> Self::Future {
std::future::ready(Ok(self.respond(req.method(), req.uri().path())))
}
}