Skip to content

Commit a633cee

Browse files
committed
fix(interpreter): short-circuit && and || inside [[ ]] to prevent set -u errors
Closes #939
1 parent 5f72ac0 commit a633cee

File tree

2 files changed

+114
-6
lines changed

2 files changed

+114
-6
lines changed

crates/bashkit/src/interpreter/mod.rs

Lines changed: 79 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1818,13 +1818,20 @@ impl Interpreter {
18181818
/// Returns exit code 0 if result is non-zero, 1 if result is zero
18191819
/// Execute a [[ conditional expression ]]
18201820
async fn execute_conditional(&mut self, words: &[Word]) -> Result<ExecResult> {
1821-
// Expand all words
1822-
let mut expanded = Vec::new();
1823-
for word in words {
1824-
expanded.push(self.expand_word(word).await?);
1821+
// Evaluate with lazy expansion to support short-circuit semantics.
1822+
// In `[[ -n "${X:-}" && "$X" != "off" ]]`, if the left side is false,
1823+
// the right side must NOT be expanded (to avoid set -u errors).
1824+
let result = self.evaluate_conditional_words(words).await;
1825+
// If a nounset error occurred during evaluation, propagate it.
1826+
if let Some(err_msg) = self.nounset_error.take() {
1827+
self.last_exit_code = 1;
1828+
return Ok(ExecResult {
1829+
stderr: err_msg,
1830+
exit_code: 1,
1831+
control_flow: ControlFlow::Return(1),
1832+
..Default::default()
1833+
});
18251834
}
1826-
1827-
let result = self.evaluate_conditional(&expanded).await;
18281835
let exit_code = if result { 0 } else { 1 };
18291836
self.last_exit_code = exit_code;
18301837

@@ -1837,6 +1844,72 @@ impl Interpreter {
18371844
})
18381845
}
18391846

1847+
/// Evaluate [[ ]] from raw words with lazy expansion for short-circuit.
1848+
fn evaluate_conditional_words<'a>(
1849+
&'a mut self,
1850+
words: &'a [Word],
1851+
) -> std::pin::Pin<Box<dyn std::future::Future<Output = bool> + Send + 'a>> {
1852+
Box::pin(async move {
1853+
if words.is_empty() {
1854+
return false;
1855+
}
1856+
1857+
// Helper: get literal text of a word (for operators like &&, ||, !, (, ))
1858+
let word_literal = |w: &Word| -> Option<String> {
1859+
if w.parts.len() == 1
1860+
&& let WordPart::Literal(s) = &w.parts[0]
1861+
{
1862+
return Some(s.clone());
1863+
}
1864+
None
1865+
};
1866+
1867+
// Handle negation
1868+
if word_literal(&words[0]).as_deref() == Some("!") {
1869+
return !self.evaluate_conditional_words(&words[1..]).await;
1870+
}
1871+
1872+
// Handle parentheses
1873+
if word_literal(&words[0]).as_deref() == Some("(")
1874+
&& word_literal(&words[words.len() - 1]).as_deref() == Some(")")
1875+
{
1876+
return self
1877+
.evaluate_conditional_words(&words[1..words.len() - 1])
1878+
.await;
1879+
}
1880+
1881+
// Look for || (lowest precedence), then && — scan right to left
1882+
for i in (0..words.len()).rev() {
1883+
if word_literal(&words[i]).as_deref() == Some("||") && i > 0 {
1884+
let left = self.evaluate_conditional_words(&words[..i]).await;
1885+
if left {
1886+
return true; // short-circuit: skip right side
1887+
}
1888+
return self.evaluate_conditional_words(&words[i + 1..]).await;
1889+
}
1890+
}
1891+
for i in (0..words.len()).rev() {
1892+
if word_literal(&words[i]).as_deref() == Some("&&") && i > 0 {
1893+
let left = self.evaluate_conditional_words(&words[..i]).await;
1894+
if !left {
1895+
return false; // short-circuit: skip right side
1896+
}
1897+
return self.evaluate_conditional_words(&words[i + 1..]).await;
1898+
}
1899+
}
1900+
1901+
// Leaf: expand words and evaluate as a simple condition
1902+
let mut expanded = Vec::new();
1903+
for word in words {
1904+
match self.expand_word(word).await {
1905+
Ok(s) => expanded.push(s),
1906+
Err(_) => return false,
1907+
}
1908+
}
1909+
self.evaluate_conditional(&expanded).await
1910+
})
1911+
}
1912+
18401913
/// Evaluate a [[ ]] conditional expression from expanded words.
18411914
fn evaluate_conditional<'a>(
18421915
&'a mut self,
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
### conditional_and_short_circuit_set_u
2+
# [[ false && unset_ref ]] should not evaluate right side
3+
set -u
4+
[[ -n "${UNSET_SC_VAR:-}" && "${UNSET_SC_VAR}" != "off" ]]
5+
echo $?
6+
### expect
7+
1
8+
### end
9+
10+
### conditional_or_short_circuit_set_u
11+
# [[ true || unset_ref ]] should not evaluate right side
12+
set -u
13+
[[ -z "${UNSET_SC_VAR2:-}" || "${UNSET_SC_VAR2}" == "x" ]]
14+
echo $?
15+
### expect
16+
0
17+
### end
18+
19+
### conditional_and_short_circuit_passes
20+
# [[ true && check ]] should evaluate both sides
21+
set -u
22+
export SC_SET_VAR="active"
23+
[[ -n "${SC_SET_VAR:-}" && "${SC_SET_VAR}" != "off" ]]
24+
echo $?
25+
### expect
26+
0
27+
### end
28+
29+
### conditional_nested_short_circuit
30+
# Nested && || should respect short-circuit
31+
set -u
32+
[[ -n "${UNSET_NSC:-}" && "${UNSET_NSC}" == "x" ]] || echo "safe"
33+
### expect
34+
safe
35+
### end

0 commit comments

Comments
 (0)