From 635b213825787a7b7843058c15b4c0b1ecbf29dd Mon Sep 17 00:00:00 2001 From: elipeter Date: Fri, 29 May 2026 13:14:29 -0500 Subject: [PATCH] refactor(server, scan): introduce target management with active target switching, enhance DB pool handling, and integrate target-aware task routes for improved modularity --- .claude/scheduled_tasks.lock | 1 - README.md | 10 + book.toml | 2 + docs/detectors.md | 11 + docs/how-it-works.md | 15 + docs/mermaid-init.js | 69 ++ docs/mermaid.css | 15 + docs/serve.md | 10 + frontend/src/api/queries/targets.ts | 42 ++ frontend/src/api/types.ts | 11 + frontend/src/components/layout/Sidebar.tsx | 177 +++++- frontend/src/contexts/SSEContext.tsx | 3 + frontend/src/styles/global.css | 159 +++++ frontend/tsconfig.tsbuildinfo | 2 +- src/commands/index.rs | 5 + src/commands/scan.rs | 8 +- src/commands/serve.rs | 21 +- src/dynamic/build_pool/go.rs | 9 +- src/dynamic/build_pool/rust.rs | 3 +- src/dynamic/harness.rs | 10 +- src/dynamic/runner.rs | 3 +- src/dynamic/sandbox/baseline.rs | 14 +- src/dynamic/spec.rs | 700 +++++++++++++++++++-- src/dynamic/trace.rs | 11 + src/dynamic/verify.rs | 43 +- src/server/app.rs | 55 +- src/server/routes/debug.rs | 27 +- src/server/routes/explorer.rs | 14 +- src/server/routes/files.rs | 3 +- src/server/routes/findings.rs | 27 +- src/server/routes/health.rs | 2 +- src/server/routes/mod.rs | 2 + src/server/routes/overview.rs | 49 +- src/server/routes/scans.rs | 116 ++-- src/server/routes/surface.rs | 2 +- src/server/routes/targets.rs | 159 +++++ src/server/routes/triage.rs | 73 +-- src/utils/mod.rs | 1 + src/utils/targets.rs | 161 +++++ tests/dynamic_workdir_clone.rs | 5 +- 40 files changed, 1810 insertions(+), 240 deletions(-) delete mode 100644 .claude/scheduled_tasks.lock create mode 100644 docs/mermaid-init.js create mode 100644 docs/mermaid.css create mode 100644 frontend/src/api/queries/targets.ts create mode 100644 src/server/routes/targets.rs create mode 100644 src/utils/targets.rs diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock deleted file mode 100644 index 9c05338b..00000000 --- a/.claude/scheduled_tasks.lock +++ /dev/null @@ -1 +0,0 @@ -{"sessionId":"6c158e05-a83e-4808-acf4-12ad7b0fe983","pid":8358,"procStart":"Fri May 29 15:24:35 2026","acquiredAt":1780071990470} \ No newline at end of file diff --git a/README.md b/README.md index 6aa05908..54141fb1 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,16 @@ Fixtures live under [`tests/benchmark/cve_corpus/`](tests/benchmark/cve_corpus/) Two passes over the filesystem, with an optional SQLite index to skip unchanged files: +```mermaid +flowchart LR + Repo["Repository files"] --> Pass1["Pass 1 per file
tree-sitter, CFG, SSA"] + Pass1 --> Summaries["Function summaries
sources, sinks, sanitizers, points-to"] + Summaries --> Index["SQLite index
optional incremental cache"] + Index --> Pass2["Pass 2 cross-file
global summaries, k=1 inline, SCC fixpoint"] + Pass2 --> Rank["Rank and dedupe
severity, evidence, exploitability"] + Rank --> Output["Console, JSON, SARIF
and browser UI"] +``` + 1. **Pass 1**: parse each file via tree-sitter, build an intra-procedural CFG (petgraph), lower to pruned SSA (Cytron phi insertion over dominance frontiers), and export per-function summaries (source/sanitizer/sink caps, taint transforms, points-to, callees). 2. **Summary merge**: union all per-file summaries into a `GlobalSummaries` map. 3. **Pass 2**: re-analyze each file with cross-file context under bounded context sensitivity (k=1 inlining for intra-file callees, SCC fixpoint capped at 64 iterations, and summary fallback for callees above the inline body-size cap). A forward dataflow worklist propagates taint through the SSA lattice with guaranteed convergence. Call-graph SCCs iterate to fixed-point (within the cap) so mutually recursive functions get accurate summaries. diff --git a/book.toml b/book.toml index a3e541da..3980d261 100644 --- a/book.toml +++ b/book.toml @@ -11,6 +11,8 @@ preferred-dark-theme = "navy" git-repository-url = "https://github.com/elicpeter/nyx" edit-url-template = "https://github.com/elicpeter/nyx/edit/master/{path}" site-url = "/nyx/" +additional-css = ["docs/mermaid.css"] +additional-js = ["docs/mermaid-init.js"] [output.html.fold] enable = true diff --git a/docs/detectors.md b/docs/detectors.md index 8bce55b4..7a2019dc 100644 --- a/docs/detectors.md +++ b/docs/detectors.md @@ -9,6 +9,17 @@ Nyx ships four independent detector families. They run together in `--mode full` | [State model](detectors/state.md) | `state-*` | Per-function state lattice | Use-after-close, double-close, leaks, unauthenticated access | | [AST patterns](detectors/patterns.md) | `..` | Tree-sitter structural match | Banned APIs, weak crypto, dangerous constructs | +```mermaid +flowchart LR + Taint["Taint analysis
cross-file source-to-sink"] --> Normalize["Normalize findings"] + Cfg["CFG structural
guards, exits, resource paths"] --> Normalize + State["State model
resource and auth lattice"] --> Normalize + Ast["AST patterns
tree-sitter structural match"] --> Normalize + Normalize --> Dedupe["Deduplicate
same site, rule, severity"] + Dedupe --> Rank["Rank
severity, evidence, context"] + Rank --> Output["Console, JSON, SARIF, UI"] +``` + The taint family is split into cap-specific rule classes when a sink callee carries multiple vulnerability classes: | Rule id | Cap | Surface | diff --git a/docs/how-it-works.md b/docs/how-it-works.md index f9dc9d70..35fa5315 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -6,6 +6,21 @@ If you're going to act on a finding, it helps to know how the scanner got there. A scan runs in two passes over the file tree, with an optional SQLite index that lets the second scan skip files whose content hash hasn't changed. +```mermaid +flowchart TD + Walk["Walk file tree"] --> Pass1["Pass 1 per file
tree-sitter parse, CFG, SSA"] + Pass1 --> Summaries["Per-function summaries
sources, sinks, sanitizers, returns, points-to"] + Pass1 --> Hierarchy["Type hierarchy index
extends, implements, impl-for, includes"] + Summaries --> Global["GlobalSummaries map
plus optional SQLite cache"] + Hierarchy --> Global + Global --> Pass2["Pass 2 per file
cross-file context"] + Pass2 --> Taint["Forward SSA taint worklist
finite lattice, guaranteed convergence"] + Pass2 --> Calls["Call precision
k=1 inline, summaries, SCC fixed-point"] + Taint --> Findings["Findings with evidence
source, path, sink, engine notes"] + Calls --> Findings + Findings --> Emit["Rank, dedupe, emit
console, JSON, SARIF, UI"] +``` + **Pass 1, per file.** Tree-sitter parses the file. Nyx builds an intra-procedural control-flow graph, lowers it to SSA, and extracts a summary per function describing what that function does at the boundary: which arguments flow to sinks, which sources it reads from, which sinks it calls, what taint it strips, what it returns. Summaries are persisted to SQLite ([`src/summary/`](https://github.com/elicpeter/nyx/tree/master/src/summary/), [`src/database.rs`](https://github.com/elicpeter/nyx/blob/master/src/database.rs)). **Summary merge.** All per-file summaries get unioned into a global map keyed by qualified function name. diff --git a/docs/mermaid-init.js b/docs/mermaid-init.js new file mode 100644 index 00000000..45a008fb --- /dev/null +++ b/docs/mermaid-init.js @@ -0,0 +1,69 @@ +(function () { + const MERMAID_URL = + "https://cdn.jsdelivr.net/npm/mermaid@10.9.3/dist/mermaid.esm.min.mjs"; + + async function renderMermaid() { + const blocks = Array.from( + document.querySelectorAll("pre > code.language-mermaid"), + ); + if (blocks.length === 0) { + return; + } + + try { + const mermaidModule = await import(MERMAID_URL); + const mermaid = mermaidModule.default; + + mermaid.initialize({ + startOnLoad: false, + securityLevel: "strict", + theme: "base", + themeVariables: { + background: "transparent", + fontFamily: + "Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif", + primaryColor: "#0f172a", + primaryTextColor: "#e5e7eb", + primaryBorderColor: "#22d3ee", + secondaryColor: "#134e4a", + secondaryTextColor: "#e5e7eb", + secondaryBorderColor: "#2dd4bf", + tertiaryColor: "#1e293b", + tertiaryTextColor: "#e5e7eb", + tertiaryBorderColor: "#64748b", + lineColor: "#94a3b8", + edgeLabelBackground: "#0f172a", + clusterBkg: "#111827", + clusterBorder: "#475569", + }, + }); + + for (const block of blocks) { + const pre = block.parentElement; + if (!pre) { + continue; + } + + const wrapper = document.createElement("div"); + wrapper.className = "nyx-mermaid"; + + const diagram = document.createElement("div"); + diagram.className = "mermaid"; + diagram.textContent = block.textContent.trim(); + + wrapper.appendChild(diagram); + pre.replaceWith(wrapper); + } + + await mermaid.run({ querySelector: ".nyx-mermaid .mermaid" }); + } catch (error) { + console.warn("Mermaid rendering failed", error); + } + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", renderMermaid); + } else { + renderMermaid(); + } +})(); diff --git a/docs/mermaid.css b/docs/mermaid.css new file mode 100644 index 00000000..9d160d6b --- /dev/null +++ b/docs/mermaid.css @@ -0,0 +1,15 @@ +.nyx-mermaid { + margin: 1.5rem 0; + padding: 1rem; + overflow-x: auto; + border: 1px solid rgba(148, 163, 184, 0.35); + border-radius: 8px; + background: rgba(15, 23, 42, 0.28); +} + +.nyx-mermaid svg { + display: block; + max-width: 100%; + height: auto; + margin: 0 auto; +} diff --git a/docs/serve.md b/docs/serve.md index 61f014e6..712afc76 100644 --- a/docs/serve.md +++ b/docs/serve.md @@ -11,6 +11,16 @@ nyx serve --no-browser # don't auto-open Persistent settings live under `[server]` in `nyx.conf` / `nyx.local`. +```mermaid +flowchart LR + Scan["nyx scan
or UI-started scan"] --> Cache[".nyx findings
plus SQLite project index"] + Cache --> Serve["nyx serve
loopback API and embedded React UI"] + Serve --> Review["Review findings
flow, evidence, history"] + Review --> Triage["Update triage state
investigate, suppress, accept, fix"] + Triage --> Sync[".nyx/triage.json
optional repo-synced state"] + Sync --> Cache +``` + Starting a scan from the UI runs dynamic verification on `Confidence >= Medium` findings by default. Check "Skip dynamic verification" in the scan modal to get a fast static-only result. See [Dynamic verification](dynamic.md) for details. diff --git a/frontend/src/api/queries/targets.ts b/frontend/src/api/queries/targets.ts new file mode 100644 index 00000000..dc1ab9dd --- /dev/null +++ b/frontend/src/api/queries/targets.ts @@ -0,0 +1,42 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { apiDelete, apiGet, apiPost } from '../client'; +import type { TargetView } from '../types'; + +export function useTargets() { + return useQuery({ + queryKey: ['targets'], + queryFn: ({ signal }) => apiGet('/targets', signal), + }); +} + +export function useAddTarget() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (body: { path: string }) => apiPost('/targets', body), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['targets'] }); + }, + }); +} + +export function useSelectTarget() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (body: { id?: string; path?: string }) => + apiPost('/targets/select', body), + onSuccess: () => { + qc.invalidateQueries(); + }, + }); +} + +export function useDeleteTarget() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => + apiDelete(`/targets/${encodeURIComponent(id)}`), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['targets'] }); + }, + }); +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index d6db58b6..e53e8abc 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -192,6 +192,17 @@ export interface ScanView { metrics?: ScanMetricsSnapshot; } +export interface TargetView { + id: string; + name: string; + path: string; + db_path: string; + last_seen_at: string; + last_scan_at?: string; + active: boolean; + exists: boolean; +} + // Scan Comparison types export interface CompareScanInfo { id: string; diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index ecfae757..0731c8e7 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -8,13 +8,17 @@ import { ConfigIcon, ExplorerIcon, DebugIcon, - FolderIcon, TagIcon, } from '../icons/Icons'; -import type { FC } from 'react'; +import { useEffect, useRef, useState, type FC, type FormEvent } from 'react'; import type { IconProps } from '../icons/Icons'; import { useHealth } from '../../api/queries/health'; import { useOverview } from '../../api/queries/overview'; +import { + useAddTarget, + useSelectTarget, + useTargets, +} from '../../api/queries/targets'; import { useSSE } from '../../contexts/SSEContext'; interface NavItem { @@ -95,6 +99,167 @@ function navLinkClass({ isActive }: { isActive: boolean }) { return `nav-link${isActive ? ' active' : ''}`; } +function targetNameFromPath(path: string) { + const parts = path.split(/[\\/]/).filter(Boolean); + return parts[parts.length - 1] || path || 'Project'; +} + +function targetInitial(name: string) { + return name.trim().charAt(0).toUpperCase() || '?'; +} + +function compactPath(path: string) { + return path.replace(/^\/Users\/[^/]+/, '~'); +} + +function TargetSwitcher({ scanRoot }: { scanRoot?: string }) { + const { data: targets = [] } = useTargets(); + const addTarget = useAddTarget(); + const selectTarget = useSelectTarget(); + const [open, setOpen] = useState(false); + const [newPath, setNewPath] = useState(''); + const menuRef = useRef(null); + + const activeTarget = + targets.find((target) => target.active) ?? + (scanRoot + ? { + id: '__active__', + name: targetNameFromPath(scanRoot), + path: scanRoot, + active: true, + exists: true, + } + : undefined); + + useEffect(() => { + if (!open) return; + function handlePointerDown(event: MouseEvent) { + if ( + menuRef.current && + event.target instanceof Node && + !menuRef.current.contains(event.target) + ) { + setOpen(false); + } + } + function handleKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') setOpen(false); + } + document.addEventListener('mousedown', handlePointerDown); + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('mousedown', handlePointerDown); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [open]); + + function handleSelect(id: string) { + selectTarget.mutate( + { id }, + { + onSuccess: () => setOpen(false), + }, + ); + } + + function handleAddSubmit(event: FormEvent) { + event.preventDefault(); + const path = newPath.trim(); + if (!path || addTarget.isPending) return; + addTarget.mutate( + { path }, + { + onSuccess: (target) => { + setNewPath(''); + selectTarget.mutate( + { id: target.id }, + { + onSuccess: () => setOpen(false), + }, + ); + }, + }, + ); + } + + const isBusy = addTarget.isPending || selectTarget.isPending; + const errorMessage = + addTarget.error instanceof Error ? addTarget.error.message : null; + + return ( +
+ + + {open && ( +
+
+ {targets.map((target) => ( + + ))} +
+ +
+ setNewPath(event.target.value)} + placeholder="/path/to/project" + aria-label="Project path" + /> + +
+ {errorMessage &&
{errorMessage}
} +
+ )} +
+ ); +} + export function Sidebar() { const { data: health } = useHealth(); const { data: overview } = useOverview(); @@ -112,6 +277,8 @@ export function Sidebar() { Nyx + +
    {primary.map((item) => (
  • @@ -161,12 +328,6 @@ export function Sidebar() {
    - {health?.scan_root && ( -
    - - {health.scan_root} -
    - )} {health?.version && (
    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> { + let mut targets = load_targets(database_dir)?; + let Some(pos) = targets.iter().position(|target| target.id == id) else { + return Ok(None); + }; + let removed = targets.remove(pos); + save_targets(database_dir, &targets)?; + Ok(Some(removed)) +} + +pub fn target_id_for_path(path: &Path) -> String { + let path_str = path.to_string_lossy(); + let hash = blake3::hash(path_str.as_bytes()).to_hex().to_string(); + let slug = display_name_for_path(path); + format!("{}-{}", sanitize_project_name(&slug), &hash[..12]) +} + +fn display_name_for_path(path: &Path) -> String { + path.file_name() + .and_then(|name| name.to_str()) + .map(str::to_string) + .unwrap_or_else(|| path.display().to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn remembers_and_updates_target() { + let data = tempfile::tempdir().unwrap(); + let project = tempfile::tempdir().unwrap(); + + let first = remember_target(data.path(), project.path(), TargetTouch::Seen).unwrap(); + assert!(first.last_scan_at.is_none()); + + let second = remember_target(data.path(), project.path(), TargetTouch::Scanned).unwrap(); + assert_eq!(first.id, second.id); + assert!(second.last_scan_at.is_some()); + + let targets = load_targets(data.path()).unwrap(); + assert_eq!(targets.len(), 1); + assert_eq!(targets[0].id, first.id); + } + + #[test] + fn target_id_is_stable_for_path() { + let project = tempfile::tempdir().unwrap(); + let a = target_id_for_path(project.path()); + let b = target_id_for_path(project.path()); + assert_eq!(a, b); + } +} diff --git a/tests/dynamic_workdir_clone.rs b/tests/dynamic_workdir_clone.rs index 23cc5ce6..cbb5ed0f 100644 --- a/tests/dynamic_workdir_clone.rs +++ b/tests/dynamic_workdir_clone.rs @@ -74,7 +74,10 @@ fn cap_lanes_beat_single_lane_by_3x() { ); let lanes = t1.elapsed(); - assert_eq!(lane_out, baseline_out, "lanes must produce identical ordered results"); + assert_eq!( + lane_out, baseline_out, + "lanes must produce identical ordered results" + ); let speedup = single_lane.as_secs_f64() / lanes.as_secs_f64(); eprintln!(