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
//! BASH003: `cd && command` Anti-pattern
//!
//! **Rule**: Detect `cd dir && command` pattern (fails silently if cd fails)
//!
//! **Why this matters**:
//! If `cd` fails, the subsequent command runs in the wrong directory:
//! - Data corruption (writing to wrong location)
//! - Security issues (operating on wrong files)
//! - Build failures in CI/CD pipelines
//! - Silent failures that are hard to debug
//!
//! **Examples**:
//!
//! ❌ **DANGEROUS** (`cd && command`):
//! ```bash
//! cd /var/data && rm -rf *
//! # If cd fails, rm runs in current directory! (catastrophic)
//!
//! cd "$BUILD_DIR" && make clean
//! # If BUILD_DIR is unset or invalid, make runs in wrong directory
//! ```
//!
//! ✅ **SAFE** (explicit error handling):
//! ```bash
//! # Option 1: Explicit error check
//! cd /var/data || exit 1
//! rm -rf *
//!
//! # Option 2: Subshell (isolated, safer)
//! (cd /var/data && rm -rf *)
//!
//! # Option 3: Check variable first
//! [ -d "$BUILD_DIR" ] || exit 1
//! cd "$BUILD_DIR" || exit 1
//! make clean
//! ```
//!
//! ## Detection Logic
//!
//! This rule detects:
//! - `cd <path> && <command>` - Command runs in potentially wrong directory
//! - Multiple commands after cd: `cd <path> && cmd1 && cmd2`
//!
//! Does NOT flag:
//! - `cd <path> || exit` - Has error handling
//! - `(cd <path> && cmd)` - Subshell (isolated)
//! - Just `cd <path>` - No subsequent command
//!
//! ## Auto-fix
//!
//! Suggests:
//! - Add explicit error handling: `cd dir || exit 1; command`
//! - Use subshell: `(cd dir && command)`
//! - Use pushd/popd for directory stack management
use crate::linter::LintResult;
use crate::linter::{Diagnostic, Severity, Span};
/// Check for dangerous `cd && command` pattern
pub fn check(source: &str) -> LintResult {
let mut result = LintResult::new();
for (line_num, line) in source.lines().enumerate() {
let trimmed = line.trim();
// Strip comments
let code_only = if let Some(pos) = trimmed.find('#') {
&trimmed[..pos]
} else {
trimmed
};
let code_only = code_only.trim();
// Skip empty lines
if code_only.is_empty() {
continue;
}
// Pattern: cd <path> && <command>
// But NOT: (cd <path> && <command>) - subshell is safe
if code_only.contains("cd ") && code_only.contains("&&") {
// Skip if it's in a subshell
if code_only.trim_start().starts_with('(') {
continue;
}
// Check if there's a command after cd && ...
if let Some(cd_pos) = code_only.find("cd ") {
if let Some(and_pos) = code_only[cd_pos..].find("&&") {
let after_and = &code_only[cd_pos + and_pos + 2..].trim();
// If there's a command after &&, it's dangerous
if !after_and.is_empty() {
let span = Span::new(line_num + 1, 1, line_num + 1, line.len());
let diag = Diagnostic::new(
"BASH003",
Severity::Warning,
"Dangerous 'cd && command' pattern - if cd fails, command runs in wrong directory; use 'cd dir || exit 1' or '(cd dir && cmd)' in subshell",
span,
);
result.add(diag);
}
}
}
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
// RED Phase: Write failing tests first (EXTREME TDD)
/// RED TEST 1: Detect cd && command pattern
#[test]
fn test_BASH003_detects_cd_and_command() {
let script = r#"#!/bin/bash
cd /var/data && rm -rf *
"#;
let result = check(script);
assert_eq!(result.diagnostics.len(), 1);
let diag = &result.diagnostics[0];
assert_eq!(diag.code, "BASH003");
assert_eq!(diag.severity, Severity::Warning);
assert!(diag.message.contains("cd && command"));
assert!(diag.message.contains("wrong directory"));
}
/// RED TEST 2: Detect cd with variable && command
#[test]
fn test_BASH003_detects_cd_variable_and_command() {
let script = r#"#!/bin/bash
cd "$BUILD_DIR" && make clean
"#;
let result = check(script);
assert_eq!(result.diagnostics.len(), 1);
let diag = &result.diagnostics[0];
assert_eq!(diag.code, "BASH003");
}
/// RED TEST 3: Detect multiple commands after cd
#[test]
fn test_BASH003_detects_cd_and_multiple_commands() {
let script = r#"#!/bin/bash
cd src && make && make install
"#;
let result = check(script);
assert_eq!(result.diagnostics.len(), 1);
let diag = &result.diagnostics[0];
assert_eq!(diag.code, "BASH003");
}
/// RED TEST 4: Pass when using subshell (safe)
#[test]
fn test_BASH003_passes_subshell() {
let script = r#"#!/bin/bash
(cd /var/data && rm -rf *)
"#;
let result = check(script);
assert_eq!(result.diagnostics.len(), 0, "Subshell pattern is safe");
}
/// RED TEST 5: Pass when cd has no subsequent command
#[test]
fn test_BASH003_passes_cd_alone() {
let script = r#"#!/bin/bash
cd /var/data
rm -rf *
"#;
let result = check(script);
assert_eq!(result.diagnostics.len(), 0, "cd alone is not flagged");
}
/// RED TEST 6: Pass when cd has error handling
#[test]
fn test_BASH003_passes_cd_with_error_handling() {
let script = r#"#!/bin/bash
cd /var/data || exit 1
rm -rf *
"#;
let result = check(script);
assert_eq!(
result.diagnostics.len(),
0,
"cd with error handling is safe"
);
}
/// RED TEST 7: Detect cd in function
#[test]
fn test_BASH003_detects_cd_in_function() {
let script = r#"#!/bin/bash
build() {
cd "$1" && make
}
"#;
let result = check(script);
assert_eq!(result.diagnostics.len(), 1);
let diag = &result.diagnostics[0];
assert_eq!(diag.code, "BASH003");
}
/// RED TEST 8: Ignore cd in comments
#[test]
fn test_BASH003_ignores_comments() {
let script = r#"#!/bin/bash
# cd /tmp && rm -rf *
echo "Safe"
"#;
let result = check(script);
assert_eq!(result.diagnostics.len(), 0, "Comments should be ignored");
}
}
#[cfg(test)]
mod property_tests {
use super::*;
use proptest::prelude::*;
proptest! {
#![proptest_config(proptest::test_runner::Config::with_cases(10))]
/// PROPERTY TEST 1: Never panics on any input
#[test]
fn prop_bash003_never_panics(s in ".*") {
let _ = check(&s);
}
/// PROPERTY TEST 2: Always detects cd && command
#[test]
fn prop_bash003_detects_cd_and(
dir in "[a-z/_]{3,20}",
cmd in "[a-z]{3,10}",
) {
let script = format!("cd {} && {}", dir, cmd);
let result = check(&script);
prop_assert_eq!(result.diagnostics.len(), 1);
prop_assert_eq!(result.diagnostics[0].code.as_str(), "BASH003");
}
/// PROPERTY TEST 3: Passes when using subshell
#[test]
fn prop_bash003_passes_subshell(
dir in "[a-z/_]{3,20}",
cmd in "[a-z]{3,10}",
) {
let script = format!("(cd {} && {})", dir, cmd);
let result = check(&script);
prop_assert_eq!(result.diagnostics.len(), 0);
}
}
}