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/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 {}