use anyhow::anyhow;
use axum::{
Json, Router,
body::Body,
extract::{Multipart, Query, State},
http::{StatusCode, header},
response::{IntoResponse, Response},
routing::{get, post},
};
use serde::Deserialize;
use serde_json::json;
use tracing::error;
use crate::AppState;
use crate::fs;
use crate::tmux;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/files", get(list_files).post(upload_file).delete(delete_file))
.route("/files/download", get(download_file))
.route("/files/read", get(read_file))
.route("/files/write", post(write_file))
.route("/files/mkdir", post(mkdir))
.route("/files/rename", post(rename))
.route("/files/move", post(move_files))
.route("/files/copy", post(copy_files))
.route("/files/search", get(search_files))
}
#[derive(Deserialize)]
struct FileQuery {
path: Option<String>,
workspace: Option<String>, session: Option<String>,
workspace_id: Option<String>, sort: Option<String>,
order: Option<String>,
allow_escape: Option<bool>,
}
#[derive(Deserialize)]
struct SearchQuery {
q: String,
path: Option<String>,
workspace: Option<String>,
session: Option<String>,
workspace_id: Option<String>,
}
#[derive(Deserialize)]
struct RenameRequest {
path: String,
#[serde(rename = "newName")]
new_name: String,
workspace: Option<String>,
session: Option<String>,
workspace_id: Option<String>,
}
#[derive(Deserialize)]
struct MoveRequest {
paths: Vec<String>,
destination: String,
workspace: Option<String>,
session: Option<String>,
workspace_id: Option<String>,
}
#[derive(Deserialize)]
struct CopyRequest {
paths: Vec<String>,
destination: String,
workspace: Option<String>,
session: Option<String>,
workspace_id: Option<String>,
}
#[derive(Deserialize)]
struct WriteRequest {
content: String,
}
pub async fn resolve_project_root(state: &AppState, project_id: &str) -> Option<String> {
sqlx::query_as::<_, (String,)>("SELECT path FROM projects WHERE id = ?")
.bind(project_id)
.fetch_optional(&state.db)
.await
.ok()
.flatten()
.map(|(p,)| p)
}
fn parse_sort(sort: Option<&str>, order: Option<&str>) -> (fs::SortKey, bool) {
let key = match sort {
Some("mtime") => fs::SortKey::Mtime,
Some("size") => fs::SortKey::Size,
_ => fs::SortKey::Name,
};
let desc = order == Some("desc");
(key, desc)
}
pub async fn resolve_session_base(state: &AppState, session_id: &str) -> Option<(String, String)> {
let row: (String, Option<String>, String) = sqlx::query_as(
"SELECT runtime_kind, tmux_session_name, workspace_path FROM sessions WHERE id = ?",
)
.bind(session_id)
.fetch_optional(&state.db)
.await
.ok()
.flatten()?;
let (runtime_kind, tmux_name_opt, workspace_path) = row;
if runtime_kind == "acp" || tmux_name_opt.is_none() {
tracing::debug!(
"session {} is non-tmux (runtime_kind={}), using workspace_path={} as FileManager cwd",
session_id,
runtime_kind,
workspace_path
);
return Some((workspace_path, String::new()));
}
let tmux_name = tmux_name_opt.expect("checked above");
match tmux::pane_cwd(&tmux_name).await {
Ok(cwd) => Some((cwd, tmux_name)),
Err(e) => {
tracing::warn!(
"tmux session '{}' unavailable ({}), attempting re-create",
tmux_name,
e
);
let (root, _project_id) =
resolve_session_workspace_root(state, session_id).await.unwrap_or_else(|| {
(std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()), String::new())
});
tmux::new_session(&tmux_name, &root, None).await.ok()?;
let cwd = tmux::pane_cwd(&tmux_name).await.ok()?;
tracing::info!("re-created tmux session '{}' at {}", tmux_name, cwd);
Some((cwd, tmux_name))
}
}
}
async fn resolve_session_workspace_root(
state: &AppState,
session_id: &str,
) -> Option<(String, String)> {
sqlx::query_as::<_, (String, String)>(
"SELECT workspace_path, project_id FROM sessions WHERE id = ?",
)
.bind(session_id)
.fetch_optional(&state.db)
.await
.ok()
.flatten()
}
async fn resolve_effective_workspace_root(
state: &AppState,
project_id: &str,
list_base_canonical: &std::path::Path,
) -> Option<String> {
if !project_id.is_empty()
&& let Some(p) = resolve_effective_in_project(state, project_id, list_base_canonical).await
{
return Some(p);
}
resolve_git_toplevel(list_base_canonical)
}
async fn resolve_effective_in_project(
state: &AppState,
project_id: &str,
list_base_canonical: &std::path::Path,
) -> Option<String> {
use crate::workspaces;
let project_root = resolve_project_root(state, project_id).await?;
let project = crate::models::project::Project {
id: project_id.to_string(),
name: String::new(),
path: project_root,
target_id: None,
created_at: String::new(),
};
let wts = workspaces::list_workspaces(&project).await;
wts.into_iter()
.filter_map(|w| {
let wp = std::path::Path::new(&w.path);
let wp_canon = wp.canonicalize().ok()?;
if list_base_canonical.starts_with(&wp_canon) { Some((wp_canon, w.path)) } else { None }
})
.max_by_key(|(p, _)| p.as_os_str().len())
.map(|(_, p)| p)
}
fn resolve_git_toplevel(path: &std::path::Path) -> Option<String> {
use std::process::{Command, Stdio};
let output = Command::new("git")
.args(["-C", path.to_str()?, "rev-parse", "--show-toplevel"])
.stdout(Stdio::piped())
.stderr(Stdio::null())
.output()
.ok()?;
if !output.status.success() {
return None;
}
let s = String::from_utf8(output.stdout).ok()?;
let trimmed = s.trim();
if trimmed.is_empty() { None } else { Some(trimmed.to_string()) }
}
pub async fn resolve_base_from_query(
state: &AppState,
session: Option<&str>,
workspace_id: Option<&str>,
project: Option<&str>,
) -> Option<(std::path::PathBuf, bool)> {
if let Some(sid) = session {
let (cwd, _) = resolve_session_base(state, sid).await?;
Some((std::path::PathBuf::from(cwd), true))
} else if let Some(wid) = workspace_id {
let pid = project.unwrap_or("default");
let root = resolve_workspace_root(state, wid, pid).await?;
Some((std::path::PathBuf::from(root), false))
} else {
let pid = project.unwrap_or("default");
let root = resolve_project_root(state, pid).await?;
Some((std::path::PathBuf::from(root), false))
}
}
async fn resolve_workspace_root(
state: &AppState,
workspace_id: &str,
project_id: &str,
) -> Option<String> {
use crate::workspaces;
let project_root = resolve_project_root(state, project_id).await?;
let project = crate::models::project::Project {
id: project_id.to_string(),
name: String::new(),
path: project_root,
target_id: None,
created_at: String::new(),
};
let wts = workspaces::list_workspaces(&project).await;
wts.into_iter().find(|w| w.id == workspace_id).map(|w| w.path)
}
async fn list_files(
State(state): State<AppState>,
Query(q): Query<FileQuery>,
) -> impl IntoResponse {
let (sort, desc) = parse_sort(q.sort.as_deref(), q.order.as_deref());
if let Some(session_id) = q.session.as_deref() {
let Some((cwd, _tmux_name)) = resolve_session_base(&state, session_id).await else {
return (
StatusCode::NOT_FOUND,
Json(json!({ "error": "session not found or tmux unavailable" })),
);
};
let (ws_root, project_id) =
resolve_session_workspace_root(&state, session_id).await.unwrap_or_default();
let rel_path = q.path.as_deref().unwrap_or("");
let base = std::path::Path::new(&cwd);
if !base.exists() {
return (
StatusCode::OK,
Json(
json!({ "files": [], "cwd": fs::display_path_str(&cwd), "is_outside_workspace": true, "workspace_root": ws_root }),
),
);
}
let list_base = if rel_path.is_empty() || rel_path == "." {
base.to_path_buf()
} else if std::path::Path::new(rel_path).is_absolute() {
std::path::Path::new(rel_path).to_path_buf()
} else {
base.join(rel_path)
};
let Ok(canonical) = list_base.canonicalize() else {
return (StatusCode::NOT_FOUND, Json(json!({ "error": "path not found" })));
};
let (is_outside, effective_root) = if !ws_root.is_empty() {
let raw_outside =
match (canonical.canonicalize(), std::path::Path::new(&ws_root).canonicalize()) {
(Ok(c), Ok(r)) => !c.starts_with(&r),
_ => !canonical.starts_with(&ws_root),
};
if raw_outside && !project_id.is_empty() {
if let Some(eff) =
resolve_effective_workspace_root(&state, &project_id, &canonical).await
{
(false, eff)
} else {
(true, ws_root)
}
} else {
(raw_outside, ws_root)
}
} else {
(false, ws_root)
};
match fs::list_dir(&canonical, "", sort, desc).await {
Ok(entries) => (
StatusCode::OK,
Json(
json!({ "files": entries, "cwd": fs::display_path(&canonical), "is_outside_workspace": is_outside, "workspace_root": effective_root }),
),
),
Err(e) => {
error!("list_files (session) failed: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": e.to_string() })))
}
}
} else if let Some(workspace_id) = q.workspace_id.as_deref() {
let project_id = q.workspace.as_deref().unwrap_or("default");
let Some(root) = resolve_workspace_root(&state, workspace_id, project_id).await else {
return (StatusCode::BAD_REQUEST, Json(json!({ "error": "workspace not found" })));
};
let base = std::path::Path::new(&root);
if !base.exists() {
return (
StatusCode::OK,
Json(
json!({ "files": [], "cwd": fs::display_path_str(&root), "is_outside_workspace": false, "workspace_root": root }),
),
);
}
let rel_path = q.path.as_deref().unwrap_or("");
let list_base = if rel_path.is_empty() || rel_path == "." {
base.to_path_buf()
} else if std::path::Path::new(rel_path).is_absolute() {
std::path::Path::new(rel_path).to_path_buf()
} else {
base.join(rel_path)
};
let Ok(canonical) = list_base.canonicalize() else {
return (StatusCode::NOT_FOUND, Json(json!({ "error": "path not found" })));
};
let is_outside = match base.canonicalize() {
Ok(canonical_root) => !canonical.starts_with(&canonical_root),
Err(_) => false,
};
match fs::list_dir(&canonical, "", sort, desc).await {
Ok(entries) => (
StatusCode::OK,
Json(
json!({ "files": entries, "cwd": fs::display_path(&canonical), "is_outside_workspace": is_outside, "workspace_root": root }),
),
),
Err(e) => {
error!("list_files (workspace) failed: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": e.to_string() })))
}
}
} else if let Some(project_id) = q.workspace.as_deref() {
let rel_path = q.path.as_deref().unwrap_or("");
let Some(root) = resolve_project_root(&state, project_id).await else {
return (StatusCode::BAD_REQUEST, Json(json!({ "error": "project not found" })));
};
let base = std::path::Path::new(&root);
if !base.exists() {
return (StatusCode::OK, Json(json!([])));
}
match fs::list_dir(base, rel_path, sort, desc).await {
Ok(entries) => (StatusCode::OK, Json(json!(entries))),
Err(e) => {
error!("list_files failed: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": e.to_string() })))
}
}
} else {
(
StatusCode::BAD_REQUEST,
Json(json!({ "error": "session, workspace_id, or workspace parameter required" })),
)
}
}
async fn upload_file(
State(state): State<AppState>,
Query(q): Query<FileQuery>,
mut multipart: Multipart,
) -> impl IntoResponse {
let rel_path = q.path.as_deref().unwrap_or("");
let allow_escape = q.allow_escape.unwrap_or(false);
let Some((base, _)) = resolve_base_from_query(
&state,
q.session.as_deref(),
q.workspace_id.as_deref(),
q.workspace.as_deref(),
)
.await
else {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "workspace or session not found" })),
);
};
let mut uploaded = Vec::new();
while let Some(field) = multipart.next_field().await.unwrap_or(None) {
let file_name = field.file_name().unwrap_or("upload").to_string();
let data = match field.bytes().await {
Ok(d) => d,
Err(e) => {
error!("failed to read upload data: {}", e);
return (StatusCode::BAD_REQUEST, Json(json!({ "error": "read failed" })));
}
};
let target_path = if rel_path.is_empty() || rel_path == "." {
file_name.clone()
} else {
format!("{}/{}", rel_path.trim_end_matches('/'), file_name)
};
if let Err(e) = if allow_escape {
fs::write_file_allow_escape(&base, &target_path, &data).await
} else {
fs::write_file(&base, &target_path, &data).await
} {
error!("upload write failed: {}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": e.to_string() })));
}
uploaded.push(json!({
"name": file_name,
"path": target_path,
"size": data.len(),
}));
}
(StatusCode::OK, Json(json!(uploaded)))
}
async fn delete_file(
State(state): State<AppState>,
Query(q): Query<FileQuery>,
) -> impl IntoResponse {
let Some(path_str) = q.path.as_deref() else {
return (StatusCode::BAD_REQUEST, Json(json!({ "error": "path required" })));
};
let Some((base, _)) = resolve_base_from_query(
&state,
q.session.as_deref(),
q.workspace_id.as_deref(),
q.workspace.as_deref(),
)
.await
else {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "workspace or session not found" })),
);
};
let allow_escape = q.allow_escape.unwrap_or(false);
let result = if allow_escape {
fs::delete_path_allow_escape(&base, path_str).await
} else {
fs::delete_path(&base, path_str).await
};
match result {
Ok(()) => (StatusCode::OK, Json(json!({ "ok": true }))),
Err(e) => {
error!("delete failed: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": e.to_string() })))
}
}
}
async fn download_file(State(state): State<AppState>, Query(q): Query<FileQuery>) -> Response {
let Some(path_str) = q.path.as_deref() else {
return (StatusCode::BAD_REQUEST, Json(json!({ "error": "path required" })))
.into_response();
};
let Some((base, _)) = resolve_base_from_query(
&state,
q.session.as_deref(),
q.workspace_id.as_deref(),
q.workspace.as_deref(),
)
.await
else {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "workspace or session not found" })),
)
.into_response();
};
let full_path = if std::path::Path::new(path_str).is_absolute() {
std::path::PathBuf::from(path_str)
} else {
match fs::sanitize_path(&base, path_str) {
Ok(p) => p,
Err(_) => base.join(path_str),
}
};
let is_dir = tokio::fs::metadata(&full_path).await.map(|m| m.is_dir()).unwrap_or(false);
if is_dir {
let dir_name = full_path.file_name().unwrap_or_default().to_string_lossy().into_owned();
let packed = match tokio::task::spawn_blocking(move || zip_directory(&full_path)).await {
Ok(Ok(bytes)) => bytes,
Ok(Err(e)) => {
error!("zip directory failed: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": e.to_string() })),
)
.into_response();
}
Err(e) => {
error!("zip task panicked: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "zip packing failed" })),
)
.into_response();
}
};
return Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/zip")
.header(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{}.zip\"", dir_name),
)
.body(Body::from(packed))
.unwrap();
}
let Ok(content) = tokio::fs::read(&full_path).await else {
return (StatusCode::NOT_FOUND, Json(json!({ "error": "file not found" }))).into_response();
};
let file_name = full_path.file_name().unwrap_or_default().to_string_lossy();
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/octet-stream")
.header(header::CONTENT_DISPOSITION, format!("attachment; filename=\"{}\"", file_name))
.body(Body::from(content))
.unwrap()
}
fn zip_directory(dir: &std::path::Path) -> anyhow::Result<Vec<u8>> {
use std::io::{Read, Write};
use zip::write::SimpleFileOptions;
let mut buf: Vec<u8> = Vec::new();
{
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
let options = SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated)
.unix_permissions(0o644);
let mut stack: Vec<std::path::PathBuf> = vec![dir.to_path_buf()];
while let Some(current) = stack.pop() {
let mut entries =
std::fs::read_dir(¤t).map_err(|e| anyhow!("read dir failed: {}", e))?;
while let Some(entry) = entries.next().transpose()? {
let path = entry.path();
let rel = path
.strip_prefix(dir.parent().unwrap_or(dir))
.unwrap_or(&path)
.to_string_lossy()
.replace('\\', "/");
let meta = std::fs::symlink_metadata(&path)?;
if meta.is_dir() {
zw.add_directory(format!("{}/", rel), options)?;
stack.push(path);
} else if meta.is_file() {
zw.start_file(rel, options)?;
let mut f = std::fs::File::open(&path)?;
let mut chunk = Vec::new();
f.read_to_end(&mut chunk)?;
zw.write_all(&chunk)?;
}
}
}
zw.finish()?;
}
Ok(buf)
}
async fn read_file(State(state): State<AppState>, Query(q): Query<FileQuery>) -> impl IntoResponse {
let Some(path_str) = q.path.as_deref() else {
return (StatusCode::BAD_REQUEST, Json(json!({ "error": "path required" })));
};
let Some((base, _)) = resolve_base_from_query(
&state,
q.session.as_deref(),
q.workspace_id.as_deref(),
q.workspace.as_deref(),
)
.await
else {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "workspace or session not found" })),
);
};
let content = if std::path::Path::new(path_str).is_absolute() {
tokio::fs::read_to_string(path_str).await.map_err(|e| anyhow!(e))
} else {
fs::read_file(&base, path_str).await
};
match content {
Ok(content) => (StatusCode::OK, Json(json!({ "content": content }))),
Err(e) => {
error!("read_file failed: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": e.to_string() })))
}
}
}
async fn write_file(
State(state): State<AppState>,
Query(q): Query<FileQuery>,
Json(req): Json<WriteRequest>,
) -> impl IntoResponse {
let Some(path_str) = q.path.as_deref() else {
return (StatusCode::BAD_REQUEST, Json(json!({ "error": "path required" })));
};
let Some((base, _)) = resolve_base_from_query(
&state,
q.session.as_deref(),
q.workspace_id.as_deref(),
q.workspace.as_deref(),
)
.await
else {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "workspace or session not found" })),
);
};
let allow_escape = q.allow_escape.unwrap_or(false);
let result: Result<(), anyhow::Error> = if std::path::Path::new(path_str).is_absolute() {
if let Some(parent) = std::path::Path::new(path_str).parent() {
let _ = tokio::fs::create_dir_all(parent).await;
}
tokio::fs::write(path_str, req.content.as_bytes()).await.map_err(|e| anyhow!(e))
} else if allow_escape {
fs::write_file_allow_escape(&base, path_str, req.content.as_bytes()).await
} else {
fs::write_file(&base, path_str, req.content.as_bytes()).await
};
match result {
Ok(()) => (StatusCode::OK, Json(json!({ "ok": true }))),
Err(e) => {
error!("write_file failed: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": e.to_string() })))
}
}
}
async fn mkdir(
State(state): State<AppState>,
Query(q): Query<FileQuery>,
Json(req): Json<serde_json::Value>,
) -> impl IntoResponse {
let session_id = req.get("session").and_then(|v| v.as_str());
let workspace_id = req.get("workspace_id").and_then(|v| v.as_str());
let project_id = req.get("workspace").and_then(|v| v.as_str());
let path = req.get("path").and_then(|v| v.as_str()).unwrap_or("");
let name = req.get("name").and_then(|v| v.as_str()).unwrap_or("");
let allow_escape = q.allow_escape.unwrap_or(false);
let Some((base, _)) =
resolve_base_from_query(&state, session_id, workspace_id, project_id).await
else {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "workspace or session not found" })),
);
};
let dir_path = if path.is_empty() || path == "." {
name.to_string()
} else {
format!("{}/{}", path.trim_end_matches('/'), name)
};
let result = if allow_escape {
fs::create_dir_allow_escape(&base, &dir_path).await
} else {
fs::create_dir(&base, &dir_path).await
};
match result {
Ok(()) => (StatusCode::OK, Json(json!({ "ok": true }))),
Err(e) => {
error!("mkdir failed: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": e.to_string() })))
}
}
}
async fn rename(
State(state): State<AppState>,
Query(q): Query<FileQuery>,
Json(req): Json<RenameRequest>,
) -> impl IntoResponse {
let allow_escape = q.allow_escape.unwrap_or(false);
let Some((base, _)) = resolve_base_from_query(
&state,
req.session.as_deref(),
req.workspace_id.as_deref(),
req.workspace.as_deref(),
)
.await
else {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "workspace or session not found" })),
);
};
let old_path = std::path::Path::new(&req.path);
let new_rel = match old_path.parent() {
Some(parent) if !parent.as_os_str().is_empty() => {
format!("{}/{}", parent.to_string_lossy().trim_end_matches('/'), req.new_name)
}
_ => req.new_name.clone(),
};
let result = if allow_escape {
fs::move_path_allow_escape(&base, &req.path, &new_rel).await
} else {
fs::move_path(&base, &req.path, &new_rel).await
};
match result {
Ok(()) => (StatusCode::OK, Json(json!({ "ok": true }))),
Err(e) => {
error!("rename failed: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": e.to_string() })))
}
}
}
async fn move_files(
State(state): State<AppState>,
Query(q): Query<FileQuery>,
Json(req): Json<MoveRequest>,
) -> impl IntoResponse {
let allow_escape = q.allow_escape.unwrap_or(false);
let Some((base, _)) = resolve_base_from_query(
&state,
req.session.as_deref(),
req.workspace_id.as_deref(),
req.workspace.as_deref(),
)
.await
else {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "workspace or session not found" })),
);
};
for p in &req.paths {
let file_name = std::path::Path::new(p)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let dest = format!("{}/{}", req.destination.trim_end_matches('/'), file_name);
let result = if allow_escape {
fs::move_path_allow_escape(&base, p, &dest).await
} else {
fs::move_path(&base, p, &dest).await
};
if let Err(e) = result {
error!("move failed: {}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": e.to_string() })));
}
}
(StatusCode::OK, Json(json!({ "ok": true })))
}
async fn copy_files(
State(state): State<AppState>,
Query(q): Query<FileQuery>,
Json(req): Json<CopyRequest>,
) -> impl IntoResponse {
let allow_escape = q.allow_escape.unwrap_or(false);
let Some((base, _)) = resolve_base_from_query(
&state,
req.session.as_deref(),
req.workspace_id.as_deref(),
req.workspace.as_deref(),
)
.await
else {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "workspace or session not found" })),
);
};
let result = if allow_escape {
fs::copy_paths_allow_escape(&base, &req.paths, &req.destination).await
} else {
fs::copy_paths(&base, &req.paths, &req.destination).await
};
match result {
Ok(()) => (StatusCode::OK, Json(json!({ "ok": true }))),
Err(e) => {
error!("copy failed: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": e.to_string() })))
}
}
}
async fn search_files(
State(state): State<AppState>,
Query(q): Query<SearchQuery>,
) -> impl IntoResponse {
let rel_path = q.path.as_deref().unwrap_or("");
let Some((base, _)) = resolve_base_from_query(
&state,
q.session.as_deref(),
q.workspace_id.as_deref(),
q.workspace.as_deref(),
)
.await
else {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "workspace or session not found" })),
);
};
match fs::search_files(&base, rel_path, &q.q).await {
Ok(entries) => (StatusCode::OK, Json(json!(entries))),
Err(e) => {
error!("search failed: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": e.to_string() })))
}
}
}
#[cfg(test)]
mod handler_tests {
use super::*;
use crate::test_utils::test_state;
use axum::body::to_bytes;
use std::path::PathBuf;
async fn fixture_session(state: &AppState) -> (PathBuf, PathBuf) {
let root = std::env::temp_dir().join(format!(
"omniterm_files_handler_{}_{}",
std::process::id(),
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
));
let base = root.join("base");
let outside = root.join("outside");
std::fs::create_dir_all(&base).unwrap();
std::fs::create_dir_all(&outside).unwrap();
sqlx::query(
"INSERT INTO projects (id, target_id, name, path, created_at) \
VALUES ('test-project', NULL, 'test', ?, '2026-01-01')",
)
.bind(base.to_str().unwrap())
.execute(&state.db)
.await
.unwrap();
sqlx::query(
"INSERT INTO sessions (id, project_id, workspace_path, name, tmux_session_name, \
hook_enabled, hook_status, created_at, runtime_kind) \
VALUES ('test-session', 'test-project', ?, 'test', NULL, 0, 'idle', '2026-01-01', 'acp')",
)
.bind(base.to_str().unwrap())
.execute(&state.db)
.await
.unwrap();
(base, outside)
}
fn session_query(path: &str, allow_escape: Option<bool>) -> FileQuery {
FileQuery {
path: Some(path.to_string()),
workspace: None,
session: Some("test-session".to_string()),
workspace_id: None,
sort: None,
order: None,
allow_escape,
}
}
fn allow_only(allow_escape: Option<bool>) -> FileQuery {
FileQuery {
path: None,
workspace: None,
session: None,
workspace_id: None,
sort: None,
order: None,
allow_escape,
}
}
async fn body_json(res: Response) -> serde_json::Value {
let bytes = to_bytes(res.into_body(), 1024 * 1024).await.unwrap();
serde_json::from_slice(&bytes).unwrap()
}
#[tokio::test]
async fn delete_file_escape_rejected_by_default_allowed_with_flag() {
let state = test_state().await;
let (_base, outside) = fixture_session(&state).await;
let target = outside.join("victim.txt");
std::fs::write(&target, b"x").unwrap();
let res =
delete_file(State(state.clone()), Query(session_query("../outside/victim.txt", None)))
.await
.into_response();
assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert!(target.exists());
let res =
delete_file(State(state), Query(session_query("../outside/victim.txt", Some(true))))
.await
.into_response();
assert_eq!(res.status(), StatusCode::OK);
assert!(!target.exists());
}
#[tokio::test]
async fn write_file_escape_rejected_by_default_allowed_with_flag() {
let state = test_state().await;
let (_base, outside) = fixture_session(&state).await;
let res = write_file(
State(state.clone()),
Query(session_query("../outside/w.txt", None)),
Json(WriteRequest { content: "hi".into() }),
)
.await
.into_response();
assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert!(!outside.join("w.txt").exists());
let res = write_file(
State(state),
Query(session_query("../outside/w.txt", Some(true))),
Json(WriteRequest { content: "hello".into() }),
)
.await
.into_response();
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(std::fs::read_to_string(outside.join("w.txt")).unwrap(), "hello");
}
#[tokio::test]
async fn mkdir_escape_rejected_by_default_allowed_with_flag() {
let state = test_state().await;
let (_base, outside) = fixture_session(&state).await;
let body = || json!({ "session": "test-session", "path": "../outside", "name": "newdir" });
let res = mkdir(State(state.clone()), Query(allow_only(None)), Json(body()))
.await
.into_response();
assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert!(!outside.join("newdir").exists());
let res =
mkdir(State(state), Query(allow_only(Some(true))), Json(body())).await.into_response();
assert_eq!(res.status(), StatusCode::OK);
assert!(outside.join("newdir").is_dir());
}
#[tokio::test]
async fn rename_escape_rejected_by_default_allowed_with_flag() {
let state = test_state().await;
let (_base, outside) = fixture_session(&state).await;
std::fs::write(outside.join("a.txt"), b"x").unwrap();
let body = || RenameRequest {
path: "../outside/a.txt".into(),
new_name: "renamed.txt".into(),
session: Some("test-session".into()),
workspace: None,
workspace_id: None,
};
let res = rename(State(state.clone()), Query(allow_only(None)), Json(body()))
.await
.into_response();
assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert!(outside.join("a.txt").exists());
let res =
rename(State(state), Query(allow_only(Some(true))), Json(body())).await.into_response();
assert_eq!(res.status(), StatusCode::OK);
assert!(outside.join("renamed.txt").exists());
assert!(!outside.join("a.txt").exists());
}
#[tokio::test]
async fn move_files_escape_rejected_by_default_allowed_with_flag() {
let state = test_state().await;
let (_base, outside) = fixture_session(&state).await;
std::fs::write(outside.join("m.txt"), b"x").unwrap();
let body = || MoveRequest {
paths: vec!["../outside/m.txt".into()],
destination: "../outside/moved".into(),
session: Some("test-session".into()),
workspace: None,
workspace_id: None,
};
let res = move_files(State(state.clone()), Query(allow_only(None)), Json(body()))
.await
.into_response();
assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert!(outside.join("m.txt").exists());
let res = move_files(State(state), Query(allow_only(Some(true))), Json(body()))
.await
.into_response();
assert_eq!(res.status(), StatusCode::OK);
assert!(outside.join("moved").join("m.txt").exists());
}
#[tokio::test]
async fn copy_files_escape_rejected_by_default_allowed_with_flag() {
let state = test_state().await;
let (_base, outside) = fixture_session(&state).await;
std::fs::write(outside.join("c.txt"), b"x").unwrap();
let body = || CopyRequest {
paths: vec!["../outside/c.txt".into()],
destination: "../outside/copied".into(),
session: Some("test-session".into()),
workspace: None,
workspace_id: None,
};
let res = copy_files(State(state.clone()), Query(allow_only(None)), Json(body()))
.await
.into_response();
assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert!(!outside.join("copied").join("c.txt").exists());
let res = copy_files(State(state), Query(allow_only(Some(true))), Json(body()))
.await
.into_response();
assert_eq!(res.status(), StatusCode::OK);
assert!(outside.join("copied").join("c.txt").exists());
}
#[tokio::test]
async fn download_out_of_base_returns_content_not_forbidden() {
let state = test_state().await;
let (_base, outside) = fixture_session(&state).await;
std::fs::write(outside.join("dl.txt"), b"download-me").unwrap();
let res =
download_file(State(state), Query(session_query("../outside/dl.txt", None))).await;
assert_eq!(res.status(), StatusCode::OK);
let bytes = to_bytes(res.into_body(), 1024).await.unwrap();
assert_eq!(&bytes[..], b"download-me");
}
#[tokio::test]
async fn list_files_session_response_includes_workspace_root() {
let state = test_state().await;
let (base, _outside) = fixture_session(&state).await;
let res = list_files(State(state), Query(session_query("", None))).await.into_response();
assert_eq!(res.status(), StatusCode::OK);
let v = body_json(res).await;
assert_eq!(v["workspace_root"].as_str(), Some(base.to_str().unwrap()));
assert_eq!(v["is_outside_workspace"].as_bool(), Some(false));
}
#[tokio::test]
async fn list_files_session_early_return_includes_workspace_root() {
let state = test_state().await;
let (base, _outside) = fixture_session(&state).await;
let ghost = base.join("does-not-exist");
sqlx::query(
"INSERT INTO sessions (id, project_id, workspace_path, name, tmux_session_name, \
hook_enabled, hook_status, created_at, runtime_kind) \
VALUES ('ghost-session', 'test-project', ?, 'ghost', NULL, 0, 'idle', '2026-01-01', 'acp')",
)
.bind(ghost.to_str().unwrap())
.execute(&state.db)
.await
.unwrap();
let q = FileQuery {
path: None,
workspace: None,
session: Some("ghost-session".to_string()),
workspace_id: None,
sort: None,
order: None,
allow_escape: None,
};
let res = list_files(State(state), Query(q)).await.into_response();
assert_eq!(res.status(), StatusCode::OK);
let v = body_json(res).await;
assert_eq!(v["workspace_root"].as_str(), Some(ghost.to_str().unwrap()));
assert_eq!(v["is_outside_workspace"].as_bool(), Some(true));
}
#[tokio::test]
async fn list_files_session_is_outside_reflects_browsed_path() {
let state = test_state().await;
let (base, outside) = fixture_session(&state).await;
let inside_sub = base.join("sub");
std::fs::create_dir_all(&inside_sub).unwrap();
let res = list_files(
State(state.clone()),
Query(session_query(inside_sub.to_str().unwrap(), None)),
)
.await
.into_response();
assert_eq!(res.status(), StatusCode::OK);
let v = body_json(res).await;
assert_eq!(
v["is_outside_workspace"].as_bool(),
Some(false),
"subdir of workspace_path must not be flagged as outside"
);
let res = list_files(State(state), Query(session_query(outside.to_str().unwrap(), None)))
.await
.into_response();
assert_eq!(res.status(), StatusCode::OK);
let v = body_json(res).await;
assert_eq!(
v["is_outside_workspace"].as_bool(),
Some(true),
"dir outside workspace_path must be flagged as outside"
);
}
#[tokio::test]
async fn list_files_session_falls_back_to_project_worktree() {
let state = test_state().await;
let (base, outside) = fixture_session(&state).await;
sqlx::query("UPDATE sessions SET workspace_path = ? WHERE id = 'test-session'")
.bind(outside.to_str().unwrap())
.execute(&state.db)
.await
.unwrap();
let res = list_files(State(state), Query(session_query(base.to_str().unwrap(), None)))
.await
.into_response();
assert_eq!(res.status(), StatusCode::OK);
let v = body_json(res).await;
assert_eq!(
v["is_outside_workspace"].as_bool(),
Some(false),
"browsed dir inside a project worktree must fall back"
);
assert_eq!(
v["workspace_root"].as_str(),
Some(base.to_str().unwrap()),
"workspace_root should be the effective worktree path"
);
}
#[tokio::test]
async fn list_files_session_falls_back_to_git_toplevel() {
let state = test_state().await;
let (_base, outside) = fixture_session(&state).await;
let git_root = std::env::temp_dir().join(format!(
"omniterm_fb_git_{}_{}",
std::process::id(),
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
));
let git_sub = git_root.join("sub");
std::fs::create_dir_all(&git_sub).unwrap();
let init = std::process::Command::new("git")
.args(["init", "-q", "-b", "main"])
.current_dir(&git_root)
.env("GIT_AUTHOR_NAME", "t")
.env("GIT_AUTHOR_EMAIL", "t@t")
.env("GIT_COMMITTER_NAME", "t")
.env("GIT_COMMITTER_EMAIL", "t@t")
.output()
.unwrap();
assert!(init.status.success(), "git init failed");
sqlx::query("UPDATE sessions SET workspace_path = ? WHERE id = 'test-session'")
.bind(outside.to_str().unwrap())
.execute(&state.db)
.await
.unwrap();
let res = list_files(State(state), Query(session_query(git_sub.to_str().unwrap(), None)))
.await
.into_response();
assert_eq!(res.status(), StatusCode::OK);
let v = body_json(res).await;
assert_eq!(
v["is_outside_workspace"].as_bool(),
Some(false),
"browsed dir inside an unrelated git repo must fall back to git toplevel"
);
let root = v["workspace_root"].as_str().expect("workspace_root set");
assert!(
std::path::Path::new(root).canonicalize().unwrap()
== std::path::Path::new(&git_root).canonicalize().unwrap(),
"workspace_root should be the git toplevel, got {root}"
);
let _ = std::fs::remove_dir_all(&git_root);
}
#[tokio::test]
async fn mkdir_in_ancestor_git_repo_falls_back_to_git_toplevel() {
let state = test_state().await;
let (base, _outside) = fixture_session(&state).await;
let parent = base.parent().unwrap().to_path_buf();
let git_root = parent.join(format!(
"sanitize_fb_git_{}_{}",
std::process::id(),
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
));
let git_sub = git_root.join("sub");
std::fs::create_dir_all(&git_sub).unwrap();
let init = std::process::Command::new("git")
.args(["init", "-q", "-b", "main"])
.current_dir(&git_root)
.env("GIT_AUTHOR_NAME", "t")
.env("GIT_AUTHOR_EMAIL", "t@t")
.env("GIT_COMMITTER_NAME", "t")
.env("GIT_COMMITTER_EMAIL", "t@t")
.output()
.unwrap();
assert!(init.status.success());
let rel = format!("../{}/sub", git_root.file_name().unwrap().to_str().unwrap());
let body = json!({ "session": "test-session", "path": rel, "name": "newdir" });
let res = mkdir(State(state), Query(allow_only(None)), Json(body)).await.into_response();
assert_eq!(
res.status(),
StatusCode::OK,
"mkdir inside an ancestor git repo must fall back to git toplevel"
);
assert!(git_sub.join("newdir").exists());
let _ = std::fs::remove_dir_all(&git_root);
let _ = std::fs::remove_dir_all(base);
}
#[tokio::test]
async fn mkdir_in_non_git_dir_still_rejected() {
let state = test_state().await;
let (_base, _outside) = fixture_session(&state).await;
let body = json!({ "session": "test-session", "path": "../outside", "name": "d" });
let res = mkdir(State(state), Query(allow_only(None)), Json(body)).await.into_response();
assert_eq!(
res.status(),
StatusCode::INTERNAL_SERVER_ERROR,
"non-git escape must still be rejected"
);
}
}
#[cfg(test)]
mod zip_tests {
use super::zip_directory;
use std::io::Read;
use std::path::Path;
#[test]
fn packs_directory_into_valid_zip() {
let dir = std::env::temp_dir().join("ot_ziptest_mod");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("sub")).unwrap();
std::fs::write(dir.join("a.txt"), b"hello").unwrap();
std::fs::write(dir.join("sub").join("b.txt"), b"world").unwrap();
let bytes = zip_directory(&dir).expect("zip should succeed");
assert!(!bytes.is_empty());
let mut cursor = std::io::Cursor::new(bytes);
let mut archive = zip::ZipArchive::new(&mut cursor).expect("valid zip archive");
let mut names = Vec::new();
for i in 0..archive.len() {
let mut f = archive.by_index(i).unwrap();
let name = f.name().to_string();
names.push(name.clone());
if name.ends_with("a.txt") {
let mut buf = String::new();
f.read_to_string(&mut buf).unwrap();
assert_eq!(buf, "hello");
}
if name.ends_with("b.txt") {
let mut buf = String::new();
f.read_to_string(&mut buf).unwrap();
assert_eq!(buf, "world");
}
}
assert!(names.iter().any(|n| n.ends_with("a.txt")), "a.txt present: {:?}", names);
assert!(names.iter().any(|n| n.ends_with("sub/b.txt")), "sub/b.txt present: {:?}", names);
assert!(
names.iter().any(|n| n.ends_with("ot_ziptest_mod/") || n.contains("ot_ziptest_mod")),
"top folder preserved: {:?}",
names
);
let _ = std::fs::remove_dir_all(&dir);
}
#[allow(dead_code)]
fn _assert_path(_: &Path) {}
}