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
//! A condition command's output belongs to the enclosing statement.
//!
//! `if cmd; then …` used only `cmd`'s exit code and dropped its `ExecResult`
//! on the floor, so everything a condition produced disappeared:
//!
//! ```text
//! if cat /nonexistent; then echo y; fi # printed NOTHING
//! if echo COND; then echo BODY; fi # printed only BODY
//! ```
//!
//! bash prints `cat: /nonexistent: No such file or directory`, and `COND`
//! before `BODY`. A shell that swallows the reason a condition failed turns a
//! loud command into a silent false — which is how `test a = a -o b = c`'s
//! clear exit-2 error reached nobody (see `test_compound_tests`), and it was
//! never specific to `test`.
//!
//! The rule already existed for the sibling case. `Expr::CommandSubst` says
//! it in a comment: "A substitution's stderr belongs to the enclosing
//! statement, never to its value." A condition's output is the same case, and
//! `Expr::Command` simply never applied it.
//!
//! The two halves arrive by different routes, because they are different
//! things. stderr rides the statement's stderr stream, which the statement
//! already drains. stdout is the statement's own value, so `eval_condition_async`
//! hands it back to the `if`/`while` arm as data and the arm folds it into the
//! result — no shared slot, and every consumer of that result (a pipe, a
//! `$(…)` capture, a redirect) carries it without learning what a condition is.
// 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};
use kaish_types::ExecResult;
async fn run(script: &str) -> ExecResult {
let k = Kernel::new(KernelConfig::isolated()).expect("kernel");
k.execute(script).await.expect("kernel execute")
}
#[tokio::test]
async fn if_condition_stderr_reaches_the_statement() {
let r = run("if cat /nonexistent; then echo y; else echo n; fi").await;
assert!(
r.err.contains("/nonexistent"),
"the condition's diagnostic must not be swallowed, got err={:?}",
r.err
);
assert_eq!(r.text_out().trim_end(), "n", "the branch itself is unchanged");
}
#[tokio::test]
async fn while_condition_stderr_reaches_the_statement() {
let r = run("while cat /nonexistent; do break; done").await;
assert!(r.err.contains("/nonexistent"), "err={:?}", r.err);
}
/// The `else` branch's own stderr still arrives too — the condition's must be
/// added to it, not replace it.
#[tokio::test]
async fn condition_and_body_stderr_both_arrive() {
let r = run("if cat /nope-cond; then echo y; else cat /nope-body; fi").await;
assert!(r.err.contains("/nope-cond"), "condition stderr missing: {:?}", r.err);
assert!(r.err.contains("/nope-body"), "body stderr missing: {:?}", r.err);
}
/// A condition that succeeds and says nothing stays silent — the fix must not
/// invent output.
#[tokio::test]
async fn a_quiet_condition_stays_quiet() {
let r = run("if true; then echo y; fi").await;
assert!(r.err.is_empty(), "err={:?}", r.err);
assert_eq!(r.text_out().trim_end(), "y");
}
/// `$(…)` in a condition already routed its stderr correctly; that must keep
/// working rather than doubling.
#[tokio::test]
async fn command_substitution_in_a_condition_reports_once() {
let r = run("if [[ -n \"$(cat /nope-subst)\" ]]; then echo y; else echo n; fi").await;
let hits = r.err.matches("/nope-subst").count();
assert_eq!(hits, 1, "expected exactly one report, got {hits} in {:?}", r.err);
}
/// bash prints `COND` then `BODY`; so does kaish now.
///
/// A condition's stdout used to be dropped: `eval_expr_async` returns a
/// `Value`, so the bytes an `Expr::Command` wrote had nowhere to go. The fix
/// hands them back to the caller instead — the `if`/`while` arm folds them
/// into the statement's own result, which is what makes them flow into a
/// pipe, a `$(…)` capture, and a redirect without any of those learning
/// about conditions.
#[tokio::test]
async fn if_condition_stdout_reaches_the_statement() {
let r = run("if echo COND; then echo BODY; fi").await;
assert_eq!(r.text_out().trim_end(), "COND\nBODY");
}
#[tokio::test]
async fn an_elif_condition_prints_too() {
let r = run("if false; then echo y; elif echo ELIF; then echo E; fi").await;
assert_eq!(r.text_out().trim_end(), "ELIF\nE");
}
/// A `while` condition runs once per iteration, so its output interleaves
/// with the body's rather than arriving in one block up front.
#[tokio::test]
async fn while_condition_stdout_interleaves_with_the_body() {
let r = run("i=0; while echo C; do i=$((i+1)); echo B; if [[ $i -ge 2 ]]; then break; fi; done").await;
assert_eq!(r.text_out().trim_end(), "C\nB\nC\nB");
}
/// Both sides of a `&&` chain in condition position print, and a
/// short-circuited side prints nothing because it never ran.
#[tokio::test]
async fn both_sides_of_a_condition_chain_print() {
let r = run("if echo A && echo B; then echo C; fi").await;
assert_eq!(r.text_out().trim_end(), "A\nB\nC");
let r = run("if false && echo NEVER; then echo y; else echo n; fi").await;
assert_eq!(r.text_out().trim_end(), "n");
let r = run("if echo A || echo NEVER; then echo C; fi").await;
assert_eq!(r.text_out().trim_end(), "A\nC");
}
/// The statement's stdout is its value, so a condition's share of it has to
/// reach every consumer of that value — not just the terminal.
#[tokio::test]
async fn condition_stdout_flows_into_a_pipe_and_a_capture() {
let r = run("if echo COND; then echo BODY; fi | cat").await;
assert_eq!(r.text_out().trim_end(), "COND\nBODY", "into a pipe");
let r = run("x=$(if echo COND; then echo BODY; fi); echo \"[$x]\"").await;
assert_eq!(r.text_out().trim_end(), "[COND\nBODY]", "into a capture");
}
/// `$(…)` is the one command in condition position whose stdout is its own
/// value, so folding it in as well would print it twice.
#[tokio::test]
async fn a_substitution_in_a_condition_is_not_printed() {
let r = run("if [[ -n \"$(echo SUB)\" ]]; then echo Y; fi").await;
assert_eq!(r.text_out().trim_end(), "Y");
}
/// The condition's EXIT CODE is consumed by the `if`; only its output is
/// carried. A false condition with no `else` still succeeds and says nothing.
#[tokio::test]
async fn a_false_condition_does_not_set_the_statements_code() {
let r = run("if false; then echo y; fi").await;
assert_eq!(r.code, 0, "an `if` with no branch taken succeeds");
assert_eq!(r.text_out().trim_end(), "");
let r = run("if cat /nonexistent; then echo y; fi").await;
assert_eq!(r.code, 0, "the condition's failure is the `if`'s answer, not its status");
}
/// A condition that succeeds and says nothing stays silent — the fix must not
/// invent output.
#[tokio::test]
async fn a_quiet_condition_prints_nothing() {
let r = run("if true; then echo BODY; fi").await;
assert_eq!(r.text_out().trim_end(), "BODY");
}
/// A condition's output obeys the output limit, like every other output.
///
/// `apply_spill_contract` is documented as "the ONE seam every execution
/// surface that produces a raw `ExecResult` must funnel through". Carrying a
/// condition's stdout made this arm one of those surfaces, and it reached
/// `execute_command` directly — under the pipeline layer that applies the
/// contract. With a 2K limit, `if seq 1 100000; then …; fi` handed back 588KB
/// at exit 0 while the same command on its own was capped at 1.7KB and exit 3.
/// An agent shell whose cap has a hole in it is worse than one with no cap,
/// because the cap is what the caller sized their context against.
#[tokio::test]
async fn a_conditions_output_obeys_the_output_limit() {
let capped = run("kaish-output-limit set 2K; if seq 1 100000; then echo T; fi").await;
let plain = run("kaish-output-limit set 2K; seq 1 100000").await;
let capped_len = capped.text_out().len();
assert!(
capped_len < 20_000,
"a condition's output must be capped like any other; got {capped_len} bytes"
);
// The cap is the same cap, not merely some smaller number.
assert!(
capped_len <= plain.text_out().len() * 4,
"condition {capped_len} vs plain {} — same limit, same order of magnitude",
plain.text_out().len()
);
assert!(
capped.text_out().contains('T'),
"the branch still ran and its output still arrives"
);
}
/// A condition succeeds even when its own output was capped. The spill
/// contract remaps a spilled result's code to 3, and reading that as the
/// condition's answer would send `if seq 1 100000` down the `else` branch —
/// the command worked, only its output was too big to keep.
#[tokio::test]
async fn a_capped_condition_is_still_true() {
let r = run("kaish-output-limit set 2K; if seq 1 100000; then echo THEN; else echo ELSE; fi")
.await;
assert!(
r.text_out().contains("THEN"),
"a capped condition still succeeded, got: {}",
r.text_out().chars().rev().take(60).collect::<String>()
);
}
/// stderr arrives in the order it was produced: the condition ran before the
/// branch, so it reports first. The `if` arm appended the branch's own `err`
/// and only then drained the stream the condition had written to, which put
/// them backwards; `while` already drained first.
#[tokio::test]
async fn condition_stderr_precedes_the_branchs() {
let r = run("if cat /nope-cond; then echo y; else cat /nope-body; fi").await;
let cond = r.err.find("nope-cond").expect("condition stderr");
let body = r.err.find("nope-body").expect("body stderr");
assert!(
cond < body,
"the condition ran first and must report first, got: {}",
r.err
);
}