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
// 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: <path>: No such file or directory`.
#[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: /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: /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: /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());
}