kobako 0.19.0 → 0.21.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 +127 -0
- data/Cargo.lock +15 -5
- data/README.md +96 -44
- data/ROADMAP.md +1 -1
- data/crates/kobako-runtime/CHANGELOG.md +44 -0
- data/crates/kobako-runtime/Cargo.toml +8 -1
- data/crates/kobako-runtime/README.md +1 -1
- data/crates/kobako-runtime/src/dispatch.rs +17 -11
- data/crates/kobako-runtime/src/error.rs +38 -6
- data/crates/kobako-runtime/src/lib.rs +13 -2
- data/crates/kobako-runtime/src/profile.rs +8 -0
- data/crates/kobako-runtime/src/runtime.rs +7 -2
- data/crates/kobako-runtime/src/snapshot.rs +13 -4
- data/crates/kobako-runtime/src/yielder.rs +4 -4
- data/crates/kobako-transport/CHANGELOG.md +31 -0
- data/crates/kobako-transport/Cargo.toml +27 -0
- data/crates/kobako-transport/README.md +43 -0
- data/crates/kobako-transport/src/abi.rs +89 -0
- data/crates/kobako-transport/src/envelope/bytes.rs +234 -0
- data/crates/kobako-transport/src/envelope/call.rs +222 -0
- data/crates/kobako-transport/src/envelope/error_record.rs +101 -0
- data/crates/kobako-transport/src/envelope/fault.rs +171 -0
- data/crates/kobako-transport/src/envelope/invocation_frames.rs +232 -0
- data/crates/kobako-transport/src/envelope/outcome.rs +261 -0
- data/crates/kobako-transport/src/envelope/reply.rs +240 -0
- data/crates/kobako-transport/src/envelope/run.rs +85 -0
- data/crates/kobako-transport/src/envelope.rs +73 -0
- data/crates/kobako-transport/src/lib.rs +21 -0
- data/crates/kobako-wasmtime/CHANGELOG.md +30 -0
- data/crates/kobako-wasmtime/Cargo.toml +5 -2
- data/crates/kobako-wasmtime/README.md +1 -1
- data/crates/kobako-wasmtime/src/config.rs +19 -12
- data/crates/kobako-wasmtime/src/dispatch.rs +15 -8
- data/crates/kobako-wasmtime/src/driver.rs +8 -27
- data/crates/kobako-wasmtime/src/frames.rs +11 -9
- data/crates/kobako-wasmtime/src/guest_mem.rs +7 -15
- data/crates/kobako-wasmtime/src/instance_pre.rs +2 -2
- data/crates/kobako-wasmtime/src/invocation.rs +1 -1
- data/crates/kobako-wasmtime/src/lib.rs +1 -1
- data/data/kobako.wasm +0 -0
- data/ext/kobako/Cargo.toml +7 -2
- data/ext/kobako/src/runtime/bridge.rs +103 -40
- data/ext/kobako/src/runtime/errors.rs +13 -5
- data/ext/kobako/src/runtime/gvl.rs +114 -0
- data/ext/kobako/src/runtime.rs +307 -207
- data/lib/kobako/catalog/extensions.rb +50 -43
- data/lib/kobako/catalog/handles.rb +7 -16
- data/lib/kobako/catalog/services.rb +24 -46
- data/lib/kobako/catalog/snippets.rb +16 -33
- data/lib/kobako/codec/decoder.rb +10 -13
- data/lib/kobako/codec/encoder.rb +7 -7
- data/lib/kobako/codec/error.rb +6 -6
- data/lib/kobako/codec/ext_types.rb +12 -72
- data/lib/kobako/codec/handle_walk.rb +6 -6
- data/lib/kobako/codec/state.rb +4 -56
- data/lib/kobako/codec/utils.rb +4 -4
- data/lib/kobako/codec.rb +11 -21
- data/lib/kobako/context.rb +208 -0
- data/lib/kobako/errors.rb +59 -16
- data/lib/kobako/execution.rb +52 -0
- data/lib/kobako/extension.rb +24 -10
- data/lib/kobako/outcome.rb +71 -128
- data/lib/kobako/payload/arguments.rb +59 -0
- data/lib/kobako/payload.rb +19 -0
- data/lib/kobako/pool.rb +1 -7
- data/lib/kobako/sandbox.rb +51 -191
- data/lib/kobako/sandbox_options.rb +24 -3
- data/lib/kobako/snippet/binary.rb +3 -4
- data/lib/kobako/snippet/source.rb +5 -6
- data/lib/kobako/snippet.rb +2 -2
- data/lib/kobako/transport/call.rb +21 -0
- data/lib/kobako/transport/dispatcher.rb +94 -142
- data/lib/kobako/transport/error.rb +21 -5
- data/lib/kobako/transport/reflection.rb +82 -0
- data/lib/kobako/transport/run.rb +25 -33
- data/lib/kobako/transport/yielder.rb +27 -36
- data/lib/kobako/transport.rb +11 -8
- data/lib/kobako/unresolved.rb +17 -0
- data/lib/kobako/usage.rb +7 -7
- data/lib/kobako/version.rb +1 -1
- data/lib/kobako.rb +1 -0
- data/release-please-config.json +47 -2
- data/sig/kobako/catalog/extensions.rbs +4 -6
- data/sig/kobako/catalog/handles.rbs +0 -2
- data/sig/kobako/catalog/services.rbs +4 -4
- data/sig/kobako/catalog/snippets.rbs +4 -2
- data/sig/kobako/codec/error.rbs +4 -4
- data/sig/kobako/codec/ext_types.rbs +0 -7
- data/sig/kobako/codec/state.rbs +0 -4
- data/sig/kobako/codec.rbs +4 -5
- data/sig/kobako/context.rbs +36 -0
- data/sig/kobako/errors.rbs +27 -6
- data/sig/kobako/execution.rbs +24 -0
- data/sig/kobako/extension.rbs +4 -2
- data/sig/kobako/outcome.rbs +12 -13
- data/sig/kobako/payload/arguments.rbs +26 -0
- data/sig/kobako/payload.rbs +4 -0
- data/sig/kobako/pool.rbs +0 -2
- data/sig/kobako/runtime.rbs +44 -14
- data/sig/kobako/sandbox.rbs +5 -24
- data/sig/kobako/sandbox_options.rbs +9 -2
- data/sig/kobako/snippet/binary.rbs +1 -1
- data/sig/kobako/snippet/source.rbs +1 -1
- data/sig/kobako/transport/call.rbs +21 -0
- data/sig/kobako/transport/dispatcher.rbs +9 -11
- data/sig/kobako/transport/error.rbs +11 -0
- data/sig/kobako/transport/reflection.rbs +15 -0
- data/sig/kobako/transport/run.rbs +1 -1
- data/sig/kobako/transport/yielder.rbs +2 -2
- data/sig/kobako/transport.rbs +8 -2
- data/sig/kobako/unresolved.rbs +4 -0
- metadata +30 -11
- data/lib/kobako/fault.rb +0 -38
- data/lib/kobako/outcome/panic.rb +0 -42
- data/lib/kobako/transport/request.rb +0 -76
- data/lib/kobako/transport/response.rb +0 -83
- data/lib/kobako/transport/yield.rb +0 -94
- data/sig/kobako/fault.rbs +0 -19
- data/sig/kobako/outcome/panic.rbs +0 -34
- data/sig/kobako/transport/request.rbs +0 -29
- data/sig/kobako/transport/response.rbs +0 -33
- data/sig/kobako/transport/yield.rbs +0 -34
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
//! The host refusing or failing a Call.
|
|
2
|
+
//!
|
|
3
|
+
//! Every byte of a Fault is kobako's — a closed category and a message —
|
|
4
|
+
//! so it rides the envelope and a guest reads a refusal with no payload
|
|
5
|
+
//! codec at all.
|
|
6
|
+
//!
|
|
7
|
+
//! Distinct from an `ErrorRecord`, which travels the other way. The
|
|
8
|
+
//! direction decides what each carries: a Fault crosses host to guest,
|
|
9
|
+
//! where a backtrace would put host paths and object graphs in front of
|
|
10
|
+
//! untrusted code, so it structurally has none.
|
|
11
|
+
|
|
12
|
+
use super::bytes::{Reader, Writer};
|
|
13
|
+
use super::DecodeError;
|
|
14
|
+
|
|
15
|
+
const KIND_RUNTIME: u8 = 0;
|
|
16
|
+
const KIND_ARGUMENT: u8 = 1;
|
|
17
|
+
const KIND_UNDEFINED: u8 = 2;
|
|
18
|
+
|
|
19
|
+
/// Which of the three failures a Fault reports.
|
|
20
|
+
///
|
|
21
|
+
/// A tag rather than a name because the set is closed: a category outside
|
|
22
|
+
/// these three is unrepresentable, so no endpoint decides what an unknown
|
|
23
|
+
/// one means. `Undefined` stays indistinguishable across its causes — an
|
|
24
|
+
/// unbound path, an unknown method, a rejected name — so a guest probing
|
|
25
|
+
/// the surface learns nothing from which refusal it got.
|
|
26
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
27
|
+
pub enum FaultKind {
|
|
28
|
+
/// A Ruby exception raised inside the Service method.
|
|
29
|
+
Runtime,
|
|
30
|
+
/// The call shape did not fit the method.
|
|
31
|
+
Argument,
|
|
32
|
+
/// No such member or method.
|
|
33
|
+
Undefined,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
impl FaultKind {
|
|
37
|
+
fn tag(self) -> u8 {
|
|
38
|
+
match self {
|
|
39
|
+
FaultKind::Runtime => KIND_RUNTIME,
|
|
40
|
+
FaultKind::Argument => KIND_ARGUMENT,
|
|
41
|
+
FaultKind::Undefined => KIND_UNDEFINED,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
fn from_tag(tag: u8) -> Result<Self, DecodeError> {
|
|
46
|
+
match tag {
|
|
47
|
+
KIND_RUNTIME => Ok(FaultKind::Runtime),
|
|
48
|
+
KIND_ARGUMENT => Ok(FaultKind::Argument),
|
|
49
|
+
KIND_UNDEFINED => Ok(FaultKind::Undefined),
|
|
50
|
+
_ => Err(DecodeError::new(
|
|
51
|
+
"Fault kind must be 0 (runtime), 1 (argument), or 2 (undefined)",
|
|
52
|
+
)),
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/// The spelling the guest raises under and a frontend reports.
|
|
57
|
+
pub fn name(self) -> &'static str {
|
|
58
|
+
match self {
|
|
59
|
+
FaultKind::Runtime => "runtime",
|
|
60
|
+
FaultKind::Argument => "argument",
|
|
61
|
+
FaultKind::Undefined => "undefined",
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/// Read a category from the name a frontend uses, or `None` when the
|
|
66
|
+
/// name is outside the closed set.
|
|
67
|
+
pub fn from_name(name: &str) -> Option<Self> {
|
|
68
|
+
match name {
|
|
69
|
+
"runtime" => Some(FaultKind::Runtime),
|
|
70
|
+
"argument" => Some(FaultKind::Argument),
|
|
71
|
+
"undefined" => Some(FaultKind::Undefined),
|
|
72
|
+
_ => None,
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/// A Service-level refusal: the guest re-raises it as a rescuable
|
|
78
|
+
/// exception, never a wasm trap.
|
|
79
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
80
|
+
pub struct Fault {
|
|
81
|
+
pub kind: FaultKind,
|
|
82
|
+
pub message: String,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
impl Fault {
|
|
86
|
+
pub fn new(kind: FaultKind, message: impl Into<String>) -> Self {
|
|
87
|
+
Fault {
|
|
88
|
+
kind,
|
|
89
|
+
message: message.into(),
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
pub(crate) fn read(reader: &mut Reader<'_>) -> Result<Self, DecodeError> {
|
|
94
|
+
let kind = FaultKind::from_tag(reader.u8()?)?;
|
|
95
|
+
let message = reader.text()?.to_owned();
|
|
96
|
+
Ok(Fault { kind, message })
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
pub(crate) fn write(&self, writer: &mut Writer) {
|
|
100
|
+
writer.u8(self.kind.tag()).bytes(self.message.as_bytes());
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
#[cfg(test)]
|
|
105
|
+
mod tests {
|
|
106
|
+
use super::*;
|
|
107
|
+
|
|
108
|
+
#[test]
|
|
109
|
+
fn every_kind_round_trips_through_the_wire() {
|
|
110
|
+
for kind in [
|
|
111
|
+
FaultKind::Runtime,
|
|
112
|
+
FaultKind::Argument,
|
|
113
|
+
FaultKind::Undefined,
|
|
114
|
+
] {
|
|
115
|
+
let fault = Fault::new(kind, "boom");
|
|
116
|
+
let mut w = Writer::new();
|
|
117
|
+
fault.write(&mut w);
|
|
118
|
+
let encoded = w.into_bytes();
|
|
119
|
+
let mut r = Reader::new(&encoded);
|
|
120
|
+
assert_eq!(
|
|
121
|
+
Fault::read(&mut r),
|
|
122
|
+
Ok(fault),
|
|
123
|
+
"every Fault kind must survive an encode and decode unchanged"
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
#[test]
|
|
129
|
+
fn a_kind_outside_the_closed_set_is_refused() {
|
|
130
|
+
let encoded = vec![3, 0, 0, 0, 0];
|
|
131
|
+
let mut r = Reader::new(&encoded);
|
|
132
|
+
assert!(
|
|
133
|
+
Fault::read(&mut r).is_err(),
|
|
134
|
+
"a Fault kind byte outside the three the contract fixes must be refused, not carried"
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
#[test]
|
|
139
|
+
fn golden_layout_is_kind_then_message() {
|
|
140
|
+
let mut w = Writer::new();
|
|
141
|
+
Fault::new(FaultKind::Argument, "m").write(&mut w);
|
|
142
|
+
assert_eq!(
|
|
143
|
+
w.into_bytes(),
|
|
144
|
+
vec![
|
|
145
|
+
1, // kind: argument
|
|
146
|
+
0, 0, 0, 1, b'm', // message
|
|
147
|
+
],
|
|
148
|
+
"the Fault byte layout must stay fixed for both peers to agree"
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
#[test]
|
|
153
|
+
fn names_map_both_ways() {
|
|
154
|
+
for kind in [
|
|
155
|
+
FaultKind::Runtime,
|
|
156
|
+
FaultKind::Argument,
|
|
157
|
+
FaultKind::Undefined,
|
|
158
|
+
] {
|
|
159
|
+
assert_eq!(
|
|
160
|
+
FaultKind::from_name(kind.name()),
|
|
161
|
+
Some(kind),
|
|
162
|
+
"a kind's name must read back as the same kind"
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
assert_eq!(
|
|
166
|
+
FaultKind::from_name("other"),
|
|
167
|
+
None,
|
|
168
|
+
"a name outside the closed set must not resolve to a kind"
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
//! The invocation frames — Frame 1's bound constant paths and Frame 3's
|
|
2
|
+
//! preloaded snippets, the two both entry points read before running
|
|
3
|
+
//! anything. The numbering skips Frame 2 because the `#eval` user source
|
|
4
|
+
//! is raw UTF-8 with no envelope of its own.
|
|
5
|
+
//!
|
|
6
|
+
//! Both are mandatory-presence even when empty, so a reader never has to
|
|
7
|
+
//! tell an absent frame from an empty one, and each frame's length reaches
|
|
8
|
+
//! the guest from the channel's own prefix rather than from the envelope.
|
|
9
|
+
|
|
10
|
+
use super::bytes::{Reader, Writer};
|
|
11
|
+
use super::DecodeError;
|
|
12
|
+
|
|
13
|
+
const SNIPPET_SOURCE: u8 = 0;
|
|
14
|
+
const SNIPPET_BYTECODE: u8 = 1;
|
|
15
|
+
|
|
16
|
+
/// Frame 1 — the bound constant paths the guest installs proxies from.
|
|
17
|
+
/// Always present; an empty list means no Service is bound.
|
|
18
|
+
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
|
19
|
+
pub struct Bindings {
|
|
20
|
+
pub paths: Vec<String>,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
impl Bindings {
|
|
24
|
+
pub fn decode(bytes: &[u8]) -> Result<Self, DecodeError> {
|
|
25
|
+
let mut reader = Reader::new(bytes);
|
|
26
|
+
let paths = reader.text_list()?;
|
|
27
|
+
reader.finish()?;
|
|
28
|
+
Ok(Bindings { paths })
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
pub fn encode(&self) -> Vec<u8> {
|
|
32
|
+
let mut writer = Writer::new();
|
|
33
|
+
writer.list(&self.paths);
|
|
34
|
+
writer.into_bytes()
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/// One preloaded snippet. Source carries the filename the guest compiles
|
|
39
|
+
/// under; bytecode does not, because its filename — when it has one — lives
|
|
40
|
+
/// in the bytecode's own debug section.
|
|
41
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
42
|
+
pub enum Snippet {
|
|
43
|
+
Source { name: String, body: String },
|
|
44
|
+
Bytecode { body: Vec<u8> },
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/// Frame 3 — the preloaded snippets, in insertion order. Always present;
|
|
48
|
+
/// a zero count means nothing was preloaded.
|
|
49
|
+
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
|
50
|
+
pub struct Snippets {
|
|
51
|
+
pub entries: Vec<Snippet>,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
impl Snippets {
|
|
55
|
+
pub fn decode(bytes: &[u8]) -> Result<Self, DecodeError> {
|
|
56
|
+
let mut reader = Reader::new(bytes);
|
|
57
|
+
let count = reader.u32()? as usize;
|
|
58
|
+
// Each entry costs at least a kind byte, so a count past the bytes
|
|
59
|
+
// left cannot be satisfied; refusing it bounds the allocation.
|
|
60
|
+
if count > reader.remaining().len() {
|
|
61
|
+
return Err(DecodeError::new(
|
|
62
|
+
"Frame 3 declares more entries than the frame holds",
|
|
63
|
+
));
|
|
64
|
+
}
|
|
65
|
+
let mut entries = Vec::with_capacity(count);
|
|
66
|
+
for _ in 0..count {
|
|
67
|
+
entries.push(match reader.u8()? {
|
|
68
|
+
SNIPPET_SOURCE => Snippet::Source {
|
|
69
|
+
name: reader.text()?.to_owned(),
|
|
70
|
+
body: reader.text()?.to_owned(),
|
|
71
|
+
},
|
|
72
|
+
SNIPPET_BYTECODE => Snippet::Bytecode {
|
|
73
|
+
body: reader.bytes()?.to_vec(),
|
|
74
|
+
},
|
|
75
|
+
_ => return Err(DecodeError::new("Frame 3 snippet kind must be 0 or 1")),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
reader.finish()?;
|
|
79
|
+
Ok(Snippets { entries })
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
pub fn encode(&self) -> Vec<u8> {
|
|
83
|
+
let mut writer = Writer::new();
|
|
84
|
+
writer.u32(self.entries.len() as u32);
|
|
85
|
+
for entry in &self.entries {
|
|
86
|
+
match entry {
|
|
87
|
+
Snippet::Source { name, body } => {
|
|
88
|
+
writer
|
|
89
|
+
.u8(SNIPPET_SOURCE)
|
|
90
|
+
.bytes(name.as_bytes())
|
|
91
|
+
.bytes(body.as_bytes());
|
|
92
|
+
}
|
|
93
|
+
Snippet::Bytecode { body } => {
|
|
94
|
+
writer.u8(SNIPPET_BYTECODE).bytes(body);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
writer.into_bytes()
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
#[cfg(test)]
|
|
103
|
+
mod tests {
|
|
104
|
+
use super::*;
|
|
105
|
+
|
|
106
|
+
#[test]
|
|
107
|
+
fn empty_bindings_round_trip() {
|
|
108
|
+
let encoded = Bindings::default().encode();
|
|
109
|
+
assert_eq!(
|
|
110
|
+
Bindings::decode(&encoded),
|
|
111
|
+
Ok(Bindings::default()),
|
|
112
|
+
"a Sandbox with no bindings must send a present, empty Frame 1"
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
#[test]
|
|
117
|
+
fn bindings_round_trip_every_path() {
|
|
118
|
+
let bindings = Bindings {
|
|
119
|
+
paths: vec!["MyService::KV".into(), "File".into()],
|
|
120
|
+
};
|
|
121
|
+
let encoded = bindings.encode();
|
|
122
|
+
assert_eq!(
|
|
123
|
+
Bindings::decode(&encoded),
|
|
124
|
+
Ok(bindings),
|
|
125
|
+
"Frame 1 must carry every bound path in order"
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
#[test]
|
|
130
|
+
fn snippets_round_trip_both_kinds_in_order() {
|
|
131
|
+
let snippets = Snippets {
|
|
132
|
+
entries: vec![
|
|
133
|
+
Snippet::Source {
|
|
134
|
+
name: "Helper".into(),
|
|
135
|
+
body: "def helper; end".into(),
|
|
136
|
+
},
|
|
137
|
+
Snippet::Bytecode {
|
|
138
|
+
body: vec![0x52, 0x49, 0x54, 0x45],
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
};
|
|
142
|
+
let encoded = snippets.encode();
|
|
143
|
+
assert_eq!(
|
|
144
|
+
Snippets::decode(&encoded),
|
|
145
|
+
Ok(snippets),
|
|
146
|
+
"Frame 3 must carry source and bytecode entries in insertion order"
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
#[test]
|
|
151
|
+
fn an_empty_snippet_table_round_trips() {
|
|
152
|
+
let encoded = Snippets::default().encode();
|
|
153
|
+
assert_eq!(
|
|
154
|
+
Snippets::decode(&encoded),
|
|
155
|
+
Ok(Snippets::default()),
|
|
156
|
+
"a Sandbox with no preloads must send a present, zero-count Frame 3"
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
#[test]
|
|
161
|
+
fn golden_layout_pins_the_bindings_as_a_counted_list() {
|
|
162
|
+
let bindings = Bindings {
|
|
163
|
+
paths: vec!["A".into()],
|
|
164
|
+
};
|
|
165
|
+
assert_eq!(
|
|
166
|
+
bindings.encode(),
|
|
167
|
+
vec![
|
|
168
|
+
0, 0, 0, 1, // path count
|
|
169
|
+
0, 0, 0, 1, b'A',
|
|
170
|
+
],
|
|
171
|
+
"Frame 1 must be a counted list of paths, so the count precedes the first entry"
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
#[test]
|
|
176
|
+
fn golden_layout_pins_the_snippet_entry_shape() {
|
|
177
|
+
let snippets = Snippets {
|
|
178
|
+
entries: vec![
|
|
179
|
+
Snippet::Source {
|
|
180
|
+
name: "N".into(),
|
|
181
|
+
body: "b".into(),
|
|
182
|
+
},
|
|
183
|
+
Snippet::Bytecode { body: vec![0x52] },
|
|
184
|
+
],
|
|
185
|
+
};
|
|
186
|
+
assert_eq!(
|
|
187
|
+
snippets.encode(),
|
|
188
|
+
vec![
|
|
189
|
+
0, 0, 0, 2, // entry count
|
|
190
|
+
0, // kind: source
|
|
191
|
+
0, 0, 0, 1, b'N', // name, source only
|
|
192
|
+
0, 0, 0, 1, b'b', // body
|
|
193
|
+
1, // kind: bytecode
|
|
194
|
+
0, 0, 0, 1, 0x52, // body, no name
|
|
195
|
+
],
|
|
196
|
+
"a source entry must encode as kind byte 0 with a name and a bytecode entry as \
|
|
197
|
+
kind byte 1 with none, so the two stay distinguishable by their tag alone"
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
#[test]
|
|
202
|
+
fn an_unknown_snippet_kind_is_refused() {
|
|
203
|
+
let bytes = {
|
|
204
|
+
let mut w = Writer::new();
|
|
205
|
+
w.u32(1).u8(9);
|
|
206
|
+
w.into_bytes()
|
|
207
|
+
};
|
|
208
|
+
assert!(
|
|
209
|
+
Snippets::decode(&bytes).is_err(),
|
|
210
|
+
"a Frame 3 snippet kind that is neither source nor bytecode must be rejected"
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
#[test]
|
|
215
|
+
fn a_snippet_count_the_frame_cannot_satisfy_is_refused() {
|
|
216
|
+
let bytes = [0xff, 0xff, 0xff, 0xff];
|
|
217
|
+
assert!(
|
|
218
|
+
Snippets::decode(&bytes).is_err(),
|
|
219
|
+
"a Frame 3 count larger than the frame must be rejected before any allocation"
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
#[test]
|
|
224
|
+
fn trailing_bytes_after_a_frame_are_refused() {
|
|
225
|
+
let mut encoded = Bindings::default().encode();
|
|
226
|
+
encoded.push(0);
|
|
227
|
+
assert!(
|
|
228
|
+
Bindings::decode(&encoded).is_err(),
|
|
229
|
+
"bytes past Frame 1's last field must fail loudly as a framing desync"
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
//! The Outcome envelope — one invocation's final result.
|
|
2
|
+
//!
|
|
3
|
+
//! Every Panic field is typed here, so a host attributes a failed
|
|
4
|
+
//! invocation and reports the correction for it without decoding a payload
|
|
5
|
+
//! byte. Only the ok arm carries codec-encoded bytes.
|
|
6
|
+
|
|
7
|
+
use super::bytes::{Reader, Writer};
|
|
8
|
+
use super::{DecodeError, ErrorRecord};
|
|
9
|
+
|
|
10
|
+
const TAG_OK: u8 = 0x01;
|
|
11
|
+
const TAG_PANIC: u8 = 0x02;
|
|
12
|
+
|
|
13
|
+
/// Who a failed invocation attributes to.
|
|
14
|
+
///
|
|
15
|
+
/// The wire field is an open set of byte strings, but only two values
|
|
16
|
+
/// carry meaning — so both sides read and write it through this type and
|
|
17
|
+
/// cannot come to spell either one differently. The fallback rule lives in
|
|
18
|
+
/// `from_name`: an unrecognised value attributes to the sandbox rather
|
|
19
|
+
/// than widening what a Service can claim.
|
|
20
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
21
|
+
pub enum Origin {
|
|
22
|
+
/// The guest script raised, or a boot step faulted.
|
|
23
|
+
#[default]
|
|
24
|
+
Sandbox,
|
|
25
|
+
/// An unrescued Service call raised.
|
|
26
|
+
Service,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
impl Origin {
|
|
30
|
+
/// This attribution's spelling on the wire.
|
|
31
|
+
pub fn name(self) -> &'static str {
|
|
32
|
+
match self {
|
|
33
|
+
Origin::Sandbox => "sandbox",
|
|
34
|
+
Origin::Service => "service",
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/// Read a wire spelling back. Everything other than `"service"`,
|
|
39
|
+
/// recognised or not, attributes to the sandbox.
|
|
40
|
+
pub fn from_name(name: &str) -> Self {
|
|
41
|
+
match name {
|
|
42
|
+
"service" => Origin::Service,
|
|
43
|
+
_ => Origin::Sandbox,
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/// How an invocation ended.
|
|
49
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
50
|
+
pub enum Outcome {
|
|
51
|
+
/// The invocation completed; the bytes are its value, codec-encoded.
|
|
52
|
+
Ok(Vec<u8>),
|
|
53
|
+
/// The invocation terminated with an uncaught exception.
|
|
54
|
+
Panic(Panic),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/// An uncaught top-level failure, plus what attribution and correction need.
|
|
58
|
+
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
|
59
|
+
pub struct Panic {
|
|
60
|
+
pub origin: Origin,
|
|
61
|
+
pub error: ErrorRecord,
|
|
62
|
+
/// The names the invocation could have used in place of the one it
|
|
63
|
+
/// named. Empty when the failure offers no correction.
|
|
64
|
+
pub available: Vec<String>,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
impl Outcome {
|
|
68
|
+
pub fn decode(bytes: &[u8]) -> Result<Self, DecodeError> {
|
|
69
|
+
let mut reader = Reader::new(bytes);
|
|
70
|
+
match reader.u8()? {
|
|
71
|
+
TAG_OK => Ok(Outcome::Ok(reader.remaining().to_vec())),
|
|
72
|
+
TAG_PANIC => {
|
|
73
|
+
let origin = Origin::from_name(reader.text()?);
|
|
74
|
+
let error = ErrorRecord::read(&mut reader)?;
|
|
75
|
+
let available = reader.text_list()?;
|
|
76
|
+
reader.finish()?;
|
|
77
|
+
Ok(Outcome::Panic(Panic {
|
|
78
|
+
origin,
|
|
79
|
+
error,
|
|
80
|
+
available,
|
|
81
|
+
}))
|
|
82
|
+
}
|
|
83
|
+
_ => Err(DecodeError::new(
|
|
84
|
+
"Outcome tag must be 0x01 (ok) or 0x02 (panic)",
|
|
85
|
+
)),
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
pub fn encode(&self) -> Vec<u8> {
|
|
90
|
+
let mut writer = Writer::new();
|
|
91
|
+
match self {
|
|
92
|
+
Outcome::Ok(value) => {
|
|
93
|
+
writer.u8(TAG_OK).remainder(value);
|
|
94
|
+
}
|
|
95
|
+
Outcome::Panic(panic) => {
|
|
96
|
+
writer.u8(TAG_PANIC).bytes(panic.origin.name().as_bytes());
|
|
97
|
+
panic.error.write(&mut writer);
|
|
98
|
+
writer.list(&panic.available);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
writer.into_bytes()
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
#[cfg(test)]
|
|
106
|
+
mod tests {
|
|
107
|
+
use super::*;
|
|
108
|
+
|
|
109
|
+
fn panic_sample() -> Panic {
|
|
110
|
+
Panic {
|
|
111
|
+
origin: Origin::Sandbox,
|
|
112
|
+
error: ErrorRecord {
|
|
113
|
+
name: "RuntimeError".into(),
|
|
114
|
+
message: "boom".into(),
|
|
115
|
+
backtrace: vec!["(eval):1".into()],
|
|
116
|
+
},
|
|
117
|
+
available: Vec::new(),
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
#[test]
|
|
122
|
+
fn an_ok_outcome_round_trips() {
|
|
123
|
+
let outcome = Outcome::Ok(vec![0x2a]);
|
|
124
|
+
let encoded = outcome.encode();
|
|
125
|
+
assert_eq!(
|
|
126
|
+
Outcome::decode(&encoded),
|
|
127
|
+
Ok(outcome),
|
|
128
|
+
"an ok Outcome must survive a host encode and decode unchanged"
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
#[test]
|
|
133
|
+
fn a_panic_round_trips() {
|
|
134
|
+
let outcome = Outcome::Panic(panic_sample());
|
|
135
|
+
let encoded = outcome.encode();
|
|
136
|
+
assert_eq!(
|
|
137
|
+
Outcome::decode(&encoded),
|
|
138
|
+
Ok(outcome),
|
|
139
|
+
"a Panic Outcome must survive a host encode and decode unchanged"
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
#[test]
|
|
144
|
+
fn a_panic_carrying_available_names_round_trips() {
|
|
145
|
+
let outcome = Outcome::Panic(Panic {
|
|
146
|
+
available: vec!["Worker".into(), "Helper".into()],
|
|
147
|
+
..panic_sample()
|
|
148
|
+
});
|
|
149
|
+
let encoded = outcome.encode();
|
|
150
|
+
assert_eq!(
|
|
151
|
+
Outcome::decode(&encoded),
|
|
152
|
+
Ok(outcome),
|
|
153
|
+
"a Panic offering a correction must carry its names through in order"
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
#[test]
|
|
158
|
+
fn a_panic_offering_no_correction_decodes_as_an_empty_list() {
|
|
159
|
+
let encoded = Outcome::Panic(panic_sample()).encode();
|
|
160
|
+
match Outcome::decode(&encoded) {
|
|
161
|
+
Ok(Outcome::Panic(panic)) => assert!(
|
|
162
|
+
panic.available.is_empty(),
|
|
163
|
+
"a Panic with no correction to offer must decode as an empty list, not a decode error"
|
|
164
|
+
),
|
|
165
|
+
other => panic!("expected a Panic, got {other:?}"),
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
#[test]
|
|
170
|
+
fn attribution_reads_origin_alone() {
|
|
171
|
+
let service = Panic {
|
|
172
|
+
origin: Origin::Service,
|
|
173
|
+
..panic_sample()
|
|
174
|
+
};
|
|
175
|
+
assert_eq!(
|
|
176
|
+
Outcome::decode(&Outcome::Panic(service.clone()).encode()),
|
|
177
|
+
Ok(Outcome::Panic(service)),
|
|
178
|
+
"a Panic written with Service attribution must decode back to it, since the \
|
|
179
|
+
origin field is all a host reads to attribute"
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
#[test]
|
|
184
|
+
fn bytes_past_the_available_list_are_refused() {
|
|
185
|
+
let mut encoded = Outcome::Panic(panic_sample()).encode();
|
|
186
|
+
encoded.push(0x2a);
|
|
187
|
+
assert!(
|
|
188
|
+
Outcome::decode(&encoded).is_err(),
|
|
189
|
+
"a Panic is self-delimiting to its last field, so trailing bytes must fail as a framing desync"
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
#[test]
|
|
194
|
+
fn an_unrecognised_origin_attributes_to_the_sandbox() {
|
|
195
|
+
// Written by hand: the origin field is an open set on the wire, so
|
|
196
|
+
// this is the shape a third-party guest can legally produce and the
|
|
197
|
+
// one the fallback rule exists for.
|
|
198
|
+
let mut writer = Writer::new();
|
|
199
|
+
writer.u8(TAG_PANIC).bytes(b"something-else");
|
|
200
|
+
panic_sample().error.write(&mut writer);
|
|
201
|
+
writer.list::<&[u8]>(&[]);
|
|
202
|
+
assert_eq!(
|
|
203
|
+
Outcome::decode(&writer.into_bytes()),
|
|
204
|
+
Ok(Outcome::Panic(panic_sample())),
|
|
205
|
+
"an origin outside the reserved set must decode as sandbox attribution rather \
|
|
206
|
+
than widening what a Service can claim"
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
#[test]
|
|
211
|
+
fn golden_layout_pins_the_ok_tag() {
|
|
212
|
+
assert_eq!(
|
|
213
|
+
Outcome::Ok(vec![0x2a]).encode(),
|
|
214
|
+
vec![0x01, 0x2a],
|
|
215
|
+
"an ok Outcome must encode as tag byte 0x01 followed by the value alone"
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
#[test]
|
|
220
|
+
fn golden_layout_pins_the_panic_field_order() {
|
|
221
|
+
let panic = Panic {
|
|
222
|
+
origin: Origin::Service,
|
|
223
|
+
error: ErrorRecord {
|
|
224
|
+
name: "E".into(),
|
|
225
|
+
message: "m".into(),
|
|
226
|
+
backtrace: vec!["l".into()],
|
|
227
|
+
},
|
|
228
|
+
available: vec!["W".into()],
|
|
229
|
+
};
|
|
230
|
+
assert_eq!(
|
|
231
|
+
Outcome::Panic(panic).encode(),
|
|
232
|
+
vec![
|
|
233
|
+
0x02, // tag: panic
|
|
234
|
+
0, 0, 0, 7, b's', b'e', b'r', b'v', b'i', b'c', b'e', // origin
|
|
235
|
+
0, 0, 0, 1, b'E', // name
|
|
236
|
+
0, 0, 0, 1, b'm', // message
|
|
237
|
+
0, 0, 0, 1, // backtrace count
|
|
238
|
+
0, 0, 0, 1, b'l', // backtrace[0]
|
|
239
|
+
0, 0, 0, 1, // available count
|
|
240
|
+
0, 0, 0, 1, b'W', // available[0]
|
|
241
|
+
],
|
|
242
|
+
"attribution reads origin before the Error Record, so the field order must stay fixed"
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
#[test]
|
|
247
|
+
fn a_zero_length_outcome_is_refused() {
|
|
248
|
+
assert!(
|
|
249
|
+
Outcome::decode(&[]).is_err(),
|
|
250
|
+
"a zero-length outcome buffer must be rejected as a wire violation"
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
#[test]
|
|
255
|
+
fn an_unknown_outcome_tag_is_refused() {
|
|
256
|
+
assert!(
|
|
257
|
+
Outcome::decode(&[0x03, 0x00]).is_err(),
|
|
258
|
+
"an Outcome tag that is neither ok nor panic must be rejected as a wire violation"
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
}
|