use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use crate::config::ConfigManager;
use crate::db::models::{Observation, ScanRun};
use crate::error::{AppError, Result};
use crate::frontend::{
fmt_ts, open_backend, print_json, value_to_short, Args, Cell, IdMaps, Table,
};
pub fn usage() {
println!(
"Usage: ploit-malper diff [run-a] [run-b] [--verbose] [--json] \
[--backend pocketbase|sqlite]"
);
println!(" (defaults to comparing the two most recent runs)");
}
pub fn cmd_diff(args: &[String], config_mgr: &mut ConfigManager) -> Result<()> {
let parsed = Args::parse(args)?;
if parsed.has("help") {
usage();
return Ok(());
}
let mut storage = open_backend(config_mgr, &parsed)?;
let runs = storage.list_scan_runs()?;
if runs.is_empty() {
return Err(AppError::Message(
"no scan runs recorded; ingest a folder with 'ploit-malper ingest <folder>' first"
.to_string(),
));
}
let (run_a, run_b) = match parsed.positionals() {
[] => {
if runs.len() < 2 {
return Err(AppError::Message(
"only one scan run exists; diff needs two runs (or ingest another folder)"
.to_string(),
));
}
let mut sorted = runs.clone();
sorted.sort_by(|a, b| b.imported_at.cmp(&a.imported_at));
(sorted[1].clone(), sorted[0].clone())
}
[a, b] => {
let resolved_a = resolve_run(&runs, a)?;
let resolved_b = resolve_run(&runs, b)?;
if resolved_a.stable_id == resolved_b.stable_id {
return Err(AppError::Message(
"run-a and run-b resolve to the same run".to_string(),
));
}
(resolved_a.clone(), resolved_b.clone())
}
_ => {
return Err(AppError::Message(
"diff accepts zero or two run ids/names".to_string(),
));
}
};
let all_observations = storage.list_all_observations()?;
let maps = IdMaps::load(&mut storage)?;
let mut deltas: Vec<DeltaItem> = all_observations
.iter()
.filter(|o| o.run_id == run_b.stable_id)
.map(|o| delta_from_observation(o, &maps))
.collect();
deltas.sort_by(|a, b| a.observed_at.cmp(&b.observed_at));
let added: Vec<&DeltaItem> = deltas.iter().filter(|d| d.category == "added").collect();
let removed: Vec<&DeltaItem> = deltas.iter().filter(|d| d.category == "removed").collect();
let changed: Vec<&DeltaItem> = deltas.iter().filter(|d| d.category == "changed").collect();
if parsed.has("json") {
let payload = json!({
"run_a": run_a,
"run_b": run_b,
"content_changed": run_a.content_hash != run_b.content_hash,
"summary": {
"added": count_by_type(&added),
"removed": count_by_type(&removed),
"changed": count_by_type(&changed),
},
"added": &added,
"removed": &removed,
"changed": &changed,
});
return print_json(&payload);
}
if deltas.is_empty() {
println!(
"No changes between {} and {} (identical content).",
run_a.stable_id, run_b.stable_id
);
return Ok(());
}
println!("Diff: {} -> {}", run_a.stable_id, run_b.stable_id);
println!(
" target: {} ({} target)",
run_b.target,
if run_a.target == run_b.target {
"same"
} else {
"different"
}
);
println!(
" imported: {} -> {}",
fmt_ts(&run_a.imported_at),
fmt_ts(&run_b.imported_at)
);
println!(
" content: {}",
if run_a.content_hash == run_b.content_hash {
"identical".to_string()
} else {
"changed".to_string()
}
);
println!();
println!(
"{}",
crate::frontend::bold(&format!(
"Summary: {} added, {} removed, {} changed",
added.len(),
removed.len(),
changed.len()
))
);
println!(
" added: {}",
count_by_type(&added)
.iter()
.map(|(k, v)| format!("{}={}", k, v))
.collect::<Vec<_>>()
.join(", ")
);
println!(
" removed: {}",
count_by_type(&removed)
.iter()
.map(|(k, v)| format!("{}={}", k, v))
.collect::<Vec<_>>()
.join(", ")
);
println!(
" changed: {}",
count_by_type(&changed)
.iter()
.map(|(k, v)| format!("{}={}", k, v))
.collect::<Vec<_>>()
.join(", ")
);
render_section("Newly discovered", &added, parsed.has("verbose"));
render_section("Removed", &removed, parsed.has("verbose"));
render_section("Changed", &changed, parsed.has("verbose"));
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct DeltaItem {
category: &'static str,
subject_type: String,
subject_id: String,
label: String,
kind: String,
before: Value,
after: Value,
detail: String,
observed_at: String,
}
fn delta_from_observation(obs: &Observation, maps: &IdMaps) -> DeltaItem {
let category = if obs.kind.ends_with("_discovered") {
"added"
} else if obs.kind.ends_with("_removed") {
"removed"
} else {
"changed"
};
let label = match obs.subject_type.as_str() {
"asset" => maps.asset_name(&obs.subject_id),
"service" => {
let service = maps.services.get(&obs.subject_id);
match service {
Some(s) => format!("{}:{}", maps.asset_name(&s.asset_id), s.port),
None => obs.subject_id.clone(),
}
}
"finding" => maps.finding_title(&obs.subject_id),
other => format!("{}:{}", other, obs.subject_id),
};
DeltaItem {
category,
subject_type: obs.subject_type.clone(),
subject_id: obs.subject_id.clone(),
label,
kind: obs.kind.clone(),
before: obs.before.clone(),
after: obs.after.clone(),
detail: obs.detail.clone(),
observed_at: obs.observed_at.clone(),
}
}
fn count_by_type(items: &[&DeltaItem]) -> Vec<(String, usize)> {
let mut counts: Vec<(String, usize)> = Vec::new();
for item in items {
if let Some(entry) = counts.iter_mut().find(|(key, _)| *key == item.subject_type) {
entry.1 += 1;
} else {
counts.push((item.subject_type.clone(), 1));
}
}
counts.sort();
counts
}
fn render_section(title: &str, items: &[&DeltaItem], verbose: bool) {
println!();
if items.is_empty() {
println!("{}: none", crate::frontend::dim(title));
return;
}
println!("{}: {}", crate::frontend::bold(title), items.len());
let mut table = Table::new(vec![
"TYPE".to_string(),
"SUBJECT".to_string(),
"KIND".to_string(),
"WHEN".to_string(),
]);
for item in items {
table.add_row(vec![
Cell::plain(item.subject_type.clone()),
Cell::plain(item.label.clone()),
Cell::plain(item.kind.clone()),
Cell::plain(fmt_ts(&item.observed_at)),
]);
}
println!("{}", table.render());
if verbose {
for item in items {
println!(
" {} {} [{}]",
crate::frontend::dim(&item.subject_type),
item.label,
item.subject_id
);
println!(" kind: {}", item.kind);
println!(" detail: {}", item.detail);
println!(" before: {}", value_to_short(&item.before));
println!(" after: {}", value_to_short(&item.after));
}
}
}
fn resolve_run<'a>(runs: &'a [ScanRun], term: &str) -> Result<&'a ScanRun> {
let lower = term.trim().to_lowercase();
if let Some(run) = runs.iter().find(|r| r.stable_id == lower) {
return Ok(run);
}
let prefix_matches: Vec<&ScanRun> = runs
.iter()
.filter(|r| r.stable_id.starts_with(&lower))
.collect();
if prefix_matches.len() == 1 {
return Ok(prefix_matches[0]);
}
let target_matches: Vec<&ScanRun> = runs
.iter()
.filter(|r| r.target.to_lowercase() == lower)
.collect();
if target_matches.len() == 1 {
return Ok(target_matches[0]);
}
if !prefix_matches.is_empty() {
return Err(AppError::Message(format!(
"run '{}' is ambiguous; matches:\n{}",
term,
prefix_matches
.iter()
.map(|r| format!(" - {}", r.stable_id))
.collect::<Vec<_>>()
.join("\n")
)));
}
let known: Vec<String> = runs.iter().map(|r| r.stable_id.clone()).collect();
Err(AppError::Message(format!(
"no run matches '{}'.\nKnown runs:\n{}",
term,
known
.iter()
.take(20)
.map(|r| format!(" - {}", r))
.collect::<Vec<_>>()
.join("\n")
)))
}