use anyhow::{Context, Result};
use globset::{Glob, GlobSet, GlobSetBuilder};
use ignore::WalkBuilder;
use serde::{Deserialize, Serialize};
use tokio::fs;
use crate::sandbox::Sandbox;
#[derive(Serialize, Deserialize, Debug)]
pub struct SearchResult {
pub message: String,
pub entries: Vec<String>,
pub success: bool,
}
pub async fn search_files(
sandbox: &Sandbox,
path: &str,
pattern: &str,
exclude_patterns: Vec<String>,
) -> Result<Vec<String>> {
tracing::info!(
path = %path,
include_pattern = %pattern,
exclude_patterns = ?exclude_patterns,
"starting directory search"
);
// Resolve path within sandbox
let canon_path = sandbox.resolve_path(path).await?;
let mut files = Vec::new();
let patterns = build_patterns(pattern, exclude_patterns)
.with_context(|| "Failed to build search patterns")?;
let walker = WalkBuilder::new(&canon_path)
.follow_links(false)
.standard_filters(false)
.build();
for entry in walker {
match entry {
Ok(entry) => {
if entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) {
let file_name = entry.file_name().to_string_lossy().to_lowercase();
if patterns.include.is_match(&file_name)
&& !patterns.exclude.is_match(&file_name)
{
files.push(entry.path().to_string_lossy().to_string());
}
}
}
Err(err) => {
tracing::warn!("{}", err);
continue;
}
}
}
files.sort();
Ok(files)
}
/// List immediate directory contents alphabetically
pub async fn list_directory(sandbox: &Sandbox, path: &str) -> Result<Vec<String>> {
tracing::info!("list directory for {}", path);
let canon_path = sandbox.resolve_path(path).await?;
let mut entries = fs::read_dir(&canon_path).await.context(format!(
"Failed to read directory: {}",
canon_path.display()
))?;
let mut results = Vec::new();
while let Some(entry) = entries.next_entry().await? {
let mut name = entry.file_name().to_string_lossy().to_string();
let file_type = entry
.file_type()
.await
.with_context(|| format!("Failed to get file type for {:?}", path))?;
if file_type.is_symlink() {
tracing::warn!("Skipping symlink {:?}", entry.path());
continue;
}
if file_type.is_dir() {
name.push('/');
}
results.push(name);
}
results.sort();
Ok(results)
}
/// Helper struct to store compiled glob patterns
struct Patterns {
include: GlobSet,
exclude: GlobSet,
}
/// Compile include/exclude glob patterns
fn build_patterns(pattern: &str, exclude_patterns: Vec<String>) -> Result<Patterns> {
let mut include_builder = GlobSetBuilder::new();
let mut exclude_builder = GlobSetBuilder::new();
include_builder.add(
Glob::new(&pattern.to_lowercase())
.with_context(|| format!("invalid include glob pattern: '{pattern}'"))?,
);
for exclude in exclude_patterns {
exclude_builder.add(
Glob::new(&exclude.to_lowercase())
.with_context(|| format!("invalid exclude glob pattern: '{exclude}'"))?,
);
}
Ok(Patterns {
include: include_builder
.build()
.context("failed to build include glob set")?,
exclude: exclude_builder
.build()
.context("failed to build exclude glob set")?,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sandbox::Sandbox;
use std::sync::Arc;
use tempfile::TempDir;
async fn setup_sandbox(temp_dir: &TempDir) -> Arc<Sandbox> {
let root = temp_dir.path().to_string_lossy().to_string();
let sandbox = Sandbox::new(vec![root]).await.expect("sandbox init failed");
Arc::new(sandbox)
}
#[tokio::test]
async fn test_search_files_basic() {
let temp_dir = tempfile::tempdir().unwrap();
let sandbox = setup_sandbox(&temp_dir).await;
let temp_path = temp_dir.path();
std::fs::write(temp_path.join("test1.txt"), "content").unwrap();
std::fs::write(temp_path.join("test2.txt"), "content").unwrap();
std::fs::create_dir(temp_path.join("subdir")).unwrap();
std::fs::write(temp_path.join("subdir/test3.txt"), "content").unwrap();
let result = search_files(&sandbox, temp_path.to_str().unwrap(), "*.txt", vec![])
.await
.expect("search_files should succeed");
assert_eq!(result.len(), 3);
assert!(result.iter().all(|f| f.ends_with(".txt")));
}
#[tokio::test]
async fn test_search_files_empty_folder() {
let temp_dir = tempfile::tempdir().unwrap();
let sandbox = setup_sandbox(&temp_dir).await;
let temp_path = temp_dir.path();
let result = search_files(&sandbox, temp_path.to_str().unwrap(), "*.txt", vec![])
.await
.expect("search_files should succeed");
assert_eq!(result.len(), 0);
}
#[tokio::test]
async fn test_search_files_excluding_patterns() {
let temp_dir = tempfile::tempdir().unwrap();
let sandbox = setup_sandbox(&temp_dir).await;
let temp_path = temp_dir.path();
std::fs::write(temp_path.join("test1.txt"), "content").unwrap();
std::fs::write(temp_path.join("test3md.txt"), "content").unwrap();
std::fs::write(temp_path.join("test4.md"), "content").unwrap();
std::fs::create_dir(temp_path.join("subdir")).unwrap();
std::fs::write(temp_path.join("subdir/test3.txt"), "content").unwrap();
let result = search_files(
&sandbox,
temp_path.to_str().unwrap(),
"*.txt",
vec!["*md.txt".to_string(), "*.md".to_string()],
)
.await
.expect("search_files should succeed");
assert_eq!(result.len(), 2);
assert!(result.iter().all(|f| f.ends_with(".txt")));
}
#[tokio::test]
async fn test_search_files_case_insensitive_matching() {
let temp_dir = tempfile::tempdir().unwrap();
let sandbox = setup_sandbox(&temp_dir).await;
let temp_path = temp_dir.path();
std::fs::write(temp_path.join("TEST.TXT"), "content").unwrap();
let result = search_files(&sandbox, temp_path.to_str().unwrap(), "*.txt", vec![])
.await
.unwrap();
assert_eq!(result.len(), 1);
}
#[tokio::test]
async fn test_search_files_outside_roots() {
let temp_dir = tempfile::tempdir().unwrap();
let out_temp_dir = tempfile::tempdir().unwrap();
let sandbox = setup_sandbox(&temp_dir).await;
let results = search_files(
&sandbox,
out_temp_dir.path().to_str().unwrap(),
"*.txt",
vec![],
)
.await;
assert_ne!(temp_dir.path(), out_temp_dir.path());
assert!(results.is_err());
}
#[cfg(unix)]
#[tokio::test]
async fn test_symlink_inside_root_pointing_outside_is_rejected() {
use std::os::unix::fs::symlink;
let root = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let sandbox = setup_sandbox(&root).await;
// outside/secret.txt
std::fs::write(outside.path().join("secret.txt"), "nope").unwrap();
// root/link -> outside
symlink(outside.path(), root.path().join("link")).unwrap();
let result = search_files(&sandbox, root.path().to_str().unwrap(), "*.txt", vec![]).await;
// MUST NOT leak outside files
assert!(result.is_err() || result.unwrap().is_empty());
}
#[tokio::test]
async fn test_path_glob_does_not_match() {
let root = tempfile::tempdir().unwrap();
let sandbox = setup_sandbox(&root).await;
std::fs::create_dir(root.path().join("sub")).unwrap();
std::fs::write(root.path().join("sub/a.txt"), "x").unwrap();
let result = search_files(&sandbox, root.path().to_str().unwrap(), "sub/*.txt", vec![])
.await
.unwrap();
assert!(result.is_empty());
}
#[tokio::test]
async fn test_dot_dot_path_is_rejected() {
let root = tempfile::tempdir().unwrap();
let sandbox = setup_sandbox(&root).await;
let path = format!("{}/..", root.path().display());
let result = search_files(&sandbox, &path, "*.txt", vec![]).await;
assert!(result.is_err());
}
#[cfg(unix)]
#[tokio::test]
async fn test_symlinked_file_inside_root_is_skipped() {
use std::os::unix::fs::symlink;
let root = tempfile::tempdir().unwrap();
let sandbox = setup_sandbox(&root).await;
let real = root.path().join("real.txt");
let link = root.path().join("link.txt");
std::fs::write(&real, "x").unwrap();
symlink(&real, &link).unwrap();
let result = search_files(&sandbox, root.path().to_str().unwrap(), "*.txt", vec![])
.await
.unwrap();
// decide and enforce policy
assert_eq!(result.len(), 1);
}
#[cfg(unix)]
#[tokio::test]
async fn test_permission_denied_is_non_fatal() {
use std::os::unix::fs::PermissionsExt;
let root = tempfile::tempdir().unwrap();
let sandbox = setup_sandbox(&root).await;
let private = root.path().join("private");
std::fs::create_dir(&private).unwrap();
std::fs::set_permissions(&private, std::fs::Permissions::from_mode(0o000)).unwrap();
let result = search_files(&sandbox, root.path().to_str().unwrap(), "*", vec![]).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_invalid_include_glob_errors() {
let root = tempfile::tempdir().unwrap();
let sandbox = setup_sandbox(&root).await;
let result = search_files(&sandbox, root.path().to_str().unwrap(), "[abc", vec![]).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_hidden_files_are_included() {
let temp_dir = tempfile::tempdir().unwrap();
let sandbox = setup_sandbox(&temp_dir).await;
let temp_path = temp_dir.path();
std::fs::write(temp_path.join(".test.txt"), "content").unwrap();
let result = search_files(&sandbox, temp_path.to_str().unwrap(), "*.txt", vec![])
.await
.unwrap();
assert_eq!(result.len(), 1);
}
#[tokio::test]
async fn test_list_directory_alfabetically() {
let temp_dir = tempfile::tempdir().unwrap();
let sandbox = setup_sandbox(&temp_dir).await;
let temp_path = temp_dir.path();
std::fs::write(temp_path.join("bfile.txt"), "content1").unwrap();
std::fs::write(temp_path.join("afile.txt"), "content2").unwrap();
std::fs::create_dir(temp_path.join("subdir")).unwrap();
let response = list_directory(&sandbox, temp_path.to_str().unwrap())
.await
.expect("list_directory should succeed");
assert_eq!(response, vec!["afile.txt", "bfile.txt", "subdir/"]);
}
#[cfg(unix)]
#[tokio::test]
async fn test_list_directory_skips_symlink() {
use std::os::unix::fs::symlink;
let root = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let sandbox = setup_sandbox(&root).await;
std::fs::write(outside.path().join("secret.txt"), "nope").unwrap();
std::fs::write(root.path().join("file.txt"), "content").unwrap();
symlink(outside.path(), root.path().join("link")).unwrap();
let response = list_directory(&sandbox, root.path().to_str().unwrap())
.await
.expect("list_directory should succeed");
assert_eq!(response, vec!["file.txt"]);
}
}