1use std::collections::{BTreeMap, BTreeSet, VecDeque};
19
20use crate::report::{AliasDoc, EdgeDoc, EntityDoc, ImpactReport, Root, Symptom};
21
22#[derive(Debug, Clone, Copy)]
24pub struct ImpactInputs<'a> {
25 pub edges: &'a [EdgeDoc],
27 pub firing: &'a BTreeSet<String>,
29 pub down: &'a BTreeSet<String>,
32}
33
34pub const MAX_DEPTH_CAP: usize = 4;
37
38type Adjacency<'a> = BTreeMap<&'a str, BTreeSet<&'a str>>;
39
40pub fn attribute(inputs: &ImpactInputs<'_>, depth_cap: usize) -> ImpactReport {
43 let mut children: Adjacency<'_> = BTreeMap::new();
44 let mut parents: Adjacency<'_> = BTreeMap::new();
45 for e in inputs.edges {
46 if !e.kind.propagates() {
47 continue;
48 }
49 let (Some(from), Some(to)) = (e.from.entity_id(), e.to.entity_id()) else {
50 continue;
53 };
54 if from == to {
55 continue;
57 }
58 children.entry(from).or_default().insert(to);
59 parents.entry(to).or_default().insert(from);
60 }
61
62 let mut walks_capped = 0u64;
63 let mut cycles_seen = 0u64;
64
65 let roots: Vec<&str> = inputs
67 .down
68 .iter()
69 .map(String::as_str)
70 .filter(|id| !has_down_ancestor(id, &parents, inputs.down, depth_cap, &mut walks_capped))
71 .collect();
72
73 let mut best: BTreeMap<&str, (usize, &str)> = BTreeMap::new();
76 let mut out_roots = Vec::with_capacity(roots.len());
77 for root in &roots {
78 let reach = descendants(
79 root,
80 &children,
81 depth_cap,
82 &mut walks_capped,
83 &mut cycles_seen,
84 );
85 for (id, depth) in &reach {
86 let cand = (*depth, *root);
87 best.entry(id)
88 .and_modify(|cur| {
89 if cand < *cur {
90 *cur = cand;
91 }
92 })
93 .or_insert(cand);
94 }
95 out_roots.push(Root {
96 entity: (*root).to_string(),
97 reached: reach.keys().map(|s| (*s).to_string()).collect(),
98 });
99 }
100
101 let mut symptoms = Vec::new();
105 for site in inputs.firing.iter().chain(inputs.down.iter()) {
106 let id = site.as_str();
107 if roots.contains(&id) {
108 continue;
109 }
110 if let Some((_, root)) = best.get(id) {
111 symptoms.push(Symptom {
112 entity: id.to_string(),
113 explained_by: (*root).to_string(),
114 });
115 }
116 }
117 symptoms.sort();
118 symptoms.dedup();
119
120 ImpactReport {
121 roots: out_roots,
122 symptoms,
123 walks_capped,
124 cycles_seen,
125 depth_cap,
126 }
127}
128
129fn has_down_ancestor(
131 id: &str,
132 parents: &Adjacency<'_>,
133 down: &BTreeSet<String>,
134 depth_cap: usize,
135 walks_capped: &mut u64,
136) -> bool {
137 let mut seen: BTreeSet<&str> = BTreeSet::from([id]);
138 let mut queue: VecDeque<(&str, usize)> = VecDeque::from([(id, 0usize)]);
139 let mut capped = false;
140 while let Some((cur, depth)) = queue.pop_front() {
141 let Some(next) = parents.get(cur) else {
142 continue;
143 };
144 if depth >= depth_cap {
145 if next.iter().any(|p| !seen.contains(p)) {
146 capped = true;
147 }
148 continue;
149 }
150 for p in next {
151 if !seen.insert(p) {
152 continue;
153 }
154 if down.contains(*p) {
155 return true;
156 }
157 queue.push_back((p, depth + 1));
158 }
159 }
160 if capped {
161 *walks_capped += 1;
162 }
163 false
164}
165
166fn descendants<'a>(
170 root: &'a str,
171 children: &Adjacency<'a>,
172 depth_cap: usize,
173 walks_capped: &mut u64,
174 cycles_seen: &mut u64,
175) -> BTreeMap<&'a str, usize> {
176 let mut out: BTreeMap<&str, usize> = BTreeMap::new();
177 let mut seen: BTreeSet<&str> = BTreeSet::from([root]);
178 let mut queue: VecDeque<(&str, usize)> = VecDeque::from([(root, 0usize)]);
179 let (mut capped, mut cycled) = (false, false);
180 while let Some((cur, depth)) = queue.pop_front() {
181 let Some(next) = children.get(cur) else {
182 continue;
183 };
184 if depth >= depth_cap {
185 if next.iter().any(|c| !seen.contains(c)) {
186 capped = true;
187 }
188 continue;
189 }
190 for c in next {
191 if *c == root {
192 cycled = true;
193 continue;
194 }
195 if !seen.insert(c) {
196 continue;
197 }
198 out.insert(c, depth + 1);
199 queue.push_back((c, depth + 1));
200 }
201 }
202 if capped {
203 *walks_capped += 1;
204 }
205 if cycled {
206 *cycles_seen += 1;
207 }
208 out
209}
210
211pub fn entity_of(origin: &str, entities: &[EntityDoc], aliases: &[AliasDoc]) -> Option<String> {
216 let by_origins = entities
217 .iter()
218 .filter(|e| e.origins.iter().any(|o| o == origin))
219 .map(|e| e.entity_id.as_str())
220 .min();
221 let by_host_id = || {
222 entities
223 .iter()
224 .filter(|e| e.host_id.as_deref() == Some(origin))
225 .map(|e| e.entity_id.as_str())
226 .min()
227 };
228 let id = by_origins.or_else(by_host_id)?;
229 let resolved = aliases
230 .iter()
231 .filter(|a| a.old_id == id)
232 .map(|a| a.entity_id.as_str())
233 .min()
234 .unwrap_or(id);
235 Some(resolved.to_string())
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241 use crate::report::{EdgeEnd, EdgeKind};
242
243 fn edge(kind: EdgeKind, from: &str, to: &str) -> EdgeDoc {
244 EdgeDoc {
245 edge_id: format!("e-{}-{from}-{to}", kind.as_str()),
246 kind,
247 from: EdgeEnd::Entity {
248 entity_id: from.into(),
249 },
250 to: EdgeEnd::Entity {
251 entity_id: to.into(),
252 },
253 attrs: BTreeMap::new(),
254 observers: Vec::new(),
255 last_updated: None,
256 }
257 }
258
259 fn set(ids: &[&str]) -> BTreeSet<String> {
260 ids.iter().map(|s| s.to_string()).collect()
261 }
262
263 #[test]
267 fn a_chain_attributes_the_leaf_alert_to_the_down_host() {
268 let edges = [
269 edge(EdgeKind::Hosts, "A", "B"),
270 edge(EdgeKind::Runs, "B", "C"),
271 ];
272 let r = attribute(
273 &ImpactInputs {
274 edges: &edges,
275 firing: &set(&["C"]),
276 down: &set(&["A"]),
277 },
278 MAX_DEPTH_CAP,
279 );
280 assert_eq!(
281 r.roots,
282 vec![Root {
283 entity: "A".into(),
284 reached: vec!["B".into(), "C".into()]
285 }]
286 );
287 assert_eq!(
288 r.symptoms,
289 vec![Symptom {
290 entity: "C".into(),
291 explained_by: "A".into()
292 }]
293 );
294 assert_eq!((r.walks_capped, r.cycles_seen, r.depth_cap), (0, 0, 4));
295
296 let r = attribute(
298 &ImpactInputs {
299 edges: &edges,
300 firing: &set(&["C"]),
301 down: &set(&["A", "B"]),
302 },
303 MAX_DEPTH_CAP,
304 );
305 assert_eq!(r.roots.len(), 1);
306 assert_eq!(
307 r.symptoms
308 .iter()
309 .map(|s| s.entity.as_str())
310 .collect::<Vec<_>>(),
311 vec!["B", "C"]
312 );
313 }
314
315 #[test]
319 fn l2_adjacent_and_unknown_kinds_never_propagate() {
320 let mut edges = vec![
321 edge(EdgeKind::L2Adjacent, "A", "B"),
322 edge(EdgeKind::Other("teleports".into()), "A", "C"),
323 ];
324 edges.push(EdgeDoc {
325 to: EdgeEnd::External {
326 ip: Some("1.1.1.1".into()),
327 mac: None,
328 name: None,
329 },
330 ..edge(EdgeKind::Probes, "A", "x")
331 });
332 let r = attribute(
333 &ImpactInputs {
334 edges: &edges,
335 firing: &set(&["B", "C"]),
336 down: &set(&["A"]),
337 },
338 MAX_DEPTH_CAP,
339 );
340 assert_eq!(r.roots[0].reached, Vec::<String>::new());
341 assert!(r.symptoms.is_empty(), "{:?}", r.symptoms);
342 }
343
344 #[test]
347 fn a_cycle_terminates_and_is_counted_once() {
348 let edges = [
349 edge(EdgeKind::Hosts, "A", "B"),
350 edge(EdgeKind::Hosts, "B", "A"),
351 ];
352 let r = attribute(
353 &ImpactInputs {
354 edges: &edges,
355 firing: &set(&["B"]),
356 down: &set(&["A"]),
357 },
358 MAX_DEPTH_CAP,
359 );
360 assert_eq!(r.cycles_seen, 1);
361 assert_eq!(r.roots[0].entity, "A");
362 assert_eq!(r.symptoms[0].explained_by, "A");
363 }
364
365 #[test]
368 fn a_walk_past_the_depth_cap_is_cut_and_reported() {
369 let edges = [
370 edge(EdgeKind::Hosts, "A", "B"),
371 edge(EdgeKind::Runs, "B", "C"),
372 edge(EdgeKind::Runs, "C", "D"),
373 edge(EdgeKind::Runs, "D", "E"),
374 edge(EdgeKind::Runs, "E", "F"),
375 ];
376 let r = attribute(
377 &ImpactInputs {
378 edges: &edges,
379 firing: &set(&["E", "F"]),
380 down: &set(&["A"]),
381 },
382 4,
383 );
384 assert_eq!(r.walks_capped, 1);
385 assert_eq!(r.roots[0].reached, vec!["B", "C", "D", "E"]);
386 assert_eq!(
387 r.symptoms
388 .iter()
389 .map(|s| s.entity.as_str())
390 .collect::<Vec<_>>(),
391 vec!["E"]
392 );
393 }
394
395 #[test]
398 fn the_output_is_deterministic_under_input_permutation() {
399 let edges = [
400 edge(EdgeKind::Hosts, "H2", "V"),
401 edge(EdgeKind::Hosts, "H1", "V"),
402 edge(EdgeKind::GatewayOf, "G", "H1"),
403 edge(EdgeKind::Runs, "V", "C"),
404 ];
405 let inputs = |edges: &[EdgeDoc]| {
406 attribute(
407 &ImpactInputs {
408 edges,
409 firing: &set(&["C", "V"]),
410 down: &set(&["H2", "H1", "G"]),
411 },
412 MAX_DEPTH_CAP,
413 )
414 };
415 let a = inputs(&edges);
416 let mut reversed = edges.to_vec();
417 reversed.reverse();
418 let b = inputs(&reversed);
419 assert_eq!(a, b);
420 assert_eq!(
421 a.roots
422 .iter()
423 .map(|r| r.entity.as_str())
424 .collect::<Vec<_>>(),
425 vec!["G", "H2"],
426 "H1 is under G; roots are ordered"
427 );
428 let v = a.symptoms.iter().find(|s| s.entity == "V").unwrap();
429 assert_eq!(v.explained_by, "H2", "H2 is one hop away, G is two");
430 }
431
432 #[test]
435 fn entity_of_joins_by_origins_then_host_id_then_alias() {
436 let entities = [
437 EntityDoc {
438 entity_id: "ent-new".into(),
439 origins: vec!["h-aaaaaaaaaaaa".into()],
440 host_id: None,
441 hostname: None,
442 rest: BTreeMap::new(),
443 },
444 EntityDoc {
445 entity_id: "ent-old".into(),
446 origins: vec![],
447 host_id: Some("h-bbbbbbbbbbbb".into()),
448 hostname: None,
449 rest: BTreeMap::new(),
450 },
451 ];
452 let aliases = [AliasDoc {
453 old_id: "ent-old".into(),
454 entity_id: "ent-merged".into(),
455 rest: BTreeMap::new(),
456 }];
457 assert_eq!(
458 entity_of("h-aaaaaaaaaaaa", &entities, &aliases).as_deref(),
459 Some("ent-new")
460 );
461 assert_eq!(
462 entity_of("h-bbbbbbbbbbbb", &entities, &aliases).as_deref(),
463 Some("ent-merged"),
464 "host_id match, then the alias re-points it"
465 );
466 assert_eq!(entity_of("h-cccccccccccc", &entities, &aliases), None);
467 }
468}