pluginabi guide

← pluginabi · API reference · source

A host drives a plugin through six operations — initial_state, apply_op, make_op, render, snapshot, load_snapshot. pluginabi is the vocabulary of those calls: a request frame (an operation, the plugin's state, an argument) and a reply frame (a payload, or an error code), both canonical CBOR bytes. The plugin's state is opaque bytes the plugin owns; the host only carries it from one call to the next.

It is not a runtime and not a sandbox. How the frame travels — a direct call, as here, or a wasm boundary — is the host's; what keeps a plugin safe is loft's admission control at load.

use pluginabi::*;
use crypto::*;      // bytes_to_base64, base64_to_bytes, base64_encode, base64_decode

The plugin: a counter whose whole state is ONE BYTE. Every operation goes through the one front door, check_request, before the plugin reads anything.

fn counter(frame: vector<u8>) -> vector<u8> {
  bad = check_request(frame);
  if bad != "" { return reply_err(bad); }
  op = req_op(frame);
  st = base64_to_bytes(req_state_b64(frame));
  n: integer = st[0] ?? 0;
  if op == OP_INITIAL_STATE { return reply_ok(bytes_to_base64([0 as u8])); }
  if op == OP_APPLY_OP {
    k: integer = base64_to_bytes(req_arg_b64(frame))[0] ?? 0;
    if n + k > 255 { return reply_err(ERR_PLUGIN); }   // a refusal: the state does not move
    next: u8 = (n + k) as u8 ?? 0;
    return reply_ok(bytes_to_base64([next]));
  }
  if op == OP_MAKE_OP { return reply_ok(req_arg_b64(frame)); }   // an op is its own bytes
  if op == OP_RENDER { return reply_ok(base64_encode("count {n}")); }
  if op == OP_SNAPSHOT || op == OP_LOAD_SNAPSHOT { return reply_ok(req_state_b64(frame)); }
  reply_err(ERR_UNKNOWN_OP)
}

The host's side: build a request, hand it over, get a reply frame back.

fn call(op: text, state: text, arg: text) -> vector<u8> { counter(request(op, state, arg)) }
fn main() {
  st = reply_out_b64(call(OP_INITIAL_STATE, "", ""));
  st = reply_out_b64(call(OP_APPLY_OP, st, bytes_to_base64([5 as u8])));
  shown = base64_decode(reply_out_b64(call(OP_RENDER, st, "")));
  println("state {st}, rendered '{shown}'");
  assert(st == "BQ==" && shown == "count 5", "one byte, 5; and what the plugin draws");

Every section below is a function, and main calls each one: a section nothing calls still compiles, but its assertions never run.

  bytes_not_text();
  ask_the_verdict_first();
  the_vocabulary_is_closed();
  a_snapshot_is_the_state();
}

That prints:

  state BQ==, rendered 'count 5'

Every payload is base64 text

A payload is the BASE64 of the bytes, never the bytes and never a plain string. That keeps 255 and 0 — values no text carries — intact across the round trip. A plain string by mistake gets no frame at all: request answers an empty frame, and the front door refuses it.

fn bytes_not_text() {
  st = reply_out_b64(call(OP_APPLY_OP, bytes_to_base64([5 as u8]), bytes_to_base64([250 as u8])));
  assert(st == "/w==" && (base64_to_bytes(st)[0] ?? 0) == 255, "5 + 250 is the byte 255");
  wrong = request(OP_APPLY_OP, "hello", "");
  assert(len(wrong) == 0 && check_request(wrong) == ERR_MALFORMED, "a plain string is no frame");
  assert(reply_err_code(counter(wrong)) == "malformed-frame", "and the plugin says so");
  println("bytes   : 255 survives; \"hello\" is refused as {ERR_MALFORMED}");
}

Ask the verdict first

A plugin may refuse a well-formed request — this one refuses to count past 255. Then reply_out_b64 answers "", exactly what a success with an empty payload answers, so only reply_is_ok tells the two apart. Ask it first, then read the one field that outcome has; on a refusal the host keeps the state it already had.

fn ask_the_verdict_first() {
  st = bytes_to_base64([255 as u8]);
  r = call(OP_APPLY_OP, st, bytes_to_base64([1 as u8]));
  assert(!reply_is_ok(r) && reply_err_code(r) == ERR_PLUGIN, "256 does not fit in the byte");
  assert(reply_out_b64(r) == "", "and its payload reads like an empty success");
  if reply_is_ok(r) { st = reply_out_b64(r); }
  assert(st == "/w==", "so the host keeps 255");
  println("refusal : {reply_err_code(r)}, state stays {st}");
}

The vocabulary is closed

An operation outside the six is refused at the front door as unknown-op — never guessed at, because a host and a plugin disagreeing about the vocabulary is version skew. takes_arg says which operations read the argument: apply_op and make_op, and no others.

fn the_vocabulary_is_closed() {
  r = call("explode", "", "");
  assert(reply_err_code(r) == ERR_UNKNOWN_OP, "not one of the six");
  assert(takes_arg(OP_APPLY_OP) && takes_arg(OP_MAKE_OP) && !takes_arg(OP_SNAPSHOT), "two take an arg");
  println("closed  : 'explode' is {reply_err_code(r)}");
}

A snapshot is the state

snapshot hands the host bytes it can store, and load_snapshot takes them back. This plugin's snapshot is its state as it stands; a plugin with caches would drop them here.

fn a_snapshot_is_the_state() {
  st = bytes_to_base64([42 as u8]);
  snap = reply_out_b64(call(OP_SNAPSHOT, st, ""));
  back = reply_out_b64(call(OP_LOAD_SNAPSHOT, snap, ""));
  assert(snap == "Kg==" && back == st, "42 out, 42 back");
  println("snapshot: {snap} and back");
}

Where to go next