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
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
//! A variable name has to read as what it is.
//!
//! Names accept identifiers in any script plus emoji. They refuse characters
//! that do not show themselves, because a name the reader cannot see is a name
//! the reader cannot check: `a\u{a0}b` renders as two words and is one name,
//! and `a\u{200b}b` renders as `ab` and is a different variable from `ab`.
// Test-fixture code: unwrap/expect on known-good setup is the idiom here.
#![allow(clippy::unwrap_used, clippy::expect_used)]
use kaish_kernel::{Kernel, KernelConfig};
fn kernel() -> Kernel {
Kernel::new(KernelConfig::isolated()).expect("kernel")
}
async fn run(source: &str) -> (i64, String) {
let k = kernel();
let r = k.execute(source).await.expect("kernel execute");
(r.code, r.text_out().trim().to_string())
}
/// Everything a reader can see stays legal, in any script.
#[tokio::test]
async fn visible_names_still_work() {
for (source, expected) in [
("v=ok; echo $v", "ok"),
("_v=ok; echo $_v", "ok"),
("café=ok; echo $café", "ok"),
("名前=ok; echo \"${名前}\"", "ok"),
("переменная=ok; echo $переменная", "ok"),
("Ω=ok; echo ${Ω}", "ok"),
("😁=grin; echo $😁", "grin"),
("x😁=ok; echo $x😁", "ok"),
// An emoji ZWJ sequence is one glyph, so the joiner inside it is fine.
("👨\u{200d}👩=family; echo ${👨\u{200d}👩}", "family"),
// A variation selector picks the emoji rendering of the glyph before it.
("❤\u{fe0f}=love; echo ${❤\u{fe0f}}", "love"),
] {
let (code, out) = run(source).await;
assert_eq!(code, 0, "{source:?} exited {code}");
assert_eq!(out, expected, "for: {source:?}");
}
}
/// A name holding whitespace looks like more than one word. Refused, and the
/// message names the codepoint — the reader cannot see it otherwise.
#[tokio::test]
async fn whitespace_in_a_name_is_refused_by_codepoint() {
for (source, codepoint) in [
("a\u{a0}b=1", "U+00A0"),
("a\u{3000}b=1", "U+3000"),
("echo $a\u{a0}b", "U+00A0"),
] {
let k = kernel();
let err = k.execute(source).await;
let text: String = match err {
Err(e) => format!("{e:#}"),
Ok(r) => {
assert_ne!(r.code, 0, "{source:?} should not succeed");
r.text_out().to_string()
}
};
assert!(text.contains(codepoint), "{source:?} must name {codepoint}, got: {text}");
assert!(text.contains("quote"), "the message must say what to do: {text}");
}
}
/// Zero-width and bidirectional controls change what the source appears to
/// say. Refused for the same reason.
#[tokio::test]
async fn invisible_characters_in_a_name_are_refused() {
for source in [
"a\u{200b}b=1", // ZERO WIDTH SPACE — renders as `ab`
"a\u{202e}b=1", // RIGHT-TO-LEFT OVERRIDE — reorders what follows
"a\u{200c}b=1", // ZERO WIDTH NON-JOINER
"a\u{feff}b=1", // ZERO WIDTH NO-BREAK SPACE
"a\u{ad}b=1", // SOFT HYPHEN
"a\u{200d}b=1", // ZWJ between letters, not carrying an emoji
] {
let k = kernel();
let failed = match k.execute(source).await {
Err(_) => true,
Ok(r) => r.code != 0,
};
assert!(failed, "{source:?} should be refused");
}
}
/// The class is identifiers plus emoji, not "anything non-ASCII" — typography
/// and mathematics are not names.
#[tokio::test]
async fn non_identifier_symbols_are_refused() {
for source in ["a→b=1", "a«b=1", "a⌘b=1"] {
let k = kernel();
let failed = match k.execute(source).await {
Err(_) => true,
Ok(r) => r.code != 0,
};
assert!(failed, "{source:?} should be refused");
}
}
/// The refusal is about NAMES. The same characters in data are untouched —
/// this is the line the change must not cross.
#[tokio::test]
async fn the_same_characters_are_fine_as_data() {
for (source, expected) in [
("echo \"a\u{a0}b\"", "a\u{a0}b"),
("echo \"a\u{200b}b\"", "a\u{200b}b"),
("echo \"a→b\"", "a→b"),
("x=\"a\u{a0}b\"; echo \"$x\"", "a\u{a0}b"),
("echo a→b", "a→b"),
] {
let (code, out) = run(source).await;
assert_eq!(code, 0, "{source:?} exited {code}");
assert_eq!(out, expected, "for: {source:?}");
}
}
// ── property: the four doors agree, for names we did not think of ───────
/// Names built from a generator rather than a list, because the bug this file
/// exists for was a *boundary* between two scanners — the kind of defect that
/// hides between the cases anyone writes by hand.
fn a_legal_name() -> impl proptest::strategy::Strategy<Value = String> {
use proptest::prelude::*;
proptest::collection::vec(
prop_oneof![
Just('a'), Just('Z'), Just('_'), Just('7'),
Just('é'), Just('名'), Just('Ω'), Just('д'), Just('😁'),
],
1..6,
)
.prop_map(|chars| chars.into_iter().collect::<String>())
// A name cannot start with a digit — that is a positional parameter.
.prop_filter("no digit-leading names", |s| !s.starts_with(|c: char| c.is_ascii_digit()))
}
proptest::proptest! {
#![proptest_config(proptest::test_runner::Config::with_cases(256))]
/// Whatever the name, `$N`, `${N}`, `"$N"`, and `"${N}"` reach the same
/// variable. This is the property that would have caught the truncation
/// bug before a line of the fix was written: the quoted door collected a
/// name with an ASCII-only rule and silently read a different variable.
#[test]
fn every_spelling_of_a_reference_agrees(name in a_legal_name()) {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("runtime");
let outputs: Vec<String> = runtime.block_on(async {
let mut seen = Vec::new();
for reference in [
format!("echo ${name}"),
format!("echo ${{{name}}}"),
format!("echo \"${name}\""),
format!("echo \"${{{name}}}\""),
] {
let k = kernel();
let source = format!("{name}=bound; {reference}");
let r = k.execute(&source).await.expect("kernel execute");
seen.push(r.text_out().trim().to_string());
}
seen
});
proptest::prop_assert!(
outputs.iter().all(|o| o == "bound"),
"the four doors disagreed for name {name:?}: {outputs:?}"
);
}
}
/// The text of a refusal, however it arrived. A name caught before execution
/// comes back as an `Err`; one caught by a builtin comes back as a nonzero
/// result. Both are refusals, and a test that knows only one shape would call
/// half the doors broken.
async fn refusal(source: &str) -> Option<String> {
let k = kernel();
match k.execute(source).await {
Err(e) => Some(format!("{e:#}")),
// A builtin's refusal goes to `err`, a parse-time one to the `Err`
// above — read both, or half the doors look like they said nothing.
Ok(r) if r.code != 0 => Some(format!("{}{}", r.text_out(), r.err)),
Ok(_) => None,
}
}
// ── every door, not just the four written spellings ──────────────────────
/// The doors a name can enter through that are *not* `$x`/`${x}`/`"$x"`/`N=`.
///
/// Each one is a place a name arrives as a runtime word or under a keyword,
/// where the parser's name scan does not meet it as a name-carrying token. A
/// door missing from this rule is the silent-write shape the whole name class
/// exists to close: bound through one spelling, refused by every read.
fn every_name_door(name: &str) -> Vec<(&'static str, String)> {
vec![
("assign", format!("{name}=1")),
("read $x", format!("echo ${name}")),
("read \"$x\"", format!("echo \"${name}\"")),
("read \"${x}\"", format!("echo \"${{{name}}}\"")),
("read \"${x:-d}\"", format!("echo \"${{{name}:-d}}\"")),
("read \"${#x}\"", format!("echo \"${{#{name}}}\"")),
("for", format!("for {name} in 1; do echo hi; done")),
("export", format!("export {name}=1")),
("read builtin", format!("read {name}")),
("unset", format!("unset {name}")),
("push", format!("push {name} 1")),
// A target in an env-scoped prefix, past the first — the spelling the
// token scan cannot tell from an argv word, caught in the tree.
("env prefix", format!("x=1 {name}=2 echo hi")),
]
}
/// Every door refuses an invisible name, and says which character it refused.
///
/// The zero-width space is the sharp case: `a\u{200b}b` renders as `ab`, so a
/// door that accepts it binds a variable the author believes is `ab` and no
/// read can reach.
#[tokio::test]
async fn every_door_refuses_an_invisible_name() {
let name = "a\u{200b}b";
for (door, source) in every_name_door(name) {
let text = refusal(&source)
.await
.unwrap_or_else(|| panic!("the `{door}` door accepted an invisible name: {source:?}"));
assert!(
text.contains("U+200B"),
"the `{door}` door refused without naming the character: {text}"
);
}
}
/// The same doors, for a name holding whitespace that does not look like it.
#[tokio::test]
async fn every_door_refuses_a_no_break_space() {
let name = "a\u{00a0}b";
for (door, source) in every_name_door(name) {
let text = refusal(&source)
.await
.unwrap_or_else(|| panic!("the `{door}` door accepted a no-break space: {source:?}"));
assert!(
text.contains("U+00A0"),
"the `{door}` door refused without naming the character: {text}"
);
}
}
/// A legal name works through every one of those doors, so the refusals above
/// are the rule doing its job rather than the doors being broken.
#[tokio::test]
async fn every_door_accepts_a_visible_name() {
for source in [
"café=1; echo $café",
"café=1; echo \"$café\"",
"for café in 1; do echo $café; done",
"export café=1",
"x=1; unset x",
"xs=[]; push xs 1",
"名前=1; echo \"${名前:-d}\"",
] {
let (code, out) = run(source).await;
assert_eq!(code, 0, "a visible name was refused: {source:?} -> {out:?}");
}
}
/// `export` refuses a dotted name and teaches the bracket form.
///
/// The assignment door already refuses `a.b=1`, and `${a.b}` is a loud
/// brackets-only error — so an `export` that accepted it would be the one
/// door minting a variable no read path can reach.
#[tokio::test]
async fn export_refuses_a_dotted_name() {
let text = refusal("export a.b=1").await.expect("export accepted a dotted name");
assert!(text.contains("bracket access"), "export did not teach the bracket form: {text}");
assert!(text.contains("a[b]"), "export did not show the corrected spelling: {text}");
}
/// `scatter --as` refuses a name no read could reach, at option-parse time.
#[tokio::test]
async fn scatter_as_refuses_an_invisible_name() {
let text = refusal("echo 1 | scatter --as a\u{200b}b -- echo hi | gather")
.await
.expect("scatter --as accepted an invisible name");
assert!(text.contains("U+200B"), "scatter --as refused without naming it: {text}");
}
// ── property: no name is legal at one door and illegal at another ────────
/// A name with exactly one invisible character spliced into an otherwise
/// legal one. The generator builds the *illegal* half of the space — the
/// legal-name property above cannot see a door disagreement, because a door
/// that never refuses anything agrees with every other door on legal input.
fn an_illegal_name() -> impl proptest::strategy::Strategy<Value = String> {
use proptest::prelude::*;
(
proptest::collection::vec(
prop_oneof![Just('a'), Just('Z'), Just('_'), Just('é'), Just('名')],
1..4,
),
prop_oneof![
Just('\u{200b}'), // ZERO WIDTH SPACE
Just('\u{00a0}'), // NO-BREAK SPACE
Just('\u{00ad}'), // SOFT HYPHEN
Just('\u{2066}'), // LEFT-TO-RIGHT ISOLATE
],
proptest::collection::vec(
prop_oneof![Just('a'), Just('Z'), Just('_'), Just('é'), Just('名')],
1..4,
),
)
.prop_map(|(head, bad, tail)| {
let mut s: String = head.into_iter().collect();
s.push(bad);
s.extend(tail);
s
})
}
proptest::proptest! {
#![proptest_config(proptest::test_runner::Config::with_cases(64))]
/// Whatever the illegal name, every door refuses it. One door accepting
/// what the others refuse is the silent write this class exists to close,
/// and it is a boundary defect — the kind that hides between hand cases.
#[test]
fn no_door_accepts_what_another_refuses(name in an_illegal_name()) {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("runtime");
let unrefused: Vec<&str> = runtime.block_on(async {
let mut unrefused = Vec::new();
for (door, source) in every_name_door(&name) {
// Not merely "it failed" — `read NAME` fails for want of
// stdin whatever the name, and that would pass this property
// without the rule ever running. The refusal has to name the
// character it refused.
let named = refusal(&source).await.is_some_and(|t| t.contains("U+"));
if !named {
unrefused.push(door);
}
}
unrefused
});
proptest::prop_assert!(
unrefused.is_empty(),
"these doors did not refuse the illegal name {name:?}: {unrefused:?}"
);
}
}
// ── an argv `key=value` word is data, not a name ─────────────────────────
/// `echo k=v` passes a word; `k=1` binds a variable. Both spell `Ident` `=`,
/// and only the second is a name — a word's bytes are its own, so refusing one
/// for holding a character a *name* may not hold rejects a valid program.
#[tokio::test]
async fn an_argv_key_value_word_is_not_a_name() {
for source in [
"echo a\u{200b}b=bar",
"echo a\u{00a0}b=bar",
"echo k=v",
"echo a\u{200b}b",
] {
let (code, out) = run(source).await;
assert_eq!(code, 0, "an argv word was refused as if it were a name: {source:?} -> {out:?}");
}
}
/// The assignment spelling is still refused wherever a statement can start.
/// These are the positions the argv rule above must not have opened up.
#[tokio::test]
async fn an_assignment_target_is_a_name_at_every_statement_start() {
let name = "a\u{200b}b";
for source in [
format!("{name}=1"),
format!("x=1; {name}=1"),
format!("true && {name}=1"),
format!("true || {name}=1"),
format!("local {name} = 1"),
format!("if true; then {name}=1; fi"),
format!("for i in 1; do {name}=1; done"),
] {
assert!(
refusal(&source).await.is_some(),
"an assignment target was accepted: {source:?}"
);
}
}