Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -176,8 +176,12 @@ jobs:

- name: Run ssh supabase.sh example and tests
run: |
cargo run --example ssh_supabase --features ssh
cargo test --features ssh -p bashkit --test ssh_supabase_tests
if nc -z -w 5 supabase.sh 22 2>/dev/null; then
cargo run --example ssh_supabase --features ssh
cargo test --features ssh -p bashkit --test ssh_supabase_tests
else
echo "::warning::supabase.sh:22 unreachable from this runner — skipping live SSH tests"
fi

- name: Run realfs bash example
run: |
Expand Down
72 changes: 50 additions & 22 deletions crates/bashkit/src/interpreter/glob.rs
Original file line number Diff line number Diff line change
Expand Up @@ -697,6 +697,29 @@ impl Interpreter {
}
}

/// Strip backslash escapes (`\X` → `X`) from a path string.
///
/// `escape_glob_metas_in_quoted_ranges` inserts `\` before metacharacters in
/// quoted segments to prevent brace-expansion. Before using a path component
/// for filesystem lookup those escapes must be removed, because VFS paths are
/// stored without them. The glob-pattern component (file_name) is left intact
/// because `glob_match_impl` and `contains_glob_chars` already understand `\`.
fn glob_path_unescape(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut escaped = false;
for ch in s.chars() {
if escaped {
result.push(ch);
escaped = false;
} else if ch == '\\' {
escaped = true;
} else {
result.push(ch);
}
}
result
}

