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
//! TM-DOS-112: sed's output sinks grow inside the builtin, so they are charged
//! to the shared live-intermediate budget before allocating.
//!
//! The stdout *capture* cap is deliberately not a sed sink limit: sed output is
//! routinely piped into another command or redirected to a file, neither of
//! which is captured stdout. Bounding the sinks by that cap truncated ordinary
//! transformations, so these tests pin both halves of the contract — the
//! amplification is refused, and below-budget output is byte-for-byte intact.
use bashkit::{Bash, ExecutionLimits};
/// `r FILE` re-reads the whole file after every input line, so output grows
/// quadratically inside the sed engine. The live-intermediate budget must refuse
/// it rather than letting the allocation abort the host.
///
/// The refusal can surface either as sed's own `sed: <error>` result or, when a
/// downstream stage of the pipeline reaches the shared budget first, as a
/// top-level resource-limit error. Which layer trips first is an implementation
/// detail; what this pins is that the amplification is always refused, with a
/// bounded diagnostic and a nonzero status, and never aborts the process.
#[tokio::test]
async fn repeated_read_file_stops_at_live_intermediate_budget() {
let limits = ExecutionLimits::new().max_live_intermediate_bytes(1 << 20);
let mut bash = Bash::builder().limits(limits).build();
// 2000 lines × 32 B = 64 KB of input; `r` re-emits all of it per line,
// so the sink would need ~128 MB against a 1 MB budget.
let script = "for i in $(seq 1 2000); do printf 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\\n'; \
done > /in.txt; sed 'r /in.txt' /in.txt | wc -c";
match bash.exec(script).await {
Ok(result) => {
assert_ne!(result.exit_code, 0, "stdout: {}", result.stdout);
assert!(result.stderr.contains("sed:"), "stderr: {}", result.stderr);
assert!(result.stderr.len() < 1024, "diagnostic must stay bounded");
}
Err(error) => {
let rendered = error.to_string();
assert!(
rendered.contains("live intermediate bytes"),
"unexpected error: {rendered}"
);
}
}
}
#[tokio::test]
async fn read_stdin_preserves_output_below_limit() {
let limits = ExecutionLimits::new().max_live_intermediate_bytes(64 * 1024);
let mut bash = Bash::builder().limits(limits).build();
let result = bash
.exec("printf 'a\\nb\\n' | sed 'r /dev/stdin'")
.await
.unwrap();
assert_eq!(result.exit_code, 0, "stderr: {}", result.stderr);
assert_eq!(result.stdout, "a\na\nb\nb\na\nb\n");
assert!(result.stderr.is_empty());
}
/// Regression for #2455: sed output larger than `max_stdout_bytes` is an
/// ordinary transformation when it is piped onward, not a resource failure.
/// The stdout cap governs what the interpreter *captures*, not sed's sink.
#[tokio::test]
async fn output_over_stdout_cap_still_flows_through_a_pipe() {
let limits = ExecutionLimits::new()
.max_stdout_bytes(4096)
.max_live_intermediate_bytes(32_000_000);
let mut bash = Bash::builder().limits(limits).build();
// ~65 KB out of sed — far over the 4 KB capture cap, far under the budget.
let result = bash
.exec("seq 1 2000 | sed 's/$/-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx/' | wc -c")
.await
.unwrap();
assert_eq!(result.exit_code, 0, "stderr: {}", result.stderr);
assert_eq!(result.stdout.trim(), "70893");
assert!(result.stderr.is_empty(), "stderr: {}", result.stderr);
}
/// Same shape, but the output lands in a VFS file. The stdout capture cap must
/// not bound a file write either.
#[tokio::test]
async fn output_over_stdout_cap_still_writes_a_file() {
let limits = ExecutionLimits::new()
.max_stdout_bytes(4096)
.max_live_intermediate_bytes(32_000_000);
let mut bash = Bash::builder().limits(limits).build();
let result = bash
.exec(
"seq 1 2000 | sed 's/$/-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx/' > /out.txt; \
wc -c < /out.txt",
)
.await
.unwrap();
assert_eq!(result.exit_code, 0, "stderr: {}", result.stderr);
assert_eq!(result.stdout.trim(), "70893");
}
/// TM-DOS-018 follow-up: `N` preserves pattern space while resetting the
/// per-cycle step budget, so `:a;N;100~100{h;G};ba` doubles pattern+hold
/// every 100 lines without tripping the step limit. Growth model: 1500
/// x-lines (~3 KB input) reach ~13 MB of pattern against a 1 MB budget.
/// The budgeted pattern/hold growth must refuse this with a bounded
/// diagnostic and a nonzero status, never OOM the host.
///
/// The refusal can surface either as sed's own `sed: <error>` result or as a
/// top-level resource-limit error. Which layer trips first is an
/// implementation detail; what this pins is that the amplification is always
/// refused, with a bounded diagnostic and a nonzero status.
#[tokio::test]
async fn pattern_hold_doubling_stops_at_live_intermediate_budget() {
let limits = ExecutionLimits::new().max_live_intermediate_bytes(1 << 20);
let mut bash = Bash::builder().limits(limits).build();
// 1500 lines x 2 B = ~3 KB of input; h;G every 100 lines doubles the
// pattern, so it would need ~13 MB against a 1 MB budget.
// No trailing pipe: the asserted status is sed's own, not wc's.
let script = "for i in $(seq 1 1500); do printf 'x\\n'; \
done > /in.txt; sed -n ':a;N;100~100{h;G};ba' /in.txt";
match bash.exec(script).await {
Ok(result) => {
assert_ne!(result.exit_code, 0, "stdout: {}", result.stdout);
assert!(
result.stdout.is_empty(),
"refused run must print nothing, got {} bytes",
result.stdout.len()
);
assert!(result.stderr.contains("sed:"), "stderr: {}", result.stderr);
assert!(result.stderr.len() < 1024, "diagnostic must stay bounded");
}
Err(error) => {
let rendered = error.to_string();
assert!(
rendered.contains("live intermediate bytes"),
"unexpected error: {rendered}"
);
}
}
}
/// Same PoC shape under the default budget is finite GNU behavior, not a
/// hang: it terminates with exit 0 and (under `-n`) empty output. This
/// control proves the refusal above comes from the budget, not from
/// breaking the script.
#[tokio::test]
async fn pattern_hold_doubling_succeeds_at_default_budget() {
let mut bash = Bash::builder().build();
let script = "for i in $(seq 1 1500); do printf 'x\\n'; \
done > /in.txt; sed -n ':a;N;100~100{h;G};ba' /in.txt";
let result = bash.exec(script).await.expect("sed runs");
assert_eq!(
result.exit_code, 0,
"default budget must run the PoC to completion; stderr: {}",
result.stderr
);
assert!(
result.stdout.is_empty(),
"nothing printed under -n, got {} bytes",
result.stdout.len()
);
}
#[tokio::test]
async fn sed_commands_stop_at_work_budget() {
let limits = ExecutionLimits::new().max_work_units(100);
let mut bash = Bash::builder().limits(limits).build();
// 200 print commands on a single input line: trivial setup, and the
// command charges alone must exceed the cap.
let mut script = String::from("printf 'a\\n' > /in.txt; sed -n '");
for _ in 0..200 {
script.push_str("p;");
}
script.push_str("' /in.txt");
match bash.exec(&script).await {
Ok(result) => {
assert_ne!(result.exit_code, 0, "stdout: {}", result.stdout);
assert!(result.stderr.contains("sed:"), "stderr: {}", result.stderr);
assert!(result.stderr.len() < 1024, "diagnostic must stay bounded");
}
Err(error) => {
let rendered = error.to_string();
assert!(rendered.contains("work"), "unexpected error: {rendered}");
}
}
}
/// TM-DOS-018 follow-up: lines read into pattern space charge aggregate
/// input. Whatever layer trips first (sed's own metering or the pipe/file
/// accounting underneath), a 600 B input against a 100 B cap is always
/// refused with a bounded diagnostic and a nonzero status, never silently
/// processed.
#[tokio::test]
async fn sed_lines_stop_at_input_budget() {
let limits = ExecutionLimits::new().max_aggregate_input_bytes(100);
let mut bash = Bash::builder().limits(limits).build();
let script = "printf '%.0shello\\n' {1..100} > /in.txt; sed -n 'p' /in.txt";
match bash.exec(script).await {
Ok(result) => {
assert_ne!(result.exit_code, 0, "stdout: {}", result.stdout);
assert!(result.stderr.len() < 1024, "diagnostic must stay bounded");
}
Err(error) => {
let rendered = error.to_string();
assert!(rendered.contains("input"), "unexpected error: {rendered}");
}
}
}