//! Debug API route handlers. //! //! Provides endpoints for inspecting engine internals: CFG, SSA IR, taint //! propagation, summaries, call graphs, abstract interpretation, and symbolic //! execution. use crate::server::app::AppState; use crate::server::debug::{self, *}; use crate::utils::path::{DEFAULT_UI_MAX_FILE_BYTES, RepoPathError, resolve_repo_path}; use axum::extract::{Query, State}; use axum::http::StatusCode; use axum::routing::get; use axum::{Json, Router}; use r2d2::Pool; use r2d2_sqlite::SqliteConnectionManager; use serde::Deserialize; use std::path::Path; pub fn routes() -> Router { Router::new() .route("/debug/functions", get(list_functions)) .route("/debug/cfg", get(get_cfg)) .route("/debug/ssa", get(get_ssa)) .route("/debug/taint", get(get_taint)) .route("/debug/summaries", get(get_summaries)) .route("/debug/call-graph", get(get_call_graph)) .route("/debug/abstract-interp", get(get_abstract_interp)) .route("/debug/symex", get(get_symex)) .route("/debug/pointer", get(get_pointer)) .route("/debug/type-facts", get(get_type_facts)) .route("/debug/auth", get(get_auth)) } // ── Query params ───────────────────────────────────────────────────────────── #[derive(Debug, Deserialize)] struct FileQuery { file: String, } #[derive(Debug, Deserialize)] struct FileFunctionQuery { file: String, function: String, } #[derive(Debug, Deserialize)] struct CallGraphQuery { scope: Option, file: Option, } #[derive(Debug, Deserialize)] struct SummaryQuery { function: Option, file: Option, } // ── Path validation ────────────────────────────────────────────────────────── fn validate_and_resolve(scan_root: &Path, file: &str) -> Result { let resolved = resolve_repo_path(scan_root, file).map_err(map_path_error)?; let metadata = std::fs::metadata(&resolved.canonical).map_err(|_| StatusCode::NOT_FOUND)?; if !metadata.file_type().is_file() { return Err(StatusCode::BAD_REQUEST); } if metadata.len() > DEFAULT_UI_MAX_FILE_BYTES { return Err(StatusCode::BAD_REQUEST); } Ok(resolved.canonical) } // ── Handlers ───────────────────────────────────────────────────────────────── /// GET /api/debug/functions?file= /// List functions available for debug inspection in a file. async fn list_functions( State(state): State, Query(q): Query, ) -> Result>, StatusCode> { let path = validate_and_resolve(&state.scan_root, &q.file)?; let config = state.config.read(); let analysis = debug::analyse_file(&path, &config)?; Ok(Json(debug::function_list(&analysis))) } fn map_path_error(err: RepoPathError) -> StatusCode { match err { RepoPathError::InvalidPath | RepoPathError::OutsideRoot => StatusCode::FORBIDDEN, RepoPathError::NotFound => StatusCode::NOT_FOUND, RepoPathError::NotFile | RepoPathError::NotDirectory | RepoPathError::TooLarge | RepoPathError::InvalidText => StatusCode::BAD_REQUEST, RepoPathError::Io => StatusCode::INTERNAL_SERVER_ERROR, } } /// GET /api/debug/cfg?file=&function= /// Return the CFG for a specific function as a graph JSON. async fn get_cfg( State(state): State, Query(q): Query, ) -> Result, StatusCode> { let path = validate_and_resolve(&state.scan_root, &q.file)?; let config = state.config.read(); let analysis = debug::analyse_file(&path, &config)?; let view = CfgGraphView::from_cfg_function(&analysis.file_cfg, &q.function, &analysis.bytes) .ok_or(StatusCode::NOT_FOUND)?; Ok(Json(view)) } /// GET /api/debug/ssa?file=&function= /// Return the SSA IR for a specific function. async fn get_ssa( State(state): State, Query(q): Query, ) -> Result, StatusCode> { let path = validate_and_resolve(&state.scan_root, &q.file)?; let config = state.config.read(); let analysis = debug::analyse_file(&path, &config)?; let (ssa, _opt, _cfg) = debug::analyse_function_ssa(&analysis, &q.function)?; Ok(Json(SsaBodyView::from_ssa(&ssa, &analysis.bytes))) } /// GET /api/debug/taint?file=&function= /// Return taint analysis results for a specific function. async fn get_taint( State(state): State, Query(q): Query, ) -> Result, StatusCode> { let path = validate_and_resolve(&state.scan_root, &q.file)?; let config = state.config.read(); let analysis = debug::analyse_file(&path, &config)?; let (ssa, opt, body_cfg) = debug::analyse_function_ssa(&analysis, &q.function)?; // Try to load global summaries from DB for cross-file context let global = load_global_summaries(&state); let cross_file_context = global.as_ref().is_some_and(|g| !g.is_empty()); let ssa_summaries_available = global .as_ref() .is_some_and(|g| !g.snapshot_ssa().is_empty()); let (events, _entry_states, exit_states) = debug::analyse_function_taint( &ssa, body_cfg, analysis.lang, analysis.summaries(), global.as_ref(), &opt, ); // Show post-block state so single-block source→sink flows are visible in // the debug UI instead of appearing empty at block entry. Ok(Json(TaintAnalysisView::from_results( &events, &exit_states, &ssa, cross_file_context, ssa_summaries_available, ))) } /// GET /api/debug/abstract-interp?file=&function= /// Return abstract interpretation state for a specific function. async fn get_abstract_interp( State(state): State, Query(q): Query, ) -> Result, StatusCode> { let path = validate_and_resolve(&state.scan_root, &q.file)?; let config = state.config.read(); let analysis = debug::analyse_file(&path, &config)?; let (ssa, opt, body_cfg) = debug::analyse_function_ssa(&analysis, &q.function)?; let global = load_global_summaries(&state); let (_events, block_states, _exit_states) = debug::analyse_function_taint( &ssa, body_cfg, analysis.lang, analysis.summaries(), global.as_ref(), &opt, ); Ok(Json(AbstractInterpView::from_taint_states( &block_states, &ssa, &opt, ))) } /// GET /api/debug/summaries?file=&function= /// Return interprocedural summaries. async fn get_summaries( State(state): State, Query(q): Query, ) -> Result>, StatusCode> { // Try DB first; fall back to on-demand single-file analysis let global = match load_global_summaries(&state) { Some(g) if !g.is_empty() => g, _ => { if let Some(ref file) = q.file { let path = validate_and_resolve(&state.scan_root, file)?; let config = state.config.read(); debug::analyse_file_summaries(&path, &config)? } else { return Ok(Json(vec![])); } } }; let views: Vec = global .iter() .filter(|(key, summary)| { let name_matches = q.function.as_ref().map(|f| key.name == *f).unwrap_or(true); let file_matches = q .file .as_ref() .map(|f| summary.file_path.contains(f.as_str())) .unwrap_or(true); name_matches && file_matches }) .map(|(key, summary)| { let ssa_summary = global.get_ssa(key); FuncSummaryView::from_global(key, summary, ssa_summary) }) .collect(); Ok(Json(views)) } /// GET /api/debug/call-graph?scope=file|project&file= /// Return the call graph. async fn get_call_graph( State(state): State, Query(q): Query, ) -> Result, StatusCode> { let scope = q.scope.as_deref().unwrap_or("project"); let global = if scope == "file" { // On-demand: parse the specified file and extract summaries let file = q.file.as_deref().ok_or(StatusCode::BAD_REQUEST)?; let path = validate_and_resolve(&state.scan_root, file)?; let config = state.config.read(); debug::analyse_file_summaries(&path, &config)? } else { // Project scope: try DB, fall back to empty graph load_global_summaries(&state).unwrap_or_default() }; let cg = crate::callgraph::build_call_graph(&global, &[]); let analysis = crate::callgraph::analyse(&cg); Ok(Json(CallGraphView::from_call_graph(&cg, &analysis))) } /// GET /api/debug/symex?file=&function= /// Return symbolic execution state for a function. async fn get_symex( State(state): State, Query(q): Query, ) -> Result, StatusCode> { let path = validate_and_resolve(&state.scan_root, &q.file)?; let config = state.config.read(); let analysis = debug::analyse_file(&path, &config)?; let (ssa, opt, body_cfg) = debug::analyse_function_ssa(&analysis, &q.function)?; let global = load_global_summaries(&state); let sym_state = debug::analyse_function_symex(&ssa, body_cfg, analysis.lang, &opt, global.as_ref()); Ok(Json(SymexView::from_symbolic_state(&sym_state, &ssa))) } /// GET /api/debug/pointer?file=&function= /// Return the field-sensitive Steensgaard points-to facts for a function. async fn get_pointer( State(state): State, Query(q): Query, ) -> Result, StatusCode> { let path = validate_and_resolve(&state.scan_root, &q.file)?; let config = state.config.read(); let analysis = debug::analyse_file(&path, &config)?; let (ssa, facts) = debug::analyse_function_pointer(&analysis, &q.function)?; Ok(Json(PointerView::from_facts(&facts, &ssa))) } /// GET /api/debug/type-facts?file=&function= /// Return per-function type-fact details derived from the SSA optimiser. async fn get_type_facts( State(state): State, Query(q): Query, ) -> Result, StatusCode> { let path = validate_and_resolve(&state.scan_root, &q.file)?; let config = state.config.read(); let analysis = debug::analyse_file(&path, &config)?; let (ssa, opt, _cfg) = debug::analyse_function_ssa(&analysis, &q.function)?; Ok(Json(TypeFactsView::from_optimize( &opt, &ssa, &analysis.bytes, ))) } /// GET /api/debug/auth?file= /// Return the file-scoped authorization model, routes, units, /// sensitive operations, and auth checks, for the debug UI. async fn get_auth( State(state): State, Query(q): Query, ) -> Result, StatusCode> { let path = validate_and_resolve(&state.scan_root, &q.file)?; let config = state.config.read(); let (model, bytes, enabled) = debug::analyse_file_auth(&path, &config)?; Ok(Json(AuthAnalysisView::from_model(&model, &bytes, enabled))) } // ── Helpers ────────────────────────────────────────────────────────────────── /// Load global summaries from DB if available. fn load_global_summaries(state: &AppState) -> Option { let pool = state.db_pool.as_ref()?; load_global_summaries_from_pool(&state.scan_root, pool) } fn load_global_summaries_from_pool( scan_root: &Path, pool: &Pool, ) -> Option { let project = scan_root.file_name()?.to_str()?; let root_str = scan_root.to_string_lossy(); let indexer = crate::database::index::Indexer::from_pool(project, pool).ok()?; let func_summaries = indexer.load_all_summaries().ok()?; let ssa_rows = indexer.load_all_ssa_summaries().ok()?; let mut global = crate::summary::merge_summaries(func_summaries, Some(&root_str)); for (_file_path, name, lang_str, arity, namespace, container, disambig, kind, summary) in ssa_rows { let lang = crate::symbol::Lang::from_slug(&lang_str).unwrap_or(crate::symbol::Lang::Rust); let key = crate::symbol::FuncKey { lang, namespace: if namespace.is_empty() { crate::symbol::normalize_namespace(&_file_path, Some(&root_str)) } else { namespace }, container, name, arity: if arity >= 0 { Some(arity as usize) } else { None }, disambig, kind, }; global.insert_ssa(key, summary); } Some(global) } #[cfg(test)] mod tests { use super::*; use crate::database::index::Indexer; use crate::labels::Cap; use crate::summary::FuncSummary; use crate::summary::ssa_summary::SsaFuncSummary; use crate::symbol::{FuncKey, Lang}; /// Helper: create a DB pool with persisted summaries for a JS helper function. fn setup_db_with_summaries( dir: &std::path::Path, scan_root: &std::path::Path, ) -> std::sync::Arc> { std::fs::create_dir_all(scan_root.join("src")).unwrap(); let file_path = scan_root.join("src/helper.js"); std::fs::write( &file_path, "function getInput() { return process.env.USER_INPUT; }\nmodule.exports = { getInput };", ) .unwrap(); let db_path = dir.join("test.sqlite"); let pool = Indexer::init(&db_path).unwrap(); let mut indexer = Indexer::from_pool(scan_root.file_name().unwrap().to_str().unwrap(), &pool).unwrap(); indexer .replace_summaries_for_file( &file_path, b"hash", &[FuncSummary { name: "getInput".into(), file_path: file_path.to_string_lossy().into_owned(), lang: "javascript".into(), param_count: 0, param_names: vec![], source_caps: Cap::all().bits(), sanitizer_caps: 0, sink_caps: 0, propagating_params: vec![], propagates_taint: false, tainted_sink_params: vec![], callees: vec![], ..Default::default() }], ) .unwrap(); indexer .replace_ssa_summaries_for_file( &file_path, b"hash", &[( "getInput".into(), 0, "javascript".into(), "src/helper.js".into(), String::new(), None, crate::symbol::FuncKind::Function, SsaFuncSummary { param_to_return: vec![], param_to_sink: vec![], source_caps: Cap::all(), param_to_sink_param: vec![], param_container_to_return: vec![], param_to_container_store: vec![], return_type: None, return_abstract: None, source_to_callback: vec![], receiver_to_return: None, receiver_to_sink: Cap::empty(), abstract_transfer: vec![], param_return_paths: vec![], points_to: Default::default(), field_points_to: Default::default(), return_path_facts: smallvec::SmallVec::new(), typed_call_receivers: vec![], validated_params_to_return: smallvec::SmallVec::new(), param_to_gate_filters: vec![], entry_kind: None, }, )], ) .unwrap(); pool } #[test] fn taint_route_reports_cross_file_context_when_summaries_present() { let dir = tempfile::tempdir().unwrap(); let scan_root = dir.path().join("myproject"); let pool = setup_db_with_summaries(dir.path(), &scan_root); let global = load_global_summaries_from_pool(&scan_root, &pool).expect("should load summaries"); let cross_file_context = !global.is_empty(); let ssa_summaries_available = !global.snapshot_ssa().is_empty(); assert!( cross_file_context, "cross_file_context should be true when DB has persisted summaries" ); assert!( ssa_summaries_available, "ssa_summaries_available should be true when DB has SSA summaries" ); } #[test] fn taint_route_reports_no_cross_file_context_when_db_empty() { let dir = tempfile::tempdir().unwrap(); let scan_root = dir.path().join("emptyproject"); std::fs::create_dir_all(&scan_root).unwrap(); let db_path = dir.path().join("empty.sqlite"); let pool = Indexer::init(&db_path).unwrap(); let _indexer = Indexer::from_pool("emptyproject", &pool).unwrap(); let global = load_global_summaries_from_pool(&scan_root, &pool); let cross_file_context = global.as_ref().is_some_and(|g| !g.is_empty()); let ssa_summaries_available = global .as_ref() .is_some_and(|g| !g.snapshot_ssa().is_empty()); assert!( !cross_file_context, "cross_file_context should be false when DB has no summaries" ); assert!( !ssa_summaries_available, "ssa_summaries_available should be false when DB has no SSA summaries" ); } #[test] fn taint_view_includes_context_flags_with_no_summaries() { // Simulate the debug view construction with no cross-file context let view = TaintAnalysisView::from_results( &[], &[], &crate::ssa::ir::SsaBody { blocks: vec![], entry: crate::ssa::ir::BlockId(0), value_defs: vec![], cfg_node_map: std::collections::HashMap::new(), exception_edges: vec![], field_interner: crate::ssa::ir::FieldInterner::default(), field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), slot_scoped_assigns: std::collections::HashSet::new(), }, false, false, ); assert!(!view.cross_file_context); assert!(!view.ssa_summaries_available); } #[test] fn taint_view_includes_context_flags_with_summaries() { let view = TaintAnalysisView::from_results( &[], &[], &crate::ssa::ir::SsaBody { blocks: vec![], entry: crate::ssa::ir::BlockId(0), value_defs: vec![], cfg_node_map: std::collections::HashMap::new(), exception_edges: vec![], field_interner: crate::ssa::ir::FieldInterner::default(), field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), slot_scoped_assigns: std::collections::HashSet::new(), }, true, true, ); assert!(view.cross_file_context); assert!(view.ssa_summaries_available); } #[test] fn taint_view_serializes_context_fields() { let view = TaintAnalysisView::from_results( &[], &[], &crate::ssa::ir::SsaBody { blocks: vec![], entry: crate::ssa::ir::BlockId(0), value_defs: vec![], cfg_node_map: std::collections::HashMap::new(), exception_edges: vec![], field_interner: crate::ssa::ir::FieldInterner::default(), field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), slot_scoped_assigns: std::collections::HashSet::new(), }, true, false, ); let json = serde_json::to_value(&view).unwrap(); assert_eq!(json["cross_file_context"], true); assert_eq!(json["ssa_summaries_available"], false); } #[test] fn load_global_summaries_graceful_on_malformed_db() { // A DB with no tables at all should not crash, just return None let dir = tempfile::tempdir().unwrap(); let scan_root = dir.path().join("badproject"); std::fs::create_dir_all(&scan_root).unwrap(); let db_path = dir.path().join("bad.sqlite"); // Create a raw SQLite file without our schema let manager = r2d2_sqlite::SqliteConnectionManager::file(&db_path); let pool = r2d2::Pool::builder().max_size(1).build(manager).unwrap(); let result = load_global_summaries_from_pool(&scan_root, &pool); // Should return None gracefully, not panic assert!( result.is_none(), "malformed DB should return None, not crash" ); } #[test] fn load_global_summaries_uses_scan_root_project_and_normalized_namespace() { let dir = tempfile::tempdir().unwrap(); let scan_root = dir.path().join("Example Project"); std::fs::create_dir_all(scan_root.join("src")).unwrap(); let file_path = scan_root.join("src/lib.rs"); std::fs::write(&file_path, "fn helper() {}").unwrap(); let db_path = dir.path().join("example_project.sqlite"); let pool = Indexer::init(&db_path).unwrap(); let mut indexer = Indexer::from_pool("Example Project", &pool).unwrap(); indexer .replace_summaries_for_file( &file_path, b"hash", &[FuncSummary { name: "helper".into(), file_path: file_path.to_string_lossy().into_owned(), lang: "rust".into(), param_count: 0, param_names: vec![], source_caps: 0, sanitizer_caps: 0, sink_caps: 0, propagating_params: vec![], propagates_taint: false, tainted_sink_params: vec![], callees: vec![], ..Default::default() }], ) .unwrap(); indexer .replace_ssa_summaries_for_file( &file_path, b"hash", &[( "helper".into(), 0, "rust".into(), "src/lib.rs".into(), String::new(), None, crate::symbol::FuncKind::Function, SsaFuncSummary { param_to_return: vec![], param_to_sink: vec![], source_caps: Cap::ENV_VAR, param_to_sink_param: vec![], param_container_to_return: vec![], param_to_container_store: vec![], return_type: None, return_abstract: None, source_to_callback: vec![], receiver_to_return: None, receiver_to_sink: Cap::empty(), abstract_transfer: vec![], param_return_paths: vec![], points_to: Default::default(), field_points_to: Default::default(), return_path_facts: smallvec::SmallVec::new(), typed_call_receivers: vec![], validated_params_to_return: smallvec::SmallVec::new(), param_to_gate_filters: vec![], entry_kind: None, }, )], ) .unwrap(); let global = load_global_summaries_from_pool(&scan_root, &pool) .expect("debug loader should recover project summaries"); let key = FuncKey { lang: Lang::Rust, namespace: "src/lib.rs".into(), name: "helper".into(), arity: Some(0), ..Default::default() }; assert!(global.get(&key).is_some()); assert!( global.get_ssa(&key).is_some(), "SSA summaries should line up with the normalized function keys" ); } }