Skip to main content

bamboo_agent/server/handlers/
workspace.rs

1use actix_web::{web, HttpResponse};
2use serde::{Deserialize, Serialize};
3use std::path::{Path, PathBuf};
4
5use crate::server::app_state::AppState;
6use crate::server::error::AppError;
7
8/// Validates and canonicalizes a workspace path to prevent directory traversal attacks.
9/// Ensures the path exists and is accessible.
10fn validate_workspace_path(input_path: &str) -> Result<PathBuf, AppError> {
11    let trimmed = input_path.trim();
12
13    if trimmed.is_empty() {
14        return Err(AppError::BadRequest("Path cannot be empty".to_string()));
15    }
16
17    // Basic path traversal check before canonicalization
18    if trimmed.contains("..") {
19        return Err(AppError::BadRequest(
20            "Path cannot contain '..' sequences".to_string(),
21        ));
22    }
23
24    let path = PathBuf::from(trimmed);
25
26    // Canonicalize to resolve symlinks and normalize path
27    let canonical = path.canonicalize().map_err(|e| {
28        if e.kind() == std::io::ErrorKind::NotFound {
29            AppError::NotFound(format!("Path does not exist: {}", trimmed))
30        } else {
31            AppError::BadRequest(format!("Invalid path: {}", e))
32        }
33    })?;
34
35    Ok(canonical)
36}
37
38#[derive(Deserialize)]
39pub struct WorkspacePathRequest {
40    path: String,
41}
42
43#[derive(Deserialize)]
44pub struct BrowseFolderRequest {
45    path: Option<String>,
46}
47
48#[derive(Deserialize)]
49pub struct WorkspaceFilesRequest {
50    path: String,
51    max_depth: Option<usize>,
52    max_entries: Option<usize>,
53    include_hidden: Option<bool>,
54}
55
56#[derive(Serialize)]
57struct BrowseFolderResponse {
58    current_path: String,
59    parent_path: Option<String>,
60    folders: Vec<FolderItem>,
61}
62
63#[derive(Serialize)]
64struct FolderItem {
65    name: String,
66    path: String,
67}
68
69#[derive(Serialize)]
70struct WorkspaceFileEntry {
71    name: String,
72    path: String,
73    is_directory: bool,
74}
75
76#[derive(Serialize, Deserialize, Clone)]
77struct WorkspaceMetadata {
78    workspace_name: Option<String>,
79    description: Option<String>,
80    tags: Option<Vec<String>>,
81}
82
83#[derive(Serialize, Deserialize, Clone)]
84struct RecentWorkspaceEntry {
85    path: String,
86    metadata: Option<WorkspaceMetadata>,
87    last_opened: u64,
88}
89
90#[derive(Serialize, Deserialize, Default)]
91struct RecentWorkspaceStore {
92    items: Vec<RecentWorkspaceEntry>,
93}
94
95#[derive(Serialize)]
96struct WorkspaceInfo {
97    path: String,
98    is_valid: bool,
99    error_message: Option<String>,
100    file_count: Option<u64>,
101    last_modified: Option<String>,
102    size_bytes: Option<u64>,
103    workspace_name: Option<String>,
104}
105
106#[derive(Serialize)]
107struct PathSuggestion {
108    path: String,
109    name: String,
110    description: Option<String>,
111    suggestion_type: String,
112}
113
114#[derive(Serialize)]
115struct PathSuggestionsResponse {
116    suggestions: Vec<PathSuggestion>,
117}
118
119#[derive(Deserialize)]
120pub struct AddRecentWorkspaceRequest {
121    path: String,
122    metadata: Option<WorkspaceMetadata>,
123}
124
125fn home_dir() -> Result<PathBuf, AppError> {
126    let home = std::env::var_os("HOME")
127        .or_else(|| std::env::var_os("USERPROFILE"))
128        .ok_or_else(|| AppError::InternalError(anyhow::anyhow!("HOME not set")))?;
129    Ok(PathBuf::from(home))
130}
131
132fn workspace_store_path(app_data_dir: &Path) -> PathBuf {
133    app_data_dir.join("workspaces").join("recent.json")
134}
135
136const DEFAULT_MAX_DEPTH: usize = 6;
137const DEFAULT_MAX_ENTRIES: usize = 2000;
138const MAX_ALLOWED_ENTRIES: usize = 10000;
139const IGNORED_DIRS: [&str; 10] = [
140    ".git",
141    "node_modules",
142    "target",
143    "dist",
144    "build",
145    ".next",
146    ".turbo",
147    ".cache",
148    ".idea",
149    ".vscode",
150];
151
152fn should_skip_entry(name: &str, is_dir: bool, include_hidden: bool) -> bool {
153    if !include_hidden && name.starts_with('.') {
154        return true;
155    }
156    if is_dir && IGNORED_DIRS.iter().any(|ignored| ignored == &name) {
157        return true;
158    }
159    false
160}
161
162fn to_display_name(root: &Path, path: &Path) -> String {
163    path.strip_prefix(root)
164        .unwrap_or(path)
165        .to_string_lossy()
166        .to_string()
167}
168
169async fn load_recent_store(app_data_dir: &Path) -> Result<RecentWorkspaceStore, AppError> {
170    let path = workspace_store_path(app_data_dir);
171    let content = match tokio::fs::read_to_string(&path).await {
172        Ok(c) => c,
173        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
174            return Ok(RecentWorkspaceStore::default())
175        }
176        Err(e) => return Err(AppError::StorageError(e)),
177    };
178    let store = serde_json::from_str::<RecentWorkspaceStore>(&content)
179        .map_err(AppError::SerializationError)?;
180    Ok(store)
181}
182
183async fn save_recent_store(
184    app_data_dir: &Path,
185    store: &RecentWorkspaceStore,
186) -> Result<(), AppError> {
187    let path = workspace_store_path(app_data_dir);
188    if let Some(parent) = path.parent() {
189        tokio::fs::create_dir_all(parent).await?;
190    }
191    let content = serde_json::to_string_pretty(store).map_err(AppError::SerializationError)?;
192    tokio::fs::write(&path, content).await?;
193    Ok(())
194}
195
196async fn build_workspace_info(path: &str) -> WorkspaceInfo {
197    let workspace_name = PathBuf::from(path)
198        .file_name()
199        .and_then(|s| s.to_str())
200        .map(|s| s.to_string());
201
202    let metadata = tokio::fs::metadata(path).await;
203    match metadata {
204        Ok(meta) => {
205            if !meta.is_dir() {
206                return WorkspaceInfo {
207                    path: path.to_string(),
208                    is_valid: false,
209                    error_message: Some("Not a directory".to_string()),
210                    file_count: None,
211                    last_modified: None,
212                    size_bytes: None,
213                    workspace_name,
214                };
215            }
216
217            let mut count: u64 = 0;
218            if let Ok(mut entries) = tokio::fs::read_dir(path).await {
219                while let Ok(Some(_)) = entries.next_entry().await {
220                    count += 1;
221                }
222            }
223
224            WorkspaceInfo {
225                path: path.to_string(),
226                is_valid: true,
227                error_message: None,
228                file_count: Some(count),
229                last_modified: None,
230                size_bytes: None,
231                workspace_name,
232            }
233        }
234        Err(err) => WorkspaceInfo {
235            path: path.to_string(),
236            is_valid: false,
237            error_message: Some(err.to_string()),
238            file_count: None,
239            last_modified: None,
240            size_bytes: None,
241            workspace_name,
242        },
243    }
244}
245
246pub async fn validate_workspace(
247    _app_state: web::Data<AppState>,
248    payload: web::Json<WorkspacePathRequest>,
249) -> Result<HttpResponse, AppError> {
250    // Validate path exists (but don't require canonicalization for validation endpoint)
251    let path = payload.path.trim();
252    if path.is_empty() {
253        return Err(AppError::BadRequest("Path cannot be empty".to_string()));
254    }
255
256    let info = build_workspace_info(path).await;
257    Ok(HttpResponse::Ok().json(info))
258}
259
260pub async fn get_recent_workspaces(
261    app_state: web::Data<AppState>,
262) -> Result<HttpResponse, AppError> {
263    let store = load_recent_store(&app_state.app_data_dir).await?;
264    let mut infos = Vec::new();
265    for item in store.items.iter() {
266        let mut info = build_workspace_info(&item.path).await;
267        if info.workspace_name.is_none() {
268            info.workspace_name = item
269                .metadata
270                .as_ref()
271                .and_then(|m| m.workspace_name.clone());
272        }
273        infos.push(info);
274    }
275    Ok(HttpResponse::Ok().json(infos))
276}
277
278pub async fn add_recent_workspace(
279    app_state: web::Data<AppState>,
280    payload: web::Json<AddRecentWorkspaceRequest>,
281) -> Result<HttpResponse, AppError> {
282    let mut store = load_recent_store(&app_state.app_data_dir).await?;
283    let now = std::time::SystemTime::now()
284        .duration_since(std::time::SystemTime::UNIX_EPOCH)
285        .unwrap_or_default()
286        .as_secs();
287
288    if let Some(existing) = store.items.iter_mut().find(|i| i.path == payload.path) {
289        existing.metadata = payload.metadata.clone();
290        existing.last_opened = now;
291    } else {
292        store.items.insert(
293            0,
294            RecentWorkspaceEntry {
295                path: payload.path.clone(),
296                metadata: payload.metadata.clone(),
297                last_opened: now,
298            },
299        );
300    }
301
302    store
303        .items
304        .sort_by(|a, b| b.last_opened.cmp(&a.last_opened));
305    store.items.truncate(50);
306
307    save_recent_store(&app_state.app_data_dir, &store).await?;
308
309    Ok(HttpResponse::NoContent().finish())
310}
311
312pub async fn get_workspace_suggestions(
313    app_state: web::Data<AppState>,
314) -> Result<HttpResponse, AppError> {
315    let mut suggestions: Vec<PathSuggestion> = Vec::new();
316
317    let home = home_dir()?;
318    let home_str = home.to_string_lossy().to_string();
319    suggestions.push(PathSuggestion {
320        path: home_str.clone(),
321        name: "Home".to_string(),
322        description: None,
323        suggestion_type: "home".to_string(),
324    });
325
326    let candidates = vec![
327        ("documents", "Documents"),
328        ("desktop", "Desktop"),
329        ("downloads", "Downloads"),
330    ];
331
332    for (suggestion_type, folder) in candidates {
333        let path = home.join(folder);
334        if tokio::fs::metadata(&path).await.is_ok() {
335            suggestions.push(PathSuggestion {
336                path: path.to_string_lossy().to_string(),
337                name: folder.to_string(),
338                description: None,
339                suggestion_type: suggestion_type.to_string(),
340            });
341        }
342    }
343
344    let store = load_recent_store(&app_state.app_data_dir).await?;
345    for item in store.items.iter() {
346        let name = item
347            .metadata
348            .as_ref()
349            .and_then(|m| m.workspace_name.clone())
350            .or_else(|| {
351                PathBuf::from(&item.path)
352                    .file_name()
353                    .and_then(|s| s.to_str())
354                    .map(|s| s.to_string())
355            })
356            .unwrap_or_else(|| item.path.clone());
357
358        suggestions.push(PathSuggestion {
359            path: item.path.clone(),
360            name,
361            description: None,
362            suggestion_type: "recent".to_string(),
363        });
364    }
365
366    let mut seen = std::collections::HashSet::new();
367    suggestions.retain(|item| seen.insert(item.path.clone()));
368
369    Ok(HttpResponse::Ok().json(PathSuggestionsResponse { suggestions }))
370}
371
372pub async fn browse_folder(
373    _app_state: web::Data<AppState>,
374    payload: web::Json<BrowseFolderRequest>,
375) -> Result<HttpResponse, AppError> {
376    let target_path = match payload.path.as_ref() {
377        Some(path) if !path.trim().is_empty() => {
378            // Validate and canonicalize user-provided path
379            validate_workspace_path(path)?
380        }
381        _ => home_dir()?,
382    };
383
384    let metadata = tokio::fs::metadata(&target_path).await?;
385    if !metadata.is_dir() {
386        return Err(AppError::NotFound("Folder".to_string()));
387    }
388
389    let mut entries = tokio::fs::read_dir(&target_path).await?;
390    let mut folders = Vec::new();
391    while let Some(entry) = entries.next_entry().await? {
392        let file_type = entry.file_type().await?;
393        if !file_type.is_dir() {
394            continue;
395        }
396        let path = entry.path();
397        let name = path
398            .file_name()
399            .and_then(|s| s.to_str())
400            .unwrap_or_default()
401            .to_string();
402        folders.push(FolderItem {
403            name,
404            path: path.to_string_lossy().to_string(),
405        });
406    }
407
408    folders.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
409
410    let parent_path = target_path
411        .parent()
412        .map(|p| p.to_string_lossy().to_string());
413
414    Ok(HttpResponse::Ok().json(BrowseFolderResponse {
415        current_path: target_path.to_string_lossy().to_string(),
416        parent_path,
417        folders,
418    }))
419}
420
421pub async fn list_workspace_files(
422    _app_state: web::Data<AppState>,
423    payload: web::Json<WorkspaceFilesRequest>,
424) -> Result<HttpResponse, AppError> {
425    // Validate and canonicalize the path
426    let root_path = validate_workspace_path(&payload.path)?;
427
428    let metadata = match tokio::fs::metadata(&root_path).await {
429        Ok(meta) => meta,
430        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
431            return Err(AppError::NotFound("Workspace".to_string()))
432        }
433        Err(err) => return Err(AppError::StorageError(err)),
434    };
435    if !metadata.is_dir() {
436        return Err(AppError::NotFound("Workspace".to_string()));
437    }
438
439    let max_depth = payload.max_depth.unwrap_or(DEFAULT_MAX_DEPTH);
440    let mut max_entries = payload.max_entries.unwrap_or(DEFAULT_MAX_ENTRIES);
441    if max_entries > MAX_ALLOWED_ENTRIES {
442        max_entries = MAX_ALLOWED_ENTRIES;
443    }
444    let include_hidden = payload.include_hidden.unwrap_or(false);
445
446    let mut files: Vec<WorkspaceFileEntry> = Vec::new();
447    let mut stack: Vec<(PathBuf, usize)> = vec![(root_path.clone(), 0)];
448
449    while let Some((current_path, depth)) = stack.pop() {
450        let mut entries = tokio::fs::read_dir(&current_path).await?;
451        while let Some(entry) = entries.next_entry().await? {
452            let file_type = entry.file_type().await?;
453            if file_type.is_symlink() {
454                continue;
455            }
456
457            let name = entry.file_name().to_string_lossy().to_string();
458            let is_dir = file_type.is_dir();
459            if should_skip_entry(&name, is_dir, include_hidden) {
460                continue;
461            }
462
463            let path = entry.path();
464            if is_dir {
465                if depth < max_depth {
466                    stack.push((path, depth + 1));
467                }
468                continue;
469            }
470
471            files.push(WorkspaceFileEntry {
472                name: to_display_name(&root_path, &path),
473                path: path.to_string_lossy().to_string(),
474                is_directory: false,
475            });
476
477            if files.len() >= max_entries {
478                return Ok(HttpResponse::Ok().json(files));
479            }
480        }
481    }
482
483    Ok(HttpResponse::Ok().json(files))
484}
485
486pub fn config(cfg: &mut web::ServiceConfig) {
487    cfg.route("/workspace/validate", web::post().to(validate_workspace))
488        .route("/workspace/recent", web::get().to(get_recent_workspaces))
489        .route("/workspace/recent", web::post().to(add_recent_workspace))
490        .route(
491            "/workspace/suggestions",
492            web::get().to(get_workspace_suggestions),
493        )
494        .route("/workspace/browse-folder", web::post().to(browse_folder))
495        .route("/workspace/files", web::post().to(list_workspace_files));
496}