use std::collections::{BTreeMap, BTreeSet, VecDeque};
use crate::report::{AliasDoc, EdgeDoc, EntityDoc, ImpactReport, Root, Symptom};
#[derive(Debug, Clone, Copy)]
pub struct ImpactInputs<'a> {
pub edges: &'a [EdgeDoc],
pub firing: &'a BTreeSet<String>,
pub down: &'a BTreeSet<String>,
}
pub const MAX_DEPTH_CAP: usize = 4;
type Adjacency<'a> = BTreeMap<&'a str, BTreeSet<&'a str>>;
pub fn attribute(inputs: &ImpactInputs<'_>, depth_cap: usize) -> ImpactReport {
let mut children: Adjacency<'_> = BTreeMap::new();
let mut parents: Adjacency<'_> = BTreeMap::new();
for e in inputs.edges {
if !e.kind.propagates() {
continue;
}
let (Some(from), Some(to)) = (e.from.entity_id(), e.to.entity_id()) else {
continue;
};
if from == to {
continue;
}
children.entry(from).or_default().insert(to);
parents.entry(to).or_default().insert(from);
}
let mut walks_capped = 0u64;
let mut cycles_seen = 0u64;
let roots: Vec<&str> = inputs
.down
.iter()
.map(String::as_str)
.filter(|id| !has_down_ancestor(id, &parents, inputs.down, depth_cap, &mut walks_capped))
.collect();
let mut best: BTreeMap<&str, (usize, &str)> = BTreeMap::new();
let mut out_roots = Vec::with_capacity(roots.len());
for root in &roots {
let reach = descendants(
root,
&children,
depth_cap,
&mut walks_capped,
&mut cycles_seen,
);
for (id, depth) in &reach {
let cand = (*depth, *root);
best.entry(id)
.and_modify(|cur| {
if cand < *cur {
*cur = cand;
}
})
.or_insert(cand);
}
out_roots.push(Root {
entity: (*root).to_string(),
reached: reach.keys().map(|s| (*s).to_string()).collect(),
});
}
let mut symptoms = Vec::new();
for site in inputs.firing.iter().chain(inputs.down.iter()) {
let id = site.as_str();
if roots.contains(&id) {
continue;
}
if let Some((_, root)) = best.get(id) {
symptoms.push(Symptom {
entity: id.to_string(),
explained_by: (*root).to_string(),
});
}
}
symptoms.sort();
symptoms.dedup();
ImpactReport {
roots: out_roots,
symptoms,
walks_capped,
cycles_seen,
depth_cap,
}
}
fn has_down_ancestor(
id: &str,
parents: &Adjacency<'_>,
down: &BTreeSet<String>,
depth_cap: usize,
walks_capped: &mut u64,
) -> bool {
let mut seen: BTreeSet<&str> = BTreeSet::from([id]);
let mut queue: VecDeque<(&str, usize)> = VecDeque::from([(id, 0usize)]);
let mut capped = false;
while let Some((cur, depth)) = queue.pop_front() {
let Some(next) = parents.get(cur) else {
continue;
};
if depth >= depth_cap {
if next.iter().any(|p| !seen.contains(p)) {
capped = true;
}
continue;
}
for p in next {
if !seen.insert(p) {
continue;
}
if down.contains(*p) {
return true;
}
queue.push_back((p, depth + 1));
}
}
if capped {
*walks_capped += 1;
}
false
}
fn descendants<'a>(
root: &'a str,
children: &Adjacency<'a>,
depth_cap: usize,
walks_capped: &mut u64,
cycles_seen: &mut u64,
) -> BTreeMap<&'a str, usize> {
let mut out: BTreeMap<&str, usize> = BTreeMap::new();
let mut seen: BTreeSet<&str> = BTreeSet::from([root]);
let mut queue: VecDeque<(&str, usize)> = VecDeque::from([(root, 0usize)]);
let (mut capped, mut cycled) = (false, false);
while let Some((cur, depth)) = queue.pop_front() {
let Some(next) = children.get(cur) else {
continue;
};
if depth >= depth_cap {
if next.iter().any(|c| !seen.contains(c)) {
capped = true;
}
continue;
}
for c in next {
if *c == root {
cycled = true;
continue;
}
if !seen.insert(c) {
continue;
}
out.insert(c, depth + 1);
queue.push_back((c, depth + 1));
}
}
if capped {
*walks_capped += 1;
}
if cycled {
*cycles_seen += 1;
}
out
}
pub fn entity_of(origin: &str, entities: &[EntityDoc], aliases: &[AliasDoc]) -> Option<String> {
let by_origins = entities
.iter()
.filter(|e| e.origins.iter().any(|o| o == origin))
.map(|e| e.entity_id.as_str())
.min();
let by_host_id = || {
entities
.iter()
.filter(|e| e.host_id.as_deref() == Some(origin))
.map(|e| e.entity_id.as_str())
.min()
};
let id = by_origins.or_else(by_host_id)?;
let resolved = aliases
.iter()
.filter(|a| a.old_id == id)
.map(|a| a.entity_id.as_str())
.min()
.unwrap_or(id);
Some(resolved.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::report::{EdgeEnd, EdgeKind};
fn edge(kind: EdgeKind, from: &str, to: &str) -> EdgeDoc {
EdgeDoc {
edge_id: format!("e-{}-{from}-{to}", kind.as_str()),
kind,
from: EdgeEnd::Entity {
entity_id: from.into(),
},
to: EdgeEnd::Entity {
entity_id: to.into(),
},
attrs: BTreeMap::new(),
observers: Vec::new(),
last_updated: None,
}
}
fn set(ids: &[&str]) -> BTreeSet<String> {
ids.iter().map(|s| s.to_string()).collect()
}
#[test]
fn a_chain_attributes_the_leaf_alert_to_the_down_host() {
let edges = [
edge(EdgeKind::Hosts, "A", "B"),
edge(EdgeKind::Runs, "B", "C"),
];
let r = attribute(
&ImpactInputs {
edges: &edges,
firing: &set(&["C"]),
down: &set(&["A"]),
},
MAX_DEPTH_CAP,
);
assert_eq!(
r.roots,
vec![Root {
entity: "A".into(),
reached: vec!["B".into(), "C".into()]
}]
);
assert_eq!(
r.symptoms,
vec![Symptom {
entity: "C".into(),
explained_by: "A".into()
}]
);
assert_eq!((r.walks_capped, r.cycles_seen, r.depth_cap), (0, 0, 4));
let r = attribute(
&ImpactInputs {
edges: &edges,
firing: &set(&["C"]),
down: &set(&["A", "B"]),
},
MAX_DEPTH_CAP,
);
assert_eq!(r.roots.len(), 1);
assert_eq!(
r.symptoms
.iter()
.map(|s| s.entity.as_str())
.collect::<Vec<_>>(),
vec!["B", "C"]
);
}
#[test]
fn l2_adjacent_and_unknown_kinds_never_propagate() {
let mut edges = vec![
edge(EdgeKind::L2Adjacent, "A", "B"),
edge(EdgeKind::Other("teleports".into()), "A", "C"),
];
edges.push(EdgeDoc {
to: EdgeEnd::External {
ip: Some("1.1.1.1".into()),
mac: None,
name: None,
},
..edge(EdgeKind::Probes, "A", "x")
});
let r = attribute(
&ImpactInputs {
edges: &edges,
firing: &set(&["B", "C"]),
down: &set(&["A"]),
},
MAX_DEPTH_CAP,
);
assert_eq!(r.roots[0].reached, Vec::<String>::new());
assert!(r.symptoms.is_empty(), "{:?}", r.symptoms);
}
#[test]
fn a_cycle_terminates_and_is_counted_once() {
let edges = [
edge(EdgeKind::Hosts, "A", "B"),
edge(EdgeKind::Hosts, "B", "A"),
];
let r = attribute(
&ImpactInputs {
edges: &edges,
firing: &set(&["B"]),
down: &set(&["A"]),
},
MAX_DEPTH_CAP,
);
assert_eq!(r.cycles_seen, 1);
assert_eq!(r.roots[0].entity, "A");
assert_eq!(r.symptoms[0].explained_by, "A");
}
#[test]
fn a_walk_past_the_depth_cap_is_cut_and_reported() {
let edges = [
edge(EdgeKind::Hosts, "A", "B"),
edge(EdgeKind::Runs, "B", "C"),
edge(EdgeKind::Runs, "C", "D"),
edge(EdgeKind::Runs, "D", "E"),
edge(EdgeKind::Runs, "E", "F"),
];
let r = attribute(
&ImpactInputs {
edges: &edges,
firing: &set(&["E", "F"]),
down: &set(&["A"]),
},
4,
);
assert_eq!(r.walks_capped, 1);
assert_eq!(r.roots[0].reached, vec!["B", "C", "D", "E"]);
assert_eq!(
r.symptoms
.iter()
.map(|s| s.entity.as_str())
.collect::<Vec<_>>(),
vec!["E"]
);
}
#[test]
fn the_output_is_deterministic_under_input_permutation() {
let edges = [
edge(EdgeKind::Hosts, "H2", "V"),
edge(EdgeKind::Hosts, "H1", "V"),
edge(EdgeKind::GatewayOf, "G", "H1"),
edge(EdgeKind::Runs, "V", "C"),
];
let inputs = |edges: &[EdgeDoc]| {
attribute(
&ImpactInputs {
edges,
firing: &set(&["C", "V"]),
down: &set(&["H2", "H1", "G"]),
},
MAX_DEPTH_CAP,
)
};
let a = inputs(&edges);
let mut reversed = edges.to_vec();
reversed.reverse();
let b = inputs(&reversed);
assert_eq!(a, b);
assert_eq!(
a.roots
.iter()
.map(|r| r.entity.as_str())
.collect::<Vec<_>>(),
vec!["G", "H2"],
"H1 is under G; roots are ordered"
);
let v = a.symptoms.iter().find(|s| s.entity == "V").unwrap();
assert_eq!(v.explained_by, "H2", "H2 is one hop away, G is two");
}
#[test]
fn entity_of_joins_by_origins_then_host_id_then_alias() {
let entities = [
EntityDoc {
entity_id: "ent-new".into(),
origins: vec!["h-aaaaaaaaaaaa".into()],
host_id: None,
hostname: None,
rest: BTreeMap::new(),
},
EntityDoc {
entity_id: "ent-old".into(),
origins: vec![],
host_id: Some("h-bbbbbbbbbbbb".into()),
hostname: None,
rest: BTreeMap::new(),
},
];
let aliases = [AliasDoc {
old_id: "ent-old".into(),
entity_id: "ent-merged".into(),
rest: BTreeMap::new(),
}];
assert_eq!(
entity_of("h-aaaaaaaaaaaa", &entities, &aliases).as_deref(),
Some("ent-new")
);
assert_eq!(
entity_of("h-bbbbbbbbbbbb", &entities, &aliases).as_deref(),
Some("ent-merged"),
"host_id match, then the alias re-points it"
);
assert_eq!(entity_of("h-cccccccccccc", &entities, &aliases), None);
}
}