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
// Scaffold tests for the glob_fuzz target.
//
// Replays crash inputs found by `cargo +nightly fuzz run glob_fuzz` so the
// regression is covered by the normal `cargo test` run instead of only by
// the nightly fuzz workflow.
//
// Design note: `glob_fuzz` inlines its raw input into shell scripts and then
// asserts the cross-tool leak invariants from `bashkit::testing`. The target
// pre-filters inputs that literally contain a banned substring, but the shell
// drops NUL bytes during word expansion, so an input like `</r\0ustc/` becomes
// the path `/rustc/` *after* the filter has run. Any diagnostic that echoes
// such a path back must therefore use a real-shell error template that
// `bashkit::testing`'s echo filter recognizes.
use bashkit::testing::{fuzz_exec, fuzz_init};
use bashkit::{Bash, ExecutionLimits};
fn fuzz_bash() -> Bash {
fuzz_init();
Bash::builder()
.limits(
ExecutionLimits::new()
.max_commands(50)
.timeout(std::time::Duration::from_millis(500)),
)
.mount_text("/tmp/a.txt", "")
.mount_text("/tmp/b.sh", "")
.mount_text("/tmp/sub/d.txt", "")
.build()
}
/// Regression: fuzz run 218 (`crash-fae53719665e9c77d2e1a1b7d4da7e43902525d0`,
/// bytes `[60, 47, 114, 0, 117, 115, 116, 99, 47]` = `</r\0ustc/`).
///
/// `ls /tmp/</r\0ustc/` is an input redirection from a path that does not
/// exist. The NUL is dropped during expansion, so the redirection target is
/// `/rustc/` — a `UNIVERSAL_BANNED` host-path shape. The diagnostic must use
/// bash's real template so the echo filter recognizes it as a shell echo of
/// user input rather than a TM-INF-016 host-path leak.
#[tokio::test]
async fn glob_fuzz_crash_nul_stripped_redirect_target() {
// Replays all three scripts the glob_fuzz target builds from one input,
// in the same order, so the scaffold covers the whole crashing iteration.
let input = "</r\0ustc/";
let scripts = [
format!("ls /tmp/{}", input),
format!(
"case \"test.txt\" in {}) echo match;; *) echo no;; esac",
input
),
format!("if [[ \"hello.world\" == {} ]]; then echo y; fi", input),
];
let mut bash = fuzz_bash();
for script in &scripts {
fuzz_exec(
&mut bash,
script,
"glob_fuzz_crash_nul_stripped_redirect_target",
&[],
)
.await;
}
}
/// Regression: fuzz run 219 (`crash-2ac9af1dcb0bb66e7849b347828a8236e12fdda3`,
/// bytes `[10, 32, 0, 123, 32, 99, 111, 100, 101, 58, 60, 40, 93, 41, 10]`
/// = `\n \0{ code:<(])\n`).
///
/// Same NUL-stripping mechanism as run 218 — the raw bytes hide the banned
/// ` { code:` behind a NUL — but a second gap too: the resulting command name
/// ends in a newline, so bash's one-line `command not found` template renders
/// across two lines and the echo filter must match it as one span.
#[tokio::test]
async fn glob_fuzz_crash_nul_stripped_newline_command_name() {
let input = "\n \0{ code:<(])\n";
// The target now skips this input outright, since the shell would echo a
// banned shape back. Assert that, then assert the harness would survive it
// anyway — the pre-filter and the echo filter are independent defenses.
assert!(bashkit::testing::input_echo_would_trip(input));
let mut bash = fuzz_bash();
for script in [
format!("ls /tmp/{}", input),
format!(
"case \"test.txt\" in {}) echo match;; *) echo no;; esac",
input
),
format!("if [[ \"hello.world\" == {} ]]; then echo y; fi", input),
] {
fuzz_exec(
&mut bash,
&script,
"glob_fuzz_crash_nul_stripped_newline_command_name",
&[],
)
.await;
}
}
/// The missing-input-redirect diagnostic must match real bash byte for byte:
/// `bash: line 1: <path>: No such file or directory` (`bash -c`).
#[tokio::test]
async fn missing_input_redirect_matches_bash_wording() {
let mut bash = fuzz_bash();
let result = bash.exec("ls /tmp/ < /nope/missing").await.unwrap();
assert_eq!(
result.stderr.to_string(),
"bash: line 1: /nope/missing: No such file or directory\n"
);
assert_eq!(result.exit_code, 1);
}
/// Same template for an output redirect whose parent directory is missing.
/// The VFS's `parent directory not found` only restates `NotFound`, so it is
/// replaced by bash's wording rather than surfaced.
#[tokio::test]
async fn missing_output_redirect_dir_matches_bash_wording() {
let mut bash = fuzz_bash();
let result = bash.exec("echo hi > /nope/missing/out.txt").await.unwrap();
assert_eq!(
result.stderr.to_string(),
"bash: line 1: /nope/missing/out.txt: No such file or directory\n"
);
assert_eq!(result.exit_code, 1);
}
/// A backend reason that says more than its errno survives: collapsing it to
/// the bare `Permission denied` would drop the only actionable detail. This
/// is what a custom [`bashkit::FileSystem`] impl's own wording relies on.
#[tokio::test]
async fn read_only_mount_keeps_its_specific_reason() {
let mut bash = Bash::builder()
.fs(std::sync::Arc::new(bashkit::ReadOnlyFs::new(
std::sync::Arc::new(bashkit::InMemoryFs::new()),
)))
.build();
let result = bash.exec("echo hi > /tmp/nope.txt").await.unwrap();
assert_eq!(
result.stderr.to_string(),
"bash: line 1: /tmp/nope.txt: filesystem is read-only\n"
);
assert!(!result.stderr.to_string().contains("io error:"));
}
/// A NUL byte inside a word is dropped, not preserved, so the redirect target
/// collapses to the same path with or without it.
#[tokio::test]
async fn nul_byte_in_word_is_dropped() {
let mut bash = fuzz_bash();
let with_nul = bash.exec("ls /tmp/ < /no\0pe").await.unwrap();
let without_nul = bash.exec("ls /tmp/ < /nope").await.unwrap();
assert_eq!(with_nul.stderr.to_string(), without_nul.stderr.to_string());
}
/// Regression: fuzz run 228 (`crash-9be3ed84f275159780cc08d447fbc341c3e88ea7`,
/// bytes `[10, 111, 100, 125, 46, 7]` = `\nod}.\x07`).
///
/// `ls /tmp/` + newline + `od}.\x07` runs `od` with the operand `}`, which the
/// VFS has no file for. `od` reported that as `Error::Internal`, so the whole
/// script aborted with `internal error: }: io error: file not found` — two
/// Rust enum shapes on a path any script can reach with a typo.
#[tokio::test]
async fn glob_fuzz_crash_missing_od_operand() {
let input = "\nod}.\u{7}";
let scripts = [
format!("ls /tmp/{}", input),
format!(
"case \"test.txt\" in {}) echo match;; *) echo no;; esac",
input
),
format!("if [[ \"hello.world\" == {} ]]; then echo y; fi", input),
];
let mut bash = fuzz_bash();
for script in &scripts {
fuzz_exec(&mut bash, script, "glob_fuzz_crash_missing_od_operand", &[]).await;
}
}
/// A missing operand is an ordinary command failure for the hex dumpers, the
/// way it is for real `od`: `od: FILE: No such file or directory`, exit 1,
/// and the rest of the script still runs. Before the fix each of these
/// aborted the script with an `Error::Internal` carrying `io error:`.
#[tokio::test]
async fn hex_dumpers_report_missing_operand_like_real_od() {
for name in ["od", "xxd", "hexdump"] {
let mut bash = fuzz_bash();
let result = bash
.exec(&format!("{name} /nope; echo after=$?"))
.await
.unwrap_or_else(|e| panic!("{name} aborted the script: {e}"));
assert_eq!(
result.stderr.to_string(),
format!("{name}: /nope: No such file or directory\n"),
"{name} stderr"
);
assert_eq!(
result.stdout.to_string(),
"after=1\n",
"{name} kept running"
);
}
}
/// Regression: fuzz run 229 (`crash-bd17afb20a1f27f7c30dc099a77b76e7b26f13bb`,
/// bytes `[10, 36, 114, 10, 126, 10, 45, 84, 111, 107, 58, 10, 126, 58]`
/// = `\n$r\n~\n-Tok:\n~:`).
///
/// Three command-resolution diagnostics in a row. None of them terminated its
/// line, so all three arrived as a single run-on line
/// `bash: /home/sandbox: Is a directorybash: -Tok:: command not found...`.
/// The echo filter matches a diagnostic per line, and that line opened with a
/// recognized template but ended with a suggestion suffix, so nothing was
/// stripped and the `-Tok:` echo (rendered `-Tok::` by the `: ` separator)
/// tripped the banned parser-token shape `Tok::`.
#[tokio::test]
async fn glob_fuzz_crash_unterminated_command_diagnostics() {
let input = "\n$r\n~\n-Tok:\n~:";
// The pre-filter cannot catch this one: the input holds `-Tok:` with a
// single colon, and the second colon comes from the template's own
// `: ` separator. The echo filter is the defense that has to hold.
assert!(!bashkit::testing::input_echo_would_trip(input));
let mut bash = fuzz_bash();
for script in [
format!("ls /tmp/{}", input),
format!(
"case \"test.txt\" in {}) echo match;; *) echo no;; esac",
input
),
format!("if [[ \"hello.world\" == {} ]]; then echo y; fi", input),
] {
fuzz_exec(
&mut bash,
&script,
"glob_fuzz_crash_unterminated_command_diagnostics",
&[],
)
.await;
}
}
/// Each command-resolution diagnostic terminates its own line, the way real
/// bash writes them. Verified against GNU bash 5.2.21:
///
/// $ bash -c $'~\n-Tok:\nnotacmd' 2>&1 | cat -A
/// bash: line 1: /root: Is a directory$
/// bash: line 2: -Tok:: command not found$
/// bash: line 3: notacmd: command not found$
#[tokio::test]
async fn consecutive_command_failures_are_separate_lines() {
let mut bash = fuzz_bash();
let result = bash.exec("nosuchcmd\nalsomissing").await.unwrap();
assert_eq!(
result.stderr.to_string(),
"bash: line 1: nosuchcmd: command not found\nbash: line 2: alsomissing: command not found\n"
);
}
/// Every `bash:` diagnostic the command resolver emits ends in a newline.
/// A run-on stderr is not just cosmetic: a host that splits stderr into
/// lines sees two failures as one, and the fuzz harness's echo filter — which
/// matches one real-shell template per line — stops recognizing them.
#[tokio::test]
async fn command_resolution_diagnostics_end_with_newline() {
let cases: &[(&str, &str)] = &[
("nosuchcmd", "bash: line 1: nosuchcmd: command not found\n"),
("/tmp", "bash: line 1: /tmp: Is a directory\n"),
(
"/nope/missing",
"bash: line 1: /nope/missing: No such file or directory\n",
),
];
for (script, expected) in cases {
let mut bash = fuzz_bash();
let result = bash.exec(script).await.unwrap();
assert_eq!(result.stderr.to_string(), *expected, "script: {script}");
}
}
/// The suggestion variant (`. Did you mean: …`) is still one terminated line.
#[tokio::test]
async fn command_not_found_suggestion_ends_with_newline() {
let mut bash = fuzz_bash();
let result = bash.exec("grpe").await.unwrap();
let stderr = result.stderr.to_string();
assert!(
stderr.starts_with("bash: line 1: grpe: command not found. Did you mean: "),
"unexpected wording: {stderr:?}"
);
assert!(stderr.ends_with("\n"), "not terminated: {stderr:?}");
assert_eq!(stderr.lines().count(), 1, "not one line: {stderr:?}");
}
/// A non-executable file reports `Permission denied` on its own line.
#[tokio::test]
async fn non_executable_script_denied_ends_with_newline() {
let mut bash = Bash::builder()
.mount_text("/tmp/noexec.sh", "echo hi")
.build();
let result = bash.exec("/tmp/noexec.sh").await.unwrap();
assert_eq!(
result.stderr.to_string(),
"bash: line 1: /tmp/noexec.sh: Permission denied\n"
);
assert_eq!(result.exit_code, 126);
}