Skip to main content

construct/tools/
glob_search.rs

1use super::traits::{Tool, ToolResult};
2use crate::security::SecurityPolicy;
3use async_trait::async_trait;
4use serde_json::json;
5use std::sync::Arc;
6
7const MAX_RESULTS: usize = 1000;
8
9/// Search for files by glob pattern within the workspace.
10pub struct GlobSearchTool {
11    security: Arc<SecurityPolicy>,
12}
13
14impl GlobSearchTool {
15    pub fn new(security: Arc<SecurityPolicy>) -> Self {
16        Self { security }
17    }
18}
19
20#[async_trait]
21impl Tool for GlobSearchTool {
22    fn name(&self) -> &str {
23        "glob_search"
24    }
25
26    fn description(&self) -> &str {
27        "Search for files matching a glob pattern within the workspace. \
28         Returns a sorted list of matching file paths relative to the workspace root. \
29         Examples: '**/*.rs' (all Rust files), 'src/**/mod.rs' (all mod.rs in src)."
30    }
31
32    fn parameters_schema(&self) -> serde_json::Value {
33        json!({
34            "type": "object",
35            "properties": {
36                "pattern": {
37                    "type": "string",
38                    "description": "Glob pattern to match files, e.g. '**/*.rs', 'src/**/mod.rs'"
39                }
40            },
41            "required": ["pattern"]
42        })
43    }
44
45    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
46        let pattern = args
47            .get("pattern")
48            .and_then(|v| v.as_str())
49            .ok_or_else(|| anyhow::anyhow!("Missing 'pattern' parameter"))?;
50
51        // Rate limit check (fast path)
52        if self.security.is_rate_limited() {
53            return Ok(ToolResult {
54                success: false,
55                output: String::new(),
56                error: Some("Rate limit exceeded: too many actions in the last hour".into()),
57            });
58        }
59
60        // Security: reject absolute paths unless under an explicit allowed root.
61        if (pattern.starts_with('/') || pattern.starts_with('\\'))
62            && !self.security.is_under_allowed_root(pattern)
63        {
64            return Ok(ToolResult {
65                success: false,
66                output: String::new(),
67                error: Some("Absolute paths are not allowed. Use a relative glob pattern.".into()),
68            });
69        }
70
71        // Security: reject path traversal
72        if pattern.contains("../") || pattern.contains("..\\") || pattern == ".." {
73            return Ok(ToolResult {
74                success: false,
75                output: String::new(),
76                error: Some("Path traversal ('..') is not allowed in glob patterns.".into()),
77            });
78        }
79
80        // Record action to consume rate limit budget
81        if !self.security.record_action() {
82            return Ok(ToolResult {
83                success: false,
84                output: String::new(),
85                error: Some("Rate limit exceeded: action budget exhausted".into()),
86            });
87        }
88
89        // Build full pattern: use resolve_tool_path to handle tilde expansion
90        // and absolute paths correctly.
91        let full_pattern = self
92            .security
93            .resolve_tool_path(pattern)
94            .to_string_lossy()
95            .to_string();
96
97        let entries = match glob::glob(&full_pattern) {
98            Ok(paths) => paths,
99            Err(e) => {
100                return Ok(ToolResult {
101                    success: false,
102                    output: String::new(),
103                    error: Some(format!("Invalid glob pattern: {e}")),
104                });
105            }
106        };
107
108        let workspace = &self.security.workspace_dir;
109        let workspace_canon = match std::fs::canonicalize(workspace) {
110            Ok(p) => p,
111            Err(e) => {
112                return Ok(ToolResult {
113                    success: false,
114                    output: String::new(),
115                    error: Some(format!("Cannot resolve workspace directory: {e}")),
116                });
117            }
118        };
119
120        let mut results = Vec::new();
121        let mut truncated = false;
122
123        for entry in entries {
124            let path = match entry {
125                Ok(p) => p,
126                Err(_) => continue, // skip unreadable entries
127            };
128
129            // Canonicalize to resolve symlinks, then verify still inside workspace
130            let resolved = match std::fs::canonicalize(&path) {
131                Ok(p) => p,
132                Err(_) => continue, // skip broken symlinks / unresolvable paths
133            };
134
135            if !self.security.is_resolved_path_allowed(&resolved) {
136                continue; // silently filter symlink escapes
137            }
138
139            // Only include files, not directories
140            if resolved.is_dir() {
141                continue;
142            }
143
144            // Convert to workspace-relative path
145            if let Ok(rel) = resolved.strip_prefix(&workspace_canon) {
146                results.push(rel.to_string_lossy().to_string());
147            }
148
149            if results.len() >= MAX_RESULTS {
150                truncated = true;
151                break;
152            }
153        }
154
155        results.sort();
156
157        let output = if results.is_empty() {
158            format!("No files matching pattern '{pattern}' found in workspace.")
159        } else {
160            use std::fmt::Write;
161            let mut buf = results.join("\n");
162            if truncated {
163                let _ = write!(
164                    buf,
165                    "\n\n[Results truncated: showing first {MAX_RESULTS} of more matches]"
166                );
167            }
168            let _ = write!(buf, "\n\nTotal: {} files", results.len());
169            buf
170        };
171
172        Ok(ToolResult {
173            success: true,
174            output,
175            error: None,
176        })
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183    use crate::security::{AutonomyLevel, SecurityPolicy};
184    use std::path::PathBuf;
185    use tempfile::TempDir;
186
187    fn test_security(workspace: PathBuf) -> Arc<SecurityPolicy> {
188        Arc::new(SecurityPolicy {
189            autonomy: AutonomyLevel::Supervised,
190            workspace_dir: workspace,
191            ..SecurityPolicy::default()
192        })
193    }
194
195    fn test_security_with(
196        workspace: PathBuf,
197        autonomy: AutonomyLevel,
198        max_actions_per_hour: u32,
199    ) -> Arc<SecurityPolicy> {
200        Arc::new(SecurityPolicy {
201            autonomy,
202            workspace_dir: workspace,
203            max_actions_per_hour,
204            ..SecurityPolicy::default()
205        })
206    }
207
208    #[test]
209    fn glob_search_name_and_schema() {
210        let tool = GlobSearchTool::new(test_security(std::env::temp_dir()));
211        assert_eq!(tool.name(), "glob_search");
212
213        let schema = tool.parameters_schema();
214        assert!(schema["properties"]["pattern"].is_object());
215        assert!(
216            schema["required"]
217                .as_array()
218                .unwrap()
219                .contains(&json!("pattern"))
220        );
221    }
222
223    #[tokio::test]
224    async fn glob_search_single_file() {
225        let dir = TempDir::new().unwrap();
226        std::fs::write(dir.path().join("hello.txt"), "content").unwrap();
227
228        let tool = GlobSearchTool::new(test_security(dir.path().to_path_buf()));
229        let result = tool.execute(json!({"pattern": "hello.txt"})).await.unwrap();
230
231        assert!(result.success);
232        assert!(result.output.contains("hello.txt"));
233    }
234
235    #[tokio::test]
236    async fn glob_search_multiple_files() {
237        let dir = TempDir::new().unwrap();
238        std::fs::write(dir.path().join("a.txt"), "").unwrap();
239        std::fs::write(dir.path().join("b.txt"), "").unwrap();
240        std::fs::write(dir.path().join("c.rs"), "").unwrap();
241
242        let tool = GlobSearchTool::new(test_security(dir.path().to_path_buf()));
243        let result = tool.execute(json!({"pattern": "*.txt"})).await.unwrap();
244
245        assert!(result.success);
246        assert!(result.output.contains("a.txt"));
247        assert!(result.output.contains("b.txt"));
248        assert!(!result.output.contains("c.rs"));
249    }
250
251    #[tokio::test]
252    async fn glob_search_recursive() {
253        let dir = TempDir::new().unwrap();
254        std::fs::create_dir_all(dir.path().join("sub/deep")).unwrap();
255        std::fs::write(dir.path().join("root.txt"), "").unwrap();
256        std::fs::write(dir.path().join("sub/mid.txt"), "").unwrap();
257        std::fs::write(dir.path().join("sub/deep/leaf.txt"), "").unwrap();
258
259        let tool = GlobSearchTool::new(test_security(dir.path().to_path_buf()));
260        let result = tool.execute(json!({"pattern": "**/*.txt"})).await.unwrap();
261
262        assert!(result.success);
263        assert!(result.output.contains("root.txt"));
264        assert!(result.output.contains("mid.txt"));
265        assert!(result.output.contains("leaf.txt"));
266    }
267
268    #[tokio::test]
269    async fn glob_search_no_matches() {
270        let dir = TempDir::new().unwrap();
271
272        let tool = GlobSearchTool::new(test_security(dir.path().to_path_buf()));
273        let result = tool
274            .execute(json!({"pattern": "*.nonexistent"}))
275            .await
276            .unwrap();
277
278        assert!(result.success);
279        assert!(result.output.contains("No files matching pattern"));
280    }
281
282    #[tokio::test]
283    async fn glob_search_missing_param() {
284        let tool = GlobSearchTool::new(test_security(std::env::temp_dir()));
285        let result = tool.execute(json!({})).await;
286        assert!(result.is_err());
287    }
288
289    #[tokio::test]
290    async fn glob_search_rejects_absolute_path() {
291        let tool = GlobSearchTool::new(test_security(std::env::temp_dir()));
292        let result = tool.execute(json!({"pattern": "/etc/**/*"})).await.unwrap();
293
294        assert!(!result.success);
295        assert!(result.error.as_ref().unwrap().contains("Absolute paths"));
296    }
297
298    #[tokio::test]
299    async fn glob_search_rejects_path_traversal() {
300        let tool = GlobSearchTool::new(test_security(std::env::temp_dir()));
301        let result = tool
302            .execute(json!({"pattern": "../../../etc/passwd"}))
303            .await
304            .unwrap();
305
306        assert!(!result.success);
307        assert!(result.error.as_ref().unwrap().contains("Path traversal"));
308    }
309
310    #[tokio::test]
311    async fn glob_search_rejects_dotdot_only() {
312        let tool = GlobSearchTool::new(test_security(std::env::temp_dir()));
313        let result = tool.execute(json!({"pattern": ".."})).await.unwrap();
314
315        assert!(!result.success);
316        assert!(result.error.as_ref().unwrap().contains("Path traversal"));
317    }
318
319    #[cfg(unix)]
320    #[tokio::test]
321    async fn glob_search_filters_symlink_escape() {
322        use std::os::unix::fs::symlink;
323
324        let root = TempDir::new().unwrap();
325        let workspace = root.path().join("workspace");
326        let outside = root.path().join("outside");
327
328        std::fs::create_dir_all(&workspace).unwrap();
329        std::fs::create_dir_all(&outside).unwrap();
330        std::fs::write(outside.join("secret.txt"), "leaked").unwrap();
331
332        // Symlink inside workspace pointing outside
333        symlink(outside.join("secret.txt"), workspace.join("escape.txt")).unwrap();
334        // Also add a legitimate file
335        std::fs::write(workspace.join("legit.txt"), "ok").unwrap();
336
337        let tool = GlobSearchTool::new(test_security(workspace.clone()));
338        let result = tool.execute(json!({"pattern": "*.txt"})).await.unwrap();
339
340        assert!(result.success);
341        assert!(result.output.contains("legit.txt"));
342        assert!(!result.output.contains("escape.txt"));
343        assert!(!result.output.contains("secret.txt"));
344    }
345
346    #[tokio::test]
347    async fn glob_search_readonly_mode() {
348        let dir = TempDir::new().unwrap();
349        std::fs::write(dir.path().join("file.txt"), "").unwrap();
350
351        let tool = GlobSearchTool::new(test_security_with(
352            dir.path().to_path_buf(),
353            AutonomyLevel::ReadOnly,
354            20,
355        ));
356        let result = tool.execute(json!({"pattern": "*.txt"})).await.unwrap();
357
358        assert!(result.success);
359        assert!(result.output.contains("file.txt"));
360    }
361
362    #[tokio::test]
363    async fn glob_search_rate_limited() {
364        let dir = TempDir::new().unwrap();
365        std::fs::write(dir.path().join("file.txt"), "").unwrap();
366
367        let tool = GlobSearchTool::new(test_security_with(
368            dir.path().to_path_buf(),
369            AutonomyLevel::Supervised,
370            0,
371        ));
372        let result = tool.execute(json!({"pattern": "*.txt"})).await.unwrap();
373
374        assert!(!result.success);
375        assert!(result.error.as_ref().unwrap().contains("Rate limit"));
376    }
377
378    #[tokio::test]
379    async fn glob_search_results_sorted() {
380        let dir = TempDir::new().unwrap();
381        std::fs::write(dir.path().join("c.txt"), "").unwrap();
382        std::fs::write(dir.path().join("a.txt"), "").unwrap();
383        std::fs::write(dir.path().join("b.txt"), "").unwrap();
384
385        let tool = GlobSearchTool::new(test_security(dir.path().to_path_buf()));
386        let result = tool.execute(json!({"pattern": "*.txt"})).await.unwrap();
387
388        assert!(result.success);
389        let lines: Vec<&str> = result.output.lines().collect();
390        // First 3 lines should be the sorted file names
391        assert!(lines.len() >= 3);
392        assert_eq!(lines[0], "a.txt");
393        assert_eq!(lines[1], "b.txt");
394        assert_eq!(lines[2], "c.txt");
395    }
396
397    #[tokio::test]
398    async fn glob_search_excludes_directories() {
399        let dir = TempDir::new().unwrap();
400        std::fs::create_dir(dir.path().join("subdir")).unwrap();
401        std::fs::write(dir.path().join("file.txt"), "").unwrap();
402
403        let tool = GlobSearchTool::new(test_security(dir.path().to_path_buf()));
404        let result = tool.execute(json!({"pattern": "*"})).await.unwrap();
405
406        assert!(result.success);
407        assert!(result.output.contains("file.txt"));
408        assert!(!result.output.contains("subdir"));
409    }
410
411    #[tokio::test]
412    async fn glob_search_invalid_pattern() {
413        let dir = TempDir::new().unwrap();
414
415        let tool = GlobSearchTool::new(test_security(dir.path().to_path_buf()));
416        let result = tool.execute(json!({"pattern": "[invalid"})).await.unwrap();
417
418        assert!(!result.success);
419        assert!(
420            result
421                .error
422                .as_ref()
423                .unwrap()
424                .contains("Invalid glob pattern")
425        );
426    }
427}