941 lines
29 KiB
Rust
Raw Normal View History

2026-03-13 00:18:14 +00:00
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::routing::{delete, get, post};
use axum::Json;
use serde::{Deserialize, Serialize};
use tracing::{error, warn};
use crate::actors::{ActorRegistry, ActorSessionView, RegisterRequest};
use crate::cache::NclCache;
use crate::notifications::{AckRequest, NotificationStore, NotificationView};
/// Shared application state injected into handlers.
#[derive(Clone)]
pub struct AppState {
pub cache: Arc<NclCache>,
pub project_root: PathBuf,
pub ontoref_root: Option<PathBuf>,
pub started_at: Instant,
pub last_activity: Arc<AtomicU64>,
pub actors: Arc<ActorRegistry>,
pub notifications: Arc<NotificationStore>,
/// Resolved NICKEL_IMPORT_PATH for UI-initiated NCL exports.
pub nickel_import_path: Option<String>,
#[cfg(feature = "db")]
pub db: Option<Arc<stratum_db::StratumDb>>,
#[cfg(feature = "nats")]
pub nats: Option<Arc<crate::nats::NatsPublisher>>,
#[cfg(feature = "ui")]
pub tera: Option<Arc<tokio::sync::RwLock<tera::Tera>>>,
#[cfg(feature = "ui")]
pub public_dir: Option<PathBuf>,
#[cfg(feature = "ui")]
pub registry: Option<Arc<crate::registry::ProjectRegistry>>,
#[cfg(feature = "ui")]
pub sessions: Arc<crate::session::SessionStore>,
/// Current project set by `set_project` MCP tool — shared across all
/// connections.
#[cfg(feature = "mcp")]
pub mcp_current_project: Arc<std::sync::RwLock<Option<String>>>,
}
impl AppState {
fn touch_activity(&self) {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
self.last_activity.store(now, Ordering::Relaxed);
}
/// Derive the project name for the default project.
pub fn default_project_name(&self) -> String {
self.project_root
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string()
}
}
pub fn router(state: AppState) -> axum::Router {
let app = axum::Router::new()
// Existing endpoints
.route("/health", get(health))
.route("/nickel/export", post(nickel_export))
.route("/cache/stats", get(cache_stats))
.route("/cache/invalidate", post(cache_invalidate))
// Actor endpoints
.route("/actors/register", post(actor_register))
.route("/actors/{token}", delete(actor_deregister))
.route("/actors/{token}/touch", post(actor_touch))
.route("/actors/{token}/profile", post(actor_update_profile))
.route("/actors", get(actors_list))
// Notification endpoints
.route("/notifications/pending", get(notifications_pending))
.route("/notifications/ack", post(notifications_ack))
// Search endpoint
.route("/search", get(search))
// Describe endpoints
.route("/describe/project", get(describe_project))
.route("/describe/capabilities", get(describe_capabilities))
.route("/describe/connections", get(describe_connections))
.route("/describe/actor-init", get(describe_actor_init))
// Backlog JSON endpoint
.route("/backlog-json", get(backlog_json))
// Q&A read endpoint
.route("/qa-json", get(qa_json));
// Gate the mutation endpoint behind the ui feature (requires crate::ui).
#[cfg(feature = "ui")]
let app = app
.route("/qa/add", post(crate::ui::handlers::qa_add))
.route("/qa/delete", post(crate::ui::handlers::qa_delete))
.route("/qa/update", post(crate::ui::handlers::qa_update));
let app = app.with_state(state.clone());
#[cfg(feature = "ui")]
let app = {
use axum::response::Redirect;
use tower_http::services::ServeDir;
let app = app
.route("/ui", get(|| async { Redirect::permanent("/ui/") }))
.nest("/ui/", crate::ui::router(state.clone()));
if let Some(ref public_dir) = state.public_dir {
app.nest_service("/public", ServeDir::new(public_dir))
} else {
app
}
};
// MCP streamable-HTTP endpoint — stateless per-request factory.
#[cfg(feature = "mcp")]
let app = {
use rmcp::transport::streamable_http_server::{
session::local::LocalSessionManager, StreamableHttpServerConfig, StreamableHttpService,
};
let mcp_state = state.clone();
let mcp_svc = StreamableHttpService::new(
move || Ok(crate::mcp::OntoreServer::new(mcp_state.clone())),
std::sync::Arc::new(LocalSessionManager::default()),
StreamableHttpServerConfig::default(),
);
app.nest_service("/mcp", mcp_svc)
};
app
}
// ── Health ──────────────────────────────────────────────────────────────
#[derive(Serialize)]
struct HealthResponse {
status: &'static str,
uptime_secs: u64,
cache_entries: usize,
cache_hits: u64,
cache_misses: u64,
project_root: String,
active_actors: usize,
#[serde(skip_serializing_if = "Option::is_none")]
db_enabled: Option<bool>,
}
async fn health(State(state): State<AppState>) -> Json<HealthResponse> {
state.touch_activity();
let db_enabled = {
#[cfg(feature = "db")]
{
Some(state.db.is_some())
}
#[cfg(not(feature = "db"))]
{
None
}
};
Json(HealthResponse {
status: "ok",
uptime_secs: state.started_at.elapsed().as_secs(),
cache_entries: state.cache.len(),
cache_hits: state.cache.hit_count(),
cache_misses: state.cache.miss_count(),
project_root: state.project_root.display().to_string(),
active_actors: state.actors.count(),
db_enabled,
})
}
// ── Nickel Export ───────────────────────────────────────────────────────
#[derive(Deserialize)]
struct ExportRequest {
path: String,
import_path: Option<String>,
}
#[derive(Serialize)]
struct ExportResponse {
data: serde_json::Value,
cached: bool,
elapsed_ms: u64,
}
async fn nickel_export(
State(state): State<AppState>,
Json(req): Json<ExportRequest>,
) -> std::result::Result<Json<ExportResponse>, (StatusCode, String)> {
state.touch_activity();
let start = Instant::now();
// Accept absolute paths — daemon is loopback-only; OS permissions are the
// boundary. Relative paths are still resolved against project_root for
// backward compatibility.
let file_path = resolve_any_path(&state.project_root, &req.path)?;
let inherited_ip = std::env::var("NICKEL_IMPORT_PATH").unwrap_or_default();
let merged_ip: Option<String> = match req.import_path.as_deref() {
Some(caller_ip) => {
if inherited_ip.is_empty() {
Some(caller_ip.to_string())
} else {
Some(format!("{caller_ip}:{inherited_ip}"))
}
}
None => {
if inherited_ip.is_empty() {
None
} else {
Some(inherited_ip)
}
}
};
let (data, was_hit) = state
.cache
.export(&file_path, merged_ip.as_deref())
.await
.map_err(|e| {
error!(path = %file_path.display(), error = %e, "nickel export failed");
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
})?;
Ok(Json(ExportResponse {
data,
cached: was_hit,
elapsed_ms: start.elapsed().as_millis() as u64,
}))
}
// ── Cache Management ────────────────────────────────────────────────────
#[derive(Serialize)]
struct CacheStatsResponse {
entries: usize,
hits: u64,
misses: u64,
hit_rate: f64,
}
async fn cache_stats(State(state): State<AppState>) -> Json<CacheStatsResponse> {
state.touch_activity();
let hits = state.cache.hit_count();
let misses = state.cache.miss_count();
let total = hits + misses;
Json(CacheStatsResponse {
entries: state.cache.len(),
hits,
misses,
hit_rate: if total > 0 {
hits as f64 / total as f64
} else {
0.0
},
})
}
#[derive(Deserialize)]
struct InvalidateRequest {
prefix: Option<String>,
file: Option<String>,
all: Option<bool>,
}
#[derive(Serialize)]
struct InvalidateResponse {
invalidated: bool,
entries_remaining: usize,
}
async fn cache_invalidate(
State(state): State<AppState>,
Json(req): Json<InvalidateRequest>,
) -> std::result::Result<Json<InvalidateResponse>, (StatusCode, String)> {
state.touch_activity();
if req.all.unwrap_or(false) {
state.cache.invalidate_all();
} else if let Some(prefix) = &req.prefix {
let path = resolve_path(&state.project_root, prefix)?;
state.cache.invalidate_prefix(&path);
} else if let Some(file) = &req.file {
let path = resolve_path(&state.project_root, file)?;
state.cache.invalidate_file(&path);
} else {
return Err((
StatusCode::BAD_REQUEST,
"at least one of 'all', 'prefix', or 'file' must be specified".to_string(),
));
}
Ok(Json(InvalidateResponse {
invalidated: true,
entries_remaining: state.cache.len(),
}))
}
// ── Actor Endpoints ─────────────────────────────────────────────────────
#[derive(Serialize)]
struct RegisterResponse {
token: String,
actors_connected: usize,
}
async fn actor_register(
State(state): State<AppState>,
Json(req): Json<RegisterRequest>,
) -> (StatusCode, Json<RegisterResponse>) {
state.touch_activity();
#[cfg(feature = "nats")]
let actor_type = req.actor_type.clone();
#[cfg(feature = "nats")]
let project = req.project.clone();
let token = state.actors.register(req);
let count = state.actors.count();
#[cfg(feature = "nats")]
{
if let Some(ref nats) = state.nats {
if let Err(e) = nats
.publish_actor_registered(&token, &actor_type, &project)
.await
{
tracing::warn!(error = %e, "failed to publish actor.registered event");
}
}
}
(
StatusCode::CREATED,
Json(RegisterResponse {
token,
actors_connected: count,
}),
)
}
async fn actor_deregister(State(state): State<AppState>, Path(token): Path<String>) -> StatusCode {
state.touch_activity();
if state.actors.deregister(&token) {
#[cfg(feature = "nats")]
{
if let Some(ref nats) = state.nats {
if let Err(e) = nats.publish_actor_deregistered(&token, "explicit").await {
tracing::warn!(error = %e, "failed to publish actor.deregistered event");
}
}
}
StatusCode::NO_CONTENT
} else {
StatusCode::NOT_FOUND
}
}
async fn actor_touch(State(state): State<AppState>, Path(token): Path<String>) -> StatusCode {
state.touch_activity();
if state.actors.touch(&token) {
StatusCode::NO_CONTENT
} else {
StatusCode::NOT_FOUND
}
}
#[derive(Deserialize)]
struct ProfileRequest {
#[serde(default)]
role: Option<String>,
#[serde(default)]
preferences: Option<serde_json::Value>,
}
async fn actor_update_profile(
State(state): State<AppState>,
Path(token): Path<String>,
Json(req): Json<ProfileRequest>,
) -> StatusCode {
state.touch_activity();
if state
.actors
.update_profile(&token, req.role, req.preferences)
{
StatusCode::NO_CONTENT
} else {
StatusCode::NOT_FOUND
}
}
#[derive(Serialize)]
struct ActorsListResponse {
actors: Vec<ActorEntry>,
total: usize,
}
#[derive(Serialize)]
struct ActorEntry {
token: String,
#[serde(flatten)]
session: ActorSessionView,
}
#[derive(Deserialize)]
struct ActorsQuery {
project: Option<String>,
}
async fn actors_list(
State(state): State<AppState>,
Query(query): Query<ActorsQuery>,
) -> Json<ActorsListResponse> {
state.touch_activity();
let entries = match query.project {
Some(ref project) => state.actors.list_for_project(project),
None => state.actors.list(),
};
let total = entries.len();
let actors = entries
.into_iter()
.map(|(token, session)| ActorEntry { token, session })
.collect();
Json(ActorsListResponse { actors, total })
}
// ── Notification Endpoints ──────────────────────────────────────────────
#[derive(Deserialize)]
struct PendingQuery {
token: String,
project: Option<String>,
#[serde(default)]
check_only: bool,
}
#[derive(Serialize)]
struct PendingResponse {
pending: usize,
#[serde(skip_serializing_if = "Option::is_none")]
notifications: Option<Vec<NotificationView>>,
}
async fn notifications_pending(
State(state): State<AppState>,
Query(query): Query<PendingQuery>,
) -> Json<PendingResponse> {
state.touch_activity();
state.actors.touch(&query.token);
let project = query
.project
.unwrap_or_else(|| state.default_project_name());
if query.check_only {
let count = state.notifications.pending_count(&project, &query.token);
Json(PendingResponse {
pending: count,
notifications: None,
})
} else {
let notifications = state.notifications.pending(&project, &query.token);
let count = notifications.len();
Json(PendingResponse {
pending: count,
notifications: Some(notifications),
})
}
}
#[derive(Serialize)]
struct AckResponse {
acknowledged: usize,
}
async fn notifications_ack(
State(state): State<AppState>,
Json(req): Json<AckRequest>,
) -> std::result::Result<Json<AckResponse>, (StatusCode, String)> {
state.touch_activity();
state.actors.touch(&req.token);
let project = req.project.unwrap_or_else(|| state.default_project_name());
let count = if req.all {
let acked = state.notifications.ack_all(&project, &req.token);
state.actors.clear_pending(&req.token);
acked
} else if let Some(id) = req.notification_id {
if state.notifications.ack_one(&project, &req.token, id) {
1
} else {
0
}
} else {
return Err((
StatusCode::BAD_REQUEST,
"either 'all: true' or 'notification_id' must be specified".to_string(),
));
};
Ok(Json(AckResponse {
acknowledged: count,
}))
}
// ── Search ───────────────────────────────────────────────────────────────
#[derive(Deserialize)]
struct SearchQuery {
q: Option<String>,
#[cfg(feature = "ui")]
slug: Option<String>,
}
#[derive(Serialize)]
struct SearchResponse {
query: String,
results: Vec<crate::search::SearchResult>,
}
async fn search(
State(state): State<AppState>,
Query(params): Query<SearchQuery>,
) -> impl IntoResponse {
let q = params.q.as_deref().unwrap_or("").trim().to_string();
if q.is_empty() {
return Json(SearchResponse {
query: q,
results: vec![],
});
}
// In multi-project mode, resolve context from slug; fall back to primary
// project.
#[cfg(feature = "ui")]
let results = {
if let (Some(slug), Some(ref registry)) = (params.slug.as_deref(), &state.registry) {
if let Some(ctx) = registry.get(slug) {
crate::search::search_project(&ctx.root, &ctx.cache, ctx.import_path.as_deref(), &q)
.await
} else {
vec![]
}
} else {
crate::search::search_project(
&state.project_root,
&state.cache,
state.nickel_import_path.as_deref(),
&q,
)
.await
}
};
#[cfg(not(feature = "ui"))]
let results = crate::search::search_project(
&state.project_root,
&state.cache,
state.nickel_import_path.as_deref(),
&q,
)
.await;
Json(SearchResponse { query: q, results })
}
// ── Describe Endpoints ───────────────────────────────────────────────────
#[derive(Deserialize)]
struct DescribeQuery {
slug: Option<String>,
}
#[derive(Deserialize)]
struct ActorInitQuery {
actor: Option<String>,
slug: Option<String>,
}
/// Resolve project context from an optional slug.
/// Falls back to the primary project when slug is absent or not found in
/// registry.
#[cfg(feature = "ui")]
fn resolve_project_ctx(
state: &AppState,
slug: Option<&str>,
) -> (PathBuf, Arc<NclCache>, Option<String>) {
if let Some(s) = slug {
if let Some(ref registry) = state.registry {
if let Some(ctx) = registry.get(s) {
return (ctx.root.clone(), ctx.cache.clone(), ctx.import_path.clone());
}
}
}
(
state.project_root.clone(),
state.cache.clone(),
state.nickel_import_path.clone(),
)
}
#[cfg(not(feature = "ui"))]
fn resolve_project_ctx(
state: &AppState,
_slug: Option<&str>,
) -> (PathBuf, Arc<NclCache>, Option<String>) {
(
state.project_root.clone(),
state.cache.clone(),
state.nickel_import_path.clone(),
)
}
async fn describe_project(
State(state): State<AppState>,
Query(q): Query<DescribeQuery>,
) -> impl IntoResponse {
state.touch_activity();
let (root, cache, import_path) = resolve_project_ctx(&state, q.slug.as_deref());
let path = root.join(".ontology").join("core.ncl");
if !path.exists() {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "core.ncl not found" })),
);
}
match cache.export(&path, import_path.as_deref()).await {
Ok((data, _)) => (StatusCode::OK, Json(data)),
Err(e) => {
error!(path = %path.display(), error = %e, "describe_project export failed");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": e.to_string() })),
)
}
}
}
async fn describe_connections(
State(state): State<AppState>,
Query(q): Query<DescribeQuery>,
) -> impl IntoResponse {
state.touch_activity();
let (root, cache, import_path) = resolve_project_ctx(&state, q.slug.as_deref());
let path = root.join(".ontology").join("connections.ncl");
if !path.exists() {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "connections.ncl not found" })),
);
}
match cache.export(&path, import_path.as_deref()).await {
Ok((data, _)) => (StatusCode::OK, Json(data)),
Err(e) => {
error!(path = %path.display(), error = %e, "describe_connections export failed");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": e.to_string() })),
)
}
}
}
async fn describe_capabilities(
State(state): State<AppState>,
Query(q): Query<DescribeQuery>,
) -> impl IntoResponse {
state.touch_activity();
let (root, cache, import_path) = resolve_project_ctx(&state, q.slug.as_deref());
let modes_dir = root.join("reflection").join("modes");
let adrs_dir = root.join("adrs");
let forms_dir = root.join("reflection").join("forms");
// Modes: export each NCL for id + trigger
let mut modes: Vec<serde_json::Value> = Vec::new();
if let Ok(entries) = std::fs::read_dir(&modes_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("ncl") {
continue;
}
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
match cache.export(&path, import_path.as_deref()).await {
Ok((json, _)) => {
let id = json
.get("id")
.and_then(|v| v.as_str())
.unwrap_or(&stem)
.to_string();
let trigger = json
.get("trigger")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
modes.push(serde_json::json!({ "id": id, "trigger": trigger }));
}
Err(e) => {
warn!(path = %path.display(), error = %e, "capabilities: mode export failed");
modes.push(serde_json::json!({ "id": stem, "trigger": "" }));
}
}
}
}
modes.sort_by_key(|v| v["id"].as_str().unwrap_or("").to_string());
// ADRs: count only
let adr_count = std::fs::read_dir(&adrs_dir)
.map(|rd| {
rd.flatten()
.filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("ncl"))
.count()
})
.unwrap_or(0);
// Forms: list file stems
let mut forms: Vec<String> = std::fs::read_dir(&forms_dir)
.map(|rd| {
rd.flatten()
.filter_map(|e| {
let p = e.path();
if p.extension().and_then(|x| x.to_str()) != Some("ncl") {
return None;
}
p.file_stem().and_then(|s| s.to_str()).map(str::to_string)
})
.collect()
})
.unwrap_or_default();
forms.sort();
let mode_count = modes.len();
let form_count = forms.len();
(
StatusCode::OK,
Json(serde_json::json!({
"modes": modes,
"adrs": adr_count,
"forms": forms,
"mode_count": mode_count,
"adr_count": adr_count,
"form_count": form_count,
})),
)
}
async fn describe_actor_init(
State(state): State<AppState>,
Query(q): Query<ActorInitQuery>,
) -> impl IntoResponse {
state.touch_activity();
let (root, cache, import_path) = resolve_project_ctx(&state, q.slug.as_deref());
let actor = q.actor.as_deref().unwrap_or("agent");
let config_path = root.join(".ontoref").join("config.ncl");
if !config_path.exists() {
return (
StatusCode::OK,
Json(serde_json::json!({ "mode": "", "auto_run": false })),
);
}
match cache.export(&config_path, import_path.as_deref()).await {
Ok((json, _)) => {
let entry = json
.get("actor_init")
.and_then(|v| v.as_array())
.and_then(|arr| {
arr.iter()
.find(|e| e.get("actor").and_then(|v| v.as_str()) == Some(actor))
})
.cloned();
let result = match entry {
Some(e) => {
let mode = e
.get("mode")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let auto_run = e.get("auto_run").and_then(|v| v.as_bool()).unwrap_or(false);
serde_json::json!({ "mode": mode, "auto_run": auto_run })
}
None => serde_json::json!({ "mode": "", "auto_run": false }),
};
(StatusCode::OK, Json(result))
}
Err(e) => {
warn!(path = %config_path.display(), error = %e, "describe_actor_init export failed");
(
StatusCode::OK,
Json(serde_json::json!({ "mode": "", "auto_run": false })),
)
}
}
}
async fn backlog_json(
State(state): State<AppState>,
Query(q): Query<DescribeQuery>,
) -> impl IntoResponse {
state.touch_activity();
let (root, cache, import_path) = resolve_project_ctx(&state, q.slug.as_deref());
let backlog_path = root.join("reflection").join("backlog.ncl");
if !backlog_path.exists() {
return (StatusCode::OK, Json(serde_json::json!([])));
}
match cache.export(&backlog_path, import_path.as_deref()).await {
Ok((json, _)) => {
let items = json
.get("items")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
(StatusCode::OK, Json(serde_json::Value::Array(items)))
}
Err(e) => {
error!(path = %backlog_path.display(), error = %e, "backlog_json export failed");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": e.to_string() })),
)
}
}
}
// ── Q&A endpoints ───────────────────────────────────────────────────────
async fn qa_json(
State(state): State<AppState>,
Query(q): Query<DescribeQuery>,
) -> impl IntoResponse {
state.touch_activity();
let (root, cache, import_path) = resolve_project_ctx(&state, q.slug.as_deref());
let qa_path = root.join("reflection").join("qa.ncl");
if !qa_path.exists() {
return (StatusCode::OK, Json(serde_json::json!([])));
}
match cache.export(&qa_path, import_path.as_deref()).await {
Ok((json, _)) => {
let entries = json
.get("entries")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
(StatusCode::OK, Json(serde_json::Value::Array(entries)))
}
Err(e) => {
error!(path = %qa_path.display(), error = %e, "qa_json export failed");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": e.to_string() })),
)
}
}
}
// ── Helpers ─────────────────────────────────────────────────────────────
/// Resolve a path that may be absolute or relative to project_root.
/// Absolute paths are accepted as-is (daemon is loopback-only; OS enforces
/// access). Relative paths are resolved against project_root and must stay
/// within it.
fn resolve_any_path(
project_root: &std::path::Path,
path: &str,
) -> std::result::Result<PathBuf, (StatusCode, String)> {
let p = PathBuf::from(path);
if p.is_absolute() {
return p.canonicalize().map_err(|e| {
(
StatusCode::BAD_REQUEST,
format!("path does not exist or is inaccessible: {e}"),
)
});
}
let joined = project_root.join(p);
let canonical = joined.canonicalize().map_err(|e| {
(
StatusCode::BAD_REQUEST,
format!("path does not exist or is inaccessible: {e}"),
)
})?;
if !canonical.starts_with(project_root) {
return Err((
StatusCode::BAD_REQUEST,
format!("path escapes project root: {path}"),
));
}
Ok(canonical)
}
/// Resolve a path relative to the project root and verify it stays within.
/// Rejects absolute paths — used for cache management operations scoped to a
/// project.
fn resolve_path(
project_root: &std::path::Path,
path: &str,
) -> std::result::Result<PathBuf, (StatusCode, String)> {
let p = PathBuf::from(path);
if p.is_absolute() {
return Err((
StatusCode::BAD_REQUEST,
"absolute paths are not accepted".to_string(),
));
}
let joined = project_root.join(p);
let canonical = joined.canonicalize().map_err(|e| {
(
StatusCode::BAD_REQUEST,
format!("path does not exist or is inaccessible: {e}"),
)
})?;
if !canonical.starts_with(project_root) {
return Err((
StatusCode::BAD_REQUEST,
format!("path escapes project root: {path}"),
));
}
Ok(canonical)
}
impl IntoResponse for crate::error::DaemonError {
fn into_response(self) -> axum::response::Response {
let body = serde_json::json!({"error": self.to_string()});
(StatusCode::INTERNAL_SERVER_ERROR, Json(body)).into_response()
}
}