crates/knut-wasm-host/src/bundle.rs
//! Bundle discovery and validation — the directory of bytes, read once.
//!
//! The host does not know what a bundle *is*. It knows a caller-supplied list of
//! file names, a caller-supplied recipe for producing them, and one universal fact
//! about the format it exists to serve: a `.wasm` file starts with `\0asm`. Every
//! other sentence in a refusal is the caller's own text, quoted back.
//!
//! A refusal is LOUD by construction: [`BundleError`]'s `Display` names every
//! problem file, the directory that was looked in, the env var (if the caller has
//! one) and the recipe. It is the text a browser gets and the text the terminal
//! gets — never a blank page.
use std::path::{Path, PathBuf};
use bytes::Bytes;
/// `\0asm` — the first four bytes of every WebAssembly module. The one thing about
/// the payload this crate is allowed to know: it is in its name.
pub const WASM_MAGIC: [u8; 4] = [0x00, b'a', b's', b'm'];
/// What a caller expects a bundle directory to hold, and what to say when it does
/// not. Built once, used for every load.
#[derive(Clone, Debug)]
pub struct BundleSpec {
headline: String,
required: Vec<String>,
recipe: String,
env_var: Option<String>,
contains: Vec<ContentCheck>,
}
/// "File `file` must contain `needle`, because `why`" — a caller's own sanity check
/// on a text file it knows the shape of. The host neither writes nor reads meaning
/// into any of the three strings.
#[derive(Clone, Debug)]
struct ContentCheck {
file: String,
needle: String,
why: String,
}
impl BundleSpec {
/// `headline` is the first line of every refusal (the caller's voice, e.g.
/// "knutpipe web: the facet-wrapped wasm bundle is not available.").
/// `required` is the file names that must be present and non-empty.
/// `recipe` is how a person produces them — free text, printed verbatim; the
/// host has never heard of the tool that writes these files.
pub fn new<H, N, I, R>(headline: H, required: I, recipe: R) -> Self
where
H: Into<String>,
N: Into<String>,
I: IntoIterator<Item = N>,
R: Into<String>,
{
BundleSpec {
headline: headline.into(),
required: required.into_iter().map(Into::into).collect(),
recipe: recipe.into(),
env_var: None,
contains: Vec::new(),
}
}
/// Name the environment variable a caller reads the directory from, so a
/// refusal can say which variable to set. See [`BundleSpec::load_from_env`].
#[must_use]
pub fn with_env_var(mut self, var: impl Into<String>) -> Self {
self.env_var = Some(var.into());
self
}
/// Require that `file`'s bytes contain `needle`, refusing with `why` when they
/// do not. The caller's check on the caller's file — this is how a loader page
/// that does not actually load anything gets caught without the host learning
/// what a loader is.
#[must_use]
pub fn requiring_content(
mut self,
file: impl Into<String>,
needle: impl Into<String>,
why: impl Into<String>,
) -> Self {
self.contains.push(ContentCheck {
file: file.into(),
needle: needle.into(),
why: why.into(),
});
self
}
/// The file names this spec requires.
#[must_use]
pub fn required(&self) -> &[String] {
&self.required
}
/// The caller's recipe for producing them.
#[must_use]
pub fn recipe(&self) -> &str {
&self.recipe
}
/// The environment variable named by [`BundleSpec::with_env_var`], if any.
#[must_use]
pub fn env_var(&self) -> Option<&str> {
self.env_var.as_deref()
}
/// Read the bundle from `dir`, collecting EVERY problem before refusing — a
/// person fixing a broken bundle should need one round trip, not three.
pub fn load(&self, dir: impl AsRef<Path>) -> Result<Bundle, BundleError> {
let dir = dir.as_ref().to_path_buf();
let mut problems = Vec::new();
let mut files: Vec<(String, Bytes)> = Vec::with_capacity(self.required.len());
for name in &self.required {
let bytes = match std::fs::read(dir.join(name)) {
Ok(b) if b.is_empty() => {
problems.push(format!("{name}: empty file"));
Bytes::new()
}
Ok(b) => Bytes::from(b),
Err(e) => {
problems.push(format!("{name}: {e}"));
Bytes::new()
}
};
files.push((name.clone(), bytes));
}
// The ONE fact about the payload this crate is entitled to know: a file
// named `.wasm` must be a WebAssembly module. Anything else (a 404 page
// saved over it, a git-lfs pointer) only fails later, in a browser console
// nobody is watching.
for (name, bytes) in &files {
if !Path::new(name).extension().is_some_and(|e| e.eq_ignore_ascii_case("wasm")) {
continue;
}
if bytes.is_empty() || bytes.starts_with(&WASM_MAGIC) {
continue;
}
problems.push(format!(
"{name}: not a WebAssembly module (magic {:02x?}, expected \\0asm)",
&bytes[..bytes.len().min(WASM_MAGIC.len())]
));
}
for check in &self.contains {
let Some((_, bytes)) = files.iter().find(|(n, _)| *n == check.file) else {
continue;
};
if bytes.is_empty() {
continue; // already accused above; do not pile on.
}
let needle = check.needle.as_bytes();
let found = !needle.is_empty()
&& bytes.len() >= needle.len()
&& bytes.windows(needle.len()).any(|w| w == needle);
if !found {
problems.push(format!(
"{}: does not contain {} — {}",
check.file, check.needle, check.why
));
}
}
if problems.is_empty() {
Ok(Bundle { dir, files })
} else {
Err(self.refuse(Some(dir), problems))
}
}
/// [`BundleSpec::load`] from the directory named by the spec's env var. No env
/// var, or an unset/empty one, is a refusal like any other — and it says which
/// variable to set.
pub fn load_from_env(&self) -> Result<Bundle, BundleError> {
let Some(var) = self.env_var.as_deref() else {
return Err(self.refuse(
None,
vec!["no environment variable is configured for the bundle directory".to_string()],
));
};
match std::env::var_os(var) {
Some(dir) if !dir.is_empty() => self.load(PathBuf::from(dir)),
_ => Err(self.refuse(None, vec![format!("{var} is not set")])),
}
}
/// Build the refusal for `problems`, carrying this spec's whole voice.
fn refuse(&self, dir: Option<PathBuf>, problems: Vec<String>) -> BundleError {
BundleError(Box::new(Refusal {
dir,
problems,
headline: self.headline.clone(),
required: self.required.clone(),
recipe: self.recipe.clone(),
env_var: self.env_var.clone(),
}))
}
}
/// A bundle directory, read into memory ONCE. The bytes are [`Bytes`] — refcounted,
/// so each of thousands of responses is a clone of a pointer, not of a wasm module.
#[derive(Clone, Debug)]
pub struct Bundle {
dir: PathBuf,
files: Vec<(String, Bytes)>,
}
impl Bundle {
/// The directory the bundle was read from.
#[must_use]
pub fn dir(&self) -> &Path {
&self.dir
}
/// The bytes of a required file, by the name the caller gave for it.
#[must_use]
pub fn file(&self, name: &str) -> Option<&Bytes> {
self.files.iter().find(|(n, _)| n == name).map(|(_, b)| b)
}
/// The names held in memory, in the order the caller listed them.
pub fn names(&self) -> impl Iterator<Item = &str> {
self.files.iter().map(|(n, _)| n.as_str())
}
}
/// Why a bundle could not be loaded. Its `Display` is the whole refusal: headline,
/// directory, every problem by file name, and the caller's recipe.
///
/// The payload is BOXED. A refusal carries six owned fields and `load` returns it
/// by value, so unboxed it would make every `Result<Bundle, BundleError>` — the
/// happy path included — three times the size of the bundle handle it usually
/// holds. The error is the rare arm; it can pay for the pointer.
#[derive(Clone, Debug)]
pub struct BundleError(Box<Refusal>);
#[derive(Clone, Debug)]
struct Refusal {
dir: Option<PathBuf>,
problems: Vec<String>,
headline: String,
required: Vec<String>,
recipe: String,
env_var: Option<String>,
}
impl BundleError {
/// The directory that was looked in, when there was one.
#[must_use]
pub fn dir(&self) -> Option<&Path> {
self.0.dir.as_deref()
}
/// One line per thing that is wrong.
#[must_use]
pub fn problems(&self) -> &[String] {
&self.0.problems
}
}
impl std::fmt::Display for BundleError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let r = &self.0;
writeln!(f, "{}", r.headline)?;
match (&r.dir, &r.env_var) {
(Some(d), _) => writeln!(f, " bundle dir: {}", d.display())?,
(None, Some(v)) => writeln!(f, " bundle dir: (none — set {v})")?,
(None, None) => writeln!(f, " bundle dir: (none)")?,
}
for p in &r.problems {
writeln!(f, " - {p}")?;
}
writeln!(f)?;
writeln!(f, "{}", r.recipe)?;
if let Some(v) = &r.env_var {
writeln!(f, "Point the host at that directory ({v}=DIR).")?;
}
write!(f, "Required files: {}", r.required.join(", "))
}
}
impl std::error::Error for BundleError {}