Skip to main content

holger_plugin_abi/
lib.rs

1//! **holger's package-handler plugin contract** — the one wire format a handler
2//! speaks whether it is linked into the server or loaded from a `.wasm` at
3//! startup.
4//!
5//! Rickard's ask was that *every package handler should be writable as native or
6//! WASM*. Before this crate holger had no wasm path at all: the sixteen
7//! repository backends are unconditional path dependencies of `server/lib`, so
8//! adding a package handler meant editing `server/lib/Cargo.toml` and rebuilding
9//! the server.
10//!
11//! # The two seams
12//!
13//! A `RepositoryBackendTrait` is a Rust trait object and cannot cross a wasm
14//! boundary, so this crate splits a handler in two along the line that actually
15//! matters:
16//!
17//! * [`PackageHandler`] — **the format logic**: coordinate parsing, the HTTP
18//!   path scheme, listing, content types. This is what varies per ecosystem and
19//!   what a plugin author writes. It is pure and target-independent, so the one
20//!   implementation compiles for the host *and* for `wasm32-unknown-unknown`.
21//! * [`BlobStore`] — **the bytes**. A wasm module has no filesystem and no
22//!   socket. The host owns storage and hands it in.
23//!
24//! That split is what makes "the wasm crate is ABI glue only" true rather than
25//! aspirational: the native backend implements `BlobStore` over the filesystem,
26//! the wasm shim implements it over four host imports, and *both* call the same
27//! `PackageHandler`. It is the same shape as znippy's maven pair, where
28//! `host-decompressors` compiles the threaded `ljar` fan-out out of the wasm
29//! build and leaves the single-threaded `linflate` both sides share — same
30//! logic, different plumbing underneath.
31//!
32//! # Which verbs cross, and why
33//!
34//! `RepositoryBackendTrait` (`traits/src/lib.rs:404`) has twelve methods. Six
35//! cross:
36//!
37//! | verb | why it is in |
38//! |---|---|
39//! | `plugin_manifest` | the module must name itself, or registration needs a config file and the whole point of scanning a directory is lost |
40//! | `fetch` | the read. Non-defaulted on the trait. |
41//! | `put` | the write. Non-defaulted on the trait. |
42//! | `list` | enumeration; the UI and retention planner both read it |
43//! | `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 |
44//! | `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. |
45//!
46//! Six do not, and the host supplies the trait's own default for each:
47//!
48//! | verb | why it is out |
49//! |---|---|
50//! | `name` / `format` / `is_writable` | constants. Read once from the manifest at load, not per call. |
51//! | `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. |
52//! | `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. |
53//!
54//! # ABI shape
55//!
56//! Every exported verb takes a `(ptr, len)` pair into linear memory and returns
57//! a pointer; the byte length of that result is read back with `result_len()`,
58//! exactly as znippy's loader does. Payloads are length-prefixed
59//! ([`codec`]) — never JSON, whose separators and missing null representation
60//! are a corruption source rather than a parser bug.
61
62pub mod codec;
63pub mod guest;
64pub mod wire;
65
66pub use codec::{DecodeError, Reader, Writer};
67pub use wire::{
68    WireArtifactEntry, WireArtifactId, WireHttpRequest, WireHttpResponse, WireListRequest,
69    WireManifest, WirePutRequest, ABI_VERSION,
70};
71
72/// Exports a `.wasm` package handler must provide. The host names the missing
73/// one when a module falls short, so a half-built module is a loud load failure
74/// rather than a backend that answers `None` to everything.
75pub mod exports {
76    /// `alloc(size: u32) -> u32` — reserve linear memory the host writes into.
77    pub const ALLOC: &str = "alloc";
78    /// `result_len() -> u32` — byte length of the last returned value.
79    pub const RESULT_LEN: &str = "result_len";
80    /// `plugin_manifest() -> u32`
81    pub const MANIFEST: &str = "plugin_manifest";
82    /// `plugin_fetch(ptr, len) -> u32`
83    pub const FETCH: &str = "plugin_fetch";
84    /// `plugin_put(ptr, len) -> u32`
85    pub const PUT: &str = "plugin_put";
86    /// `plugin_list(ptr, len) -> u32`
87    pub const LIST: &str = "plugin_list";
88    /// `plugin_coordinate_for_path(ptr, len) -> u32`
89    pub const COORDINATE_FOR_PATH: &str = "plugin_coordinate_for_path";
90    /// `plugin_http(ptr, len) -> u32`
91    pub const HTTP: &str = "plugin_http";
92
93    /// Every export, in load-check order.
94    pub const ALL: &[&str] =
95        &[ALLOC, RESULT_LEN, MANIFEST, FETCH, PUT, LIST, COORDINATE_FOR_PATH, HTTP];
96}
97
98/// Host functions a module imports from `env` to reach storage. The host owns
99/// every byte; the module owns only the format logic.
100///
101/// All four return a packed `u64`: `ptr << 32 | len`, and **`0` means the host
102/// call itself failed** — never "absent". Absence is carried inside the payload
103/// as an encoded `Option`, so a stored empty blob and a missing key stay
104/// distinguishable (the exact distinction znippy's JSON row format cannot make).
105pub mod imports {
106    /// `host_store_get(key_ptr, key_len) -> u64` → encoded `Option<Vec<u8>>`.
107    pub const STORE_GET: &str = "host_store_get";
108    /// `host_store_put(key_ptr, key_len, data_ptr, data_len) -> u64` → encoded `Result<(), String>`.
109    pub const STORE_PUT: &str = "host_store_put";
110    /// `host_store_list(prefix_ptr, prefix_len) -> u64` → encoded `Vec<(String, u64)>`.
111    pub const STORE_LIST: &str = "host_store_list";
112    /// `host_store_size(key_ptr, key_len) -> u64` → encoded `Option<Vec<u8>>` holding
113    /// an 8-byte little-endian size, so "absent" and "zero bytes" stay distinct.
114    pub const STORE_SIZE: &str = "host_store_size";
115
116    pub const ALL: &[&str] = &[STORE_GET, STORE_PUT, STORE_LIST, STORE_SIZE];
117
118    /// The module namespace all of the above are imported from.
119    pub const MODULE: &str = "env";
120}
121
122/// The bytes behind a handler. Implemented **natively** over a directory and
123/// **in wasm** over the [`imports`] host functions — the handler logic above it
124/// never learns which.
125///
126/// Keys are handler-chosen store paths (`bundles/tillsynia-1.4.znippy`), not
127/// filesystem paths: the host is free to place them wherever it likes and a
128/// module never sees an absolute path.
129pub trait BlobStore {
130    /// The stored bytes, or `None` if the key is absent. An `Err` is a *store*
131    /// failure (unreadable, permission denied) and must not be flattened into
132    /// `None` — a missing artifact and a broken disk are different answers.
133    fn get(&self, key: &str) -> Result<Option<Vec<u8>>, String>;
134
135    /// Store bytes under `key`, overwriting.
136    fn put(&self, key: &str, data: &[u8]) -> Result<(), String>;
137
138    /// `(key, size_bytes)` for every key starting with `prefix`.
139    fn list(&self, prefix: &str) -> Result<Vec<(String, u64)>, String>;
140
141    /// Size of one key without reading it. Defaults to reading and measuring —
142    /// correct everywhere, and a store with a cheaper stat overrides it.
143    fn size(&self, key: &str) -> Result<Option<u64>, String> {
144        Ok(self.get(key)?.map(|b| b.len() as u64))
145    }
146}
147
148/// **The format logic of one package handler.** Everything here is pure over a
149/// [`BlobStore`], so the single implementation compiles for the host and for
150/// `wasm32-unknown-unknown` unchanged. A handler crate implements this once; the
151/// wasm crate beside it adds no logic, only the ABI shim.
152pub trait PackageHandler {
153    /// Identity, reported to the host at load.
154    fn manifest(&self) -> WireManifest;
155
156    /// Fetch one coordinate's bytes.
157    fn fetch(
158        &self,
159        store: &dyn BlobStore,
160        id: &WireArtifactId,
161    ) -> Result<Option<Vec<u8>>, String>;
162
163    /// Store one coordinate's bytes.
164    fn put(&self, store: &dyn BlobStore, id: &WireArtifactId, data: &[u8])
165        -> Result<(), String>;
166
167    /// Enumerate held artifacts, filtered by a substring of the name.
168    fn list(
169        &self,
170        store: &dyn BlobStore,
171        name_filter: Option<&str>,
172        limit: usize,
173    ) -> Result<Vec<WireArtifactEntry>, String>;
174
175    /// Map a raw HTTP suburl back to a coordinate — the inverse of the path this
176    /// handler serves from. Pure: it must not touch the store, because the
177    /// serve-time quarantine gate calls it on every request.
178    fn coordinate_for_path(&self, suburl: &str) -> Option<WireArtifactId>;
179
180    /// Answer one HTTP request against this handler's own path scheme.
181    fn http(
182        &self,
183        store: &dyn BlobStore,
184        req: &WireHttpRequest,
185    ) -> Result<WireHttpResponse, String>;
186}