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
87 changes: 86 additions & 1 deletion crates/bashkit/src/builtins/grep.rs
Original file line number Diff line number Diff line change
Expand Up @@ -850,7 +850,7 @@ fn try_indexed_search(
#[cfg(test)]
mod tests {
use super::*;
use crate::fs::{FileSystem, InMemoryFs};
use crate::fs::{FileSystem, InMemoryFs, OverlayFs};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
Expand Down Expand Up @@ -1063,6 +1063,91 @@ mod tests {
assert!(!result.stdout.contains("b.log"));
}

#[tokio::test]
async fn test_grep_recursive_single_file() {
let grep = Grep;
let fs = Arc::new(InMemoryFs::new());
fs.mkdir(&PathBuf::from("/data"), true).await.unwrap();
fs.write_file(&PathBuf::from("/data/test.md"), b"hello world\n")
.await
.unwrap();

let mut vars = HashMap::new();
let mut cwd = PathBuf::from("/");
let args: Vec<String> = ["-r", "hello", "/data/test.md"]
.iter()
.map(|s| s.to_string())
.collect();

let ctx = Context {
args: &args,
env: &HashMap::new(),
variables: &mut vars,
cwd: &mut cwd,
fs,
stdin: None,
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};

let result = grep.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 0, "grep -r on a single file should match");
assert!(
result.stdout.contains("hello world"),
"expected 'hello world' in stdout, got: {:?}",
result.stdout
);
}

/// Regression: grep -r on a single file with OverlayFs returned empty
/// because OverlayFs::read_dir returned Ok(vec![]) for files instead of Err.
#[tokio::test]
async fn test_grep_recursive_single_file_overlay() {
let grep = Grep;
let base = Arc::new(InMemoryFs::new());
let fs: Arc<dyn FileSystem> = Arc::new(OverlayFs::new(base));
fs.mkdir(&PathBuf::from("/data"), true).await.unwrap();
fs.write_file(&PathBuf::from("/data/test.md"), b"hello world\n")
.await
.unwrap();

let mut vars = HashMap::new();
let mut cwd = PathBuf::from("/");
let args: Vec<String> = ["-r", "hello", "/data/test.md"]
.iter()
.map(|s| s.to_string())
.collect();

let ctx = Context {
args: &args,
env: &HashMap::new(),
variables: &mut vars,
cwd: &mut cwd,
fs,
stdin: None,
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};

let result = grep.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 0, "grep -r on single file via OverlayFs");
assert!(
result.stdout.contains("hello world"),
"expected 'hello world' in stdout, got: {:?}",
result.stdout
);
}

#[tokio::test]
async fn test_grep_recursive_exclude() {
let grep = Grep;
Expand Down
44 changes: 38 additions & 6 deletions crates/bashkit/src/fs/overlay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -619,13 +619,33 @@ impl FileSystem for OverlayFs {
return Err(IoError::new(ErrorKind::NotFound, "not found").into());
}

// Check if the path is a non-directory (file/symlink) — read_dir must fail
let is_dir_lower = if let Ok(meta) = self.lower.stat(&path).await {
if !meta.file_type.is_dir() {
return Err(IoError::other("not a directory").into());
}
true
} else {
false
};
let is_dir_upper = if let Ok(meta) = self.upper.stat(&path).await {
if !meta.file_type.is_dir() {
return Err(IoError::other("not a directory").into());
}
true
} else {
false
};

if !is_dir_lower && !is_dir_upper {
return Err(IoError::new(ErrorKind::NotFound, "not found").into());
}

let mut entries: std::collections::HashMap<String, DirEntry> =
std::collections::HashMap::new();

// Get entries from lower (if not whited out)
if self.lower.exists(&path).await.unwrap_or(false)
&& let Ok(lower_entries) = self.lower.read_dir(&path).await
{
if is_dir_lower && let Ok(lower_entries) = self.lower.read_dir(&path).await {
for entry in lower_entries {
// Skip whited out entries
let entry_path = path.join(&entry.name);
Expand All @@ -636,9 +656,7 @@ impl FileSystem for OverlayFs {
}

// Overlay with entries from upper (overriding lower)
if self.upper.exists(&path).await.unwrap_or(false)
&& let Ok(upper_entries) = self.upper.read_dir(&path).await
{
if is_dir_upper && let Ok(upper_entries) = self.upper.read_dir(&path).await {
for entry in upper_entries {
entries.insert(entry.name.clone(), entry);
}
Expand Down Expand Up @@ -1136,6 +1154,20 @@ mod tests {
assert!(names.contains(&&"upper.txt".to_string()));
}

/// Regression: read_dir on a file must return Err, not Ok(vec![])
#[tokio::test]
async fn test_read_dir_on_file_returns_error() {
let lower = Arc::new(InMemoryFs::new());
lower
.write_file(Path::new("/tmp/file.txt"), b"data")
.await
.unwrap();

let overlay = OverlayFs::new(lower);
let result = overlay.read_dir(Path::new("/tmp/file.txt")).await;
assert!(result.is_err(), "read_dir on a file should return Err");
}

// Issue #418: usage should deduct whited-out files
#[tokio::test]
async fn test_usage_deducts_whiteouts() {
Expand Down
Loading