use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use eframe::egui::{self, RichText};
use serde_json::Value;
use super::facett_theme::{Theme, AMBER, GREEN, RED};
use super::ops_tabs::Server;
struct Snapshot {
repo: String,
components: usize,
vulns: Vec<(String, String, Vec<String>)>, licenses: Vec<(String, usize)>,
cache_hits: usize,
cache_misses: usize,
}
type Slot = Arc<Mutex<Option<Result<Snapshot, String>>>>;
fn snap_from_report(rep: crate::security::SecurityReport) -> Snapshot {
Snapshot {
repo: rep.repo.clone(),
components: rep.components.len(),
vulns: rep
.vulns
.iter()
.map(|v| (v.crate_name.clone(), v.version.clone(), v.ids.clone()))
.collect(),
licenses: rep.license_tally(),
cache_hits: 0,
cache_misses: rep.components.len(),
}
}
fn parse_report(json: &str) -> Result<Snapshot, String> {
let v: Value = serde_json::from_str(json).map_err(|e| e.to_string())?;
let vulns = v["vulns"]
.as_array()
.map(|a| {
a.iter()
.map(|x| {
(
x["crate"].as_str().unwrap_or_default().to_string(),
x["version"].as_str().unwrap_or_default().to_string(),
x["ids"].as_array().map(|i| i.iter().filter_map(|s| s.as_str().map(String::from)).collect()).unwrap_or_default(),
)
})
.collect()
})
.unwrap_or_default();
let licenses = v["licenses"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|x| {
let pair = x.as_array()?;
Some((pair.first()?.as_str()?.to_string(), pair.get(1)?.as_u64()? as usize))
})
.collect()
})
.unwrap_or_default();
Ok(Snapshot {
repo: v["repo"].as_str().unwrap_or_default().to_string(),
components: v["components"].as_u64().unwrap_or(0) as usize,
vulns,
licenses,
cache_hits: v["cache_hits"].as_u64().unwrap_or(0) as usize,
cache_misses: v["cache_misses"].as_u64().unwrap_or(0) as usize,
})
}
pub struct SecurityTab {
workspace_root: PathBuf,
repos: Vec<String>,
scanning: Option<String>,
pending: Option<Slot>,
result: Option<Result<Snapshot, String>>,
theme: Theme,
}
impl SecurityTab {
pub fn new(workspace_root: PathBuf, repos: Vec<String>) -> Self {
Self { workspace_root, repos, scanning: None, pending: None, result: None, theme: Theme::default() }
}
pub fn set_palette(&mut self, t: Theme) {
self.theme = t;
}
pub fn set_workspace(&mut self, workspace_root: PathBuf, repos: Vec<String>) {
self.workspace_root = workspace_root;
self.repos = repos;
self.scanning = None;
self.pending = None;
self.result = None;
}
fn start_scan(&mut self, srv: Option<&Server>, workspace: &str, repo: &str) {
let slot: Slot = Arc::new(Mutex::new(None));
let sink = slot.clone();
let repo_s = repo.to_string();
match srv {
Some(s) => {
let (ep, tok, ws) = (s.endpoint.clone(), s.token.clone(), workspace.to_string());
std::thread::spawn(move || {
let r = super::remote::security_scan(&ep, &tok, &repo_s, &ws)
.map_err(|e| format!("{e:#}"))
.and_then(|j| parse_report(&j));
*sink.lock().unwrap() = Some(r);
});
}
None => {
let path = self.workspace_root.join(repo);
std::thread::spawn(move || {
let r = crate::security::scan(&path).map(snap_from_report).map_err(|e| format!("{e:#}"));
*sink.lock().unwrap() = Some(r);
});
}
}
self.scanning = Some(repo.to_string());
self.pending = Some(slot);
self.result = None;
}
pub fn draw(&mut self, ui: &mut egui::Ui, srv: Option<&Server>, workspace: &str) {
let theme = self.theme;
let busy = self.pending.is_some();
ui.horizontal_wrapped(|ui| {
ui.label("scan a workspace repo:");
if self.repos.is_empty() {
ui.colored_label(theme.text_dim, "(no repos for this workspace)");
}
for repo in self.repos.clone() {
if ui.add_enabled(!busy, egui::Button::new(format!("🛡 {repo}"))).clicked() {
self.start_scan(srv, workspace, &repo);
}
}
ui.weak(if srv.is_some() { "· server-side" } else { "· local" });
});
ui.separator();
let done = self
.pending
.as_ref()
.and_then(|slot| slot.lock().unwrap_or_else(|p| p.into_inner()).take());
if let Some(d) = done {
self.result = Some(d);
self.pending = None;
}
if self.pending.is_some() {
let r = self.scanning.as_deref().unwrap_or("");
ui.label(format!("⏳ scanning {r} — cargo metadata (deep tree) + OSV.dev …"));
ui.ctx().request_repaint();
return;
}
match &self.result {
None => {
ui.label("Pick a repo above to run a deep SBOM + vulnerability + license scan.");
ui.label(
RichText::new(
"cargo metadata → full dependency tree · OSV.dev → CVEs/RUSTSEC · CycloneDX-compatible",
)
.weak(),
);
}
Some(Err(e)) => {
ui.colored_label(RED, format!("scan failed: {e}"));
}
Some(Ok(snap)) => {
ui.horizontal(|ui| {
ui.heading(RichText::new(format!("🛡 {}", snap.repo)).color(theme.accent));
ui.label(format!("· {} crates (deep)", snap.components));
let nv: usize = snap.vulns.iter().map(|(_, _, ids)| ids.len()).sum();
let c = if nv == 0 { GREEN } else { RED };
ui.label(RichText::new(format!("· {nv} vuln(s) in {} crate(s)", snap.vulns.len())).color(c).strong());
});
if snap.cache_hits + snap.cache_misses > 0 {
let msg = if snap.cache_misses == 0 {
format!("warehouse cache: {} hit / 0 miss — zero network ✓", snap.cache_hits)
} else {
format!("warehouse cache: {} hit / {} miss (queried OSV)", snap.cache_hits, snap.cache_misses)
};
ui.label(RichText::new(msg).color(theme.text_dim).small());
}
ui.separator();
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
if snap.vulns.is_empty() {
ui.colored_label(GREEN, "✓ no known vulnerabilities");
} else {
for (name, ver, ids) in &snap.vulns {
ui.horizontal(|ui| {
let sev = if ids.iter().any(|i| i.starts_with("RUSTSEC") || i.starts_with("CVE")) {
AMBER
} else {
theme.accent
};
ui.label(RichText::new("⚠").color(sev));
ui.monospace(format!("{name} {ver}"));
ui.colored_label(theme.text_dim, ids.join(", "));
});
}
}
ui.separator();
ui.label(RichText::new("licenses").strong());
ui.horizontal_wrapped(|ui| {
for (lic, n) in snap.licenses.iter().take(12) {
ui.label(
RichText::new(format!("{lic} ×{n}"))
.color(theme.text_dim)
.monospace(),
);
ui.separator();
}
});
});
}
}
}
pub fn state_json(&self) -> serde_json::Value {
serde_json::json!({
"palette": self.theme.name,
"repos": self.repos,
"scanning": self.scanning,
"busy": self.pending.is_some(),
"result": match &self.result {
None => serde_json::json!({ "ran": false }),
Some(Err(e)) => serde_json::json!({ "ran": true, "ok": false, "error": e }),
Some(Ok(snap)) => serde_json::json!({
"ran": true, "ok": true,
"repo": snap.repo,
"components": snap.components,
"vuln_crates": snap.vulns.len(),
"vulns": snap.vulns.iter().map(|(c, v, ids)| serde_json::json!({
"crate": c, "version": v, "ids": ids,
})).collect::<Vec<_>>(),
"licenses": snap.licenses.iter().map(|(l, n)| serde_json::json!([l, n])).collect::<Vec<_>>(),
"cache_hits": snap.cache_hits,
"cache_misses": snap.cache_misses,
}),
},
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn set_workspace_rescopes_repos_and_root() {
let mut tab = SecurityTab::new(
PathBuf::from("/ws/holger"),
vec!["holger".into(), "holger-cli".into()],
);
let a = tab.state_json();
assert_eq!(a["repos"], serde_json::json!(["holger", "holger-cli"]));
tab.set_workspace(PathBuf::from("/ws/knut"), vec!["knut".into(), "knut-pipelines".into()]);
let b = tab.state_json();
assert_eq!(
b["repos"],
serde_json::json!(["knut", "knut-pipelines"]),
"Security repos must follow the picked workspace, not stay on the previous one"
);
assert_eq!(tab.workspace_root, PathBuf::from("/ws/knut"));
assert_eq!(b["busy"], serde_json::json!(false));
assert_eq!(b["scanning"], serde_json::Value::Null);
assert_eq!(b["result"], serde_json::json!({ "ran": false }));
assert!(!b["repos"].to_string().contains("holger"));
}
}