kobako 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/Cargo.lock +2347 -0
- data/Cargo.toml +11 -0
- data/LICENSE +201 -0
- data/README.md +228 -0
- data/data/kobako.wasm +0 -0
- data/ext/kobako/Cargo.toml +36 -0
- data/ext/kobako/extconf.rb +6 -0
- data/ext/kobako/src/lib.rs +10 -0
- data/ext/kobako/src/wasm/cache.rs +92 -0
- data/ext/kobako/src/wasm/dispatch.rs +110 -0
- data/ext/kobako/src/wasm/host_state.rs +59 -0
- data/ext/kobako/src/wasm/instance.rs +361 -0
- data/ext/kobako/src/wasm.rs +80 -0
- data/lib/kobako/errors.rb +88 -0
- data/lib/kobako/registry/dispatcher.rb +168 -0
- data/lib/kobako/registry/handle_table.rb +107 -0
- data/lib/kobako/registry/service_group.rb +65 -0
- data/lib/kobako/registry.rb +160 -0
- data/lib/kobako/sandbox/outcome_decoder.rb +100 -0
- data/lib/kobako/sandbox/output_buffer.rb +79 -0
- data/lib/kobako/sandbox.rb +148 -0
- data/lib/kobako/version.rb +5 -0
- data/lib/kobako/wasm.rb +35 -0
- data/lib/kobako/wire/codec/decoder.rb +87 -0
- data/lib/kobako/wire/codec/encoder.rb +41 -0
- data/lib/kobako/wire/codec/error.rb +35 -0
- data/lib/kobako/wire/codec/factory.rb +136 -0
- data/lib/kobako/wire/codec.rb +44 -0
- data/lib/kobako/wire/envelope/payloads.rb +145 -0
- data/lib/kobako/wire/envelope.rb +147 -0
- data/lib/kobako/wire/exception.rb +38 -0
- data/lib/kobako/wire/handle.rb +36 -0
- data/lib/kobako/wire.rb +40 -0
- data/lib/kobako.rb +7 -0
- data/sig/kobako.rbs +4 -0
- metadata +112 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
//! Host-side dispatch for the `__kobako_rpc_call` import.
|
|
2
|
+
//!
|
|
3
|
+
//! When the guest invokes the wasm import declared in
|
|
4
|
+
//! `wasm/kobako-wasm/src/abi.rs`, wasmtime calls back into the host
|
|
5
|
+
//! through the closure built in [`super::instance::build_instance`].
|
|
6
|
+
//! That closure delegates here. The dispatcher (SPEC.md B-12 / B-13):
|
|
7
|
+
//!
|
|
8
|
+
//! 1. Reads the Request bytes from guest linear memory.
|
|
9
|
+
//! 2. Hands them to the Ruby-side `Kobako::Registry` and recovers
|
|
10
|
+
//! Response bytes.
|
|
11
|
+
//! 3. Allocates a guest buffer via `__kobako_alloc(len)` invoked
|
|
12
|
+
//! through `Caller::get_export`.
|
|
13
|
+
//! 4. Writes the Response bytes into the guest buffer.
|
|
14
|
+
//! 5. Returns packed `(ptr<<32)|len` for the guest to decode.
|
|
15
|
+
//!
|
|
16
|
+
//! Returns 0 on any step failure. `Kobako::Sandbox` always installs a
|
|
17
|
+
//! Registry before invoking the guest, so reaching the dispatcher with
|
|
18
|
+
//! no Registry bound is itself a wire-layer fault; the guest maps a 0
|
|
19
|
+
//! return to a trap. Failures during normal dispatch surface as
|
|
20
|
+
//! Response.err envelopes from the Registry itself — they never reach
|
|
21
|
+
//! this 0-return path.
|
|
22
|
+
|
|
23
|
+
use magnus::value::{Opaque, ReprValue};
|
|
24
|
+
use magnus::{Error as MagnusError, RString, Ruby, Value};
|
|
25
|
+
use wasmtime::{Caller, Extern};
|
|
26
|
+
|
|
27
|
+
use super::host_state::HostState;
|
|
28
|
+
|
|
29
|
+
/// Drive a single `__kobako_rpc_call` invocation end-to-end. Entry point
|
|
30
|
+
/// from the wasmtime closure built in [`super::instance::build_instance`].
|
|
31
|
+
pub(crate) fn dispatch_rpc(caller: &mut Caller<'_, HostState>, req_ptr: i32, req_len: i32) -> i64 {
|
|
32
|
+
let req_bytes = match read_caller_memory(caller, req_ptr, req_len) {
|
|
33
|
+
Some(b) => b,
|
|
34
|
+
None => return 0,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// No Registry bound — return 0 to signal a wire-layer fault; the guest
|
|
38
|
+
// maps a 0 return to a trap. `Kobako::Sandbox` always installs a
|
|
39
|
+
// Registry before invoking the guest, so reaching this branch indicates
|
|
40
|
+
// a misuse rather than a normal control path.
|
|
41
|
+
let registry = match caller.data().registry {
|
|
42
|
+
Some(d) => d,
|
|
43
|
+
None => return 0,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
let resp_bytes = match invoke_registry(registry, &req_bytes) {
|
|
47
|
+
Ok(b) => b,
|
|
48
|
+
Err(_) => return 0,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
write_response(caller, &resp_bytes).unwrap_or(0)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/// Call the Ruby Registry's `#dispatch(request_bytes)` method and return
|
|
55
|
+
/// the encoded Response bytes. Errors here mean the Registry itself
|
|
56
|
+
/// failed (it is contracted never to raise — see
|
|
57
|
+
/// `Kobako::Registry#dispatch`), which we treat as a wire-layer fault.
|
|
58
|
+
fn invoke_registry(registry: Opaque<Value>, req_bytes: &[u8]) -> Result<Vec<u8>, MagnusError> {
|
|
59
|
+
// The wasmtime callback runs on the same Ruby thread that called
|
|
60
|
+
// Sandbox#run — the invariant SPEC Implementation Standards
|
|
61
|
+
// Architecture pins for the host gem — so `Ruby::get()` is always
|
|
62
|
+
// available here. Panicking with `expect` localises the violation
|
|
63
|
+
// rather than letting a nonsense error propagate.
|
|
64
|
+
let ruby = Ruby::get().expect("Ruby handle unavailable in __kobako_rpc_call");
|
|
65
|
+
let registry_value: Value = ruby.get_inner(registry);
|
|
66
|
+
let req_str = ruby.str_from_slice(req_bytes);
|
|
67
|
+
let resp: RString = registry_value.funcall("dispatch", (req_str,))?;
|
|
68
|
+
// SAFETY: the returned RString is held by the Ruby VM for the duration of
|
|
69
|
+
// this scope; copying its bytes into a Vec is a defensive standard pattern.
|
|
70
|
+
let bytes = unsafe { resp.as_slice() }.to_vec();
|
|
71
|
+
Ok(bytes)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/// Allocate a guest-side buffer through `__kobako_alloc` and copy the
|
|
75
|
+
/// response bytes into it. Returns the packed `(ptr<<32)|len` u64.
|
|
76
|
+
fn write_response(caller: &mut Caller<'_, HostState>, bytes: &[u8]) -> Option<i64> {
|
|
77
|
+
let alloc = match caller.get_export("__kobako_alloc") {
|
|
78
|
+
Some(Extern::Func(f)) => f.typed::<i32, i32>(&*caller).ok()?,
|
|
79
|
+
_ => return None,
|
|
80
|
+
};
|
|
81
|
+
let len_i32 = i32::try_from(bytes.len()).ok()?;
|
|
82
|
+
let ptr = alloc.call(&mut *caller, len_i32).ok()?;
|
|
83
|
+
if ptr == 0 {
|
|
84
|
+
return None;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let mem = match caller.get_export("memory") {
|
|
88
|
+
Some(Extern::Memory(m)) => m,
|
|
89
|
+
_ => return None,
|
|
90
|
+
};
|
|
91
|
+
mem.write(&mut *caller, ptr as usize, bytes).ok()?;
|
|
92
|
+
|
|
93
|
+
let ptr_u32 = ptr as u32;
|
|
94
|
+
let len_u32 = bytes.len() as u32;
|
|
95
|
+
Some(((ptr_u32 as i64) << 32) | (len_u32 as i64))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/// Copy `[ptr, ptr+len)` out of the guest's linear memory as seen from
|
|
99
|
+
/// `caller`. Returns `None` when `memory` is not exported or the slice
|
|
100
|
+
/// falls outside the live memory range.
|
|
101
|
+
fn read_caller_memory(caller: &mut Caller<'_, HostState>, ptr: i32, len: i32) -> Option<Vec<u8>> {
|
|
102
|
+
let mem = match caller.get_export("memory") {
|
|
103
|
+
Some(Extern::Memory(m)) => m,
|
|
104
|
+
_ => return None,
|
|
105
|
+
};
|
|
106
|
+
let data = mem.data(&caller);
|
|
107
|
+
let start = ptr as usize;
|
|
108
|
+
let end = start.checked_add(len as usize)?;
|
|
109
|
+
data.get(start..end).map(|s| s.to_vec())
|
|
110
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
//! Per-Store host state shared with every wasmtime callback.
|
|
2
|
+
//!
|
|
3
|
+
//! Owned by [`StoreCell`] (a `RefCell` shim wrapping `wasmtime::Store`)
|
|
4
|
+
//! and threaded through every host import — the `__kobako_rpc_call`
|
|
5
|
+
//! dispatcher reads `registry`, while the run-path methods on
|
|
6
|
+
//! [`crate::wasm::Instance`] mutate `wasi`, `stdout_pipe`, `stderr_pipe`
|
|
7
|
+
//! when refreshing the WASI context before each `#run` (SPEC.md B-03 /
|
|
8
|
+
//! B-04).
|
|
9
|
+
|
|
10
|
+
use std::cell::RefCell;
|
|
11
|
+
|
|
12
|
+
use magnus::{value::Opaque, Value};
|
|
13
|
+
use wasmtime::Store as WtStore;
|
|
14
|
+
use wasmtime_wasi::p1::WasiP1Ctx;
|
|
15
|
+
use wasmtime_wasi::p2::pipe::MemoryOutputPipe;
|
|
16
|
+
|
|
17
|
+
/// Per-Store host state threaded through every host import callback.
|
|
18
|
+
///
|
|
19
|
+
/// WASI p1 state is embedded as `Option<WasiP1Ctx>` so it can be replaced
|
|
20
|
+
/// fresh before each `#run` without rebuilding the Store. The `stdout_pipe`
|
|
21
|
+
/// and `stderr_pipe` clones are kept alongside so the Ruby layer can drain
|
|
22
|
+
/// captured bytes after execution without touching the WASI internals.
|
|
23
|
+
#[derive(Default)]
|
|
24
|
+
pub(crate) struct HostState {
|
|
25
|
+
/// Buffer mirror of guest's OUTCOME_BUFFER. Filled by `__kobako_take_outcome`
|
|
26
|
+
/// post-execution. Reserved for a future streaming-outcome path; not yet
|
|
27
|
+
/// consumed on the Rust side (the Ruby layer reads outcome via read_memory).
|
|
28
|
+
#[allow(dead_code)]
|
|
29
|
+
pub outcome: Vec<u8>,
|
|
30
|
+
/// WASI p1 context for the current (or most-recent) run. Replaced before
|
|
31
|
+
/// each `#run` so stdin/stdout/stderr pipes are always fresh (SPEC.md B-03).
|
|
32
|
+
pub wasi: Option<WasiP1Ctx>,
|
|
33
|
+
/// Clone of the MemoryOutputPipe wired to guest fd 1 (stdout). Retained
|
|
34
|
+
/// here so `take_stdout` can call `contents()` after execution without
|
|
35
|
+
/// having to dig into the WASI ctx internals.
|
|
36
|
+
pub stdout_pipe: Option<MemoryOutputPipe>,
|
|
37
|
+
/// Clone of the MemoryOutputPipe wired to guest fd 2 (stderr).
|
|
38
|
+
pub stderr_pipe: Option<MemoryOutputPipe>,
|
|
39
|
+
/// Ruby-side `Kobako::Registry`. When set, the `__kobako_rpc_call`
|
|
40
|
+
/// import calls `registry.dispatch(req_bytes)` and hands the returned
|
|
41
|
+
/// Response bytes back to the guest. `Opaque<Value>` is `Send + Sync`;
|
|
42
|
+
/// calling `get_inner` requires a `Ruby` handle, which we obtain on
|
|
43
|
+
/// every Ruby thread entry via `Ruby::get()`.
|
|
44
|
+
pub registry: Option<Opaque<Value>>,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/// Interior-mutability wrapper around `wasmtime::Store<HostState>`.
|
|
48
|
+
///
|
|
49
|
+
/// Magnus requires `Send + Sync` for wrapped types. `wasmtime::Store` is not
|
|
50
|
+
/// `Sync`, so we wrap it in a `RefCell`. `RefCell` alone is sufficient
|
|
51
|
+
/// because magnus enforces single-threaded GVL access from Ruby; `Send` and
|
|
52
|
+
/// `Sync` are asserted via the unsafe impls below.
|
|
53
|
+
pub(crate) struct StoreCell(pub(crate) RefCell<WtStore<HostState>>);
|
|
54
|
+
|
|
55
|
+
// SAFETY: Ruby's GVL serialises access to magnus-wrapped objects on a single
|
|
56
|
+
// OS thread at a time. `wasmtime::Store` is `Send` (verified upstream); the
|
|
57
|
+
// `RefCell`-mediated mutation is therefore safe under the GVL invariant.
|
|
58
|
+
unsafe impl Send for StoreCell {}
|
|
59
|
+
unsafe impl Sync for StoreCell {}
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
//! `Kobako::Wasm::Instance` — the only Ruby-visible wasmtime wrapper.
|
|
2
|
+
//!
|
|
3
|
+
//! Constructed via [`Instance::from_path`]; the wasmtime [`Engine`] and
|
|
4
|
+
//! compiled [`Module`] are owned by the [`super::cache`] singletons and
|
|
5
|
+
//! never surface to Ruby. The instance wraps a [`StoreCell`] (interior-
|
|
6
|
+
//! mutability around `wasmtime::Store<HostState>`) plus three cached
|
|
7
|
+
//! [`TypedFunc`] handles for the SPEC ABI exports.
|
|
8
|
+
//!
|
|
9
|
+
//! WASI stdout/stderr capture (SPEC.md B-04): wasmtime-wasi p1 bindings
|
|
10
|
+
//! route guest fd 1 and fd 2 into per-run [`MemoryOutputPipe`] instances.
|
|
11
|
+
//! After each run the host drains the pipes via [`Instance::take_stdout`]
|
|
12
|
+
//! / [`Instance::take_stderr`] and pushes the raw bytes through Ruby's
|
|
13
|
+
//! OutputBuffer (which enforces the cap and `[truncated]` marker).
|
|
14
|
+
//! Stdin carries the two-frame length-prefixed protocol: Frame 1
|
|
15
|
+
//! (preamble msgpack) followed by Frame 2 (user script UTF-8), each
|
|
16
|
+
//! prefixed by a 4-byte big-endian u32 length. Written via
|
|
17
|
+
//! [`Instance::setup_wasi_pipes`] before each run (SPEC.md ABI
|
|
18
|
+
//! Signatures).
|
|
19
|
+
//!
|
|
20
|
+
//! [`Engine`]: wasmtime::Engine
|
|
21
|
+
//! [`Module`]: wasmtime::Module
|
|
22
|
+
//! [`TypedFunc`]: wasmtime::TypedFunc
|
|
23
|
+
//! [`MemoryOutputPipe`]: wasmtime_wasi::p2::pipe::MemoryOutputPipe
|
|
24
|
+
|
|
25
|
+
use std::cell::RefCell;
|
|
26
|
+
use std::path::Path;
|
|
27
|
+
|
|
28
|
+
use magnus::RString;
|
|
29
|
+
use magnus::{value::Opaque, Error as MagnusError, Ruby, Value};
|
|
30
|
+
use wasmtime::{
|
|
31
|
+
AsContextMut, Caller, Engine as WtEngine, Extern, Instance as WtInstance, Linker, Memory,
|
|
32
|
+
Module as WtModule, Store as WtStore, TypedFunc,
|
|
33
|
+
};
|
|
34
|
+
use wasmtime_wasi::p1;
|
|
35
|
+
use wasmtime_wasi::p2::pipe::{MemoryInputPipe, MemoryOutputPipe};
|
|
36
|
+
use wasmtime_wasi::WasiCtxBuilder;
|
|
37
|
+
|
|
38
|
+
use super::cache::{cached_module, shared_engine};
|
|
39
|
+
use super::dispatch::dispatch_rpc;
|
|
40
|
+
use super::host_state::{HostState, StoreCell};
|
|
41
|
+
use super::wasm_err;
|
|
42
|
+
|
|
43
|
+
#[magnus::wrap(class = "Kobako::Wasm::Instance", free_immediately, size)]
|
|
44
|
+
pub(crate) struct Instance {
|
|
45
|
+
inner: WtInstance,
|
|
46
|
+
store: StoreCell,
|
|
47
|
+
// Cached TypedFunc handles for the three guest exports. Optional because
|
|
48
|
+
// test fixtures (a minimal "ping" module) need not provide them; real
|
|
49
|
+
// kobako.wasm always does, and the host enforces presence at run time.
|
|
50
|
+
//
|
|
51
|
+
// Exactly the SPEC ABI shape: `__kobako_run() -> ()`. Source delivery
|
|
52
|
+
// uses the WASI stdin two-frame protocol (see `setup_wasi_frames`).
|
|
53
|
+
run: Option<TypedFunc<(), ()>>,
|
|
54
|
+
take_outcome: Option<TypedFunc<(), u64>>,
|
|
55
|
+
alloc: Option<TypedFunc<i32, i32>>,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
impl Instance {
|
|
59
|
+
/// Construct an Instance from a wasm file path, using the process-wide
|
|
60
|
+
/// shared Engine and per-path Module cache. The single Ruby-facing
|
|
61
|
+
/// constructor for `Kobako::Wasm::Instance` — Engine and Module are
|
|
62
|
+
/// never visible to Ruby.
|
|
63
|
+
pub(crate) fn from_path(path: String) -> Result<Self, MagnusError> {
|
|
64
|
+
let engine = shared_engine()?;
|
|
65
|
+
let module = cached_module(Path::new(&path))?;
|
|
66
|
+
let store = WtStore::new(engine, HostState::default());
|
|
67
|
+
let store_cell = StoreCell(RefCell::new(store));
|
|
68
|
+
build_instance(engine, &module, store_cell)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/// Install the Ruby-side `Kobako::Registry` into HostState. Called by
|
|
72
|
+
/// `Kobako::Sandbox` after constructing the Registry; from this point
|
|
73
|
+
/// on, every `__kobako_rpc_call` import invocation routes through
|
|
74
|
+
/// `registry.dispatch(req_bytes)`.
|
|
75
|
+
pub(crate) fn set_registry(&self, registry: Value) -> Result<(), MagnusError> {
|
|
76
|
+
let mut store_ref = self.store.0.borrow_mut();
|
|
77
|
+
store_ref.data_mut().registry = Some(Opaque::from(registry));
|
|
78
|
+
Ok(())
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// -----------------------------------------------------------------
|
|
82
|
+
// Run-path methods. These drive the alloc → write source → run →
|
|
83
|
+
// take_outcome flow from Ruby. Each method is best-effort — it raises
|
|
84
|
+
// a Ruby `Kobako::Wasm::Error` when the corresponding export is
|
|
85
|
+
// missing or fails so the Sandbox layer can map errors to the
|
|
86
|
+
// three-class taxonomy.
|
|
87
|
+
// -----------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
/// Invoke the guest's `__kobako_alloc(size)` export and return the
|
|
90
|
+
/// resulting linear-memory offset. A return of 0 indicates an
|
|
91
|
+
/// allocation failure (caller should treat as a wire violation /
|
|
92
|
+
/// trap).
|
|
93
|
+
pub(crate) fn alloc(&self, size: i32) -> Result<i32, MagnusError> {
|
|
94
|
+
let ruby = Ruby::get().expect("Ruby thread");
|
|
95
|
+
let alloc = self
|
|
96
|
+
.alloc
|
|
97
|
+
.as_ref()
|
|
98
|
+
.ok_or_else(|| wasm_err(&ruby, "guest does not export __kobako_alloc"))?;
|
|
99
|
+
let mut store_ref = self.store.0.borrow_mut();
|
|
100
|
+
alloc
|
|
101
|
+
.call(store_ref.as_context_mut(), size)
|
|
102
|
+
.map_err(|e| wasm_err(&ruby, format!("__kobako_alloc({}): {}", size, e)))
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/// Write +bytes+ into the guest's linear memory starting at +ptr+.
|
|
106
|
+
/// Raises `Kobako::Wasm::Error` if the instance has no `memory`
|
|
107
|
+
/// export or the slice is out of bounds.
|
|
108
|
+
pub(crate) fn write_memory(&self, ptr: i32, bytes: RString) -> Result<(), MagnusError> {
|
|
109
|
+
let ruby = Ruby::get().expect("Ruby thread");
|
|
110
|
+
let mut store_ref = self.store.0.borrow_mut();
|
|
111
|
+
let mem: Memory = match self.inner.get_export(store_ref.as_context_mut(), "memory") {
|
|
112
|
+
Some(Extern::Memory(m)) => m,
|
|
113
|
+
_ => return Err(wasm_err(&ruby, "guest does not export 'memory'")),
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// SAFETY: RString::as_slice on a frozen-on-read borrow.
|
|
117
|
+
let src: &[u8] = unsafe { bytes.as_slice() };
|
|
118
|
+
let data = mem.data_mut(store_ref.as_context_mut());
|
|
119
|
+
let start = ptr as usize;
|
|
120
|
+
let end = start
|
|
121
|
+
.checked_add(src.len())
|
|
122
|
+
.ok_or_else(|| wasm_err(&ruby, "write_memory: ptr + len overflow"))?;
|
|
123
|
+
if end > data.len() {
|
|
124
|
+
return Err(wasm_err(
|
|
125
|
+
&ruby,
|
|
126
|
+
format!(
|
|
127
|
+
"write_memory: range [{}, {}) exceeds memory size {}",
|
|
128
|
+
start,
|
|
129
|
+
end,
|
|
130
|
+
data.len()
|
|
131
|
+
),
|
|
132
|
+
));
|
|
133
|
+
}
|
|
134
|
+
data[start..end].copy_from_slice(src);
|
|
135
|
+
Ok(())
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/// Read +len+ bytes from the guest's linear memory starting at
|
|
139
|
+
/// +ptr+. Returns a binary-encoded Ruby String.
|
|
140
|
+
pub(crate) fn read_memory(&self, ptr: i32, len: i32) -> Result<RString, MagnusError> {
|
|
141
|
+
let ruby = Ruby::get().expect("Ruby thread");
|
|
142
|
+
let mut store_ref = self.store.0.borrow_mut();
|
|
143
|
+
let mem: Memory = match self.inner.get_export(store_ref.as_context_mut(), "memory") {
|
|
144
|
+
Some(Extern::Memory(m)) => m,
|
|
145
|
+
_ => return Err(wasm_err(&ruby, "guest does not export 'memory'")),
|
|
146
|
+
};
|
|
147
|
+
let data = mem.data(store_ref.as_context_mut());
|
|
148
|
+
let start = ptr as usize;
|
|
149
|
+
let end = start
|
|
150
|
+
.checked_add(len as usize)
|
|
151
|
+
.ok_or_else(|| wasm_err(&ruby, "read_memory: ptr + len overflow"))?;
|
|
152
|
+
if end > data.len() {
|
|
153
|
+
return Err(wasm_err(
|
|
154
|
+
&ruby,
|
|
155
|
+
format!(
|
|
156
|
+
"read_memory: range [{}, {}) exceeds memory size {}",
|
|
157
|
+
start,
|
|
158
|
+
end,
|
|
159
|
+
data.len()
|
|
160
|
+
),
|
|
161
|
+
));
|
|
162
|
+
}
|
|
163
|
+
Ok(ruby.str_from_slice(&data[start..end]))
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/// Invoke `__kobako_run` with the SPEC `() -> ()` shape. Source is
|
|
167
|
+
/// delivered via the WASI stdin two-frame protocol written by
|
|
168
|
+
/// `setup_wasi_frames` before this call.
|
|
169
|
+
pub(crate) fn run_call(&self) -> Result<(), MagnusError> {
|
|
170
|
+
let ruby = Ruby::get().expect("Ruby thread");
|
|
171
|
+
let run = self
|
|
172
|
+
.run
|
|
173
|
+
.as_ref()
|
|
174
|
+
.ok_or_else(|| wasm_err(&ruby, "guest does not export __kobako_run"))?;
|
|
175
|
+
let mut store_ref = self.store.0.borrow_mut();
|
|
176
|
+
run.call(store_ref.as_context_mut(), ())
|
|
177
|
+
.map_err(|e| wasm_err(&ruby, format!("__kobako_run(): {}", e)))
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/// Invoke `__kobako_take_outcome`. Returns the packed u64
|
|
181
|
+
/// `(ptr << 32) | len`; the Ruby caller unpacks.
|
|
182
|
+
pub(crate) fn take_outcome(&self) -> Result<u64, MagnusError> {
|
|
183
|
+
let ruby = Ruby::get().expect("Ruby thread");
|
|
184
|
+
let take = self
|
|
185
|
+
.take_outcome
|
|
186
|
+
.as_ref()
|
|
187
|
+
.ok_or_else(|| wasm_err(&ruby, "guest does not export __kobako_take_outcome"))?;
|
|
188
|
+
let mut store_ref = self.store.0.borrow_mut();
|
|
189
|
+
take.call(store_ref.as_context_mut(), ())
|
|
190
|
+
.map_err(|e| wasm_err(&ruby, format!("__kobako_take_outcome(): {}", e)))
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// -----------------------------------------------------------------
|
|
194
|
+
// WASI capture path (SPEC.md B-04). Called by Ruby's Sandbox#run.
|
|
195
|
+
// -----------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
/// Initialise fresh WASI pipes with the two-frame stdin content.
|
|
198
|
+
///
|
|
199
|
+
/// Must be called before each `run` invocation. Creates:
|
|
200
|
+
/// * A MemoryInputPipe for stdin with Frame 1 (preamble) + Frame 2
|
|
201
|
+
/// (user script) encoded as length-prefixed frames: each frame is a
|
|
202
|
+
/// 4-byte big-endian u32 length prefix followed by the payload bytes.
|
|
203
|
+
/// The guest reads both frames from WASI stdin (SPEC.md ABI Signatures).
|
|
204
|
+
/// * A MemoryOutputPipe for fd 1 (stdout) — transport-layer pipe.
|
|
205
|
+
/// * A MemoryOutputPipe for fd 2 (stderr) — transport-layer pipe.
|
|
206
|
+
///
|
|
207
|
+
/// `stdout_cap` and `stderr_cap` are accepted but the transport pipes are
|
|
208
|
+
/// uncapped: SPEC.md B-04 requires that overflowing the OutputBuffer limit
|
|
209
|
+
/// is a non-error outcome. A capped WASI pipe would produce a real trap.
|
|
210
|
+
pub(crate) fn setup_wasi_pipes(
|
|
211
|
+
&self,
|
|
212
|
+
stdout_cap: i64,
|
|
213
|
+
stderr_cap: i64,
|
|
214
|
+
preamble_bytes: RString,
|
|
215
|
+
source_bytes: RString,
|
|
216
|
+
) -> Result<(), MagnusError> {
|
|
217
|
+
let _ = (stdout_cap, stderr_cap);
|
|
218
|
+
|
|
219
|
+
// Build the two-frame stdin content. Each frame: 4-byte BE u32 length
|
|
220
|
+
// prefix + payload bytes (SPEC.md ABI Signatures — two-frame protocol).
|
|
221
|
+
let preamble: &[u8] = unsafe { preamble_bytes.as_slice() };
|
|
222
|
+
let source: &[u8] = unsafe { source_bytes.as_slice() };
|
|
223
|
+
|
|
224
|
+
let mut stdin_content: Vec<u8> = Vec::with_capacity(4 + preamble.len() + 4 + source.len());
|
|
225
|
+
// Frame 1 — preamble
|
|
226
|
+
stdin_content.extend_from_slice(&(preamble.len() as u32).to_be_bytes());
|
|
227
|
+
stdin_content.extend_from_slice(preamble);
|
|
228
|
+
// Frame 2 — user script
|
|
229
|
+
stdin_content.extend_from_slice(&(source.len() as u32).to_be_bytes());
|
|
230
|
+
stdin_content.extend_from_slice(source);
|
|
231
|
+
|
|
232
|
+
let stdin_pipe = MemoryInputPipe::new(stdin_content);
|
|
233
|
+
let stdout_pipe = MemoryOutputPipe::new(usize::MAX);
|
|
234
|
+
let stderr_pipe = MemoryOutputPipe::new(usize::MAX);
|
|
235
|
+
|
|
236
|
+
let mut builder = WasiCtxBuilder::new();
|
|
237
|
+
builder.stdin(stdin_pipe);
|
|
238
|
+
builder.stdout(stdout_pipe.clone());
|
|
239
|
+
builder.stderr(stderr_pipe.clone());
|
|
240
|
+
let wasi = builder.build_p1();
|
|
241
|
+
|
|
242
|
+
{
|
|
243
|
+
let mut store_ref = self.store.0.borrow_mut();
|
|
244
|
+
let data = store_ref.data_mut();
|
|
245
|
+
data.wasi = Some(wasi);
|
|
246
|
+
data.stdout_pipe = Some(stdout_pipe);
|
|
247
|
+
data.stderr_pipe = Some(stderr_pipe);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
Ok(())
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/// Drain the stdout bytes captured during the most recent run.
|
|
254
|
+
///
|
|
255
|
+
/// Returns a binary Ruby String containing raw bytes from guest fd 1.
|
|
256
|
+
/// The MemoryOutputPipe clone in HostState retains its contents between
|
|
257
|
+
/// calls — it is replaced on the next `setup_wasi_pipes`.
|
|
258
|
+
pub(crate) fn take_stdout(&self) -> Result<RString, MagnusError> {
|
|
259
|
+
let ruby = Ruby::get().expect("Ruby thread");
|
|
260
|
+
let store_ref = self.store.0.borrow();
|
|
261
|
+
let bytes = store_ref
|
|
262
|
+
.data()
|
|
263
|
+
.stdout_pipe
|
|
264
|
+
.as_ref()
|
|
265
|
+
.map(|p| p.contents())
|
|
266
|
+
.unwrap_or_default();
|
|
267
|
+
Ok(ruby.str_from_slice(&bytes))
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/// Drain the stderr bytes captured during the most recent run.
|
|
271
|
+
///
|
|
272
|
+
/// Returns a binary Ruby String containing raw bytes from guest fd 2.
|
|
273
|
+
pub(crate) fn take_stderr(&self) -> Result<RString, MagnusError> {
|
|
274
|
+
let ruby = Ruby::get().expect("Ruby thread");
|
|
275
|
+
let store_ref = self.store.0.borrow();
|
|
276
|
+
let bytes = store_ref
|
|
277
|
+
.data()
|
|
278
|
+
.stderr_pipe
|
|
279
|
+
.as_ref()
|
|
280
|
+
.map(|p| p.contents())
|
|
281
|
+
.unwrap_or_default();
|
|
282
|
+
Ok(ruby.str_from_slice(&bytes))
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/// Build an `Instance` from an engine, module, and store cell. The store
|
|
287
|
+
/// cell is moved in and ends up owned by the returned Instance. Wires
|
|
288
|
+
/// the WASI p1 imports plus the `__kobako_rpc_call` host import.
|
|
289
|
+
fn build_instance(
|
|
290
|
+
engine: &WtEngine,
|
|
291
|
+
module: &WtModule,
|
|
292
|
+
store_cell: StoreCell,
|
|
293
|
+
) -> Result<Instance, MagnusError> {
|
|
294
|
+
let ruby = Ruby::get().expect("Ruby thread");
|
|
295
|
+
let mut linker: Linker<HostState> = Linker::new(engine);
|
|
296
|
+
|
|
297
|
+
// Wire the wasmtime-wasi preview1 WASI imports. Routes guest fd 1/2 to
|
|
298
|
+
// the MemoryOutputPipes set up before each run via `setup_wasi_pipes`.
|
|
299
|
+
// The closure extracts `&mut WasiP1Ctx` from HostState; if none is set
|
|
300
|
+
// (e.g. a test module that never invokes WASI functions), the Option
|
|
301
|
+
// unwrap will panic — but `setup_wasi_pipes` is always called before any
|
|
302
|
+
// WASI-enabled run.
|
|
303
|
+
p1::add_to_linker_sync(&mut linker, |state: &mut HostState| {
|
|
304
|
+
state
|
|
305
|
+
.wasi
|
|
306
|
+
.as_mut()
|
|
307
|
+
.expect("WASI context not initialised — call setup_wasi_pipes before run")
|
|
308
|
+
})
|
|
309
|
+
.map_err(|e| wasm_err(&ruby, format!("add WASI p1 to linker: {}", e)))?;
|
|
310
|
+
|
|
311
|
+
// `__kobako_rpc_call` host import. Signature per SPEC Wire ABI:
|
|
312
|
+
// (req_ptr: i32, req_len: i32) -> i64
|
|
313
|
+
// Decodes the Request bytes, dispatches via the Ruby-side
|
|
314
|
+
// `Kobako::Registry` (set per-run via `set_registry`), allocates a guest
|
|
315
|
+
// buffer through `__kobako_alloc`, writes the Response bytes there, and
|
|
316
|
+
// returns the packed `(ptr<<32)|len`. The dispatcher returns 0 on any
|
|
317
|
+
// wire-layer fault (including a missing Registry); see `dispatch_rpc`.
|
|
318
|
+
linker
|
|
319
|
+
.func_wrap(
|
|
320
|
+
"env",
|
|
321
|
+
"__kobako_rpc_call",
|
|
322
|
+
|mut caller: Caller<'_, HostState>, req_ptr: i32, req_len: i32| -> i64 {
|
|
323
|
+
dispatch_rpc(&mut caller, req_ptr, req_len)
|
|
324
|
+
},
|
|
325
|
+
)
|
|
326
|
+
.map_err(|e| wasm_err(&ruby, format!("define __kobako_rpc_call: {}", e)))?;
|
|
327
|
+
|
|
328
|
+
let instance = {
|
|
329
|
+
let mut store_ref = store_cell.0.borrow_mut();
|
|
330
|
+
linker
|
|
331
|
+
.instantiate(store_ref.as_context_mut(), module)
|
|
332
|
+
.map_err(|e| wasm_err(&ruby, format!("instantiate: {}", e)))?
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
// Best-effort export lookup. Missing exports are not an error here
|
|
336
|
+
// (test fixture is a bare module); the host enforces presence before
|
|
337
|
+
// invocation. Only the SPEC ABI `() -> ()` shape is accepted for
|
|
338
|
+
// `__kobako_run` — the transitional `(ptr, len) -> ()` shape is gone.
|
|
339
|
+
let (run, take_outcome, alloc) = {
|
|
340
|
+
let mut store_ref = store_cell.0.borrow_mut();
|
|
341
|
+
let mut ctx = store_ref.as_context_mut();
|
|
342
|
+
let run = instance
|
|
343
|
+
.get_typed_func::<(), ()>(&mut ctx, "__kobako_run")
|
|
344
|
+
.ok();
|
|
345
|
+
let take_outcome = instance
|
|
346
|
+
.get_typed_func::<(), u64>(&mut ctx, "__kobako_take_outcome")
|
|
347
|
+
.ok();
|
|
348
|
+
let alloc = instance
|
|
349
|
+
.get_typed_func::<i32, i32>(&mut ctx, "__kobako_alloc")
|
|
350
|
+
.ok();
|
|
351
|
+
(run, take_outcome, alloc)
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
Ok(Instance {
|
|
355
|
+
inner: instance,
|
|
356
|
+
store: store_cell,
|
|
357
|
+
run,
|
|
358
|
+
take_outcome,
|
|
359
|
+
alloc,
|
|
360
|
+
})
|
|
361
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Host-side wasmtime wrapper.
|
|
2
|
+
//
|
|
3
|
+
// The only Ruby-visible class is
|
|
4
|
+
//
|
|
5
|
+
// Kobako::Wasm::Instance — wraps wasmtime::Instance + cached TypedFuncs
|
|
6
|
+
//
|
|
7
|
+
// constructed via `Kobako::Wasm::Instance.from_path(path)`. The underlying
|
|
8
|
+
// wasmtime Engine and compiled Module live in a process-scope cache (see
|
|
9
|
+
// the `cache` submodule) and never surface to Ruby (SPEC.md "Code
|
|
10
|
+
// Organization": `ext/` "exposes no Wasm engine types to the Host App or
|
|
11
|
+
// downstream gems").
|
|
12
|
+
//
|
|
13
|
+
// Module layout (per CLAUDE.md principle #2 — one responsibility per file):
|
|
14
|
+
//
|
|
15
|
+
// * `cache` — process-wide Engine + per-path Module cache.
|
|
16
|
+
// * `host_state` — HostState (per-Store context) + StoreCell wrapper.
|
|
17
|
+
// * `instance` — Kobako::Wasm::Instance and its run-path methods.
|
|
18
|
+
// * `dispatch` — `__kobako_rpc_call` host-import dispatch helpers.
|
|
19
|
+
//
|
|
20
|
+
// This file is the façade: it owns the Ruby error class lazy-resolvers,
|
|
21
|
+
// the `wasm_err` constructor shared by every submodule, and the Ruby
|
|
22
|
+
// init() that registers `Kobako::Wasm::Instance` and its methods.
|
|
23
|
+
|
|
24
|
+
mod cache;
|
|
25
|
+
mod dispatch;
|
|
26
|
+
mod host_state;
|
|
27
|
+
mod instance;
|
|
28
|
+
|
|
29
|
+
use magnus::value::Lazy;
|
|
30
|
+
use magnus::{function, method, prelude::*, Error as MagnusError, ExceptionClass, RModule, Ruby};
|
|
31
|
+
|
|
32
|
+
use instance::Instance;
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Error classes (lazy-resolved from Ruby once Kobako::Wasm is defined).
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
pub(crate) static MODULE_NOT_BUILT_ERROR: Lazy<ExceptionClass> = Lazy::new(|ruby| {
|
|
39
|
+
let kobako: RModule = ruby.class_object().const_get("Kobako").unwrap();
|
|
40
|
+
let wasm: RModule = kobako.const_get("Wasm").unwrap();
|
|
41
|
+
wasm.const_get("ModuleNotBuiltError").unwrap()
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
pub(crate) static WASM_ERROR: Lazy<ExceptionClass> = Lazy::new(|ruby| {
|
|
45
|
+
let kobako: RModule = ruby.class_object().const_get("Kobako").unwrap();
|
|
46
|
+
let wasm: RModule = kobako.const_get("Wasm").unwrap();
|
|
47
|
+
wasm.const_get("Error").unwrap()
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
pub(crate) fn wasm_err(ruby: &Ruby, msg: impl Into<String>) -> MagnusError {
|
|
51
|
+
MagnusError::new(ruby.get_inner(&WASM_ERROR), msg.into())
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Ruby init
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
pub fn init(ruby: &Ruby, kobako: RModule) -> Result<(), MagnusError> {
|
|
59
|
+
let wasm = kobako.define_module("Wasm")?;
|
|
60
|
+
|
|
61
|
+
// Error hierarchy. ModuleNotBuiltError is the headline error for the
|
|
62
|
+
// common pre-build state where `data/kobako.wasm` has not yet been
|
|
63
|
+
// produced (e.g. fresh clone before `rake compile`).
|
|
64
|
+
let base_err = wasm.define_error("Error", ruby.exception_standard_error())?;
|
|
65
|
+
wasm.define_error("ModuleNotBuiltError", base_err)?;
|
|
66
|
+
|
|
67
|
+
let instance = wasm.define_class("Instance", ruby.class_object())?;
|
|
68
|
+
instance.define_singleton_method("from_path", function!(Instance::from_path, 1))?;
|
|
69
|
+
instance.define_method("alloc", method!(Instance::alloc, 1))?;
|
|
70
|
+
instance.define_method("write_memory", method!(Instance::write_memory, 2))?;
|
|
71
|
+
instance.define_method("read_memory", method!(Instance::read_memory, 2))?;
|
|
72
|
+
instance.define_method("run", method!(Instance::run_call, 0))?;
|
|
73
|
+
instance.define_method("take_outcome", method!(Instance::take_outcome, 0))?;
|
|
74
|
+
instance.define_method("set_registry", method!(Instance::set_registry, 1))?;
|
|
75
|
+
instance.define_method("setup_wasi_pipes", method!(Instance::setup_wasi_pipes, 4))?;
|
|
76
|
+
instance.define_method("take_stdout", method!(Instance::take_stdout, 0))?;
|
|
77
|
+
instance.define_method("take_stderr", method!(Instance::take_stderr, 0))?;
|
|
78
|
+
|
|
79
|
+
Ok(())
|
|
80
|
+
}
|