Skip to main content

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}