diff --git a/frontend/src/contexts/SSEContext.tsx b/frontend/src/contexts/SSEContext.tsx
index 20b4726e..397fb53d 100644
--- a/frontend/src/contexts/SSEContext.tsx
+++ b/frontend/src/contexts/SSEContext.tsx
@@ -58,6 +58,7 @@ export function SSEProvider({ children }: { children: ReactNode }) {
es.addEventListener('scan_started', () => {
setIsScanRunning(true);
queryClient.invalidateQueries({ queryKey: ['scans'] });
+ queryClient.invalidateQueries({ queryKey: ['targets'] });
});
es.addEventListener('scan_progress', (e) => {
@@ -75,12 +76,14 @@ export function SSEProvider({ children }: { children: ReactNode }) {
queryClient.invalidateQueries({ queryKey: ['scans'] });
queryClient.invalidateQueries({ queryKey: ['overview'] });
queryClient.invalidateQueries({ queryKey: ['findings'] });
+ queryClient.invalidateQueries({ queryKey: ['targets'] });
});
es.addEventListener('scan_failed', () => {
setScanProgress(null);
setIsScanRunning(false);
queryClient.invalidateQueries({ queryKey: ['scans'] });
+ queryClient.invalidateQueries({ queryKey: ['targets'] });
});
es.addEventListener('config_changed', () => {
diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css
index 3d5b2922..41ab69fa 100644
--- a/frontend/src/styles/global.css
+++ b/frontend/src/styles/global.css
@@ -177,6 +177,165 @@ a:hover {
color: var(--text-tertiary);
font-family: var(--font-mono);
}
+.target-switcher {
+ position: relative;
+ padding: 0 var(--space-3) var(--space-2);
+}
+.target-trigger,
+.target-option,
+.target-add-button {
+ appearance: none;
+ border: 0;
+ font: inherit;
+ cursor: pointer;
+}
+.target-trigger {
+ width: 100%;
+ min-height: 48px;
+ display: grid;
+ grid-template-columns: 32px minmax(0, 1fr) 12px;
+ align-items: center;
+ gap: var(--space-2);
+ padding: 7px 8px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ background: var(--surface);
+ color: var(--text);
+ text-align: left;
+}
+.target-trigger:hover,
+.target-trigger[aria-expanded='true'] {
+ border-color: var(--line-strong);
+ background: var(--bg-secondary);
+}
+.target-avatar,
+.target-option-avatar {
+ width: 32px;
+ height: 32px;
+ border-radius: var(--radius-sm);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--accent-light);
+ color: var(--accent);
+ font-weight: var(--weight-semibold);
+ flex-shrink: 0;
+}
+.target-trigger-copy,
+.target-option-copy {
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ line-height: 1.25;
+}
+.target-name,
+.target-option-name {
+ color: var(--text);
+ font-size: var(--text-sm);
+ font-weight: var(--weight-semibold);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.target-path,
+.target-option-path {
+ color: var(--text-tertiary);
+ font-size: 0.7rem;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.target-caret {
+ width: 8px;
+ height: 8px;
+ border-right: 1.5px solid var(--text-tertiary);
+ border-bottom: 1.5px solid var(--text-tertiary);
+ transform: rotate(45deg) translateY(-2px);
+ transition: transform var(--transition-base);
+}
+.target-caret.open {
+ transform: rotate(225deg) translateY(-2px);
+}
+.target-menu {
+ position: absolute;
+ left: var(--space-3);
+ right: var(--space-3);
+ top: calc(100% - var(--space-1));
+ z-index: 30;
+ padding: var(--space-2);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ background: var(--surface);
+ box-shadow: var(--shadow-lg);
+}
+.target-options {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+ max-height: 220px;
+ overflow-y: auto;
+}
+.target-option {
+ display: grid;
+ grid-template-columns: 28px minmax(0, 1fr);
+ align-items: center;
+ gap: var(--space-2);
+ width: 100%;
+ min-height: 42px;
+ padding: 5px 6px;
+ border-radius: var(--radius-sm);
+ background: transparent;
+ color: var(--text);
+ text-align: left;
+}
+.target-option:hover:not(:disabled) {
+ background: var(--bg-secondary);
+}
+.target-option.active {
+ background: var(--accent-light);
+}
+.target-option:disabled {
+ cursor: default;
+ opacity: 0.7;
+}
+.target-option-avatar {
+ width: 28px;
+ height: 28px;
+ font-size: 0.8rem;
+}
+.target-add-form {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 30px;
+ gap: var(--space-1);
+ margin-top: var(--space-2);
+ padding-top: var(--space-2);
+ border-top: 1px solid var(--border-light);
+}
+.target-add-form input {
+ min-width: 0;
+ height: 30px;
+ padding: 5px 8px;
+ font-size: 0.75rem;
+}
+.target-add-button {
+ width: 30px;
+ height: 30px;
+ border-radius: var(--radius-sm);
+ background: var(--accent);
+ color: var(--accent-contrast);
+ font-size: 1rem;
+ font-weight: var(--weight-semibold);
+}
+.target-add-button:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+}
+.target-error {
+ margin-top: var(--space-2);
+ color: var(--sev-high);
+ font-size: 0.72rem;
+ line-height: 1.3;
+}
.nav-list {
list-style: none;
padding: var(--space-3) var(--space-3);
diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo
index 4995350f..b997d172 100644
--- a/frontend/tsconfig.tsbuildinfo
+++ b/frontend/tsconfig.tsbuildinfo
@@ -1 +1 @@
-{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/api/queryclient.ts","./src/api/types.ts","./src/api/mutations/baseline.ts","./src/api/mutations/config.ts","./src/api/mutations/rules.ts","./src/api/mutations/scans.ts","./src/api/mutations/triage.ts","./src/api/queries/config.ts","./src/api/queries/debug.ts","./src/api/queries/explorer.ts","./src/api/queries/findings.ts","./src/api/queries/health.ts","./src/api/queries/overview.ts","./src/api/queries/rules.ts","./src/api/queries/scans.ts","./src/api/queries/surface.ts","./src/api/queries/triage.ts","./src/components/copymarkdownbutton.tsx","./src/components/verdictbadge.tsx","./src/components/charts/horizontalbarchart.tsx","./src/components/charts/linechart.tsx","./src/components/data-display/codeviewer.tsx","./src/components/data-display/filetree.tsx","./src/components/explorer/analysisworkspace.tsx","./src/components/icons/icons.tsx","./src/components/layout/applayout.tsx","./src/components/layout/headerbar.tsx","./src/components/layout/sidebar.tsx","./src/components/overview/overviewwidgets.tsx","./src/components/ui/commandpalette.tsx","./src/components/ui/dropdown.tsx","./src/components/ui/emptystate.tsx","./src/components/ui/errorstate.tsx","./src/components/ui/loadingstate.tsx","./src/components/ui/modal.tsx","./src/components/ui/pagination.tsx","./src/components/ui/shortcutshelp.tsx","./src/components/ui/statcard.tsx","./src/components/ui/toaster.tsx","./src/contexts/ssecontext.tsx","./src/contexts/themecontext.tsx","./src/contexts/toastcontext.tsx","./src/graph/styles.ts","./src/graph/types.ts","./src/graph/adapters/callgraph.ts","./src/graph/adapters/cfg.ts","./src/graph/adapters/surface.ts","./src/graph/components/callgraphcanvas.tsx","./src/graph/components/cfggraphcanvas.tsx","./src/graph/components/graphtoolbar.tsx","./src/graph/components/surfacegraphcanvas.tsx","./src/graph/hooks/useelklayout.ts","./src/graph/layout/elk.ts","./src/graph/layout/text.ts","./src/graph/reduction/cfgcompaction.ts","./src/graph/reduction/neighborhood.ts","./src/graph/rendering/sigma/sigmagraph.tsx","./src/graph/rendering/sigma/buildgraph.ts","./src/graph/rendering/sigma/edgeoverlay.ts","./src/hooks/usechordnavigation.ts","./src/hooks/usedebounce.ts","./src/hooks/usefiletree.ts","./src/hooks/usefindingsurlstate.ts","./src/hooks/usekeyboardshortcuts.ts","./src/hooks/usepagetitle.ts","./src/hooks/usepersistedstate.ts","./src/modals/codeviewermodal.tsx","./src/modals/newscanmodal.tsx","./src/pages/configpage.tsx","./src/pages/explorerpage.tsx","./src/pages/findingdetailpage.tsx","./src/pages/findingspage.tsx","./src/pages/overviewpage.tsx","./src/pages/rulespage.tsx","./src/pages/scancomparepage.tsx","./src/pages/scandetailpage.tsx","./src/pages/scanspage.tsx","./src/pages/surfacepage.tsx","./src/pages/triagepage.tsx","./src/pages/debug/abstractinterppage.tsx","./src/pages/debug/authanalysispage.tsx","./src/pages/debug/callgraphpage.tsx","./src/pages/debug/cfgviewerpage.tsx","./src/pages/debug/debuglayout.tsx","./src/pages/debug/functionselector.tsx","./src/pages/debug/pointerviewerpage.tsx","./src/pages/debug/ssaviewerpage.tsx","./src/pages/debug/summaryexplorerpage.tsx","./src/pages/debug/symexpage.tsx","./src/pages/debug/taintviewerpage.tsx","./src/pages/debug/typefactspage.tsx","./src/test/setup.ts","./src/test/api/client.test.ts","./src/test/components/pagination.test.tsx","./src/test/components/statcard.test.tsx","./src/test/components/dynamicverdictsection.test.tsx","./src/test/components/statecomponents.test.tsx","./src/test/components/verdictbadge.test.tsx","./src/test/graph/cfgadapter.test.ts","./src/test/graph/compactgraph.test.ts","./src/test/graph/nodestyles.test.ts","./src/test/graph/surfaceadapter.test.ts","./src/test/hooks/usedebounce.test.ts","./src/test/modals/newscanmodal.test.tsx","./src/test/utils/findingmarkdown.test.ts","./src/test/utils/formatdate.test.ts","./src/test/utils/syntaxhighlight.test.ts","./src/test/utils/truncpath.test.ts","./src/utils/findingmarkdown.ts","./src/utils/formatdate.ts","./src/utils/parsenote.ts","./src/utils/syntaxhighlight.ts","./src/utils/truncpath.ts"],"version":"6.0.3"}
\ No newline at end of file
+{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/api/queryclient.ts","./src/api/types.ts","./src/api/mutations/baseline.ts","./src/api/mutations/config.ts","./src/api/mutations/rules.ts","./src/api/mutations/scans.ts","./src/api/mutations/triage.ts","./src/api/queries/config.ts","./src/api/queries/debug.ts","./src/api/queries/explorer.ts","./src/api/queries/findings.ts","./src/api/queries/health.ts","./src/api/queries/overview.ts","./src/api/queries/rules.ts","./src/api/queries/scans.ts","./src/api/queries/surface.ts","./src/api/queries/targets.ts","./src/api/queries/triage.ts","./src/components/copymarkdownbutton.tsx","./src/components/verdictbadge.tsx","./src/components/charts/horizontalbarchart.tsx","./src/components/charts/linechart.tsx","./src/components/data-display/codeviewer.tsx","./src/components/data-display/filetree.tsx","./src/components/explorer/analysisworkspace.tsx","./src/components/icons/icons.tsx","./src/components/layout/applayout.tsx","./src/components/layout/headerbar.tsx","./src/components/layout/sidebar.tsx","./src/components/overview/overviewwidgets.tsx","./src/components/ui/commandpalette.tsx","./src/components/ui/dropdown.tsx","./src/components/ui/emptystate.tsx","./src/components/ui/errorstate.tsx","./src/components/ui/loadingstate.tsx","./src/components/ui/modal.tsx","./src/components/ui/pagination.tsx","./src/components/ui/shortcutshelp.tsx","./src/components/ui/statcard.tsx","./src/components/ui/toaster.tsx","./src/contexts/ssecontext.tsx","./src/contexts/themecontext.tsx","./src/contexts/toastcontext.tsx","./src/graph/styles.ts","./src/graph/types.ts","./src/graph/adapters/callgraph.ts","./src/graph/adapters/cfg.ts","./src/graph/adapters/surface.ts","./src/graph/components/callgraphcanvas.tsx","./src/graph/components/cfggraphcanvas.tsx","./src/graph/components/graphtoolbar.tsx","./src/graph/components/surfacegraphcanvas.tsx","./src/graph/hooks/useelklayout.ts","./src/graph/layout/elk.ts","./src/graph/layout/text.ts","./src/graph/reduction/cfgcompaction.ts","./src/graph/reduction/neighborhood.ts","./src/graph/rendering/sigma/sigmagraph.tsx","./src/graph/rendering/sigma/buildgraph.ts","./src/graph/rendering/sigma/edgeoverlay.ts","./src/hooks/usechordnavigation.ts","./src/hooks/usedebounce.ts","./src/hooks/usefiletree.ts","./src/hooks/usefindingsurlstate.ts","./src/hooks/usekeyboardshortcuts.ts","./src/hooks/usepagetitle.ts","./src/hooks/usepersistedstate.ts","./src/modals/codeviewermodal.tsx","./src/modals/newscanmodal.tsx","./src/pages/configpage.tsx","./src/pages/explorerpage.tsx","./src/pages/findingdetailpage.tsx","./src/pages/findingspage.tsx","./src/pages/overviewpage.tsx","./src/pages/rulespage.tsx","./src/pages/scancomparepage.tsx","./src/pages/scandetailpage.tsx","./src/pages/scanspage.tsx","./src/pages/surfacepage.tsx","./src/pages/triagepage.tsx","./src/pages/debug/abstractinterppage.tsx","./src/pages/debug/authanalysispage.tsx","./src/pages/debug/callgraphpage.tsx","./src/pages/debug/cfgviewerpage.tsx","./src/pages/debug/debuglayout.tsx","./src/pages/debug/functionselector.tsx","./src/pages/debug/pointerviewerpage.tsx","./src/pages/debug/ssaviewerpage.tsx","./src/pages/debug/summaryexplorerpage.tsx","./src/pages/debug/symexpage.tsx","./src/pages/debug/taintviewerpage.tsx","./src/pages/debug/typefactspage.tsx","./src/test/setup.ts","./src/test/api/client.test.ts","./src/test/components/pagination.test.tsx","./src/test/components/statcard.test.tsx","./src/test/components/dynamicverdictsection.test.tsx","./src/test/components/statecomponents.test.tsx","./src/test/components/verdictbadge.test.tsx","./src/test/graph/cfgadapter.test.ts","./src/test/graph/compactgraph.test.ts","./src/test/graph/nodestyles.test.ts","./src/test/graph/surfaceadapter.test.ts","./src/test/hooks/usedebounce.test.ts","./src/test/modals/newscanmodal.test.tsx","./src/test/utils/findingmarkdown.test.ts","./src/test/utils/formatdate.test.ts","./src/test/utils/syntaxhighlight.test.ts","./src/test/utils/truncpath.test.ts","./src/utils/findingmarkdown.ts","./src/utils/formatdate.ts","./src/utils/parsenote.ts","./src/utils/syntaxhighlight.ts","./src/utils/truncpath.ts"],"version":"6.0.3"}
\ No newline at end of file
diff --git a/src/commands/index.rs b/src/commands/index.rs
index 2598b80d..c5a1f0b3 100644
--- a/src/commands/index.rs
+++ b/src/commands/index.rs
@@ -26,6 +26,11 @@ pub fn handle(
IndexAction::Build { path, force } => {
let build_path = std::path::Path::new(&path).canonicalize()?;
let (project_name, db_path) = get_project_info(&build_path, database_dir)?;
+ let _ = crate::utils::targets::remember_target(
+ database_dir,
+ &build_path,
+ crate::utils::targets::TargetTouch::Seen,
+ );
if force || !db_path.exists() {
build_index(
diff --git a/src/commands/scan.rs b/src/commands/scan.rs
index c6a434c7..4b09fdef 100644
--- a/src/commands/scan.rs
+++ b/src/commands/scan.rs
@@ -342,7 +342,8 @@ pub(crate) fn verify_findings_for_scan(
.unwrap_or(true);
let results: Vec
= if parallel && diags.len() > 1 {
- let lane_trace = verbose.then(|| std::sync::Arc::new(crate::dynamic::trace::VerifyTrace::new()));
+ let lane_trace =
+ verbose.then(|| std::sync::Arc::new(crate::dynamic::trace::VerifyTrace::new()));
let out = crate::dynamic::runner::WorkerPool::run_in_lanes(
&*diags,
lane_trace.as_ref(),
@@ -554,6 +555,11 @@ pub fn handle(
) -> NyxResult<()> {
let scan_path = Path::new(path).canonicalize()?;
let (project_name, db_path) = get_project_info(&scan_path, database_dir)?;
+ let _ = crate::utils::targets::remember_target(
+ database_dir,
+ &scan_path,
+ crate::utils::targets::TargetTouch::Scanned,
+ );
// Detect frameworks from project manifests and enrich the config.
let config = &{
diff --git a/src/commands/serve.rs b/src/commands/serve.rs
index e5117a03..a731ab42 100644
--- a/src/commands/serve.rs
+++ b/src/commands/serve.rs
@@ -1,10 +1,9 @@
-use crate::database::index::Indexer;
use crate::errors::NyxResult;
use crate::server::app::{AppState, ServerEvent, build_router};
use crate::server::jobs::JobManager;
use crate::server::security::LocalServerSecurity;
use crate::utils::config::Config;
-use crate::utils::project::get_project_info;
+use crate::utils::targets::{TargetTouch, remember_target};
use console::style;
use parking_lot::RwLock;
use std::path::Path;
@@ -31,18 +30,7 @@ pub fn handle(
let rayon_stack_size = config.performance.rayon_thread_stack_size;
let (event_tx, _) = tokio::sync::broadcast::channel(64);
-
- // Initialize DB pool for scan persistence
- let db_pool = {
- let (_, db_path) = get_project_info(&scan_root, database_dir)?;
- match Indexer::init(&db_path) {
- Ok(pool) => Some(pool),
- Err(e) => {
- tracing::warn!("Failed to initialize scan DB: {e}");
- None
- }
- }
- };
+ let _ = remember_target(database_dir, &scan_root, TargetTouch::Seen);
let addr = socket_addr(&host, port);
@@ -75,16 +63,17 @@ pub fn handle(
let security = LocalServerSecurity::new(local_addr.port());
let state = AppState {
- scan_root: scan_root.clone(),
+ scan_root: Arc::new(RwLock::new(scan_root.clone())),
config_dir: config_dir.to_path_buf(),
database_dir: database_dir.to_path_buf(),
security,
config: Arc::new(RwLock::new(config.clone())),
job_manager: Arc::new(JobManager::new(max_jobs, rayon_stack_size)),
event_tx: event_tx.clone(),
- db_pool,
+ db_pools: Arc::new(RwLock::new(std::collections::HashMap::new())),
findings_cache: Arc::new(RwLock::new(None)),
};
+ let _ = state.db_pool_for(&scan_root);
// Invalidate the findings cache whenever a scan finishes so the next
// request rebuilds against fresh diags. The next-request rebuild keeps
diff --git a/src/dynamic/build_pool/go.rs b/src/dynamic/build_pool/go.rs
index 958e10bd..e614c30f 100644
--- a/src/dynamic/build_pool/go.rs
+++ b/src/dynamic/build_pool/go.rs
@@ -108,14 +108,7 @@ impl BuildPool for GoPool {
}
let output = base_command(&self.go_bin)
- .args([
- "build",
- "-trimpath",
- "-buildvcs=false",
- "-o",
- &dest,
- ".",
- ])
+ .args(["build", "-trimpath", "-buildvcs=false", "-o", &dest, "."])
.current_dir(workdir)
.env("GOCACHE", &go_cache)
.env("GOPATH", &go_path)
diff --git a/src/dynamic/build_pool/rust.rs b/src/dynamic/build_pool/rust.rs
index b9355d2f..3f210ffa 100644
--- a/src/dynamic/build_pool/rust.rs
+++ b/src/dynamic/build_pool/rust.rs
@@ -84,8 +84,7 @@ impl BuildPool for RustPool {
.current_dir(workdir)
.env(
"CARGO_HOME",
- std::env::var("CARGO_HOME")
- .unwrap_or_else(|_| default_cargo_home()),
+ std::env::var("CARGO_HOME").unwrap_or_else(|_| default_cargo_home()),
)
.env(
"RUSTUP_HOME",
diff --git a/src/dynamic/harness.rs b/src/dynamic/harness.rs
index 7858226b..09d49b7c 100644
--- a/src/dynamic/harness.rs
+++ b/src/dynamic/harness.rs
@@ -640,7 +640,10 @@ mod tests {
let dst = tmp.path().join("clone");
copy_workdir(&src, &dst).unwrap();
assert_eq!(fs::read(dst.join("top.txt")).unwrap(), b"top");
- assert_eq!(fs::read(dst.join("nested").join("deep.txt")).unwrap(), b"deep");
+ assert_eq!(
+ fs::read(dst.join("nested").join("deep.txt")).unwrap(),
+ b"deep"
+ );
}
#[cfg(unix)]
@@ -655,7 +658,10 @@ mod tests {
copy_workdir(&src, &dst).unwrap();
let link = dst.join("link.txt");
assert!(
- fs::symlink_metadata(&link).unwrap().file_type().is_symlink(),
+ fs::symlink_metadata(&link)
+ .unwrap()
+ .file_type()
+ .is_symlink(),
"internal symlink must be preserved, not dereferenced"
);
assert_eq!(fs::read(&link).unwrap(), b"real");
diff --git a/src/dynamic/runner.rs b/src/dynamic/runner.rs
index 67c5f659..6265bd6d 100644
--- a/src/dynamic/runner.rs
+++ b/src/dynamic/runner.rs
@@ -827,8 +827,7 @@ impl WorkerPool {
.collect();
}
- let results: Vec>> =
- (0..items.len()).map(|_| Mutex::new(None)).collect();
+ let results: Vec>> = (0..items.len()).map(|_| Mutex::new(None)).collect();
std::thread::scope(|scope| {
let results = &results;
diff --git a/src/dynamic/sandbox/baseline.rs b/src/dynamic/sandbox/baseline.rs
index 801dda7e..b47a11be 100644
--- a/src/dynamic/sandbox/baseline.rs
+++ b/src/dynamic/sandbox/baseline.rs
@@ -147,10 +147,10 @@ fn bind_mount_ro(src: &Path, dst: &Path) -> io::Result<()> {
const MS_REC: u64 = 0x4000;
fs::create_dir_all(dst)?;
- let csrc =
- CString::new(src.as_os_str().as_bytes()).map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
- let cdst =
- CString::new(dst.as_os_str().as_bytes()).map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
+ let csrc = CString::new(src.as_os_str().as_bytes())
+ .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
+ let cdst = CString::new(dst.as_os_str().as_bytes())
+ .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
let bind = unsafe {
mount(
@@ -240,7 +240,11 @@ mod tests {
// Snapshot it into a fresh per-finding workdir.
let workdir = tempfile::TempDir::new().unwrap();
baseline.snapshot_into(workdir.path()).unwrap();
- let cloned = workdir.path().join("node_modules").join("left-pad").join("index.js");
+ let cloned = workdir
+ .path()
+ .join("node_modules")
+ .join("left-pad")
+ .join("index.js");
assert!(cloned.exists(), "snapshot must materialise node_modules");
assert_eq!(fs::read(&cloned).unwrap(), b"module.exports = 1;\n");
}
diff --git a/src/dynamic/spec.rs b/src/dynamic/spec.rs
index ffc73820..1230031d 100644
--- a/src/dynamic/spec.rs
+++ b/src/dynamic/spec.rs
@@ -200,6 +200,84 @@ fn default_derivation_strategy() -> SpecDerivationStrategy {
SpecDerivationStrategy::FromFlowSteps
}
+/// Phase 25 (Track K.0) — the optional cross-file context consulted by the
+/// multi-strategy scoring derivation.
+///
+/// Bundles the three inputs every scored strategy and the cross-file source
+/// seeding read, so the public [`HarnessSpec::derive_best`] /
+/// [`HarnessSpec::derive_all_strategies`] surface takes one borrowable
+/// context rather than three positional `Option`s. Cheap to copy (two
+/// references + a bool).
+#[derive(Clone, Copy)]
+pub struct SpecDerivationCtx<'a> {
+ /// When true, skip the `Confidence >= Medium` gate so low-confidence
+ /// findings are still attempted.
+ pub verify_all_confidence: bool,
+ /// Cross-file function summaries (`FuncSummary` + `SsaFuncSummary`),
+ /// shared by every finding in a scan.
+ pub summaries: Option<&'a GlobalSummaries>,
+ /// Whole-program call graph used for reverse-edge entry resolution and
+ /// cross-file source seeding.
+ pub callgraph: Option<&'a CallGraph>,
+}
+
+impl<'a> SpecDerivationCtx<'a> {
+ /// Construct a context from the three positional inputs the legacy
+ /// `from_finding_*` constructors take.
+ pub fn new(
+ verify_all_confidence: bool,
+ summaries: Option<&'a GlobalSummaries>,
+ callgraph: Option<&'a CallGraph>,
+ ) -> Self {
+ Self {
+ verify_all_confidence,
+ summaries,
+ callgraph,
+ }
+ }
+}
+
+/// Phase 25 (Track K.0) — one scored derivation candidate.
+///
+/// Produced by [`HarnessSpec::derive_all_strategies`]; carries both the
+/// built [`HarnessSpec`] and the [`SpecDerivationStrategy`] that produced
+/// it. The strategy tag is retained alongside `spec.derivation` (which
+/// holds the same value) so the loser-ranking telemetry can report the tag
+/// without unwrapping the spec.
+#[derive(Debug, Clone)]
+pub struct SpecCandidate {
+ /// The derived harness recipe.
+ pub spec: HarnessSpec,
+ /// Which strategy produced [`Self::spec`].
+ pub strategy: SpecDerivationStrategy,
+}
+
+/// Phase 25 (Track K.0) — lexicographic score for a candidate spec.
+///
+/// Field declaration order *is* the comparison priority: the derived
+/// [`Ord`] compares `flow_depth` first, then `framework_bound`, then
+/// `cross_file_resolved`, then `payloads_available`. Higher is better, so
+/// [`HarnessSpec::derive_best`] picks the candidate whose score is the
+/// maximum. `bool` orders `false < true`, so a framework-bound /
+/// cross-file-resolved / payload-backed candidate outscores one that is
+/// not, all else equal.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
+pub struct SpecScore {
+ /// Flow-step depth the spec covers: `evidence.flow_steps.len()` plus a
+ /// hop when the entry was rewritten to an ancestor function (the
+ /// callgraph-walk strategies cover more of the call chain than the
+ /// helper that physically contains the sink).
+ pub flow_depth: u32,
+ /// A [`FrameworkBinding`] was attached to the spec.
+ pub framework_bound: bool,
+ /// The spec's entry resolves to a different file than the sink — the
+ /// source was recovered across a file boundary.
+ pub cross_file_resolved: bool,
+ /// The `(expected_cap, lang)` pair has at least one curated payload, so
+ /// the verifier has something to fire.
+ pub payloads_available: bool,
+}
+
impl HarnessSpec {
/// Build a spec from a finding. Returns `Err` with a typed reason when
/// the finding cannot be driven dynamically.
@@ -291,51 +369,15 @@ impl HarnessSpec {
summaries: Option<&GlobalSummaries>,
callgraph: Option<&CallGraph>,
) -> Result {
- if !verify_all_confidence {
- match diag.confidence {
- Some(c) if c >= Confidence::Medium => {}
- _ => return Err(UnsupportedReason::ConfidenceTooLow),
- }
- }
-
- let evidence = diag
- .evidence
- .as_ref()
- .ok_or(UnsupportedReason::NoFlowSteps)?;
-
- // Phase 04 pre-step: when both callgraph *and* summaries are
- // present, walk reverse edges to a framework-bound ancestor.
- // Takes precedence over the four-strategy ladder because a route
- // handler / CLI entry is always a stronger driving anchor than
- // the helper function that physically contains the sink.
- //
- // Strict variant: only the reverse-edge BFS (`find_entry_via_callgraph`)
- // counts here. The summary-entry-kind + rule-id substring fallbacks
- // that live in `derive_from_callgraph_entry_full` stay at strategy-4
- // priority — calling them here would short-circuit the more precise
- // strategies (FromFlowSteps / FromRuleNamespace / FromFuncSummaryAuto)
- // whenever the rule id happens to contain `.http.` / `.cli.`.
- if let (Some(s), Some(cg)) = (summaries, callgraph)
- && let Some(spec) = derive_from_callgraph_walk_only(diag, evidence, s, cg)
- {
- return Ok(spec);
- }
-
- // Try each strategy in priority order; first non-None wins.
- if let Some(spec) = derive_from_flow_steps(diag, evidence, summaries) {
- return Ok(spec);
- }
- if let Some(spec) = derive_from_rule_namespace_with(diag, evidence, summaries) {
- return Ok(spec);
- }
- if let Some(spec) = derive_from_func_summary_auto(diag, evidence, summaries) {
- return Ok(spec);
- }
- if let Some(spec) = derive_from_callgraph_entry_full(diag, evidence, summaries, callgraph) {
- return Ok(spec);
- }
-
- Err(UnsupportedReason::SpecDerivationFailed)
+ // Phase 25 (Track K.0): the legacy sequential first-match ladder is
+ // now a thin wrapper over the multi-strategy scoring path. Every
+ // strategy this method used to try in priority order is still run by
+ // `derive_all_strategies`; `derive_best` scores them and the
+ // ascending-precedence ordering reproduces the old tie-break
+ // (strict callgraph walk > flow_steps > rule_namespace >
+ // func_summary > callgraph fallback) when scores are equal.
+ let ctx = SpecDerivationCtx::new(verify_all_confidence, summaries, callgraph);
+ Self::derive_best(diag, &ctx)
}
/// Convenience wrapper around [`HarnessSpec::from_finding_full`] that
@@ -388,6 +430,133 @@ impl HarnessSpec {
SpecDerivationStrategy::FromCallgraphEntry,
]
}
+
+ /// Phase 25 (Track K.0) — run *every* derivation strategy and score each
+ /// resulting candidate.
+ ///
+ /// Unlike the legacy sequential first-match ladder, this evaluates all
+ /// strategies that fire for the finding and returns each as a
+ /// `(SpecCandidate, SpecScore)` pair. The caller
+ /// ([`Self::derive_best_ranked`]) picks the maximum-scoring candidate.
+ ///
+ /// Candidates are returned in *ascending precedence* order (lowest-priority
+ /// strategy first). This is load-bearing: [`SpecScore`] is intentionally
+ /// coarse and genuine ties are common (e.g. two strategies that both name
+ /// the sink's own enclosing function as the entry). When scores tie, the
+ /// winner-selection in [`Self::derive_best_ranked`] keeps the *last*
+ /// maximal element, so ascending precedence here reproduces the legacy
+ /// ladder's tie-break (flow-steps beats rule-namespace beats
+ /// func-summary, and the strict callgraph walk beats every other
+ /// strategy) without baking strategy rank into the score itself.
+ ///
+ /// Returns an empty `Vec` when the finding carries no evidence or no
+ /// strategy fires.
+ pub fn derive_all_strategies(
+ diag: &Diag,
+ ctx: &SpecDerivationCtx,
+ ) -> Vec<(SpecCandidate, SpecScore)> {
+ let Some(evidence) = diag.evidence.as_ref() else {
+ return Vec::new();
+ };
+ let summaries = ctx.summaries;
+ let callgraph = ctx.callgraph;
+
+ // Build raw candidates in ascending precedence (lowest first). The
+ // two callgraph entries mirror the legacy two call sites: the
+ // `*_full` variant carries the low-precedence summary-kind / rule-id
+ // fallback, the `*_walk_only` and cross-file-seed variants are the
+ // high-precedence reverse-edge walks.
+ let mut raw: Vec<(HarnessSpec, SpecDerivationStrategy)> = Vec::new();
+ if let Some(spec) = derive_from_callgraph_entry_full(diag, evidence, summaries, callgraph) {
+ raw.push((spec, SpecDerivationStrategy::FromCallgraphEntry));
+ }
+ if let Some(spec) = derive_from_func_summary_auto(diag, evidence, summaries) {
+ raw.push((spec, SpecDerivationStrategy::FromFuncSummaryWalk));
+ }
+ if let Some(spec) = derive_from_rule_namespace_with(diag, evidence, summaries) {
+ raw.push((spec, SpecDerivationStrategy::FromRuleNamespace));
+ }
+ if let Some(spec) = derive_from_flow_steps(diag, evidence, summaries) {
+ raw.push((spec, SpecDerivationStrategy::FromFlowSteps));
+ }
+ if let (Some(s), Some(cg)) = (summaries, callgraph) {
+ if let Some(spec) = derive_from_callgraph_walk_only(diag, evidence, s, cg) {
+ raw.push((spec, SpecDerivationStrategy::FromCallgraphEntry));
+ }
+ if let Some(spec) = derive_from_cross_file_seed(diag, evidence, s, cg) {
+ raw.push((spec, SpecDerivationStrategy::FromCallgraphEntry));
+ }
+ }
+
+ let sink_file = sink_file_of(diag, evidence);
+ raw.into_iter()
+ .map(|(spec, strategy)| {
+ let score = score_candidate(&spec, evidence, &sink_file);
+ (SpecCandidate { spec, strategy }, score)
+ })
+ .collect()
+ }
+
+ /// Phase 25 (Track K.0) — derive the single best spec for a finding.
+ ///
+ /// Runs [`Self::derive_all_strategies`] and returns the maximum-scoring
+ /// candidate's spec. The error contract matches the legacy
+ /// [`Self::from_finding_full`]:
+ /// - `Err(UnsupportedReason::ConfidenceTooLow)` when the confidence gate
+ /// fails (and `ctx.verify_all_confidence` is false),
+ /// - `Err(UnsupportedReason::NoFlowSteps)` when the finding carries no
+ /// `Evidence` at all,
+ /// - `Err(UnsupportedReason::SpecDerivationFailed)` when evidence is
+ /// present but no strategy fired.
+ pub fn derive_best(diag: &Diag, ctx: &SpecDerivationCtx) -> Result {
+ Self::derive_best_ranked(diag, ctx).map(|(spec, _runners_up)| spec)
+ }
+
+ /// Phase 25 (Track K.0) — like [`Self::derive_best`] but also returns the
+ /// loser ranking for telemetry.
+ ///
+ /// The second tuple element lists every non-winning candidate's
+ /// `(strategy, score)` in descending score order, so the verifier can
+ /// emit a [`crate::dynamic::trace::TraceStage::SpecScoringResult`] event
+ /// that makes engine gaps visible (which strategies fired, how they
+ /// scored, and which one lost the tie-break).
+ pub fn derive_best_ranked(
+ diag: &Diag,
+ ctx: &SpecDerivationCtx,
+ ) -> Result<(Self, Vec<(SpecDerivationStrategy, SpecScore)>), UnsupportedReason> {
+ if !ctx.verify_all_confidence {
+ match diag.confidence {
+ Some(c) if c >= Confidence::Medium => {}
+ _ => return Err(UnsupportedReason::ConfidenceTooLow),
+ }
+ }
+ // Distinguish "no evidence at all" (NoFlowSteps) from "evidence
+ // present but no strategy fired" (SpecDerivationFailed) — the
+ // verifier lifts only the latter to `Inconclusive`.
+ if diag.evidence.is_none() {
+ return Err(UnsupportedReason::NoFlowSteps);
+ }
+
+ let mut scored = Self::derive_all_strategies(diag, ctx);
+ if scored.is_empty() {
+ return Err(UnsupportedReason::SpecDerivationFailed);
+ }
+
+ // Stable sort by score ascending. `derive_all_strategies` returns
+ // candidates in ascending precedence, and a stable sort preserves
+ // that order within equal scores — so the final element is the
+ // highest-scoring candidate, and on a score tie it is the
+ // highest-precedence one (legacy ladder tie-break).
+ scored.sort_by(|a, b| a.1.cmp(&b.1));
+ let (winner, _winner_score) = scored.pop().expect("non-empty checked above");
+ let mut runners_up: Vec<(SpecDerivationStrategy, SpecScore)> = scored
+ .into_iter()
+ .map(|(cand, score)| (cand.strategy, score))
+ .collect();
+ // Report losers best-first.
+ runners_up.reverse();
+ Ok((winner.spec, runners_up))
+ }
}
// ── Strategy 1: from flow_steps (original path) ──────────────────────────────
@@ -962,6 +1131,201 @@ fn entry_kind_from_summary(_kind: &crate::entry_points::EntryKind) -> EntryKind
EntryKind::HttpRoute
}
+// ── Phase 25 (Track K.0): multi-strategy scoring + cross-file seeding ────────
+
+/// Maximum reverse-edge hops the cross-file source seeding walks before
+/// giving up. Bounds the BFS so a deep call chain cannot stall derivation;
+/// the [`crate::dynamic::spec`] Phase 25 spec fixes this at 5.
+const CROSS_FILE_SEED_MAX_DEPTH: usize = 5;
+
+/// The sink call-site's file: the last `Sink` flow step, falling back to the
+/// diag's own path. Used by [`score_candidate`] to decide whether a
+/// candidate's entry was resolved across a file boundary.
+fn sink_file_of(diag: &Diag, evidence: &crate::evidence::Evidence) -> String {
+ evidence
+ .flow_steps
+ .iter()
+ .rev()
+ .find(|s| matches!(s.kind, FlowStepKind::Sink))
+ .map(|s| s.file.clone())
+ .unwrap_or_else(|| diag.path.clone())
+}
+
+/// Flow-step depth a candidate covers.
+///
+/// Base is `evidence.flow_steps.len()`. A candidate whose entry was
+/// rewritten to a *different* function than the sink's enclosing function
+/// (i.e. one of the callgraph-walk strategies climbed the call chain to a
+/// route handler / source ancestor) earns a `+1` hop bonus, so it scores
+/// strictly above the strategies that merely name the sink's own enclosing
+/// helper as the entry. This is what lets a successful reverse-edge walk
+/// win the [`SpecScore`] comparison without baking strategy rank into the
+/// score.
+fn candidate_flow_depth(spec: &HarnessSpec, evidence: &crate::evidence::Evidence) -> u32 {
+ let base = evidence.flow_steps.len() as u32;
+ let hop = match enclosing_function_from_flow_steps(evidence) {
+ Some(ref f) if !f.is_empty() && *f != spec.entry_name => 1,
+ _ => 0,
+ };
+ base + hop
+}
+
+/// True when the `(cap, lang)` pair has at least one curated payload to fire.
+///
+/// `expected_cap` may carry several bits; a direct multi-bit lookup misses
+/// (the corpus is keyed by single caps), so on a miss we test each set bit
+/// individually.
+fn candidate_has_payloads(cap: Cap, lang: Lang) -> bool {
+ use crate::dynamic::corpus::registry::payloads_for_lang;
+ if !payloads_for_lang(cap, lang).is_empty() {
+ return true;
+ }
+ cap.iter()
+ .any(|bit| !payloads_for_lang(bit, lang).is_empty())
+}
+
+/// Score a single candidate spec on the four Phase 25 axes.
+fn score_candidate(
+ spec: &HarnessSpec,
+ evidence: &crate::evidence::Evidence,
+ sink_file: &str,
+) -> SpecScore {
+ SpecScore {
+ flow_depth: candidate_flow_depth(spec, evidence),
+ framework_bound: spec.framework.is_some(),
+ cross_file_resolved: !sink_file.is_empty()
+ && !spec.entry_file.is_empty()
+ && spec.entry_file != sink_file,
+ payloads_available: candidate_has_payloads(spec.expected_cap, spec.lang),
+ }
+}
+
+/// Phase 25 (Track K.0) deliverable 4 — cross-file source seeding.
+///
+/// Walks reverse call-graph edges from the sink's enclosing function,
+/// consulting [`GlobalSummaries::get_ssa`] (the `ssa_by_key` index) at each
+/// ancestor, until it finds either:
+/// * a **Source** — an ancestor whose [`crate::summary::ssa_summary::SsaFuncSummary::source_caps`]
+/// is non-empty, i.e. it introduces externally-controlled input, or
+/// * a **framework binding** — an ancestor that satisfies [`is_entry_point`].
+///
+/// Bounded at [`CROSS_FILE_SEED_MAX_DEPTH`] reverse hops. Unlike
+/// [`find_entry_via_callgraph`], which stops only at framework entry points,
+/// this also stops at SSA-confirmed sources — so it recovers a drivable
+/// entry for findings whose taint originates in a cross-file helper that
+/// reads input but is not itself a route handler. That additional reach is
+/// the lever Phase 25 pulls to cut the `Inconclusive(SpecDerivationFailed)`
+/// rate.
+fn seed_cross_file_source<'a>(
+ diag: &Diag,
+ evidence: &crate::evidence::Evidence,
+ summaries: &'a GlobalSummaries,
+ callgraph: &CallGraph,
+ lang: Lang,
+) -> Option> {
+ let enclosing = enclosing_function_from_flow_steps(evidence)
+ .or_else(|| resolve_enclosing_function(diag, evidence, Some(summaries), lang))?;
+ let sink_key = summaries
+ .iter()
+ .find(|(k, s)| {
+ k.lang == lang && s.name == enclosing && paths_match(&s.file_path, &diag.path)
+ })
+ .map(|(k, _)| k.clone())?;
+ let start = *callgraph.index.get(&sink_key)?;
+
+ let mut visited: HashSet = HashSet::new();
+ visited.insert(start);
+ let mut frontier: Vec = vec![start];
+ for _ in 0..CROSS_FILE_SEED_MAX_DEPTH {
+ let mut next: Vec = Vec::new();
+ for node in frontier.drain(..) {
+ for caller in callgraph
+ .graph
+ .neighbors_directed(node, petgraph::Direction::Incoming)
+ {
+ if !visited.insert(caller) {
+ continue;
+ }
+ let caller_key = &callgraph.graph[caller];
+ let summary = summaries.get(caller_key);
+ let is_source = summaries
+ .get_ssa(caller_key)
+ .is_some_and(|ssa| !ssa.source_caps.is_empty());
+ let is_framework = summary.is_some_and(|s| is_entry_point(s, callgraph));
+ if (is_source || is_framework)
+ && let Some(s) = summary
+ {
+ return Some(EntryHit {
+ key: caller_key.clone(),
+ summary: s,
+ });
+ }
+ next.push(caller);
+ }
+ }
+ frontier = next;
+ if frontier.is_empty() {
+ break;
+ }
+ }
+ None
+}
+
+/// Strategy candidate built from [`seed_cross_file_source`].
+///
+/// Rewrites the spec's entry to the cross-file Source / framework ancestor
+/// the seed walk resolved, classifying its [`EntryKind`] from the ancestor's
+/// summary (HTTP-shaped static entry kinds → [`EntryKind::HttpRoute`], else
+/// name-based). Tagged [`SpecDerivationStrategy::FromCallgraphEntry`] — it
+/// is a reverse-edge call-graph walk, like the other two callgraph
+/// candidates — and emitted at the highest precedence in
+/// [`HarnessSpec::derive_all_strategies`].
+fn derive_from_cross_file_seed(
+ diag: &Diag,
+ evidence: &crate::evidence::Evidence,
+ summaries: &GlobalSummaries,
+ callgraph: &CallGraph,
+) -> Option {
+ let lang = lang_from_path(&diag.path)?;
+ let expected_cap = Cap::from_bits_truncate(evidence.sink_caps);
+ if expected_cap.is_empty() {
+ return None;
+ }
+ let found = seed_cross_file_source(diag, evidence, summaries, callgraph, lang)?;
+ let entry_kind = found
+ .summary
+ .entry_kind
+ .as_ref()
+ .map(entry_kind_from_summary)
+ .unwrap_or_else(|| name_to_entry_kind(&found.summary.name));
+ let entry_file = if !found.summary.file_path.is_empty() {
+ found.summary.file_path.clone()
+ } else {
+ diag.path.clone()
+ };
+ let (sink_file, sink_line) = evidence
+ .flow_steps
+ .iter()
+ .rev()
+ .find(|s| matches!(s.kind, FlowStepKind::Sink))
+ .map(|s| (s.file.clone(), s.line))
+ .unwrap_or_else(|| (diag.path.clone(), diag.line as u32));
+ let mut spec = finalize_spec(
+ diag,
+ entry_file,
+ found.summary.name.clone(),
+ lang,
+ expected_cap,
+ sink_file,
+ sink_line,
+ SpecDerivationStrategy::FromCallgraphEntry,
+ Some(summaries),
+ );
+ spec.entry_kind = entry_kind;
+ spec.spec_hash = compute_spec_hash(&spec);
+ Some(spec)
+}
+
// ── Helpers ──────────────────────────────────────────────────────────────────
/// Resolve the language for a finding path using extension first, then a
@@ -2573,4 +2937,250 @@ mod tests {
assert_eq!(spec.spec_hash, pre_hash);
assert!(spec.framework.is_some());
}
+
+ // ── Phase 25 (Track K.0): multi-strategy scoring + cross-file seeding ────
+
+ #[test]
+ fn spec_score_orders_lexicographically() {
+ // `flow_depth` dominates every lower-priority axis.
+ let deep = SpecScore {
+ flow_depth: 3,
+ framework_bound: false,
+ cross_file_resolved: false,
+ payloads_available: false,
+ };
+ let shallow_but_rich = SpecScore {
+ flow_depth: 2,
+ framework_bound: true,
+ cross_file_resolved: true,
+ payloads_available: true,
+ };
+ assert!(deep > shallow_but_rich);
+
+ // Equal `flow_depth`: `framework_bound` breaks the tie.
+ let fw = SpecScore {
+ flow_depth: 2,
+ framework_bound: true,
+ cross_file_resolved: false,
+ payloads_available: false,
+ };
+ let no_fw = SpecScore {
+ flow_depth: 2,
+ framework_bound: false,
+ cross_file_resolved: true,
+ payloads_available: true,
+ };
+ assert!(fw > no_fw);
+
+ // Equal `flow_depth` + `framework_bound`: `cross_file_resolved` wins.
+ let xfile = SpecScore {
+ flow_depth: 1,
+ framework_bound: false,
+ cross_file_resolved: true,
+ payloads_available: false,
+ };
+ let no_xfile = SpecScore {
+ flow_depth: 1,
+ framework_bound: false,
+ cross_file_resolved: false,
+ payloads_available: true,
+ };
+ assert!(xfile > no_xfile);
+
+ // Only `payloads_available` differs: it is the final tie-breaker.
+ let with_payloads = SpecScore {
+ flow_depth: 1,
+ framework_bound: false,
+ cross_file_resolved: false,
+ payloads_available: true,
+ };
+ let without = SpecScore {
+ flow_depth: 1,
+ framework_bound: false,
+ cross_file_resolved: false,
+ payloads_available: false,
+ };
+ assert!(with_payloads > without);
+ }
+
+ #[test]
+ fn derive_all_strategies_empty_without_evidence() {
+ // No `Evidence` struct at all → no strategy has anything to derive
+ // from, so the candidate set is empty (and `derive_best_ranked`
+ // lifts this to `NoFlowSteps`, exercised separately).
+ let diag = crate::commands::scan::Diag {
+ confidence: Some(Confidence::High),
+ evidence: None,
+ ..Default::default()
+ };
+ let ctx = SpecDerivationCtx::new(false, None, None);
+ assert!(HarnessSpec::derive_all_strategies(&diag, &ctx).is_empty());
+ }
+
+ #[test]
+ fn derive_best_ranked_reports_runner_up_strategies() {
+ use crate::labels::Cap;
+ // A finding both the flow-steps and rule-namespace strategies can
+ // drive: identical entry → identical score → flow_steps wins the
+ // precedence tie-break, and rule_namespace is reported as a loser.
+ let evidence = Evidence {
+ flow_steps: vec![
+ source_step("src/handler.py", "handle_request"),
+ sink_step("src/handler.py"),
+ ],
+ sink_caps: Cap::SHELL_ESCAPE.bits(),
+ ..Default::default()
+ };
+ let diag = crate::commands::scan::Diag {
+ id: "py.cmdi.os_system".into(),
+ confidence: Some(Confidence::High),
+ evidence: Some(evidence),
+ path: "src/handler.py".into(),
+ ..Default::default()
+ };
+ let ctx = SpecDerivationCtx::new(false, None, None);
+ let (spec, runners_up) = HarnessSpec::derive_best_ranked(&diag, &ctx).unwrap();
+ assert_eq!(spec.derivation, SpecDerivationStrategy::FromFlowSteps);
+ assert!(
+ runners_up
+ .iter()
+ .any(|(s, _)| *s == SpecDerivationStrategy::FromRuleNamespace),
+ "rule-namespace strategy must appear in the runner-up ranking, got {runners_up:?}",
+ );
+ }
+
+ #[test]
+ fn seed_cross_file_source_stops_at_cross_file_source() {
+ use crate::labels::Cap;
+ use crate::summary::CalleeSite;
+ use crate::summary::ssa_summary::SsaFuncSummary;
+ use crate::symbol::FuncKey;
+
+ let mut gs = GlobalSummaries::new();
+
+ // Sink helper in db.rs — contains the dangerous call, no callees.
+ let run_query = build_summary(
+ "run_query",
+ "src/db.rs",
+ "rust",
+ Cap::SHELL_ESCAPE.bits(),
+ vec![0],
+ None,
+ );
+ let run_query_key = FuncKey::new_function(Lang::Rust, "src/db.rs", "run_query", Some(1));
+ gs.insert(run_query_key, run_query);
+
+ // Source ancestor in input.rs — reads external input, calls run_query.
+ let mut read_input = build_summary("read_input", "src/input.rs", "rust", 0, vec![], None);
+ read_input.callees = vec![CalleeSite::bare("run_query")];
+ let read_input_key =
+ FuncKey::new_function(Lang::Rust, "src/input.rs", "read_input", Some(1));
+ gs.insert(read_input_key.clone(), read_input);
+ // SSA summary marks read_input a Source (non-empty source_caps) —
+ // the signal `seed_cross_file_source` stops on.
+ gs.insert_ssa(
+ read_input_key,
+ SsaFuncSummary {
+ source_caps: Cap::SHELL_ESCAPE,
+ ..Default::default()
+ },
+ );
+
+ // A caller of read_input gives it in-degree 1, so the
+ // `is_entry_point` zero-caller heuristic does NOT fire — proving the
+ // walk stops because read_input is a SOURCE, not a framework entry.
+ let mut dispatch = build_summary("dispatch", "src/main.rs", "rust", 0, vec![], None);
+ dispatch.callees = vec![CalleeSite::bare("read_input")];
+ let dispatch_key = FuncKey::new_function(Lang::Rust, "src/main.rs", "dispatch", Some(1));
+ gs.insert(dispatch_key, dispatch);
+
+ let cg = crate::callgraph::build_call_graph(&gs, &[]);
+
+ let ev = Evidence {
+ flow_steps: vec![sink_only_step_with_function("src/db.rs", "run_query")],
+ sink_caps: Cap::SHELL_ESCAPE.bits(),
+ ..Default::default()
+ };
+ let diag = crate::commands::scan::Diag {
+ id: "rust.cmdi.command".into(),
+ path: "src/db.rs".into(),
+ line: 6,
+ confidence: Some(Confidence::High),
+ evidence: Some(ev.clone()),
+ ..Default::default()
+ };
+
+ let hit = seed_cross_file_source(&diag, &ev, &gs, &cg, Lang::Rust)
+ .expect("reverse walk must reach the cross-file source ancestor");
+ assert_eq!(hit.summary.name, "read_input");
+ assert_eq!(hit.summary.file_path, "src/input.rs");
+ // read_input must not itself be a framework entry point — confirming
+ // the stop was on the source condition.
+ assert!(!is_entry_point(hit.summary, &cg));
+ }
+
+ #[test]
+ fn derive_from_cross_file_seed_rewrites_entry_across_file_boundary() {
+ use crate::labels::Cap;
+ use crate::summary::CalleeSite;
+ use crate::summary::ssa_summary::SsaFuncSummary;
+ use crate::symbol::FuncKey;
+
+ let mut gs = GlobalSummaries::new();
+ let run_query = build_summary(
+ "run_query",
+ "src/db.rs",
+ "rust",
+ Cap::SHELL_ESCAPE.bits(),
+ vec![0],
+ None,
+ );
+ gs.insert(
+ FuncKey::new_function(Lang::Rust, "src/db.rs", "run_query", Some(1)),
+ run_query,
+ );
+
+ let mut read_input = build_summary("read_input", "src/input.rs", "rust", 0, vec![], None);
+ read_input.callees = vec![CalleeSite::bare("run_query")];
+ let read_input_key =
+ FuncKey::new_function(Lang::Rust, "src/input.rs", "read_input", Some(1));
+ gs.insert(read_input_key.clone(), read_input);
+ gs.insert_ssa(
+ read_input_key,
+ SsaFuncSummary {
+ source_caps: Cap::SHELL_ESCAPE,
+ ..Default::default()
+ },
+ );
+
+ let cg = crate::callgraph::build_call_graph(&gs, &[]);
+
+ let ev = Evidence {
+ flow_steps: vec![sink_only_step_with_function("src/db.rs", "run_query")],
+ sink_caps: Cap::SHELL_ESCAPE.bits(),
+ ..Default::default()
+ };
+ let diag = crate::commands::scan::Diag {
+ id: "rust.cmdi.command".into(),
+ path: "src/db.rs".into(),
+ line: 6,
+ confidence: Some(Confidence::High),
+ evidence: Some(ev.clone()),
+ ..Default::default()
+ };
+
+ let spec = derive_from_cross_file_seed(&diag, &ev, &gs, &cg)
+ .expect("cross-file seed must derive a spec");
+ assert_eq!(spec.entry_name, "read_input");
+ assert_eq!(spec.entry_file, "src/input.rs");
+ assert_eq!(spec.derivation, SpecDerivationStrategy::FromCallgraphEntry);
+
+ // End-to-end: the scorer prefers the cross-file entry — deeper flow
+ // (one reverse hop) plus cross-file resolution beats the sink-local
+ // strategies that name `run_query` itself as the entry.
+ let ctx = SpecDerivationCtx::new(false, Some(&gs), Some(&cg));
+ let best = HarnessSpec::derive_best(&diag, &ctx).expect("derive_best must succeed");
+ assert_eq!(best.entry_name, "read_input");
+ assert_eq!(best.derivation, SpecDerivationStrategy::FromCallgraphEntry);
+ }
}
diff --git a/src/dynamic/trace.rs b/src/dynamic/trace.rs
index 3910750c..78a55d6e 100644
--- a/src/dynamic/trace.rs
+++ b/src/dynamic/trace.rs
@@ -60,6 +60,12 @@ pub enum TraceStage {
/// trace consumer can audit how a mixed-cap batch fanned out across
/// lanes without head-of-line blocking.
WorkerLaneAssigned,
+ /// Track K.0 (Phase 25) — the multi-strategy spec-derivation scoring
+ /// picked a winning candidate. `detail` carries
+ /// `winner= runners_up=` so a trace consumer can
+ /// audit which strategies fired and which lost the score / tie-break,
+ /// making engine derivation gaps visible without re-running.
+ SpecScoringResult,
}
impl TraceStage {
@@ -79,6 +85,7 @@ impl TraceStage {
Self::OracleObserved => "oracle_observed",
Self::Verdict => "verdict",
Self::WorkerLaneAssigned => "worker_lane_assigned",
+ Self::SpecScoringResult => "spec_scoring_result",
}
}
}
@@ -246,5 +253,9 @@ mod tests {
TraceStage::WorkerLaneAssigned.as_str(),
"worker_lane_assigned"
);
+ assert_eq!(
+ TraceStage::SpecScoringResult.as_str(),
+ "spec_scoring_result"
+ );
}
}
diff --git a/src/dynamic/verify.rs b/src/dynamic/verify.rs
index 2be29a06..e3d86881 100644
--- a/src/dynamic/verify.rs
+++ b/src/dynamic/verify.rs
@@ -437,6 +437,31 @@ fn spec_derivation_failed_verdict(
}
}
+/// Phase 25 (Track K.0): render the [`crate::dynamic::trace::TraceStage::SpecScoringResult`]
+/// detail string.
+///
+/// Deterministic and within the trace-detail budget: the winning strategy
+/// followed by the loser ranking in descending-score order, each tagged with
+/// its covered flow depth so a trace consumer sees *why* the winner won.
+fn format_spec_scoring_detail(
+ winner: SpecDerivationStrategy,
+ runners_up: &[(SpecDerivationStrategy, crate::dynamic::spec::SpecScore)],
+) -> String {
+ use std::fmt::Write as _;
+ let mut detail = format!("winner={winner} runners_up=");
+ if runners_up.is_empty() {
+ detail.push_str("none");
+ } else {
+ for (i, (strat, score)) in runners_up.iter().enumerate() {
+ if i > 0 {
+ detail.push(',');
+ }
+ let _ = write!(detail, "{strat}:{}", score.flow_depth);
+ }
+ }
+ detail
+}
+
/// True when the finding has *some* derivable signal (rule namespace, sink
/// caps, or evidence) so a spec-derivation failure should be surfaced as
/// `Inconclusive` rather than `Unsupported`.
@@ -550,13 +575,23 @@ pub fn verify_finding(diag: &Diag, opts: &VerifyOptions) -> VerifyResult {
};
}
- let spec = match HarnessSpec::from_finding_full(
- diag,
+ // Phase 25 (Track K.0): derive the spec through the multi-strategy
+ // scoring path. `derive_best_ranked` runs every strategy, scores each
+ // candidate, and returns the winner plus the loser ranking for
+ // telemetry.
+ let ctx = crate::dynamic::spec::SpecDerivationCtx::new(
opts.verify_all_confidence,
opts.summaries.as_deref(),
opts.callgraph.as_deref(),
- ) {
- Ok(s) => s,
+ );
+ let spec = match HarnessSpec::derive_best_ranked(diag, &ctx) {
+ Ok((s, runners_up)) => {
+ trace.record(
+ crate::dynamic::trace::TraceStage::SpecScoringResult,
+ Some(format_spec_scoring_detail(s.derivation, &runners_up)),
+ );
+ s
+ }
Err(reason) => {
trace.record(
crate::dynamic::trace::TraceStage::Verdict,
diff --git a/src/server/app.rs b/src/server/app.rs
index 83144753..12f454f6 100644
--- a/src/server/app.rs
+++ b/src/server/app.rs
@@ -5,10 +5,12 @@ use crate::server::progress::TimingBreakdown;
use crate::server::routes;
use crate::server::security::LocalServerSecurity;
use crate::utils::config::Config;
+use crate::utils::project::get_project_info;
use axum::Router;
use parking_lot::RwLock;
use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager;
+use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::broadcast;
@@ -61,17 +63,62 @@ pub struct CachedFindings {
/// Shared application state accessible to all route handlers.
#[derive(Clone)]
pub struct AppState {
- pub scan_root: PathBuf,
+ pub scan_root: Arc>,
pub config_dir: PathBuf,
pub database_dir: PathBuf,
pub security: Arc,
pub config: Arc>,
pub job_manager: Arc,
pub event_tx: broadcast::Sender,
- pub db_pool: Option>>,
+ pub db_pools: Arc>>>>,
pub findings_cache: Arc>>,
}
+impl AppState {
+ pub fn active_scan_root(&self) -> PathBuf {
+ self.scan_root.read().clone()
+ }
+
+ pub fn set_active_scan_root(&self, scan_root: PathBuf) {
+ *self.scan_root.write() = scan_root;
+ *self.findings_cache.write() = None;
+ }
+
+ pub fn db_pool_for(
+ &self,
+ scan_root: &std::path::Path,
+ ) -> Option>> {
+ let canonical = scan_root
+ .canonicalize()
+ .unwrap_or_else(|_| scan_root.to_path_buf());
+ if let Some(pool) = self.db_pools.read().get(&canonical).cloned() {
+ return Some(pool);
+ }
+
+ let (_, db_path) = match get_project_info(&canonical, &self.database_dir) {
+ Ok(info) => info,
+ Err(e) => {
+ tracing::warn!("Failed to resolve target DB path: {e}");
+ return None;
+ }
+ };
+ let pool = match crate::database::index::Indexer::init(&db_path) {
+ Ok(pool) => pool,
+ Err(e) => {
+ tracing::warn!("Failed to initialize target DB {}: {e}", db_path.display());
+ return None;
+ }
+ };
+
+ self.db_pools.write().insert(canonical, Arc::clone(&pool));
+ Some(pool)
+ }
+
+ pub fn active_db_pool(&self) -> Option>> {
+ self.db_pool_for(&self.active_scan_root())
+ }
+}
+
/// 50 MiB cap on request bodies, generous for config uploads, tight
/// enough to prevent OOM from a rogue client.
const MAX_BODY_BYTES: usize = 50 * 1024 * 1024;
@@ -135,14 +182,14 @@ mod tests {
fn test_state(scan_root: PathBuf, port: u16) -> AppState {
let (event_tx, _) = broadcast::channel(8);
AppState {
- scan_root: scan_root.clone(),
+ scan_root: Arc::new(RwLock::new(scan_root.clone())),
config_dir: scan_root.clone(),
database_dir: scan_root,
security: LocalServerSecurity::new(port),
config: Arc::new(RwLock::new(Config::default())),
job_manager: Arc::new(JobManager::new(4, 8 * 1024 * 1024)),
event_tx,
- db_pool: None,
+ db_pools: Arc::new(RwLock::new(HashMap::new())),
findings_cache: Arc::new(RwLock::new(None)),
}
}
diff --git a/src/server/routes/debug.rs b/src/server/routes/debug.rs
index b0604305..aec095f3 100644
--- a/src/server/routes/debug.rs
+++ b/src/server/routes/debug.rs
@@ -78,7 +78,7 @@ async fn list_functions(
State(state): State,
Query(q): Query,
) -> Result>, StatusCode> {
- let path = validate_and_resolve(&state.scan_root, &q.file)?;
+ let path = validate_and_resolve(&state.active_scan_root(), &q.file)?;
let config = state.config.read();
let analysis = debug::analyse_file(&path, &config)?;
Ok(Json(debug::function_list(&analysis)))
@@ -102,7 +102,7 @@ async fn get_cfg(
State(state): State,
Query(q): Query,
) -> Result, StatusCode> {
- let path = validate_and_resolve(&state.scan_root, &q.file)?;
+ let path = validate_and_resolve(&state.active_scan_root(), &q.file)?;
let config = state.config.read();
let analysis = debug::analyse_file(&path, &config)?;
@@ -117,7 +117,7 @@ async fn get_ssa(
State(state): State,
Query(q): Query,
) -> Result, StatusCode> {
- let path = validate_and_resolve(&state.scan_root, &q.file)?;
+ let path = validate_and_resolve(&state.active_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)?;
@@ -130,7 +130,7 @@ async fn get_taint(
State(state): State,
Query(q): Query,
) -> Result, StatusCode> {
- let path = validate_and_resolve(&state.scan_root, &q.file)?;
+ let path = validate_and_resolve(&state.active_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)?;
@@ -168,7 +168,7 @@ async fn get_abstract_interp(
State(state): State,
Query(q): Query,
) -> Result, StatusCode> {
- let path = validate_and_resolve(&state.scan_root, &q.file)?;
+ let path = validate_and_resolve(&state.active_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)?;
@@ -202,7 +202,7 @@ async fn get_summaries(
Some(g) if !g.is_empty() => g,
_ => {
if let Some(ref file) = q.file {
- let path = validate_and_resolve(&state.scan_root, file)?;
+ let path = validate_and_resolve(&state.active_scan_root(), file)?;
let config = state.config.read();
debug::analyse_file_summaries(&path, &config)?
} else {
@@ -242,7 +242,7 @@ async fn get_call_graph(
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 path = validate_and_resolve(&state.active_scan_root(), file)?;
let config = state.config.read();
debug::analyse_file_summaries(&path, &config)?
} else {
@@ -262,7 +262,7 @@ async fn get_symex(
State(state): State,
Query(q): Query,
) -> Result, StatusCode> {
- let path = validate_and_resolve(&state.scan_root, &q.file)?;
+ let path = validate_and_resolve(&state.active_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)?;
@@ -281,7 +281,7 @@ async fn get_pointer(
State(state): State,
Query(q): Query,
) -> Result, StatusCode> {
- let path = validate_and_resolve(&state.scan_root, &q.file)?;
+ let path = validate_and_resolve(&state.active_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)?;
@@ -294,7 +294,7 @@ async fn get_type_facts(
State(state): State,
Query(q): Query,
) -> Result, StatusCode> {
- let path = validate_and_resolve(&state.scan_root, &q.file)?;
+ let path = validate_and_resolve(&state.active_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)?;
@@ -312,7 +312,7 @@ async fn get_auth(
State(state): State,
Query(q): Query,
) -> Result, StatusCode> {
- let path = validate_and_resolve(&state.scan_root, &q.file)?;
+ let path = validate_and_resolve(&state.active_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)))
@@ -322,8 +322,9 @@ async fn get_auth(
/// 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)
+ let scan_root = state.active_scan_root();
+ let pool = state.active_db_pool()?;
+ load_global_summaries_from_pool(&scan_root, &pool)
}
fn load_global_summaries_from_pool(
diff --git a/src/server/routes/explorer.rs b/src/server/routes/explorer.rs
index cb0ca332..5877c06b 100644
--- a/src/server/routes/explorer.rs
+++ b/src/server/routes/explorer.rs
@@ -126,8 +126,8 @@ async fn get_tree(
State(state): State,
Query(query): Query,
) -> Result>, StatusCode> {
- let resolved =
- resolve_repo_dir(&state.scan_root, query.path.as_deref()).map_err(map_path_error)?;
+ let scan_root = state.active_scan_root();
+ let resolved = resolve_repo_dir(&scan_root, query.path.as_deref()).map_err(map_path_error)?;
let canonical = resolved.canonical;
// Load findings and pre-compute per-file and per-directory aggregates
@@ -245,14 +245,15 @@ async fn get_symbols(
State(state): State,
Query(query): Query,
) -> Result>, StatusCode> {
- let resolved = resolve_repo_path(&state.scan_root, &query.path).map_err(map_path_error)?;
+ let scan_root = state.active_scan_root();
+ let resolved = resolve_repo_path(&scan_root, &query.path).map_err(map_path_error)?;
- let pool = match &state.db_pool {
+ let pool = match state.active_db_pool() {
Some(p) => p,
None => return Ok(Json(vec![])),
};
- let idx = Indexer::from_pool("_scans", pool).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
+ let idx = Indexer::from_pool("_scans", &pool).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// Build absolute path for DB lookup (DB stores absolute paths)
let canonical_root = resolved.root;
@@ -330,7 +331,8 @@ async fn get_findings(
State(state): State,
Query(query): Query,
) -> Result>, StatusCode> {
- let resolved = resolve_repo_path(&state.scan_root, &query.path).map_err(map_path_error)?;
+ let scan_root = state.active_scan_root();
+ let resolved = resolve_repo_path(&scan_root, &query.path).map_err(map_path_error)?;
let findings = load_latest_findings(&state);
let root_str = resolved.root.to_string_lossy();
diff --git a/src/server/routes/files.rs b/src/server/routes/files.rs
index fdb1366f..118dbf78 100644
--- a/src/server/routes/files.rs
+++ b/src/server/routes/files.rs
@@ -34,7 +34,8 @@ async fn get_file(
State(state): State,
Query(query): Query,
) -> ApiResult> {
- let opened = open_repo_text_file(&state.scan_root, &query.path, DEFAULT_UI_MAX_FILE_BYTES)
+ let scan_root = state.active_scan_root();
+ let opened = open_repo_text_file(&scan_root, &query.path, DEFAULT_UI_MAX_FILE_BYTES)
.map_err(|e| map_path_error(e, &query.path))?;
let content = opened.content;
let all_lines: Vec<&str> = content.lines().collect();
diff --git a/src/server/routes/findings.rs b/src/server/routes/findings.rs
index 4482518d..6d18c2b9 100644
--- a/src/server/routes/findings.rs
+++ b/src/server/routes/findings.rs
@@ -4,6 +4,7 @@ use crate::commands::scan::Diag;
use crate::database::index::Indexer;
use crate::server::app::{AppState, CachedFindings};
use crate::server::error::{ApiError, ApiResult};
+use crate::server::jobs::JobStatus;
use crate::server::models::{
FilterValues, FindingSummary, FindingView, collect_filter_values, dynamic_status_label,
finding_from_diag, finding_from_diag_with_detail, overlay_triage_states, summarize_findings,
@@ -38,23 +39,30 @@ struct LoadedFindings {
/// Load findings for the latest completed scan, falling back to DB if no
/// in-memory completed scan exists (e.g. after a server restart).
fn load_latest_findings_internal(state: &AppState) -> LoadedFindings {
- if let Some(job) = state.job_manager.get_latest_completed() {
+ let scan_root = state.active_scan_root();
+ let root_key = scan_root.display().to_string();
+ if let Some(job) = state
+ .job_manager
+ .list_jobs()
+ .into_iter()
+ .find(|job| job.status == JobStatus::Completed && job.scan_root == scan_root)
+ {
if let Some(ref findings) = job.findings {
return LoadedFindings {
- cache_key: job.id.clone(),
+ cache_key: format!("{root_key}:{}", job.id),
findings: Arc::clone(findings),
};
}
}
- if let Some(ref pool) = state.db_pool {
- if let Ok(idx) = Indexer::from_pool("_scans", pool) {
+ if let Some(pool) = state.active_db_pool() {
+ if let Ok(idx) = Indexer::from_pool("_scans", &pool) {
if let Ok(scans) = idx.list_scans(20) {
for scan in scans {
if scan.status == "completed" {
if let Some(json) = scan.findings_json.as_deref() {
if let Ok(diags) = serde_json::from_str::>(json) {
return LoadedFindings {
- cache_key: format!("{DB_FALLBACK_KEY}:{}", scan.id),
+ cache_key: format!("{root_key}:{DB_FALLBACK_KEY}:{}", scan.id),
findings: Arc::new(diags),
};
}
@@ -65,7 +73,7 @@ fn load_latest_findings_internal(state: &AppState) -> LoadedFindings {
}
}
LoadedFindings {
- cache_key: DB_FALLBACK_KEY.to_string(),
+ cache_key: format!("{root_key}:{DB_FALLBACK_KEY}"),
findings: Arc::new(Vec::new()),
}
}
@@ -120,8 +128,8 @@ fn cached_for_latest(state: &AppState) -> CachedFindings {
/// the cached views so concurrent readers see consistent data and the cache
/// stays valid across triage edits.
fn apply_triage_overlay(state: &AppState, views: &mut [FindingView]) {
- if let Some(ref pool) = state.db_pool {
- if let Ok(idx) = Indexer::from_pool("_triage", pool) {
+ if let Some(pool) = state.active_db_pool() {
+ if let Ok(idx) = Indexer::from_pool("_triage", &pool) {
let triage_map = idx.get_all_triage_states().unwrap_or_default();
let rules = idx.get_suppression_rules().unwrap_or_default();
overlay_triage_states(views, &triage_map, &rules);
@@ -270,7 +278,8 @@ async fn get_finding(
let diag = findings
.get(index)
.ok_or_else(|| ApiError::not_found(format!("finding {index} not found")))?;
- let mut view = finding_from_diag_with_detail(index, diag, &state.scan_root, &findings);
+ let scan_root = state.active_scan_root();
+ let mut view = finding_from_diag_with_detail(index, diag, &scan_root, &findings);
apply_triage_overlay(&state, std::slice::from_mut(&mut view));
Ok(Json(view))
}
diff --git a/src/server/routes/health.rs b/src/server/routes/health.rs
index a835ceea..46e9855e 100644
--- a/src/server/routes/health.rs
+++ b/src/server/routes/health.rs
@@ -13,7 +13,7 @@ async fn health_check(State(state): State) -> Json
Json(serde_json::json!({
"status": "ok",
"version": env!("CARGO_PKG_VERSION"),
- "scan_root": state.scan_root.display().to_string(),
+ "scan_root": state.active_scan_root().display().to_string(),
}))
}
diff --git a/src/server/routes/mod.rs b/src/server/routes/mod.rs
index 7986edad..bff3a60b 100644
--- a/src/server/routes/mod.rs
+++ b/src/server/routes/mod.rs
@@ -9,6 +9,7 @@ pub mod overview;
pub mod rules;
pub mod scans;
pub mod surface;
+pub mod targets;
pub mod triage;
use crate::server::app::AppState;
@@ -28,5 +29,6 @@ pub fn api_routes() -> Router {
.merge(overview::routes())
.merge(explorer::routes())
.merge(surface::routes())
+ .merge(targets::routes())
.merge(debug::routes())
}
diff --git a/src/server/routes/overview.rs b/src/server/routes/overview.rs
index 55b85866..9a08cae8 100644
--- a/src/server/routes/overview.rs
+++ b/src/server/routes/overview.rs
@@ -176,8 +176,8 @@ async fn overview(State(state): State) -> Json {
async fn overview_trends(State(state): State) -> Json> {
let mut points = Vec::new();
- if let Some(ref pool) = state.db_pool {
- if let Ok(idx) = Indexer::from_pool("_scans", pool) {
+ if let Some(pool) = state.active_db_pool() {
+ if let Ok(idx) = Indexer::from_pool("_scans", &pool) {
if let Ok(scans) = idx.list_scans(20) {
let completed: Vec<&ScanRecord> =
scans.iter().filter(|s| s.status == "completed").collect();
@@ -238,10 +238,9 @@ fn set_baseline_inner(state: &AppState, scan_id: &str) -> Result Result) -> Result {
let pool = state
- .db_pool
- .as_ref()
+ .active_db_pool()
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
- let idx = Indexer::from_pool("_scans", pool).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
+ let idx = Indexer::from_pool("_scans", &pool).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
idx.delete_metadata(BASELINE_KEY)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(StatusCode::NO_CONTENT)
@@ -284,10 +282,10 @@ impl ScanHistory {
let mut scans = Vec::new();
let mut first_seen: HashMap = HashMap::new();
- let Some(ref pool) = state.db_pool else {
+ let Some(pool) = state.active_db_pool() else {
return Self { scans, first_seen };
};
- let Ok(idx) = Indexer::from_pool("_scans", pool) else {
+ let Ok(idx) = Indexer::from_pool("_scans", &pool) else {
return Self { scans, first_seen };
};
@@ -408,10 +406,11 @@ impl ScanHistory {
fn collect_recent_scans(state: &AppState, limit: usize) -> Vec {
let mut seen = HashSet::new();
let mut scans = Vec::new();
+ let scan_root = state.active_scan_root();
// In-memory first
for job in state.job_manager.list_jobs() {
- if seen.insert(job.id.clone()) {
+ if job.scan_root == scan_root && seen.insert(job.id.clone()) {
scans.push(ScanSummary {
id: job.id.clone(),
status: format!("{:?}", job.status).to_ascii_lowercase(),
@@ -423,8 +422,8 @@ fn collect_recent_scans(state: &AppState, limit: usize) -> Vec {
}
// DB fallback
- if let Some(ref pool) = state.db_pool {
- if let Ok(idx) = Indexer::from_pool("_scans", pool) {
+ if let Some(pool) = state.active_db_pool() {
+ if let Ok(idx) = Indexer::from_pool("_scans", &pool) {
if let Ok(records) = idx.list_scans(limit as i64) {
for r in records {
if seen.insert(r.id.clone()) {
@@ -452,10 +451,10 @@ fn compute_triage_coverage(state: &AppState, findings: &[Diag]) -> f64 {
return 0.0;
}
- let Some(ref pool) = state.db_pool else {
+ let Some(pool) = state.active_db_pool() else {
return 0.0;
};
- let Ok(idx) = Indexer::from_pool("_scans", pool) else {
+ let Ok(idx) = Indexer::from_pool("_scans", &pool) else {
return 0.0;
};
@@ -497,10 +496,10 @@ fn compute_noisy_rules(
findings: &[Diag],
by_rule: &HashMap,
) -> Vec {
- let Some(ref pool) = state.db_pool else {
+ let Some(pool) = state.active_db_pool() else {
return vec![];
};
- let Ok(idx) = Indexer::from_pool("_scans", pool) else {
+ let Ok(idx) = Indexer::from_pool("_scans", &pool) else {
return vec![];
};
@@ -766,8 +765,8 @@ fn compute_scanner_quality(
findings: &[Diag],
latest_scan_id: Option<&str>,
) -> Option {
- let pool = state.db_pool.as_ref()?;
- let idx = Indexer::from_pool("_scans", pool).ok()?;
+ let pool = state.active_db_pool()?;
+ let idx = Indexer::from_pool("_scans", &pool).ok()?;
let mut files_scanned = 0u64;
let mut files_skipped = 0u64;
@@ -887,10 +886,10 @@ fn compute_suppression_hygiene(state: &AppState, findings: &[Diag]) -> Suppressi
if findings.is_empty() {
return hygiene;
}
- let Some(ref pool) = state.db_pool else {
+ let Some(pool) = state.active_db_pool() else {
return hygiene;
};
- let Ok(idx) = Indexer::from_pool("_scans", pool) else {
+ let Ok(idx) = Indexer::from_pool("_scans", &pool) else {
return hygiene;
};
let triage_map = idx.get_all_triage_states().unwrap_or_default();
@@ -950,8 +949,8 @@ fn compute_backlog(state: &AppState, findings: &[Diag], history: &ScanHistory) -
// Pull DB-cached first_seen first; fall back to in-memory history map.
let fingerprints: Vec = findings.iter().map(compute_fingerprint).collect();
let mut cached: HashMap = HashMap::new();
- if let Some(ref pool) = state.db_pool {
- if let Ok(idx) = Indexer::from_pool("_scans", pool) {
+ if let Some(pool) = state.active_db_pool() {
+ if let Ok(idx) = Indexer::from_pool("_scans", &pool) {
cached = idx.get_first_seen_map(&fingerprints).unwrap_or_default();
}
}
@@ -1013,8 +1012,8 @@ fn compute_backlog(state: &AppState, findings: &[Diag], history: &ScanHistory) -
}
fn compute_baseline_info(state: &AppState, findings: &[Diag]) -> Option {
- let pool = state.db_pool.as_ref()?;
- let idx = Indexer::from_pool("_scans", pool).ok()?;
+ let pool = state.active_db_pool()?;
+ let idx = Indexer::from_pool("_scans", &pool).ok()?;
let scan_id = idx.get_metadata(BASELINE_KEY).ok().flatten()?;
if scan_id.is_empty() {
return None;
diff --git a/src/server/routes/scans.rs b/src/server/routes/scans.rs
index d011a5ed..b155b408 100644
--- a/src/server/routes/scans.rs
+++ b/src/server/routes/scans.rs
@@ -9,6 +9,7 @@ use crate::server::models::{
};
use crate::server::progress::ScanMetricsSnapshot;
use crate::server::scan_log::ScanLogEntry;
+use crate::utils::targets::{TargetTouch, remember_target};
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::routing::{get, post};
@@ -130,7 +131,10 @@ async fn start_scan(
body: Option>,
) -> Result<(StatusCode, Json), (StatusCode, Json)> {
let req = body.map(|b| b.0).unwrap_or_default();
- let scan_root = resolve_requested_scan_root(req.scan_root.as_deref(), &state.scan_root)?;
+ let active_root = state.active_scan_root();
+ let scan_root = resolve_requested_scan_root(req.scan_root.as_deref(), &active_root)?;
+ let _ = remember_target(&state.database_dir, &scan_root, TargetTouch::Scanned);
+ state.set_active_scan_root(scan_root.clone());
let mut config = state.config.read().clone();
if let Some(ref mode) = req.mode {
@@ -176,7 +180,7 @@ async fn start_scan(
}
let event_tx = state.event_tx.clone();
- let db_pool = state.db_pool.clone();
+ let db_pool = state.db_pool_for(&scan_root);
let database_dir = state.database_dir.clone();
match state
@@ -196,22 +200,19 @@ async fn start_scan(
fn resolve_requested_scan_root(
requested_root: Option<&str>,
- configured_root: &Path,
+ active_root: &Path,
) -> Result)> {
if let Some(root) = requested_root {
let requested = Path::new(root)
.canonicalize()
.map_err(|_| bad_request("invalid scan_root"))?;
- if requested != configured_root {
- return Err(bad_request(
- "scan_root must match the repository passed to nyx serve",
- ));
+ if !requested.is_dir() {
+ return Err(bad_request("scan_root must be a directory"));
}
+ return Ok(requested);
}
- // The request value is validation-only; scans always run against the
- // canonical root configured when the server started.
- Ok(configured_root.to_path_buf())
+ Ok(active_root.to_path_buf())
}
fn bad_request(message: &str) -> (StatusCode, Json) {
@@ -222,16 +223,18 @@ fn bad_request(message: &str) -> (StatusCode, Json) {
}
async fn list_scans(State(state): State) -> Json> {
+ let scan_root = state.active_scan_root();
let mut views: Vec = state
.job_manager
.list_jobs()
- .iter()
- .map(|j| job_to_view(j))
+ .into_iter()
+ .filter(|j| j.scan_root == scan_root)
+ .map(|j| job_to_view(&j))
.collect();
// Merge historical scans from DB (deduplicate by ID)
- if let Some(ref pool) = state.db_pool {
- if let Ok(idx) = Indexer::from_pool("_scans", pool) {
+ if let Some(pool) = state.active_db_pool() {
+ if let Ok(idx) = Indexer::from_pool("_scans", &pool) {
if let Ok(records) = idx.list_scans(100) {
let in_memory_ids: HashSet = views.iter().map(|v| v.id.clone()).collect();
for record in records {
@@ -250,9 +253,11 @@ async fn list_scans(State(state): State) -> Json> {
}
async fn active_scan(State(state): State) -> Result, StatusCode> {
+ let scan_root = state.active_scan_root();
let job = state
.job_manager
.active_job()
+ .filter(|job| job.scan_root == scan_root)
.ok_or(StatusCode::NOT_FOUND)?;
Ok(Json(job_to_view(&job)))
}
@@ -261,14 +266,17 @@ async fn get_scan(
State(state): State,
axum::extract::Path(id): axum::extract::Path,
) -> Result, StatusCode> {
+ let scan_root = state.active_scan_root();
// Check in-memory first
if let Some(job) = state.job_manager.get_job(&id) {
- return Ok(Json(job_to_view(&job)));
+ if job.scan_root == scan_root {
+ return Ok(Json(job_to_view(&job)));
+ }
}
// Fall back to DB
- if let Some(ref pool) = state.db_pool {
- if let Ok(idx) = Indexer::from_pool("_scans", pool) {
+ if let Some(pool) = state.active_db_pool() {
+ if let Ok(idx) = Indexer::from_pool("_scans", &pool) {
if let Ok(Some(record)) = idx.get_scan(&id) {
let mut view = scan_record_to_view(&record);
// Load metrics from DB
@@ -299,8 +307,8 @@ async fn delete_scan(
}
// Delete from DB (CASCADE handles metrics + logs)
- if let Some(ref pool) = state.db_pool {
- if let Ok(idx) = Indexer::from_pool("_scans", pool) {
+ if let Some(pool) = state.active_db_pool() {
+ if let Ok(idx) = Indexer::from_pool("_scans", &pool) {
let _ = idx.delete_scan(&id);
}
}
@@ -319,11 +327,14 @@ struct FindingsQuery {
/// Load findings for a scan by ID (in-memory first, then DB fallback).
fn load_scan_findings(state: &AppState, id: &str) -> Result, StatusCode> {
+ let scan_root = state.active_scan_root();
if let Some(job) = state.job_manager.get_job(id) {
- return Ok(job.findings.map(|f| (*f).clone()).unwrap_or_default());
+ if job.scan_root == scan_root {
+ return Ok(job.findings.map(|f| (*f).clone()).unwrap_or_default());
+ }
}
- if let Some(ref pool) = state.db_pool {
- if let Ok(idx) = Indexer::from_pool("_scans", pool) {
+ if let Some(pool) = state.active_db_pool() {
+ if let Ok(idx) = Indexer::from_pool("_scans", &pool) {
if let Ok(Some(record)) = idx.get_scan(id) {
return Ok(record
.findings_json
@@ -338,15 +349,18 @@ fn load_scan_findings(state: &AppState, id: &str) -> Result, StatusCod
/// Load minimal scan info for comparison headers.
fn load_scan_info(state: &AppState, id: &str) -> Result {
+ let scan_root = state.active_scan_root();
if let Some(job) = state.job_manager.get_job(id) {
- return Ok(CompareScanInfo {
- id: job.id.clone(),
- started_at: job.started_at.map(|t| t.to_rfc3339()),
- finding_count: job.findings.as_ref().map(|f| f.len()).unwrap_or(0),
- });
+ if job.scan_root == scan_root {
+ return Ok(CompareScanInfo {
+ id: job.id.clone(),
+ started_at: job.started_at.map(|t| t.to_rfc3339()),
+ finding_count: job.findings.as_ref().map(|f| f.len()).unwrap_or(0),
+ });
+ }
}
- if let Some(ref pool) = state.db_pool {
- if let Ok(idx) = Indexer::from_pool("_scans", pool) {
+ if let Some(pool) = state.active_db_pool() {
+ if let Ok(idx) = Indexer::from_pool("_scans", &pool) {
if let Ok(Some(record)) = idx.get_scan(id) {
return Ok(CompareScanInfo {
id: record.id.clone(),
@@ -390,13 +404,14 @@ async fn get_scan_findings(
let page = query.page.unwrap_or(1).max(1);
let per_page = query.per_page.unwrap_or(50).min(200);
let start = (page - 1) * per_page;
+ let scan_root = state.active_scan_root();
let page_findings: Vec = filtered
.into_iter()
.enumerate()
.skip(start)
.take(per_page)
- .map(|(i, d)| models::finding_from_diag_with_context(i, d, &state.scan_root))
+ .map(|(i, d)| models::finding_from_diag_with_context(i, d, &scan_root))
.collect();
Ok(Json(serde_json::json!({
@@ -424,6 +439,7 @@ async fn compare_scans(
let left_findings = load_scan_findings(&state, &query.left)?;
let right_findings = load_scan_findings(&state, &query.right)?;
+ let scan_root = state.active_scan_root();
// Build fingerprint → Vec<(index, diag)> multi-maps so duplicate
// fingerprints are preserved instead of silently dropped.
@@ -457,7 +473,7 @@ async fn compare_scans(
for i in 0..matched {
let (idx, diag) = right_group[i];
let (_, left_diag) = left_group[i];
- let view = models::finding_from_diag_with_context(idx, diag, &state.scan_root);
+ let view = models::finding_from_diag_with_context(idx, diag, &scan_root);
let changes = compute_field_changes(left_diag, diag);
if changes.is_empty() {
unchanged_findings.push(ComparedFinding {
@@ -476,7 +492,7 @@ async fn compare_scans(
for &(idx, diag) in &right_group[matched..] {
new_findings.push(ComparedFinding {
fingerprint: fp.clone(),
- finding: models::finding_from_diag_with_context(idx, diag, &state.scan_root),
+ finding: models::finding_from_diag_with_context(idx, diag, &scan_root),
});
}
} else {
@@ -484,7 +500,7 @@ async fn compare_scans(
for &(idx, diag) in right_group {
new_findings.push(ComparedFinding {
fingerprint: fp.clone(),
- finding: models::finding_from_diag_with_context(idx, diag, &state.scan_root),
+ finding: models::finding_from_diag_with_context(idx, diag, &scan_root),
});
}
}
@@ -498,7 +514,7 @@ async fn compare_scans(
for &(idx, diag) in &left_group[start..] {
fixed_findings.push(ComparedFinding {
fingerprint: fp.clone(),
- finding: models::finding_from_diag_with_context(idx, diag, &state.scan_root),
+ finding: models::finding_from_diag_with_context(idx, diag, &scan_root),
});
}
}
@@ -593,9 +609,12 @@ async fn get_scan_logs(
axum::extract::Path(id): axum::extract::Path,
Query(query): Query,
) -> Result>, StatusCode> {
+ let scan_root = state.active_scan_root();
// Check in-memory (running scan)
if let Some(job) = state.job_manager.get_job(&id) {
- if let Some(ref collector) = job.log_collector {
+ if job.scan_root == scan_root
+ && let Some(ref collector) = job.log_collector
+ {
let mut logs = collector.snapshot();
if let Some(ref level) = query.level {
logs.retain(|l| l.level.to_string().eq_ignore_ascii_case(level));
@@ -605,8 +624,8 @@ async fn get_scan_logs(
}
// Fall back to DB
- if let Some(ref pool) = state.db_pool {
- if let Ok(idx) = Indexer::from_pool("_scans", pool) {
+ if let Some(pool) = state.active_db_pool() {
+ if let Ok(idx) = Indexer::from_pool("_scans", &pool) {
if let Ok(logs) = idx.get_scan_logs(&id, query.level.as_deref()) {
return Ok(Json(logs));
}
@@ -620,16 +639,19 @@ async fn get_scan_metrics(
State(state): State,
axum::extract::Path(id): axum::extract::Path,
) -> Result, StatusCode> {
+ let scan_root = state.active_scan_root();
// Check in-memory (running scan)
if let Some(job) = state.job_manager.get_job(&id) {
- if let Some(ref metrics) = job.metrics {
+ if job.scan_root == scan_root
+ && let Some(ref metrics) = job.metrics
+ {
return Ok(Json(metrics.snapshot()));
}
}
// Fall back to DB
- if let Some(ref pool) = state.db_pool {
- if let Ok(idx) = Indexer::from_pool("_scans", pool) {
+ if let Some(pool) = state.active_db_pool() {
+ if let Ok(idx) = Indexer::from_pool("_scans", &pool) {
if let Ok(Some(metrics)) = idx.get_scan_metrics(&id) {
return Ok(Json(metrics));
}
@@ -710,7 +732,7 @@ mod tests {
}
#[test]
- fn resolve_requested_scan_root_accepts_matching_root_but_uses_configured_path() {
+ fn resolve_requested_scan_root_accepts_matching_root() {
let dir = tempfile::tempdir().unwrap();
let configured = dir.path().canonicalize().unwrap();
let requested = dir.path().join(".");
@@ -723,21 +745,17 @@ mod tests {
}
#[test]
- fn resolve_requested_scan_root_rejects_different_root() {
+ fn resolve_requested_scan_root_accepts_different_root() {
let configured_dir = tempfile::tempdir().unwrap();
let other_dir = tempfile::tempdir().unwrap();
let configured = configured_dir.path().canonicalize().unwrap();
- let err = resolve_requested_scan_root(
+ let resolved = resolve_requested_scan_root(
Some(other_dir.path().to_string_lossy().as_ref()),
&configured,
)
- .unwrap_err();
+ .unwrap();
- assert_eq!(err.0, StatusCode::BAD_REQUEST);
- assert_eq!(
- err.1.0["error"],
- "scan_root must match the repository passed to nyx serve"
- );
+ assert_eq!(resolved, other_dir.path().canonicalize().unwrap());
}
}
diff --git a/src/server/routes/surface.rs b/src/server/routes/surface.rs
index 155ca42e..b2b94415 100644
--- a/src/server/routes/surface.rs
+++ b/src/server/routes/surface.rs
@@ -20,7 +20,7 @@ pub fn routes() -> Router {
}
async fn get_surface(State(state): State) -> ApiResult> {
- let scan_root = state.scan_root.clone();
+ let scan_root = state.active_scan_root();
let database_dir = state.database_dir.clone();
let cfg = state.config.read().clone();
diff --git a/src/server/routes/targets.rs b/src/server/routes/targets.rs
new file mode 100644
index 00000000..93102bff
--- /dev/null
+++ b/src/server/routes/targets.rs
@@ -0,0 +1,159 @@
+use crate::server::app::AppState;
+use crate::server::error::{ApiError, ApiResult};
+use crate::utils::targets::{
+ TargetRecord, TargetTouch, load_targets, remember_target, remove_target, target_id_for_path,
+};
+use axum::extract::{Path, State};
+use axum::routing::{delete, get, post};
+use axum::{Json, Router};
+use serde::{Deserialize, Serialize};
+use std::path::{Path as FsPath, PathBuf};
+
+pub fn routes() -> Router {
+ Router::new()
+ .route("/targets", get(list_targets).post(add_target))
+ .route("/targets/select", post(select_target))
+ .route("/targets/{id}", delete(delete_target))
+}
+
+#[derive(Debug, Serialize)]
+struct TargetView {
+ id: String,
+ name: String,
+ path: String,
+ db_path: String,
+ last_seen_at: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ last_scan_at: Option,
+ active: bool,
+ exists: bool,
+}
+
+#[derive(Debug, Deserialize)]
+struct TargetPathRequest {
+ path: String,
+}
+
+#[derive(Debug, Deserialize)]
+struct SelectTargetRequest {
+ id: Option,
+ path: Option,
+}
+
+async fn list_targets(State(state): State) -> ApiResult>> {
+ ensure_active_target_record(&state)?;
+ let active = state.active_scan_root();
+ let targets = load_targets(&state.database_dir)
+ .map_err(|e| ApiError::internal(format!("failed to load targets: {e}")))?;
+ Ok(Json(targets_to_views(&targets, &active)))
+}
+
+async fn add_target(
+ State(state): State,
+ Json(body): Json,
+) -> ApiResult> {
+ let path = canonical_project_path(&body.path)?;
+ let record = remember_target(&state.database_dir, &path, TargetTouch::Seen)
+ .map_err(|e| ApiError::internal(format!("failed to remember target: {e}")))?;
+ let _ = state.db_pool_for(&path);
+ Ok(Json(record_to_view(&record, &state.active_scan_root())))
+}
+
+async fn select_target(
+ State(state): State,
+ Json(body): Json,
+) -> ApiResult> {
+ let path = if let Some(id) = body.id.as_deref() {
+ target_path_by_id(&state, id)?
+ } else if let Some(path) = body.path.as_deref() {
+ canonical_project_path(path)?
+ } else {
+ return Err(ApiError::bad_request("target id or path is required"));
+ };
+
+ let record = remember_target(&state.database_dir, &path, TargetTouch::Seen)
+ .map_err(|e| ApiError::internal(format!("failed to remember target: {e}")))?;
+ state.set_active_scan_root(path.clone());
+ let _ = state.db_pool_for(&path);
+ Ok(Json(record_to_view(&record, &path)))
+}
+
+async fn delete_target(
+ State(state): State,
+ Path(id): Path,
+) -> ApiResult> {
+ let removed = remove_target(&state.database_dir, &id)
+ .map_err(|e| ApiError::internal(format!("failed to remove target: {e}")))?;
+ if removed.is_none() {
+ return Err(ApiError::not_found(format!("target {id} not found")));
+ }
+ Ok(Json(serde_json::json!({ "status": "deleted", "id": id })))
+}
+
+fn ensure_active_target_record(state: &AppState) -> ApiResult<()> {
+ let active = state.active_scan_root();
+ let active_id = target_id_for_path(&active);
+ let targets = load_targets(&state.database_dir)
+ .map_err(|e| ApiError::internal(format!("failed to load targets: {e}")))?;
+ if targets.iter().any(|target| target.id == active_id) {
+ return Ok(());
+ }
+ remember_target(&state.database_dir, &active, TargetTouch::Seen)
+ .map(|_| ())
+ .map_err(|e| ApiError::internal(format!("failed to remember active target: {e}")))
+}
+
+fn canonical_project_path(path: &str) -> ApiResult {
+ let trimmed = path.trim();
+ if trimmed.is_empty() {
+ return Err(ApiError::bad_request("path is required"));
+ }
+ let path = FsPath::new(trimmed)
+ .canonicalize()
+ .map_err(|_| ApiError::bad_request("path does not exist"))?;
+ if !path.is_dir() {
+ return Err(ApiError::bad_request("path must be a directory"));
+ }
+ Ok(path)
+}
+
+fn target_path_by_id(state: &AppState, id: &str) -> ApiResult {
+ let targets = load_targets(&state.database_dir)
+ .map_err(|e| ApiError::internal(format!("failed to load targets: {e}")))?;
+ let record = targets
+ .iter()
+ .find(|target| target.id == id)
+ .ok_or_else(|| ApiError::not_found(format!("target {id} not found")))?;
+ let path = canonical_project_path(&record.path)?;
+ if target_id_for_path(&path) != id {
+ return Err(ApiError::bad_request("target path no longer matches id"));
+ }
+ Ok(path)
+}
+
+fn targets_to_views(targets: &[TargetRecord], active: &FsPath) -> Vec {
+ targets
+ .iter()
+ .map(|record| record_to_view(record, active))
+ .collect()
+}
+
+fn record_to_view(record: &TargetRecord, active: &FsPath) -> TargetView {
+ let target_path = FsPath::new(&record.path);
+ let active = active
+ .canonicalize()
+ .unwrap_or_else(|_| active.to_path_buf());
+ let target_canonical = target_path
+ .canonicalize()
+ .unwrap_or_else(|_| target_path.to_path_buf());
+ TargetView {
+ id: record.id.clone(),
+ name: record.name.clone(),
+ path: record.path.clone(),
+ db_path: record.db_path.clone(),
+ last_seen_at: record.last_seen_at.clone(),
+ last_scan_at: record.last_scan_at.clone(),
+ active: target_canonical == active,
+ exists: target_path.is_dir(),
+ }
+}
diff --git a/src/server/routes/triage.rs b/src/server/routes/triage.rs
index ead1a63e..6f6879b4 100644
--- a/src/server/routes/triage.rs
+++ b/src/server/routes/triage.rs
@@ -50,12 +50,12 @@ async fn set_triage(
));
}
- let pool = state.db_pool.as_ref().ok_or((
+ let pool = state.active_db_pool().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
Json(serde_json::json!({ "error": "database not available" })),
))?;
- let idx = Indexer::from_pool("_triage", pool).map_err(|e| {
+ let idx = Indexer::from_pool("_triage", &pool).map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": e.to_string() })),
@@ -100,10 +100,10 @@ async fn list_triage(
Query(query): Query,
) -> Result, StatusCode> {
let pool = state
- .db_pool
- .as_ref()
+ .active_db_pool()
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
- let idx = Indexer::from_pool("_triage", pool).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
+ let idx =
+ Indexer::from_pool("_triage", &pool).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let page = query.page.unwrap_or(1).max(1);
let per_page = query.per_page.unwrap_or(50).clamp(1, 500);
@@ -167,10 +167,10 @@ async fn get_audit_log(
Query(query): Query,
) -> Result, StatusCode> {
let pool = state
- .db_pool
- .as_ref()
+ .active_db_pool()
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
- let idx = Indexer::from_pool("_triage", pool).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
+ let idx =
+ Indexer::from_pool("_triage", &pool).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let page = query.page.unwrap_or(1).max(1);
let per_page = query.per_page.unwrap_or(50).clamp(1, 500);
@@ -210,12 +210,12 @@ async fn add_suppression(
));
}
- let pool = state.db_pool.as_ref().ok_or((
+ let pool = state.active_db_pool().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
Json(serde_json::json!({ "error": "database not available" })),
))?;
- let idx = Indexer::from_pool("_triage", pool).map_err(|e| {
+ let idx = Indexer::from_pool("_triage", &pool).map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": e.to_string() })),
@@ -277,10 +277,10 @@ async fn list_suppressions(
State(state): State,
) -> Result, StatusCode> {
let pool = state
- .db_pool
- .as_ref()
+ .active_db_pool()
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
- let idx = Indexer::from_pool("_triage", pool).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
+ let idx =
+ Indexer::from_pool("_triage", &pool).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let rules = idx
.get_suppression_rules()
@@ -301,10 +301,10 @@ async fn remove_suppression(
Query(query): Query,
) -> Result, StatusCode> {
let pool = state
- .db_pool
- .as_ref()
+ .active_db_pool()
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
- let idx = Indexer::from_pool("_triage", pool).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
+ let idx =
+ Indexer::from_pool("_triage", &pool).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let deleted = idx
.delete_suppression_rule(query.id)
@@ -323,9 +323,10 @@ fn auto_sync_to_file(state: &AppState) {
if !sync_enabled {
return;
}
- if let Some(ref pool) = state.db_pool {
+ if let Some(pool) = state.active_db_pool() {
+ let scan_root = state.active_scan_root();
let findings = load_latest_findings(state);
- let _ = crate::server::triage_sync::sync_to_file(pool, &findings, &state.scan_root);
+ let _ = crate::server::triage_sync::sync_to_file(&pool, &findings, &scan_root);
}
}
@@ -334,28 +335,29 @@ fn auto_sync_to_file(state: &AppState) {
async fn export_triage_file(
State(state): State,
) -> Result, (StatusCode, Json)> {
- let pool = state.db_pool.as_ref().ok_or((
+ let pool = state.active_db_pool().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
Json(serde_json::json!({ "error": "database not available" })),
))?;
let findings = load_latest_findings(&state);
- let file = crate::server::triage_sync::export_triage(pool, &findings, &state.scan_root)
- .map_err(|e| {
+ let scan_root = state.active_scan_root();
+ let file =
+ crate::server::triage_sync::export_triage(&pool, &findings, &scan_root).map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": e })),
)
})?;
- crate::server::triage_sync::save_triage_file(&state.scan_root, &file).map_err(|e| {
+ crate::server::triage_sync::save_triage_file(&scan_root, &file).map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": e })),
)
})?;
- let path = crate::server::triage_sync::triage_file_path(&state.scan_root).map_err(|e| {
+ let path = crate::server::triage_sync::triage_file_path(&scan_root).map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": e })),
@@ -373,12 +375,13 @@ async fn export_triage_file(
async fn import_triage_file(
State(state): State,
) -> Result, (StatusCode, Json)> {
- let pool = state.db_pool.as_ref().ok_or((
+ let pool = state.active_db_pool().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
Json(serde_json::json!({ "error": "database not available" })),
))?;
- let file = crate::server::triage_sync::load_triage_file_checked(&state.scan_root)
+ let scan_root = state.active_scan_root();
+ let file = crate::server::triage_sync::load_triage_file_checked(&scan_root)
.map_err(|e| {
(
StatusCode::BAD_REQUEST,
@@ -391,14 +394,13 @@ async fn import_triage_file(
))?;
let findings = load_latest_findings(&state);
- let applied =
- crate::server::triage_sync::import_triage(pool, &findings, &state.scan_root, &file)
- .map_err(|e| {
- (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(serde_json::json!({ "error": e })),
- )
- })?;
+ let applied = crate::server::triage_sync::import_triage(&pool, &findings, &scan_root, &file)
+ .map_err(|e| {
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(serde_json::json!({ "error": e })),
+ )
+ })?;
Ok(Json(serde_json::json!({
"imported": applied,
@@ -410,8 +412,9 @@ async fn import_triage_file(
// ── GET /api/triage/sync-status ─────────────────────────────────────────────
async fn get_sync_status(State(state): State) -> Json {
- let path = crate::server::triage_sync::triage_file_path(&state.scan_root).ok();
- let file = crate::server::triage_sync::load_triage_file(&state.scan_root);
+ let scan_root = state.active_scan_root();
+ let path = crate::server::triage_sync::triage_file_path(&scan_root).ok();
+ let file = crate::server::triage_sync::load_triage_file(&scan_root);
let sync_enabled = state.config.read().server.triage_sync;
Json(serde_json::json!({
diff --git a/src/utils/mod.rs b/src/utils/mod.rs
index 137bac33..f572f020 100644
--- a/src/utils/mod.rs
+++ b/src/utils/mod.rs
@@ -20,6 +20,7 @@ pub mod project;
pub(crate) mod query_cache;
pub mod redact;
pub(crate) mod snippet;
+pub mod targets;
pub use analysis_options::{AnalysisOptions, SymexOptions};
pub use config::Config;
diff --git a/src/utils/targets.rs b/src/utils/targets.rs
new file mode 100644
index 00000000..eef0d151
--- /dev/null
+++ b/src/utils/targets.rs
@@ -0,0 +1,161 @@
+use crate::errors::{NyxError, NyxResult};
+use crate::utils::project::{get_project_info, sanitize_project_name};
+use chrono::Utc;
+use serde::{Deserialize, Serialize};
+use std::fs;
+use std::path::{Path, PathBuf};
+
+const TARGETS_FILE: &str = "targets.json";
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum TargetTouch {
+ Seen,
+ Scanned,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct TargetRecord {
+ pub id: String,
+ pub name: String,
+ pub path: String,
+ pub db_path: String,
+ pub last_seen_at: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub last_scan_at: Option,
+}
+
+#[derive(Debug, Default, Serialize, Deserialize)]
+struct TargetFile {
+ #[serde(default)]
+ targets: Vec,
+}
+
+pub fn targets_path(database_dir: &Path) -> PathBuf {
+ database_dir.join(TARGETS_FILE)
+}
+
+pub fn load_targets(database_dir: &Path) -> NyxResult> {
+ let path = targets_path(database_dir);
+ if !path.exists() {
+ return Ok(Vec::new());
+ }
+ let bytes = fs::read(path)?;
+ if bytes.is_empty() {
+ return Ok(Vec::new());
+ }
+ let file: TargetFile =
+ serde_json::from_slice(&bytes).map_err(|e| NyxError::Other(Box::new(e)))?;
+ Ok(file.targets)
+}
+
+pub fn save_targets(database_dir: &Path, targets: &[TargetRecord]) -> NyxResult<()> {
+ fs::create_dir_all(database_dir)?;
+ let path = targets_path(database_dir);
+ let file = TargetFile {
+ targets: targets.to_vec(),
+ };
+ let bytes = serde_json::to_vec_pretty(&file).map_err(|e| NyxError::Other(Box::new(e)))?;
+ fs::write(path, bytes)?;
+ Ok(())
+}
+
+pub fn remember_target(
+ database_dir: &Path,
+ project_path: &Path,
+ touch: TargetTouch,
+) -> NyxResult {
+ let canonical = project_path.canonicalize()?;
+ let path_str = canonical.to_string_lossy().to_string();
+ let now = Utc::now().to_rfc3339();
+ let (_, db_path) = get_project_info(&canonical, database_dir)?;
+ let mut targets = load_targets(database_dir)?;
+ let id = target_id_for_path(&canonical);
+
+ let mut record = TargetRecord {
+ id: id.clone(),
+ name: display_name_for_path(&canonical),
+ path: path_str.clone(),
+ db_path: db_path.to_string_lossy().to_string(),
+ last_seen_at: now.clone(),
+ last_scan_at: (touch == TargetTouch::Scanned).then_some(now.clone()),
+ };
+
+ if let Some(existing) = targets.iter_mut().find(|target| target.id == id) {
+ existing.name = record.name.clone();
+ existing.path = record.path.clone();
+ existing.db_path = record.db_path.clone();
+ existing.last_seen_at = now;
+ if touch == TargetTouch::Scanned {
+ existing.last_scan_at = record.last_scan_at.clone();
+ } else {
+ record.last_scan_at = existing.last_scan_at.clone();
+ }
+ record = existing.clone();
+ } else {
+ targets.push(record.clone());
+ }
+
+ targets.sort_by(|a, b| {
+ b.last_scan_at
+ .as_deref()
+ .unwrap_or(&b.last_seen_at)
+ .cmp(a.last_scan_at.as_deref().unwrap_or(&a.last_seen_at))
+ .then_with(|| a.name.cmp(&b.name))
+ });
+ save_targets(database_dir, &targets)?;
+ Ok(record)
+}
+
+pub fn remove_target(database_dir: &Path, id: &str) -> NyxResult