planarian_backend_netwatch/model.rs
1//! What the kernel attributed to whom, and what it means (§12d).
2
3use crate::contract::{AHEAD_SECS, FRESH_SECS};
4use planarian_schema::UnitId;
5use serde::{Deserialize, Serialize};
6use std::collections::{BTreeMap, BTreeSet};
7use std::net::IpAddr;
8
9/// Who made a change: a unit (its cgroup), or nobody the kernel could name.
10#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
11pub enum Owner {
12 Unit(UnitId),
13 Unattributed,
14}
15
16/// The kind of object a netlink message touched.
17#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
18pub enum ChangeKind {
19 Interface,
20 Route,
21 Rule,
22 Dns,
23 Proxy,
24 Firewall,
25}
26
27/// One attributed change: `object` names what was touched (an interface, a
28/// route prefix, a rule, an nft table or chain), so a later removal of the
29/// same object undoes it. `steers` is whether it currently steers traffic
30/// (§12d: a default or all-covering route, a catch-all rule, DNS on the
31/// unit's interface, the system proxy, a dropping or redirecting firewall
32/// rule); an interface by itself steers nothing.
33#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
34pub struct Change {
35 pub owner: Owner,
36 pub kind: ChangeKind,
37 pub object: String,
38 /// The interface the object belongs to or routes out of, if any.
39 pub iface: Option<String>,
40 pub steers: bool,
41 /// The object was deleted (a route or rule removed, an interface gone).
42 pub removed: bool,
43}
44
45#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
46pub enum Proto {
47 Tcp,
48 Udp,
49}
50
51#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
52pub struct Endpoint {
53 pub proto: Proto,
54 pub addr: IpAddr,
55 pub port: u16,
56}
57
58/// A unit's footprint as the files carry it.
59#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
60pub struct Footprint {
61 /// When it was last confirmed, in [`crate::CLOCK`] seconds.
62 pub at: u64,
63 pub protecting: bool,
64 pub ifaces: BTreeSet<String>,
65 pub endpoints: BTreeSet<Endpoint>,
66 /// A lost event may have touched this unit.
67 pub unattributed: bool,
68 /// The unit's latest systemd InvocationID netwatch has seen (32
69 /// lowercase hex), from its cgroup's `trusted.invocation_id`.
70 #[serde(default)]
71 pub invocation: Option<String>,
72 /// That invocation's cgroup is gone and the ring was drained after:
73 /// nothing it did is still to come (crate docs, "The hold's order").
74 #[serde(default)]
75 pub ended: bool,
76 /// A new invocation's cgroup exists but its id is not read yet:
77 /// `protecting unknown` (and `protecting` is true: fail closed).
78 #[serde(default)]
79 pub unknown: bool,
80}
81
82/// What netwatch knows of a unit's latest invocation, from its cgroup.
83#[derive(Clone, Debug, Default, PartialEq, Eq)]
84pub struct Invocation {
85 /// Its InvocationID, once read; `None` while systemd has not set it.
86 pub id: Option<String>,
87 /// Its cgroup is gone and the ring was drained after.
88 pub ended: bool,
89}
90
91/// The watch's state, as `.watch` carries it.
92#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
93pub enum WatchState {
94 /// All probes attached and events flowing, confirmed at this time.
95 Attached { at: u64 },
96 /// A probe failed to attach, or the watch was lost; why, on one line.
97 Failed { reason: String },
98 /// No `.watch`, or one this crate cannot read: not known to be healthy.
99 Unknown,
100}
101
102/// Everything the hold needs, read back from the files (or scripted by the
103/// fake): the watch, why `.unattributed` stands (empty: it is absent), and
104/// the footprint of each network tool, keyed by its file name
105/// ([`crate::file_name`]).
106#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
107pub struct FootprintView {
108 pub watch: WatchState,
109 pub unattributed: Vec<String>,
110 pub units: BTreeMap<String, Footprint>,
111}
112
113impl Default for FootprintView {
114 fn default() -> Self {
115 FootprintView { watch: WatchState::Unknown, unattributed: vec![], units: BTreeMap::new() }
116 }
117}
118
119/// Fresh: at most [`FRESH_SECS`] old and at most [`AHEAD_SECS`] ahead.
120pub(crate) fn fresh(at: u64, now: u64) -> bool {
121 let age = now as i128 - at as i128;
122 age <= FRESH_SECS as i128 && age >= -(AHEAD_SECS as i128)
123}
124
125impl FootprintView {
126 /// §12d, fail closed: whether a failed network tool `unit` (its file
127 /// name) counts as protecting traffic at `now`. True unless the watch is
128 /// attached and fresh, no unattributed change stands, and the unit's
129 /// footprint exists, is fresh, is not protecting and is not marked
130 /// unattributed. The hold's script applies the same rules to the files.
131 pub fn protecting_now(&self, unit: &str, now: u64) -> bool {
132 let healthy = matches!(self.watch, WatchState::Attached { at } if fresh(at, now));
133 if !healthy || !self.unattributed.is_empty() {
134 return true;
135 }
136 match self.units.get(unit) {
137 None => true,
138 Some(f) => !fresh(f.at, now) || f.protecting || f.unattributed,
139 }
140 }
141}
142
143impl FootprintView {
144 /// Which of `tools` count as protecting traffic at `now` (§12d, fail
145 /// closed): what the daemon carries in its observation.
146 pub fn protecting(&self, tools: &[UnitId], now: u64) -> BTreeSet<UnitId> {
147 tools.iter().filter(|u| self.protecting_now(&crate::file_name(u), now)).cloned().collect()
148 }
149}
150
151/// The objects a unit has changed that still stand, and whether each one
152/// steers traffic.
153#[derive(Clone, Debug, Default)]
154struct Standing {
155 objects: BTreeMap<(ChangeKind, String), (Option<String>, bool)>,
156 endpoints: BTreeSet<Endpoint>,
157 unattributed: bool,
158}
159
160/// Each unit's current footprint, kept from the kernel's reports (or the
161/// fake's script). `apply` takes one attributed change; `lost` records an
162/// event the ring buffer dropped; `view` renders what the files carry.
163#[derive(Clone, Debug, Default)]
164pub struct Tracker {
165 units: BTreeMap<UnitId, Standing>,
166 /// Steering changes that stand, attributed to nobody, with the interface
167 /// a route goes out of.
168 unattributed: BTreeMap<(ChangeKind, String), Option<String>>,
169 /// Links that something created (a link kind; or created through
170 /// netlink while watched, which a physical NIC never is). §12d's
171 /// steering route is one over "an interface it created": a default route
172 /// over the machine's own NIC is not one, watched or found at attach.
173 virtual_ifaces: BTreeSet<String>,
174 /// Each unit's latest invocation seen (not kept across restarts: the
175 /// cgroups are read again on attach).
176 invocations: BTreeMap<UnitId, Invocation>,
177 /// A lost event could not be placed: everything counts as unattributed
178 /// until the watch restarts (which re-reads the kernel's state).
179 lost_unplaced: bool,
180 /// Steering objects found standing on attach that no footprint claimed:
181 /// made while nobody watched (§12d, fail closed). Each clears when its
182 /// object is removed.
183 pre_attach: BTreeMap<(ChangeKind, String), Option<String>>,
184 /// Steering routes found standing on attach over an interface a unit
185 /// claims (object → interface): they protect through that interface.
186 route_through: BTreeMap<String, String>,
187 /// Per unit with an invocation: what made its current invocation
188 /// protecting at any point (crate docs, "The hold's order").
189 latched: BTreeMap<UnitId, Latch>,
190 /// Per unit: what made it protecting while it had no live invocation
191 /// (none known yet, or the current one ended). An ended invocation does
192 /// nothing more, so this is the next one's, applied before its cgroup
193 /// was seen (made between the cgroups drain and the ring drain).
194 next_latch: BTreeMap<UnitId, Latch>,
195 /// Bugs the hold-order property must catch (tests only): no latch at
196 /// all; no carry to the next invocation.
197 #[cfg(test)]
198 pub(crate) no_latch: bool,
199 #[cfg(test)]
200 pub(crate) no_carry: bool,
201}
202
203/// The latch: an invocation found protecting once stays protecting, with
204/// the `iface` and `endpoint` lines that justified it, until a new
205/// invocation replaces it (reviewer, coordinator, Nov 3). What stands after
206/// a tool dies is not what it did: a tun-based VPN's interface leaves with
207/// its last process, before its cgroup goes, with no netlink request.
208#[derive(Clone, Debug, Default)]
209struct Latch {
210 protecting: bool,
211 ifaces: BTreeSet<String>,
212 endpoints: BTreeSet<Endpoint>,
213}
214
215impl Latch {
216 fn merge(&mut self, other: Latch) {
217 self.protecting |= other.protecting;
218 self.ifaces.extend(other.ifaces);
219 self.endpoints.extend(other.endpoints);
220 }
221}
222
223/// The tracker's claims, as `.state` keeps them across a restart of the
224/// watch (a dot-file the hold does not read).
225#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
226pub struct Claims {
227 /// (unit, kind, object, iface, steers)
228 pub objects: Vec<(UnitId, ChangeKind, String, Option<String>, bool)>,
229 pub endpoints: Vec<(UnitId, Endpoint)>,
230 pub unattributed_units: Vec<UnitId>,
231}
232
233impl Claims {
234 /// Every unit claimed for: an object, an endpoint, or an unattributed
235 /// mark.
236 pub fn units(&self) -> BTreeSet<UnitId> {
237 let objects = self.objects.iter().map(|o| o.0.clone());
238 let endpoints = self.endpoints.iter().map(|e| e.0.clone());
239 objects.chain(endpoints).chain(self.unattributed_units.iter().cloned()).collect()
240 }
241}
242
243impl Tracker {
244 pub fn apply(&mut self, c: &Change) {
245 self.apply_change(c);
246 self.relatch();
247 }
248
249 fn apply_change(&mut self, c: &Change) {
250 let key = (c.kind, c.object.clone());
251 if c.removed {
252 // An object gone is gone for everyone who claimed it, whoever
253 // removed it (and a pre-attach one clears with it).
254 for s in self.units.values_mut() {
255 s.objects.remove(&key);
256 }
257 self.unattributed.remove(&key);
258 self.pre_attach.remove(&key);
259 if c.kind == ChangeKind::Interface {
260 self.virtual_ifaces.remove(&c.object);
261 }
262 if c.kind == ChangeKind::Route {
263 self.route_through.remove(&c.object);
264 }
265 if c.kind == ChangeKind::Interface {
266 // The kernel drops an interface's routes with it, without a
267 // message for each: drop every claim on a route through it.
268 let gone = Some(c.object.clone());
269 for s in self.units.values_mut() {
270 s.objects.retain(|(k, _), (i, _)| !(*k == ChangeKind::Route && *i == gone));
271 }
272 self.route_through.retain(|_, i| Some(i.clone()) != gone);
273 self.unattributed.retain(|(k, _), i| !(*k == ChangeKind::Route && *i == gone));
274 self.pre_attach.retain(|(k, _), i| !(*k == ChangeKind::Route && *i == gone));
275 }
276 return;
277 }
278 if c.kind == ChangeKind::Interface {
279 // Created through netlink: a virtual link (dummy, wireguard, tun).
280 self.virtual_ifaces.insert(c.object.clone());
281 }
282 match &c.owner {
283 Owner::Unattributed => {
284 if !c.steers {
285 self.unattributed.remove(&key);
286 } else {
287 self.unattributed.insert(key, c.iface.clone());
288 }
289 }
290 Owner::Unit(u) => {
291 let s = self.units.entry(u.clone()).or_default();
292 if c.removed {
293 s.objects.remove(&key);
294 } else {
295 s.objects.insert(key, (c.iface.clone(), c.steers));
296 }
297 }
298 }
299 }
300
301 /// What `.state` keeps.
302 pub fn claims(&self) -> Claims {
303 let mut c = Claims::default();
304 for (u, s) in &self.units {
305 for ((k, o), (i, steers)) in &s.objects {
306 c.objects.push((u.clone(), *k, o.clone(), i.clone(), *steers));
307 }
308 c.endpoints.extend(s.endpoints.iter().map(|e| (u.clone(), *e)));
309 if s.unattributed {
310 c.unattributed_units.push(u.clone());
311 }
312 }
313 c
314 }
315
316 /// §12d, on every attach (boot or restart): start from the claims kept
317 /// before (`.state`, or the footprint files' `iface` lines when it is
318 /// gone), keep each claim whose object still stands in `standing` (a
319 /// snapshot of the kernel's steering state, as changes), drop the rest,
320 /// and record every standing steering object nobody claims as
321 /// pre-attach: it was made while nobody watched. A restart therefore
322 /// never downgrades a footprint, and a change made in the gap fails
323 /// closed until its object is gone.
324 ///
325 /// `virtual_ifaces` are the standing links that something created (they
326 /// have a link kind: dummy, wireguard, tun, …). An unclaimed steering
327 /// route counts as pre-attach only over one of those, or over no
328 /// interface: a default route over a physical NIC no unit created (the
329 /// machine's own uplink) is not "an interface it created" (§12d).
330 pub fn attach(previous: &Claims, standing: &[Change], virtual_ifaces: &BTreeSet<String>) -> Tracker {
331 let present: BTreeMap<(ChangeKind, String), &Change> =
332 standing.iter().map(|c| ((c.kind, c.object.clone()), c)).collect();
333 let mut t = Tracker { virtual_ifaces: virtual_ifaces.clone(), ..Default::default() };
334 for (u, k, o, i, steers) in &previous.objects {
335 if present.contains_key(&(*k, o.clone())) {
336 t.apply(&Change {
337 owner: Owner::Unit(u.clone()),
338 kind: *k,
339 object: o.clone(),
340 iface: i.clone(),
341 steers: *steers,
342 removed: false,
343 });
344 }
345 }
346 for (u, e) in &previous.endpoints {
347 t.add_endpoint(u, *e);
348 }
349 for u in &previous.unattributed_units {
350 t.lost(Some(u));
351 }
352 let claimed: BTreeSet<(ChangeKind, String)> =
353 t.units.values().flat_map(|s| s.objects.keys().cloned()).collect();
354 for (key, c) in &present {
355 if c.steers && !claimed.contains(key) {
356 t.pre_attach.insert(key.clone(), c.iface.clone());
357 }
358 }
359 // An unclaimed steering route over an interface a unit claims is
360 // that unit's (it carries the unit's traffic), not pre-attach.
361 let claimed_ifaces: BTreeSet<String> = t
362 .units
363 .values()
364 .flat_map(|s| s.objects.iter())
365 .filter(|((k, _), _)| *k == ChangeKind::Interface)
366 .map(|((_, n), _)| n.clone())
367 .collect();
368 t.pre_attach.retain(|key, _| {
369 let iface = present[key].iface.as_ref();
370 !(key.0 == ChangeKind::Route
371 && iface.is_some_and(|i| claimed_ifaces.contains(i) || !virtual_ifaces.contains(i)))
372 });
373 // Routes over a claimed interface still steer through it.
374 for c in standing {
375 if c.kind == ChangeKind::Route
376 && c.steers
377 && !claimed.contains(&(c.kind, c.object.clone()))
378 && let Some(i) = &c.iface
379 && claimed_ifaces.contains(i)
380 {
381 t.route_through.insert(c.object.clone(), i.clone());
382 }
383 }
384 t
385 }
386
387 /// The claims the footprint files carry (each `iface` line), for an
388 /// attach when `.state` is gone.
389 pub fn claims_from_files(view: &FootprintView) -> Claims {
390 let mut c = Claims::default();
391 for (name, f) in &view.units {
392 let u = UnitId::service(name);
393 for i in &f.ifaces {
394 c.objects.push((u.clone(), ChangeKind::Interface, i.clone(), Some(i.clone()), false));
395 }
396 c.endpoints.extend(f.endpoints.iter().map(|e| (u.clone(), *e)));
397 if f.unattributed {
398 c.unattributed_units.push(u);
399 }
400 }
401 c
402 }
403
404 pub fn add_endpoint(&mut self, unit: &UnitId, e: Endpoint) {
405 self.units.entry(unit.clone()).or_default().endpoints.insert(e);
406 self.relatch();
407 }
408
409 /// The unit's latest invocation as its cgroup shows it; whether it
410 /// changed. A new invocation (a new id, or a new cgroup whose id is
411 /// not read yet) resets the latch; an id read late, or `ended`, does not.
412 pub fn set_invocation(&mut self, unit: &UnitId, inv: Invocation) -> bool {
413 let before = self.invocations.get(unit);
414 if before == Some(&inv) {
415 return false;
416 }
417 let new = match (before.and_then(|b| b.id.as_ref()), inv.id.as_ref()) {
418 (Some(a), Some(b)) => a != b,
419 (Some(_), None) => true,
420 (None, _) => before.is_none(),
421 };
422 if new {
423 // The new invocation starts from what was seen while there was
424 // no live one; one replaced before it ended (its removal and the
425 // next start seen at once) passes on its latch too: whose events
426 // those were is not known, so fail closed.
427 let ended = before.is_none_or(|b| b.ended);
428 let mut l = self.next_latch.remove(unit).unwrap_or_default();
429 #[cfg(test)]
430 if self.no_carry {
431 l = Latch::default();
432 }
433 if let Some(old) = self.latched.remove(unit).filter(|_| !ended) {
434 l.merge(old);
435 }
436 if l.protecting {
437 self.latched.insert(unit.clone(), l);
438 }
439 }
440 self.invocations.insert(unit.clone(), inv);
441 self.relatch();
442 true
443 }
444
445 /// The virtual links standing now (`host::read_virtual_ifaces`): read
446 /// live each tick, so a tun made by ioctl or a link moved in from
447 /// another netns is never taken for a physical NIC. An interface gone
448 /// from it no longer counts for what stands, but stays in the latch of
449 /// an invocation it made protecting.
450 pub fn set_virtual(&mut self, live: BTreeSet<String>) {
451 self.virtual_ifaces = live;
452 self.relatch();
453 }
454
455 /// Latch what makes each unit protecting now: into its current
456 /// invocation's latch, and while it has no live one, into the next's.
457 fn relatch(&mut self) {
458 #[cfg(test)]
459 if self.no_latch {
460 return;
461 }
462 let units: Vec<UnitId> = self.units.keys().cloned().collect();
463 for u in units {
464 let (protecting, ifaces, endpoints) = self.standing(&u);
465 if !protecting {
466 continue;
467 }
468 let l = Latch { protecting, ifaces, endpoints };
469 let inv = self.invocations.get(&u);
470 if inv.is_none_or(|i| i.ended) {
471 self.next_latch.entry(u.clone()).or_default().merge(l.clone());
472 }
473 if inv.is_some() {
474 self.latched.entry(u).or_default().merge(l);
475 }
476 }
477 }
478
479 /// An event was lost. With the unit known, its footprint is marked
480 /// unattributed; without, the global marker stands.
481 pub fn lost(&mut self, unit: Option<&UnitId>) {
482 match unit {
483 Some(u) => self.units.entry(u.clone()).or_default().unattributed = true,
484 None => self.lost_unplaced = true,
485 }
486 }
487
488 /// The footprint of `unit` now, confirmed at `at`: what stands, and
489 /// what its current invocation latched.
490 pub fn footprint(&self, unit: &UnitId, at: u64) -> Footprint {
491 let inv = self.invocations.get(unit);
492 let invocation = inv.and_then(|i| i.id.clone());
493 let ended = inv.is_some_and(|i| i.ended && i.id.is_some());
494 // A cgroup whose id is not read yet reads as protecting, but is not
495 // latched: it says nothing of what the invocation did.
496 let unknown = inv.is_some_and(|i| i.id.is_none());
497 let (protecting, mut ifaces, mut endpoints) = self.standing(unit);
498 let latch = self.latched.get(unit).filter(|_| inv.is_some());
499 if let Some(l) = latch {
500 ifaces.extend(l.ifaces.iter().cloned());
501 endpoints.extend(l.endpoints.iter().cloned());
502 }
503 let unattributed = self.units.get(unit).is_some_and(|s| s.unattributed);
504 Footprint {
505 at,
506 protecting: unknown || protecting || latch.is_some_and(|l| l.protecting),
507 ifaces,
508 endpoints,
509 unattributed,
510 invocation,
511 ended,
512 unknown,
513 }
514 }
515
516 /// Whether what stands of `unit`'s changes protects, and the `iface`
517 /// and `endpoint` lines that go with it.
518 fn standing(&self, unit: &UnitId) -> (bool, BTreeSet<String>, BTreeSet<Endpoint>) {
519 let Some(s) = self.units.get(unit) else {
520 return (false, BTreeSet::new(), BTreeSet::new());
521 };
522 // Only a virtual link can be one a tool created; a physical NIC is
523 // registered by its driver, whoever's context that runs in.
524 let created: BTreeSet<String> = s
525 .objects
526 .iter()
527 .filter(|((k, name), _)| *k == ChangeKind::Interface && self.virtual_ifaces.contains(name))
528 .map(|((_, name), _)| name.clone())
529 .collect();
530 // §12d: an interface the unit created that carries a default or
531 // all-covering route protects, whoever added the route; so do its own
532 // catch-all rules, DNS, proxy and firewall changes. A route alone
533 // does not: a default route over an interface the unit did not create
534 // (NetworkManager's DHCP route over wlan0) steers nothing of its own,
535 // watched or found at attach (coordinator, Nov 2). `iface` lines name
536 // the created interfaces that steer: what the hold passes.
537 let mut ifaces: BTreeSet<String> = self.steering_ifaces().intersection(&created).cloned().collect();
538 // (coordinator, Nov 2 and 3; the spec reads "...over a virtual
539 // interface it did not create...") A default or all-covering route
540 // the unit added over a *virtual* link it did not create makes it
541 // protecting too: one another unit made (OpenVPN on a persistent tun
542 // a setup unit made: both hold), one moved in from another netns, one
543 // made before the watch attached. Virtual is the live sysfs check, so
544 // a route over a physical NIC (NetworkManager's DHCP default over
545 // wlan0) still steers nothing of the unit's own. Leans fail-closed.
546 for ((k, _), (i, steers)) in &s.objects {
547 if *k == ChangeKind::Route
548 && *steers
549 && let Some(i) = i
550 && self.virtual_ifaces.contains(i)
551 {
552 ifaces.insert(i.clone());
553 }
554 }
555 let own = |k: &ChangeKind| !matches!(k, ChangeKind::Interface | ChangeKind::Route);
556 // A change charged here that could not be fully attributed counts as
557 // protecting (fail closed), and the file says so: `protecting yes`
558 // with its `unattributed` line, as the hold reads it anyway.
559 let protecting =
560 s.unattributed || !ifaces.is_empty() || s.objects.iter().any(|((k, _), (_, steers))| *steers && own(k));
561 (protecting, ifaces, s.endpoints.clone())
562 }
563
564 /// Interfaces some standing steering route goes out of, whoever made it.
565 fn steering_ifaces(&self) -> BTreeSet<String> {
566 self.units
567 .values()
568 .flat_map(|s| s.objects.iter())
569 .filter(|((k, _), (_, steers))| *k == ChangeKind::Route && *steers)
570 .filter_map(|(_, (iface, _))| iface.clone())
571 .chain(self.route_through.values().cloned())
572 .chain(
573 self.unattributed.iter().filter(|((k, _), _)| *k == ChangeKind::Route).filter_map(|(_, i)| i.clone()),
574 )
575 .collect()
576 }
577
578 /// What the files carry: a footprint for every network tool in `tools`
579 /// (absent from the tracker: `protecting no`), confirmed at `now`.
580 pub fn view(&self, watch: WatchState, tools: &[UnitId], now: u64) -> FootprintView {
581 let units = tools.iter().map(|u| (crate::file_name(u), self.footprint(u, now))).collect();
582 // An unattributed route over a physical NIC steers nothing (the same
583 // rule as at attach); over a virtual link, or none, it fails closed.
584 let mut why: Vec<String> = self
585 .unattributed
586 .iter()
587 .filter(|((k, _), i)| {
588 !(*k == ChangeKind::Route && i.as_ref().is_some_and(|i| !self.virtual_ifaces.contains(i)))
589 })
590 .map(|((k, o), _)| format!("{k:?} {o}: no unit made it"))
591 .collect();
592 why.extend(self.pre_attach.keys().map(|(k, o)| format!("pre-attach {k:?} {o}")));
593 if self.lost_unplaced {
594 why.push("an event was lost and could not be placed".into());
595 }
596 FootprintView { watch, unattributed: why, units }
597 }
598}