collet 0.1.1

Relentless agentic coding orchestrator with zero-drop agent loops
Documentation
//! File browsing REST API routes.

use std::path::{Path, PathBuf};
use std::sync::Arc;

use axum::Router;
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::Json;
use axum::routing::get;
use serde::{Deserialize, Serialize};

use super::state::AppState;

/// Maximum file size allowed for reading (100 KB).
const MAX_FILE_SIZE: u64 = 100 * 1024;

pub fn router() -> Router<Arc<AppState>> {
    Router::new()
        .route("/api/files", get(list_directory))
        .route("/api/files/{*path}", get(read_file))
}

// ── List directory ──────────────────────────────────────────────────────

#[derive(Deserialize)]
struct ListQuery {
    path: Option<String>,
}

#[derive(Serialize)]
struct FileEntry {
    name: String,
    path: String,
    is_dir: bool,
    size: u64,
}

async fn list_directory(
    State(state): State<Arc<AppState>>,
    Query(query): Query<ListQuery>,
) -> Result<Json<Vec<FileEntry>>, (StatusCode, String)> {
    let base = state.working_dir().await;
    let target = match &query.path {
        Some(p) if !p.is_empty() => resolve_safe_path(&base, p)?,
        _ => PathBuf::from(&base),
    };

    let mut reader = tokio::fs::read_dir(&target)
        .await
        .map_err(|e| (StatusCode::NOT_FOUND, format!("Cannot read directory: {e}")))?;

    let mut entries = Vec::new();
    while let Some(entry) = reader.next_entry().await.map_err(|e| {
        (
            StatusCode::INTERNAL_SERVER_ERROR,
            format!("Read error: {e}"),
        )
    })? {
        let metadata = entry.metadata().await.map_err(|e| {
            (
                StatusCode::INTERNAL_SERVER_ERROR,
                format!("Metadata error: {e}"),
            )
        })?;

        let name = entry.file_name().to_string_lossy().to_string();

        // Skip hidden files/directories.
        if name.starts_with('.') {
            continue;
        }

        let absolute = entry.path();
        let relative = absolute
            .strip_prefix(&base)
            .unwrap_or(&absolute)
            .to_string_lossy()
            .to_string();

        entries.push(FileEntry {
            name,
            path: relative,
            is_dir: metadata.is_dir(),
            size: metadata.len(),
        });
    }

    entries.sort_by(|a, b| match (a.is_dir, b.is_dir) {
        (true, false) => std::cmp::Ordering::Less,
        (false, true) => std::cmp::Ordering::Greater,
        _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
    });

    Ok(Json(entries))
}

// ── Read file ───────────────────────────────────────────────────────────

#[derive(Serialize)]
struct FileContent {
    path: String,
    content: String,
    lines: usize,
}

async fn read_file(
    State(state): State<Arc<AppState>>,
    axum::extract::Path(path): axum::extract::Path<String>,
) -> Result<Json<FileContent>, (StatusCode, String)> {
    let base = state.working_dir().await;
    let target = resolve_safe_path(&base, &path)?;

    let metadata = tokio::fs::metadata(&target)
        .await
        .map_err(|e| (StatusCode::NOT_FOUND, format!("File not found: {e}")))?;

    if metadata.is_dir() {
        return Err((StatusCode::BAD_REQUEST, "Path is a directory".into()));
    }

    if metadata.len() > MAX_FILE_SIZE {
        return Err((
            StatusCode::BAD_REQUEST,
            format!(
                "File too large ({} bytes, max {})",
                metadata.len(),
                MAX_FILE_SIZE
            ),
        ));
    }

    let content = tokio::fs::read_to_string(&target).await.map_err(|e| {
        (
            StatusCode::INTERNAL_SERVER_ERROR,
            format!("Read error: {e}"),
        )
    })?;

    let lines = content.lines().count();
    let relative = target
        .strip_prefix(&base)
        .unwrap_or(&target)
        .to_string_lossy()
        .to_string();

    Ok(Json(FileContent {
        path: relative,
        content,
        lines,
    }))
}

// ── Path safety ─────────────────────────────────────────────────────────

/// Resolve a user-provided path relative to `base`, rejecting traversal attempts.
///
/// Canonicalizes only `base` (stable, not user-controlled) and normalizes
/// `user_path` lexically — no second `canonicalize` call on the joined path,
/// eliminating the TOCTOU window where a symlink could be swapped between the
/// canonicalize and the `starts_with` check.
fn resolve_safe_path(base: &str, user_path: &str) -> Result<PathBuf, (StatusCode, String)> {
    let canonical_base = std::fs::canonicalize(base).map_err(|e| {
        (
            StatusCode::INTERNAL_SERVER_ERROR,
            format!("Base path error: {e}"),
        )
    })?;

    // Lexically resolve ../  without I/O so there is no TOCTOU window.
    let normalized = normalize_path_lexical(&canonical_base.join(user_path));

    if !normalized.starts_with(&canonical_base) {
        return Err((StatusCode::FORBIDDEN, "Access denied".into()));
    }

    Ok(normalized)
}

/// Collapse `.` and `..` components without performing any filesystem I/O.
fn normalize_path_lexical(path: &Path) -> PathBuf {
    use std::path::Component;
    let mut out = PathBuf::new();
    for component in path.components() {
        match component {
            Component::ParentDir => {
                out.pop();
            }
            Component::CurDir => {}
            c => out.push(c),
        }
    }
    out
}