holger-plugin-abi 0.1.0

holger package-handler plugin ABI: the wire contract a handler speaks whether it is linked in or loaded from a .wasm
Documentation
//! **holger's package-handler plugin contract** — the one wire format a handler
//! speaks whether it is linked into the server or loaded from a `.wasm` at
//! startup.
//!
//! Rickard's ask was that *every package handler should be writable as native or
//! WASM*. Before this crate holger had no wasm path at all: the sixteen
//! repository backends are unconditional path dependencies of `server/lib`, so
//! adding a package handler meant editing `server/lib/Cargo.toml` and rebuilding
//! the server.
//!
//! # The two seams
//!
//! A `RepositoryBackendTrait` is a Rust trait object and cannot cross a wasm
//! boundary, so this crate splits a handler in two along the line that actually
//! matters:
//!
//! * [`PackageHandler`] — **the format logic**: coordinate parsing, the HTTP
//!   path scheme, listing, content types. This is what varies per ecosystem and
//!   what a plugin author writes. It is pure and target-independent, so the one
//!   implementation compiles for the host *and* for `wasm32-unknown-unknown`.
//! * [`BlobStore`] — **the bytes**. A wasm module has no filesystem and no
//!   socket. The host owns storage and hands it in.
//!
//! That split is what makes "the wasm crate is ABI glue only" true rather than
//! aspirational: the native backend implements `BlobStore` over the filesystem,
//! the wasm shim implements it over four host imports, and *both* call the same
//! `PackageHandler`. It is the same shape as znippy's maven pair, where
//! `host-decompressors` compiles the threaded `ljar` fan-out out of the wasm
//! build and leaves the single-threaded `linflate` both sides share — same
//! logic, different plumbing underneath.
//!
//! # Which verbs cross, and why
//!
//! `RepositoryBackendTrait` (`traits/src/lib.rs:404`) has twelve methods. Six
//! cross:
//!
//! | verb | why it is in |
//! |---|---|
//! | `plugin_manifest` | the module must name itself, or registration needs a config file and the whole point of scanning a directory is lost |
//! | `fetch` | the read. Non-defaulted on the trait. |
//! | `put` | the write. Non-defaulted on the trait. |
//! | `list` | enumeration; the UI and retention planner both read it |
//! | `coordinate_for_path` | the serve-time quarantine gate calls it before bytes go out, and it is pure path logic — the cheapest and most format-specific thing a handler owns |
//! | `handle_http2_request` | non-defaulted, and it is the actual door a package client knocks on. A handler that could not answer it would not be a package handler. |
//!
//! Six do not, and the host supplies the trait's own default for each:
//!
//! | verb | why it is out |
//! |---|---|
//! | `name` / `format` / `is_writable` | constants. Read once from the manifest at load, not per call. |
//! | `archive_files` / `archive_info` / `has_archive` | these describe a *znippy archive handle*. A wasm module has none, and the trait already defaults them to "no archive" — which is the truth here, not a stub. |
//! | `delete_artifact` | the trait default fails **closed**, and the contract requires the implementer to recompute the stored digest and refuse a mismatch. A sandboxed module cannot be trusted to have done that, and a wrong answer deletes bytes. Failing closed is the correct answer, not a missing feature. |
//!
//! # ABI shape
//!
//! Every exported verb takes a `(ptr, len)` pair into linear memory and returns
//! a pointer; the byte length of that result is read back with `result_len()`,
//! exactly as znippy's loader does. Payloads are length-prefixed
//! ([`codec`]) — never JSON, whose separators and missing null representation
//! are a corruption source rather than a parser bug.

pub mod codec;
pub mod guest;
pub mod wire;

pub use codec::{DecodeError, Reader, Writer};
pub use wire::{
    WireArtifactEntry, WireArtifactId, WireHttpRequest, WireHttpResponse, WireListRequest,
    WireManifest, WirePutRequest, ABI_VERSION,
};

/// Exports a `.wasm` package handler must provide. The host names the missing
/// one when a module falls short, so a half-built module is a loud load failure
/// rather than a backend that answers `None` to everything.
pub mod exports {
    /// `alloc(size: u32) -> u32` — reserve linear memory the host writes into.
    pub const ALLOC: &str = "alloc";
    /// `result_len() -> u32` — byte length of the last returned value.
    pub const RESULT_LEN: &str = "result_len";
    /// `plugin_manifest() -> u32`
    pub const MANIFEST: &str = "plugin_manifest";
    /// `plugin_fetch(ptr, len) -> u32`
    pub const FETCH: &str = "plugin_fetch";
    /// `plugin_put(ptr, len) -> u32`
    pub const PUT: &str = "plugin_put";
    /// `plugin_list(ptr, len) -> u32`
    pub const LIST: &str = "plugin_list";
    /// `plugin_coordinate_for_path(ptr, len) -> u32`
    pub const COORDINATE_FOR_PATH: &str = "plugin_coordinate_for_path";
    /// `plugin_http(ptr, len) -> u32`
    pub const HTTP: &str = "plugin_http";

