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.
Files changed (124) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/CHANGELOG.md +127 -0
  4. data/Cargo.lock +15 -5
  5. data/README.md +96 -44
  6. data/ROADMAP.md +1 -1
  7. data/crates/kobako-runtime/CHANGELOG.md +44 -0
  8. data/crates/kobako-runtime/Cargo.toml +8 -1
  9. data/crates/kobako-runtime/README.md +1 -1
  10. data/crates/kobako-runtime/src/dispatch.rs +17 -11
  11. data/crates/kobako-runtime/src/error.rs +38 -6
  12. data/crates/kobako-runtime/src/lib.rs +13 -2
  13. data/crates/kobako-runtime/src/profile.rs +8 -0
  14. data/crates/kobako-runtime/src/runtime.rs +7 -2
  15. data/crates/kobako-runtime/src/snapshot.rs +13 -4
  16. data/crates/kobako-runtime/src/yielder.rs +4 -4
  17. data/crates/kobako-transport/CHANGELOG.md +31 -0
  18. data/crates/kobako-transport/Cargo.toml +27 -0
  19. data/crates/kobako-transport/README.md +43 -0
  20. data/crates/kobako-transport/src/abi.rs +89 -0
  21. data/crates/kobako-transport/src/envelope/bytes.rs +234 -0
  22. data/crates/kobako-transport/src/envelope/call.rs +222 -0
  23. data/crates/kobako-transport/src/envelope/error_record.rs +101 -0
  24. data/crates/kobako-transport/src/envelope/fault.rs +171 -0
  25. data/crates/kobako-transport/src/envelope/invocation_frames.rs +232 -0
  26. data/crates/kobako-transport/src/envelope/outcome.rs +261 -0
  27. data/crates/kobako-transport/src/envelope/reply.rs +240 -0
  28. data/crates/kobako-transport/src/envelope/run.rs +85 -0
  29. data/crates/kobako-transport/src/envelope.rs +73 -0
  30. data/crates/kobako-transport/src/lib.rs +21 -0
  31. data/crates/kobako-wasmtime/CHANGELOG.md +30 -0
  32. data/crates/kobako-wasmtime/Cargo.toml +5 -2
  33. data/crates/kobako-wasmtime/README.md +1 -1
  34. data/crates/kobako-wasmtime/src/config.rs +19 -12
  35. data/crates/kobako-wasmtime/src/dispatch.rs +15 -8
  36. data/crates/kobako-wasmtime/src/driver.rs +8 -27
  37. data/crates/kobako-wasmtime/src/frames.rs +11 -9
  38. data/crates/kobako-wasmtime/src/guest_mem.rs +7 -15
  39. data/crates/kobako-wasmtime/src/instance_pre.rs +2 -2
  40. data/crates/kobako-wasmtime/src/invocation.rs +1 -1
  41. data/crates/kobako-wasmtime/src/lib.rs +1 -1
  42. data/data/kobako.wasm +0 -0
  43. data/ext/kobako/Cargo.toml +7 -2
  44. data/ext/kobako/src/runtime/bridge.rs +103 -40
  45. data/ext/kobako/src/runtime/errors.rs +13 -5
  46. data/ext/kobako/src/runtime/gvl.rs +114 -0
  47. data/ext/kobako/src/runtime.rs +307 -207
  48. data/lib/kobako/catalog/extensions.rb +50 -43
  49. data/lib/kobako/catalog/handles.rb +7 -16
  50. data/lib/kobako/catalog/services.rb +24 -46
  51. data/lib/kobako/catalog/snippets.rb +16 -33
  52. data/lib/kobako/codec/decoder.rb +10 -13
  53. data/lib/kobako/codec/encoder.rb +7 -7
  54. data/lib/kobako/codec/error.rb +6 -6
  55. data/lib/kobako/codec/ext_types.rb +12 -72
  56. data/lib/kobako/codec/handle_walk.rb +6 -6
  57. data/lib/kobako/codec/state.rb +4 -56
  58. data/lib/kobako/codec/utils.rb +4 -4
  59. data/lib/kobako/codec.rb +11 -21
  60. data/lib/kobako/context.rb +208 -0
  61. data/lib/kobako/errors.rb +59 -16
  62. data/lib/kobako/execution.rb +52 -0
  63. data/lib/kobako/extension.rb +24 -10
  64. data/lib/kobako/outcome.rb +71 -128
  65. data/lib/kobako/payload/arguments.rb +59 -0
  66. data/lib/kobako/payload.rb +19 -0
  67. data/lib/kobako/pool.rb +1 -7
  68. data/lib/kobako/sandbox.rb +51 -191
  69. data/lib/kobako/sandbox_options.rb +24 -3
  70. data/lib/kobako/snippet/binary.rb +3 -4
  71. data/lib/kobako/snippet/source.rb +5 -6
  72. data/lib/kobako/snippet.rb +2 -2
  73. data/lib/kobako/transport/call.rb +21 -0
  74. data/lib/kobako/transport/dispatcher.rb +94 -142
  75. data/lib/kobako/transport/error.rb +21 -5
  76. data/lib/kobako/transport/reflection.rb +82 -0
  77. data/lib/kobako/transport/run.rb +25 -33
  78. data/lib/kobako/transport/yielder.rb +27 -36
  79. data/lib/kobako/transport.rb +11 -8
  80. data/lib/kobako/unresolved.rb +17 -0
  81. data/lib/kobako/usage.rb +7 -7
  82. data/lib/kobako/version.rb +1 -1
  83. data/lib/kobako.rb +1 -0
  84. data/release-please-config.json +47 -2
  85. data/sig/kobako/catalog/extensions.rbs +4 -6
  86. data/sig/kobako/catalog/handles.rbs +0 -2
  87. data/sig/kobako/catalog/services.rbs +4 -4
  88. data/sig/kobako/catalog/snippets.rbs +4 -2
  89. data/sig/kobako/codec/error.rbs +4 -4
  90. data/sig/kobako/codec/ext_types.rbs +0 -7
  91. data/sig/kobako/codec/state.rbs +0 -4
  92. data/sig/kobako/codec.rbs +4 -5
  93. data/sig/kobako/context.rbs +36 -0
  94. data/sig/kobako/errors.rbs +27 -6
  95. data/sig/kobako/execution.rbs +24 -0
  96. data/sig/kobako/extension.rbs +4 -2
  97. data/sig/kobako/outcome.rbs +12 -13
  98. data/sig/kobako/payload/arguments.rbs +26 -0
  99. data/sig/kobako/payload.rbs +4 -0
  100. data/sig/kobako/pool.rbs +0 -2
  101. data/sig/kobako/runtime.rbs +44 -14
  102. data/sig/kobako/sandbox.rbs +5 -24
  103. data/sig/kobako/sandbox_options.rbs +9 -2
  104. data/sig/kobako/snippet/binary.rbs +1 -1
  105. data/sig/kobako/snippet/source.rbs +1 -1
  106. data/sig/kobako/transport/call.rbs +21 -0
  107. data/sig/kobako/transport/dispatcher.rbs +9 -11
  108. data/sig/kobako/transport/error.rbs +11 -0
  109. data/sig/kobako/transport/reflection.rbs +15 -0
  110. data/sig/kobako/transport/run.rbs +1 -1
  111. data/sig/kobako/transport/yielder.rbs +2 -2
  112. data/sig/kobako/transport.rbs +8 -2
  113. data/sig/kobako/unresolved.rbs +4 -0
  114. metadata +30 -11
  115. data/lib/kobako/fault.rb +0 -38
  116. data/lib/kobako/outcome/panic.rb +0 -42
  117. data/lib/kobako/transport/request.rb +0 -76
  118. data/lib/kobako/transport/response.rb +0 -83
  119. data/lib/kobako/transport/yield.rb +0 -94
  120. data/sig/kobako/fault.rbs +0 -19
  121. data/sig/kobako/outcome/panic.rbs +0 -34
  122. data/sig/kobako/transport/request.rbs +0 -29
  123. data/sig/kobako/transport/response.rbs +0 -33
  124. 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
+ }