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
//! Tests for issue #853: `result=$(cmd 2>&1 >file)` redirect ordering.
//!
//! Bash processes redirects left-to-right. `2>&1 >file` means:
//! 1. stderr → where stdout currently points (the $() capture pipe)
//! 2. stdout → file
//!
//! So `result=$(cmd 2>&1 >file)` captures stderr in result, stdout goes to file.
use bashkit::Bash;
/// Core reproduction: 2>&1 >file inside command substitution
#[tokio::test]
async fn redirect_2_to_1_then_file_in_cmdsub() {
let mut bash = Bash::new();
let result = bash
.exec(
r#"
f() { echo "stdout"; echo "stderr" >&2; }
result=$(f 2>&1 >"/tmp/out.txt")
echo "result=[$result]"
echo "file=[$(cat /tmp/out.txt)]"
"#,
)
.await
.unwrap();
let stdout = result.stdout;
// result should capture stderr (because 2>&1 copies stdout's fd which is the capture pipe)
assert!(
stdout.contains("result=[stderr]"),
"expected result=[stderr], got: {stdout}"
);
// file should contain stdout (because >file redirects stdout to file)
assert!(
stdout.contains("file=[stdout]"),
"expected file=[stdout], got: {stdout}"
);
}
/// Simpler case: 2>&1 >file outside command substitution
#[tokio::test]
async fn redirect_2_to_1_then_file_outside_cmdsub() {
let mut bash = Bash::new();
let result = bash
.exec(
r#"
f() { echo "stdout"; echo "stderr" >&2; }
f 2>&1 >"/tmp/out2.txt"
echo "---"
cat /tmp/out2.txt
"#,
)
.await
.unwrap();
let stdout = result.stdout;
// stderr should go to where stdout was (the terminal/capture) since 2>&1 comes first
// stdout should go to the file since >file comes second
assert!(
stdout.contains("stderr"),
"stderr should appear in stdout: {stdout}"
);
assert!(
stdout.contains("---\nstdout"),
"file should contain stdout: {stdout}"
);
}
/// Reverse order: >file 2>&1 should send both to file
#[tokio::test]
async fn redirect_file_then_2_to_1() {
let mut bash = Bash::new();
let result = bash
.exec(
r#"
f() { echo "stdout"; echo "stderr" >&2; }
f >"/tmp/out3.txt" 2>&1
cat /tmp/out3.txt
"#,
)
.await
.unwrap();
let stdout = result.stdout;
// Both should go to file (stdout→file first, then stderr→where stdout points = file)
assert!(
stdout.contains("stdout"),
"file should contain stdout: {stdout}"
);
assert!(
stdout.contains("stderr"),
"file should contain stderr: {stdout}"
);
}
/// Regression guard: mixed 2>&1 + >file must still truncate/create file on empty output.
#[tokio::test]
async fn redirect_2_to_1_then_file_truncates_on_empty_output() {
let mut bash = Bash::new();
let result = bash
.exec(
r#"
echo "stale" >"/tmp/out-empty.txt"
true 2>&1 >"/tmp/out-empty.txt"
wc -c <"/tmp/out-empty.txt"
"#,
)
.await
.unwrap();
assert_eq!(
result.stdout.trim(),
"0",
"expected redirected file to be truncated to zero bytes"
);
}