use std::path::{Path, PathBuf};
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::Json;
use super::types::*;
pub(super) async fn list_dirs(
State(state): State<AppState>,
Query(query): Query<DirsQuery>,
) -> Result<Json<DirsResp>, (StatusCode, Json<ErrorResponse>)> {
let root = state.limits.workdir_root.as_deref();
let cwd = known_dir_or_fs_root(std::env::current_dir().ok());
let listed = match &query.path {
Some(p) => {
let requested = PathBuf::from(p);
if !requested.is_absolute() {
return Err(err(
StatusCode::BAD_REQUEST,
"path must be absolute".to_string(),
));
}
if let Some(root) = root
&& !leviath_core::resolves_within(&requested, root)
{
return Err(err(
StatusCode::FORBIDDEN,
format!("path '{p}' is outside the configured --workdir-root"),
));
}
requested
}
None => match root {
Some(root) if !leviath_core::resolves_within(&cwd, root) => root.to_path_buf(),
_ => cwd.clone(),
},
};
match std::fs::metadata(&listed) {
Ok(m) if !m.is_dir() => {
return Err(err(
StatusCode::BAD_REQUEST,
format!("'{}' is a file, not a directory", listed.display()),
));
}
Ok(_) => {}
Err(_) => {
return Err(err(
StatusCode::NOT_FOUND,
format!("directory '{}' not found", listed.display()),
));
}
}
let entries = std::fs::read_dir(&listed).map_err(|e| {
err(
StatusCode::NOT_FOUND,
format!("could not read '{}': {e}", listed.display()),
)
})?;
let mut dirs = Vec::new();
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
if !query.hidden && name.starts_with('.') {
continue;
}
let path = entry.path();
let Ok(meta) = std::fs::metadata(&path) else {
continue;
};
if !meta.is_dir() {
continue;
}
if let Some(root) = root
&& !leviath_core::resolves_within(&path, root)
{
continue;
}
dirs.push(DirEntry {
name,
path: path.to_string_lossy().into_owned(),
});
}
dirs.sort_by(|a, b| a.name.cmp(&b.name));
let parent = match root.is_some_and(|r| same_dir(&listed, r)) {
true => None,
false => listed.parent().map(|p| p.to_string_lossy().into_owned()),
};
Ok(Json(DirsResp {
path: listed.to_string_lossy().into_owned(),
parent,
home: known_dir_or_fs_root(dirs::home_dir())
.to_string_lossy()
.into_owned(),
cwd: cwd.to_string_lossy().into_owned(),
root: root.map(|r| r.to_string_lossy().into_owned()),
dirs,
}))
}
fn known_dir_or_fs_root(dir: Option<PathBuf>) -> PathBuf {
dir.unwrap_or_else(|| PathBuf::from("/"))
}
fn same_dir(a: &Path, b: &Path) -> bool {
let canon = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
canon(a) == canon(b)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use axum::Router;
use axum::body::Body;
use axum::http::Request;
use axum::routing::get;
use tokio::sync::broadcast;
use tower::ServiceExt;
use crate::commands::serve::testutil::no_daemon_client;
use crate::config::Config;
fn app_with_root(workdir_root: Option<PathBuf>) -> Router {
let (tx, _) = broadcast::channel(64);
let state = AppState {
config: Arc::new(Config::default()),
event_tx: tx,
control: no_daemon_client(),
mcp: crate::commands::serve::mcp::McpAdmin::default(),
limits: Arc::new(ServeLimits {
workdir_root,
..Default::default()
}),
};
Router::new()
.route("/api/fs/dirs", get(list_dirs))
.with_state(state)
}
async fn get_dirs(app: Router, path: Option<&str>) -> (StatusCode, Vec<u8>) {
let uri = match path {
Some(p) => format!("/api/fs/dirs?path={p}"),
None => "/api/fs/dirs".to_string(),
};
let req = Request::builder().uri(uri).body(Body::empty()).unwrap();
let resp = app.oneshot(req).await.unwrap();
let status = resp.status();
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
(status, body.to_vec())
}
fn listing_of(body: &[u8]) -> DirsResp {
serde_json::from_slice(body).unwrap()
}
fn error_of(body: &[u8]) -> String {
serde_json::from_slice::<serde_json::Value>(body).unwrap()["error"]
.as_str()
.unwrap()
.to_string()
}
#[tokio::test]
async fn list_dirs_defaults_to_the_serve_process_cwd() {
let (status, body) = get_dirs(app_with_root(None), None).await;
assert_eq!(status, StatusCode::OK);
let got = listing_of(&body);
let cwd = std::env::current_dir().unwrap();
assert_eq!(got.path, cwd.to_string_lossy());
assert_eq!(got.cwd, cwd.to_string_lossy());
assert!(got.root.is_none());
assert_eq!(
got.home,
dirs::home_dir().unwrap().to_string_lossy().into_owned()
);
assert!(got.dirs.iter().any(|d| d.name == "src"), "{:?}", got.dirs);
}
#[tokio::test]
async fn list_dirs_lists_an_explicit_absolute_path() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("sub")).unwrap();
let (status, body) =
get_dirs(app_with_root(None), Some(&dir.path().to_string_lossy())).await;
assert_eq!(status, StatusCode::OK);
let got = listing_of(&body);
assert_eq!(got.path, dir.path().to_string_lossy());
assert_eq!(
got.parent.as_deref(),
Some(dir.path().parent().unwrap().to_string_lossy().as_ref())
);
assert_eq!(got.dirs.len(), 1);
assert_eq!(got.dirs[0].name, "sub");
assert_eq!(got.dirs[0].path, dir.path().join("sub").to_string_lossy());
}
#[tokio::test]
async fn list_dirs_returns_subdirectories_only_name_sorted() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("zeta")).unwrap();
std::fs::create_dir(dir.path().join("alpha")).unwrap();
std::fs::create_dir(dir.path().join("mid")).unwrap();
std::fs::write(dir.path().join("a-file.txt"), "not a dir").unwrap();
let (status, body) =
get_dirs(app_with_root(None), Some(&dir.path().to_string_lossy())).await;
assert_eq!(status, StatusCode::OK);
let names: Vec<String> = listing_of(&body).dirs.into_iter().map(|d| d.name).collect();
assert_eq!(names, ["alpha", "mid", "zeta"]);
}
#[tokio::test]
async fn list_dirs_excludes_dotted_names() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join(".git")).unwrap();
std::fs::create_dir(dir.path().join("visible")).unwrap();
let (status, body) =
get_dirs(app_with_root(None), Some(&dir.path().to_string_lossy())).await;
assert_eq!(status, StatusCode::OK);
let names: Vec<String> = listing_of(&body).dirs.into_iter().map(|d| d.name).collect();
assert_eq!(names, ["visible"]);
}
#[tokio::test]
async fn list_dirs_hidden_true_includes_dotted_names() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join(".git")).unwrap();
std::fs::create_dir(dir.path().join("visible")).unwrap();
let uri = format!(
"/api/fs/dirs?path={}&hidden=true",
dir.path().to_string_lossy()
);
let req = Request::builder().uri(uri).body(Body::empty()).unwrap();
let resp = app_with_root(None).oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let names: Vec<String> = listing_of(&body).dirs.into_iter().map(|d| d.name).collect();
assert_eq!(names, [".git", "visible"]);
}
#[tokio::test]
async fn list_dirs_relative_path_returns_400() {
let (status, body) = get_dirs(app_with_root(None), Some("some/relative")).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(error_of(&body), "path must be absolute");
}
#[tokio::test]
async fn list_dirs_missing_directory_returns_404() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("nope");
let (status, body) = get_dirs(app_with_root(None), Some(&missing.to_string_lossy())).await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(
error_of(&body),
format!("directory '{}' not found", missing.display())
);
}
#[tokio::test]
async fn list_dirs_file_returns_400() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("plain.txt");
std::fs::write(&file, "not a directory").unwrap();
let (status, body) = get_dirs(app_with_root(None), Some(&file.to_string_lossy())).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(
error_of(&body),
format!("'{}' is a file, not a directory", file.display())
);
}
#[tokio::test]
async fn list_dirs_refuses_a_path_outside_the_workdir_root() {
let root = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let app = app_with_root(Some(root.path().to_path_buf()));
let (status, body) = get_dirs(app, Some(&outside.path().to_string_lossy())).await;
assert_eq!(status, StatusCode::FORBIDDEN);
assert_eq!(
error_of(&body),
format!(
"path '{}' is outside the configured --workdir-root",
outside.path().display()
)
);
}
#[tokio::test]
async fn list_dirs_parent_is_null_at_the_workdir_root() {
let root = tempfile::tempdir().unwrap();
std::fs::create_dir(root.path().join("inside")).unwrap();
let app = app_with_root(Some(root.path().to_path_buf()));
let (status, body) = get_dirs(app, Some(&root.path().to_string_lossy())).await;
assert_eq!(status, StatusCode::OK);
let got = listing_of(&body);
assert!(got.parent.is_none());
assert_eq!(got.root.as_deref(), Some(root.path().to_str().unwrap()));
let app = app_with_root(Some(root.path().to_path_buf()));
let inside = root.path().join("inside");
let (status, body) = get_dirs(app, Some(&inside.to_string_lossy())).await;
assert_eq!(status, StatusCode::OK);
let got = listing_of(&body);
assert_eq!(
got.parent.as_deref(),
Some(root.path().to_string_lossy().as_ref())
);
}
#[tokio::test]
async fn list_dirs_default_is_clamped_to_the_workdir_root() {
let root = tempfile::tempdir().unwrap();
std::fs::create_dir(root.path().join("only")).unwrap();
let app = app_with_root(Some(root.path().to_path_buf()));
let (status, body) = get_dirs(app, None).await;
assert_eq!(status, StatusCode::OK);
let got = listing_of(&body);
assert_eq!(got.path, root.path().to_string_lossy());
assert!(got.parent.is_none());
assert_eq!(got.dirs.len(), 1);
assert_eq!(got.dirs[0].name, "only");
assert_eq!(
got.cwd,
std::env::current_dir().unwrap().to_string_lossy().as_ref()
);
}
#[cfg(unix)]
#[tokio::test]
async fn list_dirs_symlinks_out_of_the_root_are_excluded() {
let root = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
std::fs::create_dir(root.path().join("inside")).unwrap();
std::os::unix::fs::symlink(outside.path(), root.path().join("escape")).unwrap();
let app = app_with_root(Some(root.path().to_path_buf()));
let (status, body) = get_dirs(app, Some(&root.path().to_string_lossy())).await;
assert_eq!(status, StatusCode::OK);
let names: Vec<String> = listing_of(&body).dirs.into_iter().map(|d| d.name).collect();
assert_eq!(names, ["inside"]);
let (status, body) =
get_dirs(app_with_root(None), Some(&root.path().to_string_lossy())).await;
assert_eq!(status, StatusCode::OK);
let names: Vec<String> = listing_of(&body).dirs.into_iter().map(|d| d.name).collect();
assert_eq!(names, ["escape", "inside"]);
}
#[cfg(unix)]
#[tokio::test]
async fn list_dirs_unreadable_directory_is_reported() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let locked = dir.path().join("locked");
std::fs::create_dir(&locked).unwrap();
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
let (status, body) = get_dirs(app_with_root(None), Some(&locked.to_string_lossy())).await;
assert_eq!(status, StatusCode::NOT_FOUND);
let msg = error_of(&body);
let expected = format!("could not read '{}'", locked.display());
assert!(msg.starts_with(&expected), "{msg}");
let _ = std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755));
}
#[test]
fn known_dir_or_fs_root_falls_back_to_the_fs_root() {
assert_eq!(
known_dir_or_fs_root(Some(PathBuf::from("/somewhere"))),
PathBuf::from("/somewhere")
);
assert_eq!(known_dir_or_fs_root(None), PathBuf::from("/"));
}
#[test]
fn same_dir_compares_noncanonicalizable_paths_literally() {
assert!(same_dir(
Path::new("/no/such/dir/anywhere"),
Path::new("/no/such/dir/anywhere")
));
assert!(!same_dir(
Path::new("/no/such/dir/anywhere"),
Path::new("/no/such/dir/elsewhere")
));
}
#[cfg(unix)]
#[tokio::test]
async fn list_dirs_skips_unreadable_children() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("fine")).unwrap();
std::os::unix::fs::symlink(dir.path().join("gone"), dir.path().join("dangling")).unwrap();
let (status, body) =
get_dirs(app_with_root(None), Some(&dir.path().to_string_lossy())).await;
assert_eq!(status, StatusCode::OK);
let names: Vec<String> = listing_of(&body).dirs.into_iter().map(|d| d.name).collect();
assert_eq!(names, ["fine"]);
}
#[cfg(windows)]
#[tokio::test]
async fn list_dirs_symlinks_out_of_the_root_are_excluded_windows() {
let root = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
std::fs::create_dir(root.path().join("inside")).unwrap();
std::os::windows::fs::symlink_dir(outside.path(), root.path().join("escape")).unwrap();
let app = app_with_root(Some(root.path().to_path_buf()));
let (status, body) = get_dirs(app, Some(&root.path().to_string_lossy())).await;
assert_eq!(status, StatusCode::OK);
let names: Vec<String> = listing_of(&body).dirs.into_iter().map(|d| d.name).collect();
assert_eq!(names, ["inside"]);
let (status, body) =
get_dirs(app_with_root(None), Some(&root.path().to_string_lossy())).await;
assert_eq!(status, StatusCode::OK);
let names: Vec<String> = listing_of(&body).dirs.into_iter().map(|d| d.name).collect();
assert_eq!(names, ["escape", "inside"]);
}
#[cfg(windows)]
#[tokio::test]
async fn list_dirs_unreadable_directory_is_reported_windows() {
use std::fs::OpenOptions;
use std::os::windows::fs::OpenOptionsExt;
const GENERIC_READ: u32 = 0x8000_0000;
const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
let dir = tempfile::tempdir().unwrap();
let locked = dir.path().join("locked");
std::fs::create_dir(&locked).unwrap();
let handle = OpenOptions::new()
.access_mode(GENERIC_READ)
.share_mode(0)
.custom_flags(FILE_FLAG_BACKUP_SEMANTICS)
.open(&locked)
.unwrap();
let (status, body) = get_dirs(app_with_root(None), Some(&locked.to_string_lossy())).await;
assert_eq!(status, StatusCode::NOT_FOUND);
let msg = error_of(&body);
let expected = format!("could not read '{}'", locked.display());
assert!(msg.starts_with(&expected), "{msg}");
drop(handle);
}
#[cfg(windows)]
#[tokio::test]
async fn list_dirs_skips_unreadable_children_windows() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("fine")).unwrap();
std::os::windows::fs::symlink_dir(dir.path().join("gone"), dir.path().join("dangling"))
.unwrap();
let (status, body) =
get_dirs(app_with_root(None), Some(&dir.path().to_string_lossy())).await;
assert_eq!(status, StatusCode::OK);
let names: Vec<String> = listing_of(&body).dirs.into_iter().map(|d| d.name).collect();
assert_eq!(names, ["fine"]);
}
}