Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/knut

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())))
    }
}