kobako 0.6.2 → 0.8.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 +4 -4
- data/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +30 -0
- data/Cargo.lock +292 -214
- data/README.md +23 -0
- data/data/kobako.wasm +0 -0
- data/ext/kobako/Cargo.toml +3 -3
- data/ext/kobako/src/runtime/trap.rs +48 -4
- data/ext/kobako/src/runtime.rs +50 -0
- data/lib/kobako/version.rb +1 -1
- data/release-please-config.json +11 -0
- metadata +1 -1
data/README.md
CHANGED
|
@@ -283,6 +283,29 @@ sandbox.run(:Greeter, name: "world") # => "hello, world"
|
|
|
283
283
|
|
|
284
284
|
Use the source form for snippets authored in your repo (compile errors fail fast at `#preload`); use the bytecode form when snippets ship as build artifacts from a separate `mrbc` pipeline. Both replay through the same per-invocation path.
|
|
285
285
|
|
|
286
|
+
## Security
|
|
287
|
+
|
|
288
|
+
kobako isolates the guest, but **what it may reach is whatever you `bind`** — and `bind`
|
|
289
|
+
exposes *every* public method of the object. So bind a purpose-built object scoped to the
|
|
290
|
+
task, not a capable one whose other methods leak more than you intend.
|
|
291
|
+
|
|
292
|
+
```ruby
|
|
293
|
+
class ThemeReader # only #color is reachable; AppConfig.secret_key is not
|
|
294
|
+
def color = AppConfig.theme.color
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
sandbox = Kobako::Sandbox.new
|
|
298
|
+
sandbox.define(:Cfg).bind(:Settings, ThemeReader.new) # not: bind(:Settings, AppConfig)
|
|
299
|
+
|
|
300
|
+
sandbox.eval('Cfg::Settings.color') # => "#3366ff" — every other method raises NoMethodError
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
Guest code can name any `<Namespace>::<Member>` path, but a forged name only resolves to
|
|
304
|
+
something you bound — the real authorization gate is this host-side allowlist. Give each
|
|
305
|
+
trust context its own Sandbox, and see [`docs/security.md`](docs/security.md) for the rest
|
|
306
|
+
as security-design concerns: validating untrusted input, default-deny external effects,
|
|
307
|
+
and controlling the return surface.
|
|
308
|
+
|
|
286
309
|
## Performance
|
|
287
310
|
|
|
288
311
|
Order-of-magnitude figures on macOS arm64, Ruby 3.4.7, YJIT off. Absolute values vary by hardware but ratios are stable across machines. Full numbers, methodology, and the +10%-regression gate live in [`benchmark/README.md`](benchmark/README.md).
|
data/data/kobako.wasm
CHANGED
|
Binary file
|
data/ext/kobako/Cargo.toml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "kobako"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.8.0"
|
|
4
4
|
edition = "2021"
|
|
5
5
|
authors = ["Aotokitsuruya <contact@aotoki.me>"]
|
|
6
6
|
license = "Apache-2.0"
|
|
@@ -19,7 +19,7 @@ magnus = { version = "0.8.2" }
|
|
|
19
19
|
# later via `wasmtime-wasi` once stdin/stdout wiring is needed (item #16).
|
|
20
20
|
# `cache` / `parallel-compilation` / `pooling` / `component-model` / `async`
|
|
21
21
|
# are intentionally off — kobako runs short-lived synchronous sandboxes.
|
|
22
|
-
wasmtime = { version = "
|
|
22
|
+
wasmtime = { version = "45.0.0", default-features = false, features = [
|
|
23
23
|
"cranelift",
|
|
24
24
|
"runtime",
|
|
25
25
|
"gc",
|
|
@@ -33,4 +33,4 @@ wasmtime = { version = "44.0.1", default-features = false, features = [
|
|
|
33
33
|
# WasiCtxBuilder + preview1 adapter which wires fd 1/2 to pipes. We omit
|
|
34
34
|
# `p2` (component-model) and `p0`/`p3` (async) because kobako runs
|
|
35
35
|
# synchronous sandboxes only.
|
|
36
|
-
wasmtime-wasi = { version = "
|
|
36
|
+
wasmtime-wasi = { version = "45.0.0", default-features = false, features = ["p1"] }
|
|
@@ -76,8 +76,9 @@ fn classify_trap(err: &wasmtime::Error) -> TrapClass {
|
|
|
76
76
|
/// `"linear memory growth exceeded memory_limit: ..."`). The wasmtime
|
|
77
77
|
/// outer wrapper at `format!("{}", err)` would otherwise surface only
|
|
78
78
|
/// the `"error while executing at wasm backtrace: ..."` framing, which
|
|
79
|
-
/// is operator noise on a cap trap. For `TrapClass::Other` the
|
|
80
|
-
///
|
|
79
|
+
/// is operator noise on a cap trap. For `TrapClass::Other` the framing
|
|
80
|
+
/// is kept but the chain's root cause is appended (see
|
|
81
|
+
/// `other_trap_message`) so the real trap reason survives.
|
|
81
82
|
pub(super) fn call_err(ruby: &Ruby, err: wasmtime::Error) -> MagnusError {
|
|
82
83
|
match classify_trap(&err) {
|
|
83
84
|
TrapClass::Timeout => {
|
|
@@ -94,7 +95,24 @@ pub(super) fn call_err(ruby: &Ruby, err: wasmtime::Error) -> MagnusError {
|
|
|
94
95
|
.unwrap_or_else(|| format!("{}", err));
|
|
95
96
|
memory_limit_err(ruby, msg)
|
|
96
97
|
}
|
|
97
|
-
TrapClass::Other => trap_err(ruby,
|
|
98
|
+
TrapClass::Other => trap_err(ruby, other_trap_message(&err)),
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/// Compose the message for a non-cap trap. wasmtime's `Display` surfaces only
|
|
103
|
+
/// the `"error while executing at wasm backtrace: ..."` framing; the actual
|
|
104
|
+
/// trap reason (e.g. `"wasm trap: indirect call type mismatch"`) is the
|
|
105
|
+
/// chain's root cause and would otherwise be dropped, making real guest
|
|
106
|
+
/// faults undiagnosable. Append the root cause unless the framing already
|
|
107
|
+
/// carries it. Pure so it can be exercised from `cargo test` without the
|
|
108
|
+
/// magnus surface.
|
|
109
|
+
fn other_trap_message(err: &wasmtime::Error) -> String {
|
|
110
|
+
let display = format!("{}", err);
|
|
111
|
+
let root = err.root_cause().to_string();
|
|
112
|
+
if display.contains(&root) {
|
|
113
|
+
display
|
|
114
|
+
} else {
|
|
115
|
+
format!("{display}\n\n{root}")
|
|
98
116
|
}
|
|
99
117
|
}
|
|
100
118
|
|
|
@@ -111,7 +129,7 @@ pub(super) fn instantiate_err(ruby: &Ruby, err: wasmtime::Error) -> MagnusError
|
|
|
111
129
|
|
|
112
130
|
#[cfg(test)]
|
|
113
131
|
mod tests {
|
|
114
|
-
use super::{classify_trap, TrapClass};
|
|
132
|
+
use super::{classify_trap, other_trap_message, TrapClass};
|
|
115
133
|
use crate::runtime::invocation::{MemoryLimitTrap, TimeoutTrap};
|
|
116
134
|
|
|
117
135
|
#[test]
|
|
@@ -131,4 +149,30 @@ mod tests {
|
|
|
131
149
|
let err = wasmtime::Error::msg("some other wasmtime fault");
|
|
132
150
|
assert_eq!(classify_trap(&err), TrapClass::Other);
|
|
133
151
|
}
|
|
152
|
+
|
|
153
|
+
// A guest hard trap reaches the host as a wasmtime error whose Display is
|
|
154
|
+
// only the backtrace framing, with the trap reason buried as the chain's
|
|
155
|
+
// root cause. The named-capture regex bug surfaced as exactly this shape.
|
|
156
|
+
#[test]
|
|
157
|
+
fn other_trap_message_surfaces_buried_trap_reason() {
|
|
158
|
+
let err = wasmtime::Error::msg("wasm trap: indirect call type mismatch")
|
|
159
|
+
.context("error while executing at wasm backtrace:\n 0: 0x1 - <unknown>");
|
|
160
|
+
let msg = other_trap_message(&err);
|
|
161
|
+
assert!(
|
|
162
|
+
msg.contains("indirect call type mismatch"),
|
|
163
|
+
"a non-cap trap surfaced through Kobako::TrapError must carry the root trap reason, not only the backtrace framing; got: {msg}"
|
|
164
|
+
);
|
|
165
|
+
assert!(
|
|
166
|
+
msg.contains("error while executing"),
|
|
167
|
+
"a non-cap trap surfaced through Kobako::TrapError must keep the wasm backtrace framing; got: {msg}"
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// A flat error (no cause chain) is its own root_cause; appending it would
|
|
172
|
+
// duplicate the whole message.
|
|
173
|
+
#[test]
|
|
174
|
+
fn other_trap_message_does_not_duplicate_a_flat_error() {
|
|
175
|
+
let err = wasmtime::Error::msg("plain fault");
|
|
176
|
+
assert_eq!(other_trap_message(&err), "plain fault");
|
|
177
|
+
}
|
|
134
178
|
}
|
data/ext/kobako/src/runtime.rs
CHANGED
|
@@ -65,6 +65,12 @@ use self::config::Config;
|
|
|
65
65
|
use self::exports::Exports;
|
|
66
66
|
use self::invocation::{Invocation, StoreCell};
|
|
67
67
|
|
|
68
|
+
/// The wire ABI version this host implements (docs/wire-codec.md § ABI
|
|
69
|
+
/// Version). A Guest Binary is accepted only when its
|
|
70
|
+
/// `__kobako_abi_version` export reports the same value (B-40 / E-42);
|
|
71
|
+
/// the guest-side mirror is `kobako_core::abi::ABI_VERSION`.
|
|
72
|
+
const ABI_VERSION: u32 = 1;
|
|
73
|
+
|
|
68
74
|
/// Copy the bytes of +s+ into a fresh `Vec<u8>`. Single safe entry to
|
|
69
75
|
/// what would otherwise be an inline +unsafe { rstring.as_slice() }
|
|
70
76
|
/// .to_vec()+ duplicated at every host-↔-guest boundary. The borrow
|
|
@@ -349,6 +355,8 @@ impl Runtime {
|
|
|
349
355
|
.map_err(|e| trap::instantiate_err(&ruby, e))?
|
|
350
356
|
};
|
|
351
357
|
|
|
358
|
+
Self::validate_abi_version(&instance, &store_cell, &ruby)?;
|
|
359
|
+
|
|
352
360
|
let exports = Exports::resolve(&instance, &store_cell);
|
|
353
361
|
|
|
354
362
|
Ok(Self {
|
|
@@ -364,6 +372,48 @@ impl Runtime {
|
|
|
364
372
|
})
|
|
365
373
|
}
|
|
366
374
|
|
|
375
|
+
/// Probe the guest's `__kobako_abi_version` export once at
|
|
376
|
+
/// construction and require equality with `ABI_VERSION`
|
|
377
|
+
/// (docs/behavior.md B-40). An absent export or a non-equal value is
|
|
378
|
+
/// E-42 — a deterministic artifact fault raised as
|
|
379
|
+
/// `Kobako::SetupError`.
|
|
380
|
+
fn validate_abi_version(
|
|
381
|
+
instance: &WtInstance,
|
|
382
|
+
store: &StoreCell,
|
|
383
|
+
ruby: &Ruby,
|
|
384
|
+
) -> Result<(), MagnusError> {
|
|
385
|
+
let mut store_ref = store.borrow_mut();
|
|
386
|
+
let mut ctx = store_ref.as_context_mut();
|
|
387
|
+
let probe = instance
|
|
388
|
+
.get_typed_func::<(), u32>(&mut ctx, "__kobako_abi_version")
|
|
389
|
+
.map_err(|_| {
|
|
390
|
+
setup_err(
|
|
391
|
+
ruby,
|
|
392
|
+
format!(
|
|
393
|
+
"the Guest Binary does not export __kobako_abi_version; \
|
|
394
|
+
rebuild it against ABI version {ABI_VERSION}"
|
|
395
|
+
),
|
|
396
|
+
)
|
|
397
|
+
})?;
|
|
398
|
+
let reported = probe.call(&mut ctx, ()).map_err(|e| {
|
|
399
|
+
setup_err(
|
|
400
|
+
ruby,
|
|
401
|
+
format!("failed to read the Guest Binary's ABI version: {e}"),
|
|
402
|
+
)
|
|
403
|
+
})?;
|
|
404
|
+
if reported != ABI_VERSION {
|
|
405
|
+
return Err(setup_err(
|
|
406
|
+
ruby,
|
|
407
|
+
format!(
|
|
408
|
+
"the Guest Binary reports ABI version {reported}, but this host \
|
|
409
|
+
implements ABI version {ABI_VERSION}; rebuild the Guest Binary \
|
|
410
|
+
against the host's version"
|
|
411
|
+
),
|
|
412
|
+
));
|
|
413
|
+
}
|
|
414
|
+
Ok(())
|
|
415
|
+
}
|
|
416
|
+
|
|
367
417
|
/// Register the Ruby-side dispatch +Proc+ on the active Invocation.
|
|
368
418
|
/// Bound to Ruby as +Kobako::Runtime#on_dispatch=+. From this point on,
|
|
369
419
|
/// every +__kobako_dispatch+ host import invocation calls the Proc
|
data/lib/kobako/version.rb
CHANGED
data/release-please-config.json
CHANGED
|
@@ -7,6 +7,17 @@
|
|
|
7
7
|
"component": "kobako",
|
|
8
8
|
"include-component-in-tag": false,
|
|
9
9
|
"release-type": "ruby"
|
|
10
|
+
},
|
|
11
|
+
"wasm/kobako-core": {
|
|
12
|
+
"component": "kobako-core",
|
|
13
|
+
"release-type": "rust",
|
|
14
|
+
"extra-files": [
|
|
15
|
+
{
|
|
16
|
+
"type": "toml",
|
|
17
|
+
"path": "/wasm/Cargo.lock",
|
|
18
|
+
"jsonpath": "$.package[?(@.name=='kobako-core')].version"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
10
21
|
}
|
|
11
22
|
},
|
|
12
23
|
"extra-files": [
|