use crate::commands::config as config_cmd; use crate::labels; use crate::server::app::{AppState, ServerEvent}; use crate::server::models::{LabelEntryView, ProfileView, RuleView, TerminatorView}; use crate::utils::config::{CapName, RuleKind, ScanProfile}; use axum::extract::{Path, State}; use axum::http::StatusCode; use axum::routing::get; use axum::{Json, Router}; pub fn routes() -> Router { Router::new() .route("/config", get(get_config)) .route( "/config/rules", get(list_rules).post(add_rule).delete(remove_rule), ) .route( "/config/terminators", get(list_terminators) .post(add_terminator) .delete(remove_terminator), ) // Sources/sinks/sanitizers split by kind .route( "/config/sources", get(list_sources).post(add_source).delete(remove_source), ) .route( "/config/sinks", get(list_sinks).post(add_sink).delete(remove_sink), ) .route( "/config/sanitizers", get(list_sanitizers) .post(add_sanitizer) .delete(remove_sanitizer), ) // Triage sync toggle .route("/config/triage-sync", axum::routing::post(set_triage_sync)) // Profiles .route("/config/profiles", get(list_profiles).post(save_profile)) .route( "/config/profiles/{name}", axum::routing::delete(delete_profile), ) .route( "/config/profiles/{name}/activate", axum::routing::post(activate_profile), ) } async fn get_config(State(state): State) -> Json { let config = state.config.read(); Json(serde_json::to_value(&*config).unwrap_or_default()) } // ── Custom rules (existing endpoints) ──────────────────────────────────────── async fn list_rules(State(state): State) -> Json> { let config = state.config.read(); let mut rules = Vec::new(); for (lang, lang_cfg) in &config.analysis.languages { for rule in &lang_cfg.rules { rules.push(RuleView { lang: lang.clone(), matchers: rule.matchers.clone(), kind: rule.kind.to_string(), cap: format!("{:?}", rule.cap).to_ascii_lowercase(), }); } } Json(rules) } async fn add_rule( State(state): State, Json(rule): Json, ) -> Result<(StatusCode, Json), (StatusCode, Json)> { let rule_kind: RuleKind = rule.kind.parse().map_err(|e: String| bad_request(&e))?; let cap_name: CapName = rule.cap.parse().map_err(|e: String| bad_request(&e))?; if let Err(e) = config_cmd::add_rule( &state.config_dir, &rule.lang, &rule.matchers.join(","), &rule.kind, &rule.cap, ) { return Err(bad_request(&e.to_string())); } { let mut config = state.config.write(); let lang_cfg = config .analysis .languages .entry(rule.lang.clone()) .or_default(); let new_rule = crate::utils::config::ConfigLabelRule { matchers: rule.matchers.clone(), kind: rule_kind, cap: cap_name, case_sensitive: false, }; if !lang_cfg.rules.contains(&new_rule) { lang_cfg.rules.push(new_rule); } } let _ = state.event_tx.send(ServerEvent::ConfigChanged); Ok(( StatusCode::CREATED, Json(serde_json::json!({ "status": "ok" })), )) } async fn remove_rule( State(state): State, Json(rule): Json, ) -> Result, (StatusCode, Json)> { let rule_kind: RuleKind = rule.kind.parse().map_err(|e: String| bad_request(&e))?; let cap_name: CapName = rule.cap.parse().map_err(|e: String| bad_request(&e))?; let removed = { let mut config = state.config.write(); if let Some(lang_cfg) = config.analysis.languages.get_mut(&rule.lang) { let before = lang_cfg.rules.len(); lang_cfg.rules.retain(|r| { !(r.matchers == rule.matchers && r.kind == rule_kind && r.cap == cap_name) }); lang_cfg.rules.len() < before } else { false } }; if removed { let config = state.config.read(); let local_path = state.config_dir.join("nyx.local"); let _ = config_cmd::save_local_config(&local_path, &config); let _ = state.event_tx.send(ServerEvent::ConfigChanged); } Ok(Json(serde_json::json!({ "removed": removed }))) } // ── Terminators ────────────────────────────────────────────────────────────── async fn list_terminators(State(state): State) -> Json> { let config = state.config.read(); let mut terminators = Vec::new(); for (lang, lang_cfg) in &config.analysis.languages { for name in &lang_cfg.terminators { terminators.push(TerminatorView { lang: lang.clone(), name: name.clone(), }); } } Json(terminators) } async fn add_terminator( State(state): State, Json(term): Json, ) -> Result<(StatusCode, Json), (StatusCode, Json)> { if let Err(e) = config_cmd::add_terminator(&state.config_dir, &term.lang, &term.name) { return Err(bad_request(&e.to_string())); } { let mut config = state.config.write(); let lang_cfg = config .analysis .languages .entry(term.lang.clone()) .or_default(); if !lang_cfg.terminators.contains(&term.name) { lang_cfg.terminators.push(term.name.clone()); } } let _ = state.event_tx.send(ServerEvent::ConfigChanged); Ok(( StatusCode::CREATED, Json(serde_json::json!({ "status": "ok" })), )) } async fn remove_terminator( State(state): State, Json(term): Json, ) -> Result, (StatusCode, Json)> { let removed = { let mut config = state.config.write(); if let Some(lang_cfg) = config.analysis.languages.get_mut(&term.lang) { let before = lang_cfg.terminators.len(); lang_cfg.terminators.retain(|n| n != &term.name); lang_cfg.terminators.len() < before } else { false } }; if removed { let config = state.config.read(); let local_path = state.config_dir.join("nyx.local"); let _ = config_cmd::save_local_config(&local_path, &config); let _ = state.event_tx.send(ServerEvent::ConfigChanged); } Ok(Json(serde_json::json!({ "removed": removed }))) } // ── Sources / Sinks / Sanitizers (by kind) ─────────────────────────────────── fn list_by_kind(state: &AppState, target_kind: &str) -> Vec { let builtins = labels::enumerate_builtin_rules(); let config = state.config.read(); let mut out: Vec = builtins .iter() .filter(|r| r.kind == target_kind && !r.is_gated) .map(|r| LabelEntryView { lang: r.language.clone(), matchers: r.matchers.clone(), cap: r.cap.clone(), case_sensitive: r.case_sensitive, is_builtin: true, }) .collect(); // Add custom rules of the target kind let target_rule_kind = match target_kind { "source" => RuleKind::Source, "sanitizer" => RuleKind::Sanitizer, "sink" => RuleKind::Sink, _ => return out, }; for (lang, lang_cfg) in &config.analysis.languages { for cr in &lang_cfg.rules { if cr.kind == target_rule_kind { out.push(LabelEntryView { lang: lang.clone(), matchers: cr.matchers.clone(), cap: cr.cap.to_string(), case_sensitive: cr.case_sensitive, is_builtin: false, }); } } } out } fn add_by_kind( state: &AppState, entry: LabelEntryView, target_kind: RuleKind, ) -> Result<(), String> { let cap_name: CapName = entry.cap.parse().map_err(|e: String| e)?; if let Err(e) = config_cmd::add_rule( &state.config_dir, &entry.lang, &entry.matchers.join(","), &target_kind.to_string(), &entry.cap, ) { return Err(e.to_string()); } { let mut config = state.config.write(); let lang_cfg = config .analysis .languages .entry(entry.lang.clone()) .or_default(); let new_rule = crate::utils::config::ConfigLabelRule { matchers: entry.matchers, kind: target_kind, cap: cap_name, case_sensitive: entry.case_sensitive, }; if !lang_cfg.rules.contains(&new_rule) { lang_cfg.rules.push(new_rule); } } let _ = state.event_tx.send(ServerEvent::ConfigChanged); Ok(()) } fn remove_by_kind(state: &AppState, entry: LabelEntryView, target_kind: RuleKind) -> bool { if entry.is_builtin { return false; // cannot remove built-in rules } let cap_name: CapName = match entry.cap.parse() { Ok(c) => c, Err(_) => return false, }; let removed = { let mut config = state.config.write(); if let Some(lang_cfg) = config.analysis.languages.get_mut(&entry.lang) { let before = lang_cfg.rules.len(); lang_cfg.rules.retain(|r| { !(r.matchers == entry.matchers && r.kind == target_kind && r.cap == cap_name) }); lang_cfg.rules.len() < before } else { false } }; if removed { let config = state.config.read(); let local_path = state.config_dir.join("nyx.local"); let _ = config_cmd::save_local_config(&local_path, &config); let _ = state.event_tx.send(ServerEvent::ConfigChanged); } removed } async fn list_sources(State(state): State) -> Json> { Json(list_by_kind(&state, "source")) } async fn add_source( State(state): State, Json(entry): Json, ) -> Result<(StatusCode, Json), (StatusCode, Json)> { add_by_kind(&state, entry, RuleKind::Source).map_err(|e| bad_request(&e))?; Ok(( StatusCode::CREATED, Json(serde_json::json!({ "status": "ok" })), )) } async fn remove_source( State(state): State, Json(entry): Json, ) -> Json { let removed = remove_by_kind(&state, entry, RuleKind::Source); Json(serde_json::json!({ "removed": removed })) } async fn list_sinks(State(state): State) -> Json> { Json(list_by_kind(&state, "sink")) } async fn add_sink( State(state): State, Json(entry): Json, ) -> Result<(StatusCode, Json), (StatusCode, Json)> { add_by_kind(&state, entry, RuleKind::Sink).map_err(|e| bad_request(&e))?; Ok(( StatusCode::CREATED, Json(serde_json::json!({ "status": "ok" })), )) } async fn remove_sink( State(state): State, Json(entry): Json, ) -> Json { let removed = remove_by_kind(&state, entry, RuleKind::Sink); Json(serde_json::json!({ "removed": removed })) } async fn list_sanitizers(State(state): State) -> Json> { Json(list_by_kind(&state, "sanitizer")) } async fn add_sanitizer( State(state): State, Json(entry): Json, ) -> Result<(StatusCode, Json), (StatusCode, Json)> { add_by_kind(&state, entry, RuleKind::Sanitizer).map_err(|e| bad_request(&e))?; Ok(( StatusCode::CREATED, Json(serde_json::json!({ "status": "ok" })), )) } async fn remove_sanitizer( State(state): State, Json(entry): Json, ) -> Json { let removed = remove_by_kind(&state, entry, RuleKind::Sanitizer); Json(serde_json::json!({ "removed": removed })) } // ── Profiles ───────────────────────────────────────────────────────────────── const BUILTIN_PROFILE_NAMES: &[&str] = &[ "quick", "full", "ci", "taint_only", "conservative_large_repo", ]; async fn list_profiles(State(state): State) -> Json> { let config = state.config.read(); let mut profiles: Vec = Vec::new(); // Built-in profiles for &name in BUILTIN_PROFILE_NAMES { if let Some(p) = config.resolve_profile(name) { let is_user_override = config.profiles.contains_key(name); profiles.push(ProfileView { name: name.to_string(), is_builtin: !is_user_override, settings: serde_json::to_value(&p).unwrap_or_default(), }); } } // User profiles not matching a built-in name for (name, p) in &config.profiles { if !BUILTIN_PROFILE_NAMES.contains(&name.as_str()) { profiles.push(ProfileView { name: name.clone(), is_builtin: false, settings: serde_json::to_value(p).unwrap_or_default(), }); } } Json(profiles) } async fn save_profile( State(state): State, Json(body): Json, ) -> Result<(StatusCode, Json), (StatusCode, Json)> { let name = body["name"] .as_str() .ok_or_else(|| bad_request("missing name"))? .to_string(); let settings: ScanProfile = serde_json::from_value(body.get("settings").cloned().unwrap_or_default()) .map_err(|e| bad_request(&e.to_string()))?; { let mut config = state.config.write(); config.profiles.insert(name.clone(), settings); let local_path = state.config_dir.join("nyx.local"); config_cmd::save_local_config(&local_path, &config) .map_err(|e| bad_request(&e.to_string()))?; } let _ = state.event_tx.send(ServerEvent::ConfigChanged); Ok(( StatusCode::CREATED, Json(serde_json::json!({ "status": "ok", "name": name })), )) } async fn delete_profile( State(state): State, Path(name): Path, ) -> Result, (StatusCode, Json)> { if BUILTIN_PROFILE_NAMES.contains(&name.as_str()) { let config = state.config.read(); if !config.profiles.contains_key(&name) { return Err(bad_request("cannot delete built-in profile")); } } let removed = { let mut config = state.config.write(); let existed = config.profiles.remove(&name).is_some(); if existed { let local_path = state.config_dir.join("nyx.local"); let _ = config_cmd::save_local_config(&local_path, &config); } existed }; if removed { let _ = state.event_tx.send(ServerEvent::ConfigChanged); } Ok(Json(serde_json::json!({ "removed": removed }))) } async fn activate_profile( State(state): State, Path(name): Path, ) -> Result, (StatusCode, Json)> { { let mut config = state.config.write(); config .apply_profile(&name) .map_err(|e| bad_request(&e.to_string()))?; } let _ = state.event_tx.send(ServerEvent::ConfigChanged); Ok(Json(serde_json::json!({ "status": "ok", "profile": name }))) } // ── Triage Sync ────────────────────────────────────────────────────────────── async fn set_triage_sync( State(state): State, Json(body): Json, ) -> Result, (StatusCode, Json)> { let enabled = body["enabled"] .as_bool() .ok_or_else(|| bad_request("missing enabled field"))?; { let mut config = state.config.write(); config.server.triage_sync = enabled; // Note: triage_sync is in the server section, which save_local_config // doesn't currently persist. We write the full config here. let local_path = state.config_dir.join("nyx.local"); config_cmd::save_local_config(&local_path, &config) .map_err(|e| bad_request(&e.to_string()))?; } let _ = state.event_tx.send(ServerEvent::ConfigChanged); Ok(Json( serde_json::json!({ "status": "ok", "triage_sync": enabled }), )) } fn bad_request(msg: &str) -> (StatusCode, Json) { ( StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": msg })), ) }