1use serde::{Deserialize, Serialize};
2use serde_json::{json, Value};
3
4use crate::config::ConfigManager;
5use crate::db::models::{Observation, ScanRun};
6use crate::error::{AppError, Result};
7use crate::frontend::{
8 fmt_ts, open_backend, print_json, value_to_short, Args, Cell, IdMaps, Table,
9};
10
11pub fn usage() {
12 println!(
13 "Usage: ploit-malper diff [run-a] [run-b] [--verbose] [--json] \
14 [--backend pocketbase|sqlite]"
15 );
16 println!(" (defaults to comparing the two most recent runs)");
17}
18
19pub fn cmd_diff(args: &[String], config_mgr: &mut ConfigManager) -> Result<()> {
20 let parsed = Args::parse(args)?;
21 if parsed.has("help") {
22 usage();
23 return Ok(());
24 }
25
26 let mut storage = open_backend(config_mgr, &parsed)?;
27 let runs = storage.list_scan_runs()?;
28 if runs.is_empty() {
29 return Err(AppError::Message(
30 "no scan runs recorded; ingest a folder with 'ploit-malper ingest <folder>' first"
31 .to_string(),
32 ));
33 }
34
35 let (run_a, run_b) = match parsed.positionals() {
36 [] => {
37 if runs.len() < 2 {
38 return Err(AppError::Message(
39 "only one scan run exists; diff needs two runs (or ingest another folder)"
40 .to_string(),
41 ));
42 }
43 let mut sorted = runs.clone();
44 sorted.sort_by(|a, b| b.imported_at.cmp(&a.imported_at));
45 (sorted[1].clone(), sorted[0].clone())
46 }
47 [a, b] => {
48 let resolved_a = resolve_run(&runs, a)?;
49 let resolved_b = resolve_run(&runs, b)?;
50 if resolved_a.stable_id == resolved_b.stable_id {
51 return Err(AppError::Message(
52 "run-a and run-b resolve to the same run".to_string(),
53 ));
54 }
55 (resolved_a.clone(), resolved_b.clone())
56 }
57 _ => {
58 return Err(AppError::Message(
59 "diff accepts zero or two run ids/names".to_string(),
60 ));
61 }
62 };
63
64 let all_observations = storage.list_all_observations()?;
65 let maps = IdMaps::load(&mut storage)?;
66
67 let mut deltas: Vec<DeltaItem> = all_observations
68 .iter()
69 .filter(|o| o.run_id == run_b.stable_id)
70 .map(|o| delta_from_observation(o, &maps))
71 .collect();
72 deltas.sort_by(|a, b| a.observed_at.cmp(&b.observed_at));
73
74 let added: Vec<&DeltaItem> = deltas.iter().filter(|d| d.category == "added").collect();
75 let removed: Vec<&DeltaItem> = deltas.iter().filter(|d| d.category == "removed").collect();
76 let changed: Vec<&DeltaItem> = deltas.iter().filter(|d| d.category == "changed").collect();
77
78 if parsed.has("json") {
79 let payload = json!({
80 "run_a": run_a,
81 "run_b": run_b,
82 "content_changed": run_a.content_hash != run_b.content_hash,
83 "summary": {
84 "added": count_by_type(&added),
85 "removed": count_by_type(&removed),
86 "changed": count_by_type(&changed),
87 },
88 "added": &added,
89 "removed": &removed,
90 "changed": &changed,
91 });
92 return print_json(&payload);
93 }
94
95 if deltas.is_empty() {
96 println!(
97 "No changes between {} and {} (identical content).",
98 run_a.stable_id, run_b.stable_id
99 );
100 return Ok(());
101 }
102
103 println!("Diff: {} -> {}", run_a.stable_id, run_b.stable_id);
104 println!(
105 " target: {} ({} target)",
106 run_b.target,
107 if run_a.target == run_b.target {
108 "same"
109 } else {
110 "different"
111 }
112 );
113 println!(
114 " imported: {} -> {}",
115 fmt_ts(&run_a.imported_at),
116 fmt_ts(&run_b.imported_at)
117 );
118 println!(
119 " content: {}",
120 if run_a.content_hash == run_b.content_hash {
121 "identical".to_string()
122 } else {
123 "changed".to_string()
124 }
125 );
126 println!();
127 println!(
128 "{}",
129 crate::frontend::bold(&format!(
130 "Summary: {} added, {} removed, {} changed",
131 added.len(),
132 removed.len(),
133 changed.len()
134 ))
135 );
136 println!(
137 " added: {}",
138 count_by_type(&added)
139 .iter()
140 .map(|(k, v)| format!("{}={}", k, v))
141 .collect::<Vec<_>>()
142 .join(", ")
143 );
144 println!(
145 " removed: {}",
146 count_by_type(&removed)
147 .iter()
148 .map(|(k, v)| format!("{}={}", k, v))
149 .collect::<Vec<_>>()
150 .join(", ")
151 );
152 println!(
153 " changed: {}",
154 count_by_type(&changed)
155 .iter()
156 .map(|(k, v)| format!("{}={}", k, v))
157 .collect::<Vec<_>>()
158 .join(", ")
159 );
160
161 render_section("Newly discovered", &added, parsed.has("verbose"));
162 render_section("Removed", &removed, parsed.has("verbose"));
163 render_section("Changed", &changed, parsed.has("verbose"));
164
165 Ok(())
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
169struct DeltaItem {
170 category: &'static str,
171 subject_type: String,
172 subject_id: String,
173 label: String,
174 kind: String,
175 before: Value,
176 after: Value,
177 detail: String,
178 observed_at: String,
179}
180
181fn delta_from_observation(obs: &Observation, maps: &IdMaps) -> DeltaItem {
182 let category = if obs.kind.ends_with("_discovered") {
183 "added"
184 } else if obs.kind.ends_with("_removed") {
185 "removed"
186 } else {
187 "changed"
188 };
189 let label = match obs.subject_type.as_str() {
190 "asset" => maps.asset_name(&obs.subject_id),
191 "service" => {
192 let service = maps.services.get(&obs.subject_id);
193 match service {
194 Some(s) => format!("{}:{}", maps.asset_name(&s.asset_id), s.port),
195 None => obs.subject_id.clone(),
196 }
197 }
198 "finding" => maps.finding_title(&obs.subject_id),
199 other => format!("{}:{}", other, obs.subject_id),
200 };
201 DeltaItem {
202 category,
203 subject_type: obs.subject_type.clone(),
204 subject_id: obs.subject_id.clone(),
205 label,
206 kind: obs.kind.clone(),
207 before: obs.before.clone(),
208 after: obs.after.clone(),
209 detail: obs.detail.clone(),
210 observed_at: obs.observed_at.clone(),
211 }
212}
213
214fn count_by_type(items: &[&DeltaItem]) -> Vec<(String, usize)> {
215 let mut counts: Vec<(String, usize)> = Vec::new();
216 for item in items {
217 if let Some(entry) = counts.iter_mut().find(|(key, _)| *key == item.subject_type) {
218 entry.1 += 1;
219 } else {
220 counts.push((item.subject_type.clone(), 1));
221 }
222 }
223 counts.sort();
224 counts
225}
226
227fn render_section(title: &str, items: &[&DeltaItem], verbose: bool) {
228 println!();
229 if items.is_empty() {
230 println!("{}: none", crate::frontend::dim(title));
231 return;
232 }
233 println!("{}: {}", crate::frontend::bold(title), items.len());
234 let mut table = Table::new(vec![
235 "TYPE".to_string(),
236 "SUBJECT".to_string(),
237 "KIND".to_string(),
238 "WHEN".to_string(),
239 ]);
240 for item in items {
241 table.add_row(vec![
242 Cell::plain(item.subject_type.clone()),
243 Cell::plain(item.label.clone()),
244 Cell::plain(item.kind.clone()),
245 Cell::plain(fmt_ts(&item.observed_at)),
246 ]);
247 }
248 println!("{}", table.render());
249 if verbose {
250 for item in items {
251 println!(
252 " {} {} [{}]",
253 crate::frontend::dim(&item.subject_type),
254 item.label,
255 item.subject_id
256 );
257 println!(" kind: {}", item.kind);
258 println!(" detail: {}", item.detail);
259 println!(" before: {}", value_to_short(&item.before));
260 println!(" after: {}", value_to_short(&item.after));
261 }
262 }
263}
264
265fn resolve_run<'a>(runs: &'a [ScanRun], term: &str) -> Result<&'a ScanRun> {
266 let lower = term.trim().to_lowercase();
267
268 if let Some(run) = runs.iter().find(|r| r.stable_id == lower) {
269 return Ok(run);
270 }
271
272 let prefix_matches: Vec<&ScanRun> = runs
273 .iter()
274 .filter(|r| r.stable_id.starts_with(&lower))
275 .collect();
276 if prefix_matches.len() == 1 {
277 return Ok(prefix_matches[0]);
278 }
279
280 let target_matches: Vec<&ScanRun> = runs
281 .iter()
282 .filter(|r| r.target.to_lowercase() == lower)
283 .collect();
284 if target_matches.len() == 1 {
285 return Ok(target_matches[0]);
286 }
287
288 if !prefix_matches.is_empty() {
289 return Err(AppError::Message(format!(
290 "run '{}' is ambiguous; matches:\n{}",
291 term,
292 prefix_matches
293 .iter()
294 .map(|r| format!(" - {}", r.stable_id))
295 .collect::<Vec<_>>()
296 .join("\n")
297 )));
298 }
299
300 let known: Vec<String> = runs.iter().map(|r| r.stable_id.clone()).collect();
301 Err(AppError::Message(format!(
302 "no run matches '{}'.\nKnown runs:\n{}",
303 term,
304 known
305 .iter()
306 .take(20)
307 .map(|r| format!(" - {}", r))
308 .collect::<Vec<_>>()
309 .join("\n")
310 )))
311}