    /// Every export, in load-check order.
    pub const ALL: &[&str] =
        &[ALLOC, RESULT_LEN, MANIFEST, FETCH, PUT, LIST, COORDINATE_FOR_PATH, HTTP];
}

/// Host functions a module imports from `env` to reach storage. The host owns
/// every byte; the module owns only the format logic.
///
/// All four return a packed `u64`: `ptr << 32 | len`, and **`0` means the host
/// call itself failed** — never "absent". Absence is carried inside the payload
/// as an encoded `Option`, so a stored empty blob and a missing key stay
/// distinguishable (the exact distinction znippy's JSON row format cannot make).
pub mod imports {
    /// `host_store_get(key_ptr, key_len) -> u64` → encoded `Option<Vec<u8>>`.
    pub const STORE_GET: &str = "host_store_get";
    /// `host_store_put(key_ptr, key_len, data_ptr, data_len) -> u64` → encoded `Result<(), String>`.
    pub const STORE_PUT: &str = "host_store_put";
    /// `host_store_list(prefix_ptr, prefix_len) -> u64` → encoded `Vec<(String, u64)>`.
    pub const STORE_LIST: &str = "host_store_list";
    /// `host_store_size(key_ptr, key_len) -> u64` → encoded `Option<Vec<u8>>` holding
    /// an 8-byte little-endian size, so "absent" and "zero bytes" stay distinct.
    pub const STORE_SIZE: &str = "host_store_size";

    pub const ALL: &[&str] = &[STORE_GET, STORE_PUT, STORE_LIST, STORE_SIZE];

    /// The module namespace all of the above are imported from.
    pub const MODULE: &str = "env";
}

/// The bytes behind a handler. Implemented **natively** over a directory and
/// **in wasm** over the [`imports`] host functions — the handler logic above it
/// never learns which.
///
/// Keys are handler-chosen store paths (`bundles/tillsynia-1.4.znippy`), not
/// filesystem paths: the host is free to place them wherever it likes and a
/// module never sees an absolute path.
pub trait BlobStore {
    /// The stored bytes, or `None` if the key is absent. An `Err` is a *store*
    /// failure (unreadable, permission denied) and must not be flattened into
    /// `None` — a missing artifact and a broken disk are different answers.
    fn get(&self, key: &str) -> Result<Option<Vec<u8>>, String>;

    /// Store bytes under `key`, overwriting.
    fn put(&self, key: &str, data: &[u8]) -> Result<(), String>;

    /// `(key, size_bytes)` for every key starting with `prefix`.
    fn list(&self, prefix: &str) -> Result<Vec<(String, u64)>, String>;

    /// Size of one key without reading it. Defaults to reading and measuring —
    /// correct everywhere, and a store with a cheaper stat overrides it.
    fn size(&self, key: &str) -> Result<Option<u64>, String> {
        Ok(self.get(key)?.map(|b| b.len() as u64))
    }
}

/// **The format logic of one package handler.** Everything here is pure over a
/// [`BlobStore`], so the single implementation compiles for the host and for
/// `wasm32-unknown-unknown` unchanged. A handler crate implements this once; the
/// wasm crate beside it adds no logic, only the ABI shim.
pub trait PackageHandler {
    /// Identity, reported to the host at load.
    fn manifest(&self) -> WireManifest;

    /// Fetch one coordinate's bytes.
    fn fetch(
        &self,
        store: &dyn BlobStore,
        id: &WireArtifactId,
    ) -> Result<Option<Vec<u8>>, String>;

    /// Store one coordinate's bytes.
    fn put(&self, store: &dyn BlobStore, id: &WireArtifactId, data: &[u8])
        -> Result<(), String>;

    /// Enumerate held artifacts, filtered by a substring of the name.
    fn list(
        &self,
        store: &dyn BlobStore,
        name_filter: Option<&str>,
        limit: usize,
    ) -> Result<Vec<WireArtifactEntry>, String>;

    /// Map a raw HTTP suburl back to a coordinate — the inverse of the path this
    /// handler serves from. Pure: it must not touch the store, because the
    /// serve-time quarantine gate calls it on every request.
    fn coordinate_for_path(&self, suburl: &str) -> Option<WireArtifactId>;

    /// Answer one HTTP request against this handler's own path scheme.
    fn http(
        &self,
        store: &dyn BlobStore,
        req: &WireHttpRequest,
    ) -> Result<WireHttpResponse, String>;
}