1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
#![cfg(all(feature = "compile", feature = "verify"))]
// A flat `Text` field built from a static string literal points at the
// immortal bytecode image (rodata), not an ephemeral arena copy (B28 P3 item
// 4). A private `.data` composite slot whose body carries such a field
// therefore reads back correctly after a RESET: the persistent body survives
// the RESET in place (item 3a), and the rodata the text points at is immortal,
// so the rebuilt handle is always live.
//
// Before item 4 the static text was copied into the ephemeral arena top, so
// the persistent body's text pointer dangled after a RESET (the read resolved
// stale, and the length observed below would be 0 on iteration 2). This test
// pins the rodata residence that fixes it.
//
// Survival is observed by passing the slot's text to a host native that
// returns its byte length, then yielding that `Word`; the composite itself is
// never yielded (that path is gated separately) and string `==` is not used
// (it compares by variant, not content, across `StaticStr`/`KStr`). A static
// (rodata) text survives and yields length 7; a dynamic (ephemeral) text does
// not survive and a post-RESET read of it faults cleanly stale, the secure
// failure mode the second test pins.
//
// Guarded off the narrow-word builds, where `Text` is kept boxed (a host
// pointer does not fit a narrow word) and the flat-text path does not exist.
#![cfg(not(any(
feature = "narrow-word-8",
feature = "narrow-word-16",
feature = "narrow-word-32"
)))]
extern crate alloc;
use alloc::string::String;
use keleusma::Arena;
use keleusma::VmError;
use keleusma::bytecode::Value;
use keleusma::compiler::compile;
use keleusma::lexer::tokenize;
use keleusma::parser::parse;
use keleusma::vm::{DEFAULT_ARENA_CAPACITY, Vm, VmState, required_persistent_capacity_for};
#[test]
fn private_static_text_struct_survives_reset() {
// Iteration 1 (seed 0) writes a struct with a static-text field into the
// private composite slot, then yields the byte length of the slot's text.
// Resuming past the yield reaches the loop body end, which RESETs the
// ephemeral arena. The restarted stream (seed 1) reads the slot's text
// WITHOUT rewriting it, so observing the same length again requires both
// the persistent body to survive the RESET (item 3a) and the text bytes to
// be immortal rodata (item 4).
let src = "use host::slen(Text) -> Word\n\
struct S { msg: Text, n: Word }\n\
private data d { s: S }\n\
loop main(seed: Word) -> Word { \
if seed == 0 { d.s = S { msg: \"persist\", n: 7 }; }; \
let _ = yield host::slen(d.s.msg); \
0 \
}";
let m = compile(&parse(&tokenize(src).expect("lex")).expect("parse")).expect("compile");
let need = required_persistent_capacity_for(&m);
let mut arena = Arena::with_capacity(DEFAULT_ARENA_CAPACITY + need);
arena.resize_persistent(need).expect("resize_persistent");
let mut vm = Vm::new(m, &arena).expect("verify");
vm.register_fn("host::slen", |s: String| -> i64 { s.len() as i64 });
// First call: writes the slot, yields the length of "persist" (7).
match vm.call(&[Value::Int(0)]).expect("call") {
VmState::Yielded(Value::Int(n)) => assert_eq!(n, 7, "iteration 1 text length"),
other => panic!("iter 1 expected Yielded(7), got {:?}", other),
}
// Resume past the yield: the body end RESETs the ephemeral arena.
match vm.resume(Value::Int(1)).expect("resume") {
VmState::Reset => {}
other => panic!("expected Reset at loop body end, got {:?}", other),
}
// Restart with seed 1: no rewrite, so the slot's text must have survived
// the RESET as immortal rodata. A correct read still yields length 7; a
// reclaimed (ephemeral) text would resolve stale and yield 0.
match vm.resume(Value::Int(1)).expect("resume") {
VmState::Yielded(Value::Int(n)) => {
assert_eq!(n, 7, "iteration 2 text survives RESET as rodata")
}
other => panic!("iter 2 expected Yielded(7), got {:?}", other),
}
}
#[test]
fn private_dynamic_text_struct_faults_stale_after_reset() {
// The complement of the rodata case: a DYNAMIC text field (a host-returned
// string that matches no module constant) is copied into the ephemeral
// arena, so it does NOT survive a RESET. The contract this pins is the
// secure failure mode: a post-RESET read that resolves the string raises a
// clean stale fault rather than dereferencing reclaimed memory or returning
// the wrong (or empty) content. The stale ephemeral handle does not match
// the advanced arena epoch, so resolving it through the native-argument
// decode errors with a "dynamic string is stale" `TypeError`.
let src = "use host::dyntext() -> Text\n\
use host::slen(Text) -> Word\n\
struct S { msg: Text, n: Word }\n\
private data d { s: S }\n\
loop main(seed: Word) -> Word { \
if seed == 0 { d.s = S { msg: host::dyntext(), n: 7 }; }; \
let _ = yield host::slen(d.s.msg); \
0 \
}";
let m = compile(&parse(&tokenize(src).expect("lex")).expect("parse")).expect("compile");
let need = required_persistent_capacity_for(&m);
let mut arena = Arena::with_capacity(DEFAULT_ARENA_CAPACITY + need);
arena.resize_persistent(need).expect("resize_persistent");
let mut vm = Vm::new(m, &arena).expect("verify");
// A 14-byte string that is not a literal anywhere in the script, so it is
// never resolved to a module constant and stays an ephemeral arena copy.
vm.register_fn("host::dyntext", || -> String {
String::from("ephemeral_only")
});
vm.register_fn("host::slen", |s: String| -> i64 { s.len() as i64 });
// First call: writes the dynamic text, yields its length (14).
match vm.call(&[Value::Int(0)]).expect("call") {
VmState::Yielded(Value::Int(n)) => assert_eq!(n, 14, "iteration 1 dynamic text length"),
other => panic!("iter 1 expected Yielded(14), got {:?}", other),
}
// Resume past the yield: the body end RESETs the ephemeral arena.
match vm.resume(Value::Int(1)).expect("resume") {
VmState::Reset => {}
other => panic!("expected Reset at loop body end, got {:?}", other),
}
// Restart with seed 1: no rewrite. The dynamic text did not survive, so
// resolving it through the native argument raises a clean stale fault --
// secure failure, not a dangling read of reclaimed memory.
match vm.resume(Value::Int(1)) {
Err(VmError::TypeError(msg)) => {
assert!(
msg.contains("stale"),
"expected a stale fault, got TypeError({msg:?})"
);
}
other => panic!("iter 2 expected a stale TypeError, got {:?}", other),
}
}
#[test]
fn private_static_text_module_hot_swaps_cleanly() {
// A module that writes a static-text composite into a private slot is hot
// swapped, then runs again. This pins that the item-4 rodata residence and
// the persistent composite body pool survive a swap soundly: the swap drops
// and re-initialises every private slot (severing any link to the old
// pool body) and zeros the composite body pool tail, so the old module's
// bytecode image, freed by the swap, is never dereferenced. The swapped-in
// module writes its slot fresh and reads back the correct rodata text.
let src = "use host::slen(Text) -> Word\n\
struct S { msg: Text, n: Word }\n\
private data d { s: S }\n\
loop main(seed: Word) -> Word { \
if seed == 0 { d.s = S { msg: \"alpha\", n: 1 }; }; \
let _ = yield host::slen(d.s.msg); \
0 \
}";
let compile_module =
|| compile(&parse(&tokenize(src).expect("lex")).expect("parse")).expect("compile");
let m = compile_module();
let need = required_persistent_capacity_for(&m);
let mut arena = Arena::with_capacity(DEFAULT_ARENA_CAPACITY + need);
arena.resize_persistent(need).expect("resize_persistent");
let mut vm = Vm::new(m, &arena).expect("verify");
vm.register_fn("host::slen", |s: String| -> i64 { s.len() as i64 });
// Run the first module: write the static text, yield its length (5).
match vm.call(&[Value::Int(0)]).expect("call") {
VmState::Yielded(Value::Int(n)) => assert_eq!(n, 5, "pre-swap text length"),
other => panic!("pre-swap expected Yielded(5), got {:?}", other),
}
match vm.resume(Value::Int(1)).expect("resume") {
VmState::Reset => {}
other => panic!("expected Reset, got {:?}", other),
}
// Hot swap to a fresh copy of the same module. The one private composite
// slot is re-initialised to Unit; the swapped-in module writes it fresh
// before reading. This frees the old bytecode image, so a read of a
// surviving old pool body would dereference freed memory; the swap-time
// zeroing and the slot re-initialisation prevent that.
vm.replace_module(compile_module(), alloc::vec![Value::Unit])
.expect("hot swap");
// The swapped-in module runs cleanly: it writes its slot fresh on seed 0
// and reads back the correct rodata text length (5).
match vm.call(&[Value::Int(0)]).expect("post-swap call") {
VmState::Yielded(Value::Int(n)) => assert_eq!(n, 5, "post-swap text length"),
other => panic!("post-swap expected Yielded(5), got {:?}", other),
}
}