1use std::collections::{BTreeSet, HashMap, HashSet};
17use std::net::{Ipv4Addr, SocketAddrV4};
18
19use bacnet_transport::bvll::BvlcResultCode;
20
21use super::{BdtRow, Outcome};
22
23pub type Probe = Outcome<Vec<BdtRow>>;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum Cell {
29 Listed {
32 mask: Ipv4Addr,
34 mixed: bool,
36 },
37 Absent,
39 Missing,
42 Unknown,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum Finding {
49 Asymmetric {
52 from: SocketAddrV4,
54 to: SocketAddrV4,
56 },
57 MissingSelf(SocketAddrV4),
59 NoPeers(SocketAddrV4),
62 MixedMasks {
64 peer: SocketAddrV4,
66 masks: Vec<Ipv4Addr>,
68 },
69 Nak {
71 bbmd: SocketAddrV4,
73 code: BvlcResultCode,
75 },
76 Unreachable {
78 bbmd: SocketAddrV4,
80 reason: String,
82 },
83}
84
85impl Finding {
86 pub fn describe(&self) -> String {
88 match self {
89 Self::Asymmetric { from, to } => {
90 format!(
91 "{from} lists {to}, but {to} does not list {from}: broadcasts go one way only"
92 )
93 }
94 Self::MissingSelf(bbmd) => {
95 format!("{bbmd} has no entry for itself (Annex J.4.3.2)")
96 }
97 Self::NoPeers(bbmd) => {
98 format!("{bbmd} has an empty BDT: it forwards to no peer BBMD")
99 }
100 Self::MixedMasks { peer, masks } => {
101 let masks: Vec<String> = masks.iter().map(Ipv4Addr::to_string).collect();
102 format!(
103 "{peer} is listed with different masks: {}",
104 masks.join(", ")
105 )
106 }
107 Self::Nak { bbmd, code } => format!("{bbmd} refused the BDT read ({code})"),
108 Self::Unreachable { bbmd, reason } => format!("{bbmd} could not be read ({reason})"),
109 }
110 }
111}
112
113pub const GRID_CAP: usize = 32;
116
117#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct Matrix {
120 pub bbmds: Vec<SocketAddrV4>,
123 pub cells: Vec<Vec<Cell>>,
125 pub omitted: usize,
127 pub findings: Vec<Finding>,
129}
130
131impl Matrix {
132 pub fn build(probes: &[(SocketAddrV4, Probe)]) -> Self {
139 let mut seen = HashSet::new();
140 let mut bbmds: Vec<SocketAddrV4> = Vec::new();
141 for (bbmd, _) in probes {
142 if seen.insert(*bbmd) {
143 bbmds.push(*bbmd);
144 }
145 }
146 for (_, probe) in probes {
147 for row in probe.table().map_or(&[][..], Vec::as_slice) {
148 if seen.insert(row.address) {
149 bbmds.push(row.address);
150 }
151 }
152 }
153 let mut tables: HashMap<SocketAddrV4, &[BdtRow]> = HashMap::new();
155 for (bbmd, probe) in probes {
156 if let Some(rows) = probe.table() {
157 tables.entry(*bbmd).or_insert(rows.as_slice());
158 }
159 }
160 let listed = |row: SocketAddrV4, column: SocketAddrV4| -> Option<Ipv4Addr> {
162 tables
163 .get(&row)?
164 .iter()
165 .find(|entry| entry.address == column)
166 .map(|entry| entry.mask)
167 };
168 let mut findings = Vec::new();
169 for (bbmd, probe) in probes {
170 match probe {
171 Probe::Nak(code) => findings.push(Finding::Nak {
172 bbmd: *bbmd,
173 code: *code,
174 }),
175 Probe::Unreachable(reason) => findings.push(Finding::Unreachable {
176 bbmd: *bbmd,
177 reason: reason.clone(),
178 }),
179 Probe::Table(_) | Probe::Skipped => {}
180 }
181 }
182 let mut masks: HashMap<SocketAddrV4, BTreeSet<Ipv4Addr>> = HashMap::new();
184 for rows in tables.values() {
185 let mut first = HashSet::new();
186 for entry in rows.iter().filter(|entry| first.insert(entry.address)) {
187 masks.entry(entry.address).or_default().insert(entry.mask);
188 }
189 }
190 let mixed: HashSet<SocketAddrV4> = bbmds
191 .iter()
192 .copied()
193 .filter(|bbmd| masks.get(bbmd).is_some_and(|set| set.len() > 1))
194 .collect();
195 for bbmd in bbmds.iter().filter(|bbmd| mixed.contains(bbmd)) {
196 findings.push(Finding::MixedMasks {
197 peer: *bbmd,
198 masks: masks[bbmd].iter().copied().collect(),
199 });
200 }
201 let read: Vec<SocketAddrV4> = bbmds
203 .iter()
204 .copied()
205 .filter(|bbmd| tables.contains_key(bbmd))
206 .collect();
207 for &row in &read {
208 for &column in &read {
209 if listed(row, column).is_some() {
210 continue;
211 }
212 if row == column {
213 findings.push(match tables[&row].is_empty() {
214 true => Finding::NoPeers(row),
215 false => Finding::MissingSelf(row),
216 });
217 } else if listed(column, row).is_some() {
218 findings.push(Finding::Asymmetric {
219 from: column,
220 to: row,
221 });
222 }
223 }
224 }
225 let omitted = bbmds.len().saturating_sub(GRID_CAP);
226 bbmds.truncate(GRID_CAP);
227 let n = bbmds.len();
228 let mut cells = vec![vec![Cell::Unknown; n]; n];
229 for (r, &row) in bbmds.iter().enumerate() {
230 let Some(table) = tables.get(&row) else {
231 continue;
232 };
233 for (c, &column) in bbmds.iter().enumerate() {
234 cells[r][c] = match listed(row, column) {
235 Some(mask) => Cell::Listed {
236 mask,
237 mixed: mixed.contains(&column),
238 },
239 None if row == column && !table.is_empty() => Cell::Missing,
240 None if row != column && listed(column, row).is_some() => Cell::Missing,
241 None => Cell::Absent,
242 };
243 }
244 }
245 Self {
246 bbmds,
247 cells,
248 omitted,
249 findings,
250 }
251 }
252
253 pub fn consistent(&self) -> bool {
256 self.omitted == 0
257 && self
258 .findings
259 .iter()
260 .all(|f| matches!(f, Finding::NoPeers(_)))
261 && self
262 .cells
263 .iter()
264 .flatten()
265 .all(|cell| *cell != Cell::Unknown)
266 }
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272
273 const UNICAST: Ipv4Addr = Ipv4Addr::BROADCAST;
274 const SUBNET: Ipv4Addr = Ipv4Addr::new(255, 255, 255, 0);
275
276 fn at(last: u8) -> SocketAddrV4 {
277 SocketAddrV4::new(Ipv4Addr::new(10, 0, last, 1), 47808)
278 }
279
280 fn table(rows: &[(u8, Ipv4Addr)]) -> Probe {
281 Probe::Table(
282 rows.iter()
283 .map(|(last, mask)| BdtRow {
284 address: at(*last),
285 mask: *mask,
286 })
287 .collect(),
288 )
289 }
290
291 fn listed(mask: Ipv4Addr) -> Cell {
292 Cell::Listed { mask, mixed: false }
293 }
294
295 #[test]
296 fn symmetric_tables_are_consistent() {
297 let both = table(&[(1, UNICAST), (2, UNICAST)]);
298 let matrix = Matrix::build(&[(at(1), both.clone()), (at(2), both)]);
299 assert_eq!(matrix.bbmds, [at(1), at(2)]);
300 assert!(matrix.findings.is_empty(), "{:?}", matrix.findings);
301 assert!(matrix.consistent());
302 assert!(matrix.cells.iter().flatten().all(|c| *c == listed(UNICAST)));
303 }
304
305 #[test]
306 fn a_one_way_entry_is_flagged_on_the_side_that_lacks_it() {
307 let matrix = Matrix::build(&[
308 (at(1), table(&[(1, UNICAST), (2, UNICAST)])),
309 (at(2), table(&[(2, UNICAST)])),
310 ]);
311 assert_eq!(matrix.cells[0][1], listed(UNICAST));
313 assert_eq!(matrix.cells[1][0], Cell::Missing);
314 assert_eq!(
315 matrix.findings,
316 [Finding::Asymmetric {
317 from: at(1),
318 to: at(2)
319 }]
320 );
321 assert!(!matrix.consistent());
322 assert!(matrix.findings[0].describe().contains("one way"));
323 }
324
325 #[test]
326 fn a_table_without_its_own_entry_is_flagged() {
327 let matrix = Matrix::build(&[
328 (at(1), table(&[(2, UNICAST)])),
329 (at(2), table(&[(1, UNICAST), (2, UNICAST)])),
330 ]);
331 assert_eq!(matrix.cells[0][0], Cell::Missing);
332 assert_eq!(matrix.findings, [Finding::MissingSelf(at(1))]);
333 }
334
335 #[test]
336 fn two_masks_for_one_bbmd_are_flagged_in_its_column() {
337 let matrix = Matrix::build(&[
338 (at(1), table(&[(1, UNICAST), (2, SUBNET)])),
339 (at(2), table(&[(1, UNICAST), (2, UNICAST)])),
340 ]);
341 let mixed = |mask| Cell::Listed { mask, mixed: true };
342 assert_eq!(matrix.cells[0][1], mixed(SUBNET));
343 assert_eq!(matrix.cells[1][1], mixed(UNICAST));
344 assert_eq!(matrix.cells[0][0], listed(UNICAST));
345 assert_eq!(
346 matrix.findings,
347 [Finding::MixedMasks {
348 peer: at(2),
349 masks: vec![SUBNET, UNICAST]
350 }]
351 );
352 }
353
354 #[test]
355 fn unread_peers_are_unknown_and_named() {
356 let matrix = Matrix::build(&[
357 (at(1), table(&[(1, UNICAST), (2, UNICAST), (3, UNICAST)])),
358 (
359 at(2),
360 Probe::Nak(BvlcResultCode::READ_BROADCAST_DISTRIBUTION_TABLE_NAK),
361 ),
362 (at(3), Probe::Unreachable("no answer".into())),
363 ]);
364 assert_eq!(matrix.cells[1], [Cell::Unknown; 3]);
365 assert_eq!(matrix.cells[2], [Cell::Unknown; 3]);
366 assert_eq!(matrix.cells[0], [listed(UNICAST); 3]);
368 assert_eq!(matrix.findings.len(), 2);
369 assert!(matches!(matrix.findings[0], Finding::Nak { bbmd, .. } if bbmd == at(2)));
370 assert!(matches!(
371 matrix.findings[1],
372 Finding::Unreachable { bbmd, .. } if bbmd == at(3)
373 ));
374 assert!(!matrix.consistent());
375 }
376
377 #[test]
378 fn a_listed_bbmd_without_a_probe_is_a_row_that_was_not_read() {
379 let matrix = Matrix::build(&[(at(1), table(&[(1, UNICAST), (4, UNICAST)]))]);
380 assert_eq!(matrix.bbmds, [at(1), at(4)]);
381 assert_eq!(matrix.cells[1], [Cell::Unknown; 2]);
382 assert!(matrix.findings.is_empty());
383 assert!(!matrix.consistent(), "an unread table is not consistent");
384 }
385
386 #[test]
389 fn an_empty_table_is_a_note_not_a_problem() {
390 let matrix = Matrix::build(&[(at(1), table(&[]))]);
391 assert_eq!(matrix.findings, [Finding::NoPeers(at(1))]);
392 assert_eq!(matrix.cells, [[Cell::Absent]]);
393 assert!(matrix.consistent());
394 let matrix = Matrix::build(&[
395 (at(1), table(&[(1, UNICAST), (2, UNICAST)])),
396 (at(2), table(&[])),
397 ]);
398 assert_eq!(
399 matrix.findings,
400 [
401 Finding::Asymmetric {
402 from: at(1),
403 to: at(2)
404 },
405 Finding::NoPeers(at(2)),
406 ]
407 );
408 assert!(!matrix.consistent());
409 }
410
411 #[test]
412 fn peers_that_list_neither_way_are_absent_not_missing() {
413 let matrix = Matrix::build(&[
414 (at(1), table(&[(1, UNICAST), (2, UNICAST), (3, UNICAST)])),
415 (at(2), table(&[(1, UNICAST), (2, UNICAST)])),
416 (at(3), table(&[(1, UNICAST), (3, UNICAST)])),
417 ]);
418 assert_eq!(matrix.cells[1][2], Cell::Absent);
419 assert_eq!(matrix.cells[2][1], Cell::Absent);
420 assert!(matrix.consistent());
421 }
422}