/// Expand a glob pattern against the filesystem
pub(crate) async fn expand_glob(&self, pattern: &str) -> Result<Vec<String>> {
// Check for ** (recursive glob) — only when globstar is enabled
Expand All @@ -708,24 +731,36 @@ impl Interpreter {
let dotglob = self.is_dotglob();
let nocase = self.is_nocaseglob();

// Split pattern into directory and filename parts
// Split pattern into directory and filename parts.
// For absolute paths, `lookup_dir` is the unescaped directory PathBuf used for
// both VFS lookup and (via Path::join) output path construction.
// For relative paths, we keep the original parent string for output fidelity
// (to preserve `./` or `../` prefixes) but resolve against cwd for lookup.
let path = Path::new(pattern);
let (dir, file_pattern) = if path.is_absolute() {
let (lookup_dir, rel_output_prefix, file_pattern) = if path.is_absolute() {
let parent = path.parent().unwrap_or(Path::new("/"));
// Unescape: \{ \[ \* are glob escapes that prevent brace/glob expansion
// but must not appear in filesystem paths.
let unescaped = Self::glob_path_unescape(&parent.to_string_lossy());
let name = path.file_name().map(|s| s.to_string_lossy().to_string());
(parent.to_path_buf(), name)
(PathBuf::from(&unescaped), None::<String>, name)
} else {
// Relative path - use cwd
// Relative path — use the original parent string for output to preserve the
// `./` or relative prefix supplied by the caller; use the resolved absolute
// path for VFS lookup.
let parent = path.parent();
let name = path.file_name().map(|s| s.to_string_lossy().to_string());
if let Some(p) = parent {
if p.as_os_str().is_empty() {
(self.cwd.clone(), name)
(self.cwd.clone(), Some(String::new()), name)
} else {
(crate::fs::normalize_path(&self.cwd.join(p)), name)
let unescaped = Self::glob_path_unescape(&p.to_string_lossy());
let joined = crate::fs::normalize_path(&self.cwd.join(&unescaped));
let original_prefix = p.to_string_lossy().to_string();
(joined, Some(original_prefix), name)
}
} else {
(self.cwd.clone(), name)
(self.cwd.clone(), Some(String::new()), name)
}
};

Expand All @@ -735,12 +770,12 @@ impl Interpreter {
};

// Check if the directory exists
if !self.fs.exists(&dir).await.unwrap_or(false) {
if !self.fs.exists(&lookup_dir).await.unwrap_or(false) {
return Ok(matches);
}

// Read directory entries
let entries = match self.fs.read_dir(&dir).await {
let entries = match self.fs.read_dir(&lookup_dir).await {
Ok(e) => e,
Err(_) => return Ok(matches),
};
Expand All @@ -756,20 +791,13 @@ impl Interpreter {
}

if self.glob_match_impl(&entry.name, &file_pattern, nocase, 0) {
// Construct the full path
let full_path = if path.is_absolute() {
dir.join(&entry.name).to_string_lossy().to_string()
} else {
// For relative patterns, return relative path
if let Some(parent) = path.parent() {
if parent.as_os_str().is_empty() {
entry.name.clone()
} else {
format!("{}/{}", parent.to_string_lossy(), entry.name)
}
} else {
entry.name.clone()
let full_path = match &rel_output_prefix {
None => {
// Absolute path: use PathBuf::join so root "/" is handled correctly.
lookup_dir.join(&entry.name).to_string_lossy().to_string()
}
Some(prefix) if prefix.is_empty() => entry.name.clone(),
Some(prefix) => format!("{}/{}", prefix, entry.name),
};
matches.push(full_path);
}
Expand Down
87 changes: 76 additions & 11 deletions crates/bashkit/src/parser/lexer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1468,28 +1468,93 @@ impl<'a> Lexer<'a> {
if quoted_ranges.is_empty() {
return s.to_string();
}
// Collect chars with their byte positions for range-boundary checks.
let char_vec: Vec<(usize, char)> = {
let mut pos = 0usize;
s.chars()
.map(|c| {
let p = pos;
pos += c.len_utf8();
(p, c)
})
.collect()
};

let mut result = String::with_capacity(s.len() + 8);
let mut byte_pos = 0usize;
let mut range_idx = 0usize;
for ch in s.chars() {
// Stack of opening delimiters ('{' or '(') for active ${ } / $( ) constructs.
// While non-empty we are inside an expansion and must NOT escape anything,
// because the content is still unexpanded at parse time and characters like
// { } [ ] are structural (e.g. ${arr[0]}, $(cmd)).
let mut expansion_stack: Vec<char> = Vec::new();
let n = char_vec.len();
let mut i = 0usize;

while i < n {
let (byte_pos, ch) = char_vec[i];
let ch_end = byte_pos + ch.len_utf8();
// Advance past ranges that have ended.

// Advance past quoted-range entries that ended before this char.
while range_idx < quoted_ranges.len() && quoted_ranges[range_idx].1 <= byte_pos {
range_idx += 1;
}
let in_quoted = range_idx < quoted_ranges.len()
&& byte_pos >= quoted_ranges[range_idx].0
&& ch_end <= quoted_ranges[range_idx].1;
if in_quoted
&& matches!(
ch,
'\\' | '*' | '?' | '[' | ']' | '{' | '}' | '@' | '!' | '+' | '(' | ')' | '|'
)
{
result.push('\\');

if in_quoted {
if expansion_stack.is_empty() && ch == '$' {
// Peek at next char to detect ${ or $(
if let Some(&(_, next)) = char_vec.get(i + 1)
&& (next == '{' || next == '(')
{
expansion_stack.push(next);
result.push(ch);
result.push(next);
i += 2;
continue;
}
} else if !expansion_stack.is_empty() {
// Track nesting: ${ … { … } … } and $( … ( … ) … )
let top = *expansion_stack.last().unwrap();
match (top, ch) {
('{', '{') | ('(', '(') => expansion_stack.push(ch),
('{', '}') | ('(', ')') => {
expansion_stack.pop();
}
_ => {}
}
result.push(ch);
i += 1;
continue;
}

// Outside any ${ }/$( ) construct: escape glob / brace / extglob metas
// so that runtime brace-expansion and glob-expansion treat them as literals,
// matching how bash handles metacharacters inside double quotes.
if expansion_stack.is_empty()
&& matches!(
ch,
'\\' | '*'
| '?'
| '['
| ']'
| '{'
| '}'
| '@'
| '!'
| '+'
| '('
| ')'
| '|'
)
{
result.push('\\');
}
}

result.push(ch);
byte_pos = ch_end;
i += 1;
}
result
}
Expand Down
87 changes: 87 additions & 0 deletions crates/bashkit/tests/integration/blackbox_security_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1420,6 +1420,93 @@ mod finding_mixed_quoted_word_quote_metadata {
"brace expression from quoted segment was expanded"
);
}

#[tokio::test]
async fn literal_brace_expression_in_quoted_glob_prefix_stays_literal() {
// "{a,b}"/*/ is a QuotedGlobWord (unquoted glob suffix `*/`). The quoted
// segment `{a,b}` must NOT brace-expand: the pattern should match the literal
// directory name `{a,b}`, not `a` and `b`.
//
// We create:
// /tmp/tqgb/{a,b}/child (literal dir named {a,b})
// /tmp/tqgb/a/ (would match if brace-expansion fired)
// /tmp/tqgb/b/ (would match if brace-expansion fired)
//
// Correct result: only /tmp/tqgb/{a,b}/child found.
let mut bash = tight_bash();
bash.exec("mkdir -p '/tmp/tqgb/{a,b}/child' /tmp/tqgb/a /tmp/tqgb/b")
.await
.unwrap();
let result = bash
.exec(
r#"shopt -s nullglob; res=(); for d in "/tmp/tqgb/{a,b}"/*/; do res+=("${d%/}"); done; echo "${res[*]}""#,
)
.await
.unwrap();
let stdout = result.stdout.trim().to_string();
// If brace-expansion fired incorrectly: matches /tmp/tqgb/a and /tmp/tqgb/b
// (but those dirs are empty so nullglob drops them → empty output).
// Either way, the literal {a,b}/child should be found.
assert!(
stdout.contains("/tmp/tqgb/{a,b}/child"),
"literal {{a,b}} in quoted glob prefix should not brace-expand; got: {stdout}"
);
}

#[tokio::test]
async fn subscript_var_expansion_in_quoted_glob_prefix_not_corrupted() {
// Regression: escape_glob_metas_in_quoted_ranges was escaping [ ] inside
// ${arr[0]}, producing ${arr\[0\]} which is not a valid subscript at runtime.
let mut bash = tight_bash();
bash.exec("mkdir -p /tmp/tqg4/sub").await.unwrap();
let result = bash
.exec(r#"dirs=("/tmp/tqg4"); for d in "${dirs[0]}"/sub; do echo "$d"; done"#)
.await
.unwrap();
assert_eq!(
result.stdout.trim(),
"/tmp/tqg4/sub",
"${{dirs[0]}} subscript in quoted glob prefix was not expanded \
(brackets escaped by escape_glob_metas_in_quoted_ranges)"
);
}

#[tokio::test]
async fn var_expansion_in_quoted_glob_prefix_not_corrupted() {
// Regression: escape_glob_metas_in_quoted_ranges was escaping { and }
// inside ${ } variable references, producing $\{VAR\} which is not
// recognised as a variable reference at runtime. Pattern:
// "${VAR}"/suffix — quoted prefix + unquoted suffix
let mut bash = tight_bash();
bash.exec("mkdir -p /tmp/tqg/sub").await.unwrap();
let result = bash
.exec(r#"MYDIR=/tmp/tqg; for d in "${MYDIR}"/sub; do echo "$d"; done"#)
.await
.unwrap();
assert_eq!(
result.stdout.trim(),
"/tmp/tqg/sub",
"${{MYDIR}} in quoted glob prefix was not expanded (braces escaped by escape_glob_metas_in_quoted_ranges)"
);
}

#[tokio::test]
async fn var_expansion_in_quoted_glob_prefix_with_star() {
// Same regression but with an actual glob in the unquoted suffix.
let mut bash = tight_bash();
bash.exec("mkdir -p /tmp/tqg2/a /tmp/tqg2/b").await.unwrap();
let result = bash
.exec(
r##"MYDIR=/tmp/tqg2; dirs=(); for d in "${MYDIR}"/*/; do dirs+=("${d%/}"); done; echo "${dirs[*]}""##,
)
.await
.unwrap();
let stdout = result.stdout.trim().to_string();
assert!(
stdout.contains("/tmp/tqg2/a") && stdout.contains("/tmp/tqg2/b"),
"glob \"${{MYDIR}}\"/*/ did not expand correctly, got: {stdout}"
);
}
}

mod state_isolation_passing {
Expand Down
Loading