Skip to main content

detcore_model/
container_mounts.rs

1/*
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 * All rights reserved.
4 *
5 * This source code is licensed under the BSD-style license found in the
6 * LICENSE file in the root directory of this source tree.
7 */
8
9//! The guest's container mount table: the mounts `/proc/<pid>/mountinfo` and
10//! `/proc/<pid>/mounts` show, which hermit-cli builds once, in the completed
11//! container namespace before the guest starts, and Detcore renders on every
12//! backend (package K slice K3a,
13//! <https://github.com/rrnewton/hermit/issues/4213>).
14//!
15//! A container's mount namespace starts as a copy of the host's, so a table
16//! read from the kernel has a row for every host mount that existed when
17//! Hermit started: other users' FUSE checkouts, other agents' bind mounts,
18//! logind's `/run/user/<uid>`. Which of those exist changes from one run to
19//! the next, and a row added or removed renumbers every row after it. The
20//! table below shows only the mounts a fixed rule selects
21//! ([`ContainerMountTable::select`]), so host mounts outside that rule, made
22//! or removed between two runs, change nothing the guest reads.
23//!
24//! The rows the rule shows are still host facts where they are host mounts:
25//! - the base mountpoints;
26//! - the host mounts above a mount Hermit installed (rule 3). A mount Hermit
27//!   stages below `/tmp` is installed first in its private tmp directory,
28//!   below `$TMPDIR` or `--tmp`, so rule 3 then shows the host mount holding
29//!   that directory too;
30//! - the copies a recursive bind of the plan makes of the host mounts below
31//!   its source (every user `--bind` and `--mount type=bind` of a directory
32//!   is recursive), which are the bind's own rows (rule 1), ephemeral seeds
33//!   included, so they come and go with the host's mounts below that source.
34//!
35//! The kernel lists mounts in creation order (by unique mount ID since Linux
36//! 6.8, in attach order before). The host rows are the copies made when the
37//! container's namespace was created, so a host remount moves no row; only a
38//! host unmount and a new mount at a shown row's path, between two runs,
39//! moves that row.
40
41use std::collections::BTreeMap;
42use std::collections::BTreeSet;
43
44use serde::Deserialize;
45use serde::Serialize;
46
47use crate::config::MountInfoRootRewrite;
48
49/// The mountpoints the guest sees whenever its container has a mount at one:
50/// the filesystems every Linux program may expect to find, modelled on the
51/// minimal machine of gVisor's `/proc`. A host mount at any other path is
52/// hidden unless it holds a mount Hermit installed ([`ContainerMountTable::select`]).
53pub const BASE_MOUNTPOINTS: [&str; 9] = [
54    "/",
55    "/proc",
56    "/dev",
57    "/dev/pts",
58    "/dev/shm",
59    "/dev/mqueue",
60    "/sys",
61    "/sys/fs/cgroup",
62    "/run",
63];
64
65/// One row of the guest's mount table: every field of a Linux
66/// `/proc/<pid>/mountinfo` line, as the kernel wrote it in the completed
67/// container namespace (paths and the source escaped as the kernel escapes
68/// them), except the parent, which names the nearest mount the table shows
69/// ([`ContainerMountTable::select`]). Mount IDs, devices and peer groups are
70/// the kernel's raw values; Detcore numbers them for the guest through the
71/// same pools that number `stat`, `statx` and fdinfo.
72#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
73pub struct ContainerMount {
74    /// Field 1: the mount ID.
75    pub raw_mount_id: u64,
76    /// Field 2: the parent's mount ID.
77    pub raw_parent_id: u64,
78    /// Field 3: the device's major number.
79    pub major: u32,
80    /// Field 3: the device's minor number.
81    pub minor: u32,
82    /// Field 4: the root of the mount within its filesystem.
83    pub root: Vec<u8>,
84    /// Field 5: the mountpoint.
85    pub mountpoint: Vec<u8>,
86    /// Field 6: the per-mount options, `ro` or `rw` first.
87    pub mount_options: Vec<u8>,
88    /// The optional fields before the separator (`shared:N`, `master:N`,
89    /// `propagate_from:N`, `unbindable`).
90    pub optional_fields: Vec<Vec<u8>>,
91    /// The filesystem type.
92    pub fstype: Vec<u8>,
93    /// The mount source.
94    pub source: Vec<u8>,
95    /// The superblock options, `ro` or `rw` first.
96    pub super_options: Vec<u8>,
97}
98
99/// The superblock flags Linux's `show_sb_opts` prints, in its order, right
100/// after the superblock's `ro` or `rw` and before the filesystem's own
101/// options.
102const SUPERBLOCK_FLAG_OPTIONS: [&[u8]; 4] = [b"sync", b"dirsync", b"mand", b"lazytime"];
103
104impl ContainerMount {
105    /// One `mountinfo` line, without its newline, in the grammar
106    /// [`crate::procfs::parse_mountinfo`] accepts.
107    fn parse(line: &[u8]) -> Option<Self> {
108        let fields = line.split(|byte| *byte == b' ').collect::<Vec<_>>();
109        let separator = fields.iter().position(|field| *field == b"-")?;
110        if separator < 6 || separator + 4 != fields.len() {
111            return None;
112        }
113        let decimal = |field: &[u8]| std::str::from_utf8(field).ok()?.parse::<u64>().ok();
114        let (major, minor) = {
115            let text = std::str::from_utf8(fields[2]).ok()?;
116            let (major, minor) = text.split_once(':')?;
117            (major.parse().ok()?, minor.parse().ok()?)
118        };
119        Some(Self {
120            raw_mount_id: decimal(fields[0])?,
121            raw_parent_id: decimal(fields[1])?,
122            major,
123            minor,
124            root: fields[3].to_vec(),
125            mountpoint: fields[4].to_vec(),
126            mount_options: fields[5].to_vec(),
127            optional_fields: fields[6..separator].iter().map(|f| f.to_vec()).collect(),
128            fstype: fields[separator + 1].to_vec(),
129            source: fields[separator + 2].to_vec(),
130            super_options: fields[separator + 3].to_vec(),
131        })
132    }
133
134    /// Whether this row is an ephemeral host FUSE seed
135    /// (`crate::procfs::is_ephemeral_host_seed_mount`), which the table never
136    /// shows unless Hermit installed it.
137    pub fn is_ephemeral_host_seed(&self) -> bool {
138        // From the fields, not a rendered line: a container's namespace can
139        // hold hundreds of rows, and this runs for each at every launch.
140        crate::procfs::is_ephemeral_host_seed_fields(&self.mountpoint, &self.fstype)
141    }
142
143    /// Append this row as Linux's `show_mountinfo` writes it.
144    fn write_mountinfo(&self, out: &mut Vec<u8>) {
145        out.extend_from_slice(
146            format!(
147                "{} {} {}:{} ",
148                self.raw_mount_id, self.raw_parent_id, self.major, self.minor
149            )
150            .as_bytes(),
151        );
152        for field in [&self.root, &self.mountpoint, &self.mount_options] {
153            out.extend_from_slice(field);
154            out.push(b' ');
155        }
156        for field in &self.optional_fields {
157            out.extend_from_slice(field);
158            out.push(b' ');
159        }
160        out.extend_from_slice(b"- ");
161        out.extend_from_slice(&self.fstype);
162        out.push(b' ');
163        out.extend_from_slice(&self.source);
164        out.push(b' ');
165        out.extend_from_slice(&self.super_options);
166        out.push(b'\n');
167    }
168
169    /// The options Linux's `show_vfsmnt` writes for this mount in
170    /// `/proc/<pid>/mounts`: `ro` if the mount or its superblock is
171    /// read-only (`__mnt_is_readonly`), else `rw`; then the superblock flags
172    /// (`show_sb_opts`), the per-mount flags (`show_mnt_opts`) and the
173    /// filesystem's own options (`show_options`). `mountinfo` splits the same
174    /// options into its field 6 (the mount's `ro` or `rw` and its flags) and
175    /// its superblock options (the superblock's `ro` or `rw`, its flags and the
176    /// filesystem's options), so they are taken apart from there.
177    ///
178    /// A filesystem whose own options begin with one of the superblock flags'
179    /// names would have it shown before the per-mount flags rather than after:
180    /// no filesystem Linux ships prints one of those names first.
181    fn mounts_options(&self) -> Vec<u8> {
182        let mut mount = self.mount_options.split(|byte| *byte == b',');
183        let mut superblock = self.super_options.split(|byte| *byte == b',');
184        let mount_read_only = mount.next() == Some(b"ro".as_slice());
185        let superblock_read_only = superblock.next() == Some(b"ro".as_slice());
186        let superblock = superblock.collect::<Vec<_>>();
187        let flags = superblock
188            .iter()
189            .take_while(|option| SUPERBLOCK_FLAG_OPTIONS.contains(option))
190            .count();
191        let mut options: Vec<&[u8]> = vec![if mount_read_only || superblock_read_only {
192            b"ro"
193        } else {
194            b"rw"
195        }];
196        options.extend(&superblock[..flags]);
197        options.extend(mount.filter(|option| !option.is_empty()));
198        options.extend(
199            superblock[flags..]
200                .iter()
201                .filter(|option| !option.is_empty()),
202        );
203        options.join(&b","[..])
204    }
205}
206
207/// Parse a Linux `/proc/<pid>/mountinfo` snapshot into its rows, in the
208/// grammar [`crate::procfs::parse_mountinfo`] accepts: `None` for a malformed
209/// row, an optional field this crate cannot map, or a repeated mount ID.
210pub fn parse_container_mounts(contents: &[u8]) -> Option<Vec<ContainerMount>> {
211    crate::procfs::parse_mountinfo(contents)?;
212    if contents.is_empty() {
213        return Some(Vec::new());
214    }
215    contents
216        .strip_suffix(b"\n")
217        .unwrap_or(contents)
218        .split(|byte| *byte == b'\n')
219        .map(ContainerMount::parse)
220        .collect()
221}
222
223/// The guest's container mount table (see the module documentation).
224#[derive(Clone, Default, Eq, PartialEq, Serialize, Deserialize)]
225pub struct ContainerMountTable {
226    /// The rows the guest sees, in the kernel's row order.
227    pub mounts: Vec<ContainerMount>,
228    /// For each mount of the namespace the table hides, the nearest mount
229    /// above it that the table shows, as raw mount IDs: a file on a hidden
230    /// mount reports that mount's ID in fdinfo `mnt_id` and `STATX_MNT_ID`.
231    pub hidden_ancestors: Vec<(u64, u64)>,
232}
233
234/// Only the table's size: Detcore logs its whole configuration, this table
235/// included, on one line at each run's start, and verbose verification
236/// compares that line between two runs character by character. The rows'
237/// raw mount IDs differ from run to run, and their bytes print as decimal
238/// lists, so in a container with long mount options (an overlay root's layer
239/// list) the line, and that comparison, grew without bound: the validate
240/// node running hermit_modes hit its 4 GiB memory cap on it.
241impl std::fmt::Debug for ContainerMountTable {
242    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
243        formatter
244            .debug_struct("ContainerMountTable")
245            .field("rows", &self.mounts.len())
246            .field("hidden", &self.hidden_ancestors.len())
247            .finish()
248    }
249}
250
251impl ContainerMountTable {
252    // TODO-HUMAN-REVIEW(https://github.com/rrnewton/hermit/issues/4213): Review
253    // the container mount table's membership rule, a new determinization
254    // strategy: real host mounts outside the rule are hidden from the guest.
255    /// The table of the namespace whose rows are `namespace` (in the kernel's
256    /// order). A row is shown exactly when it is:
257    ///
258    /// 1. a mount Hermit's mount plan installed: hermit-cli records the mount
259    ///    IDs each installation creates as it installs it ([`owned_mounts`]),
260    ///    and passes those of every installation but a backend's own
261    ///    (`installed`);
262    /// 2. a mount at one of [`BASE_MOUNTPOINTS`], proven the same way: `base`
263    ///    holds each base mountpoint, as the kernel escapes it, with the
264    ///    mount ID of a descriptor held on it, and the row counts only if it
265    ///    is mounted at that very path (not merely the mount containing it);
266    /// 3. an ancestor of a row of (1), so that every mount Hermit installed
267    ///    hangs from rows the table shows.
268    ///
269    /// A row in `never` (a backend's own mount, an ephemeral host FUSE seed)
270    /// is not shown by rule 2 or 3, and a chain of parents passes through it.
271    /// No other host mount is shown, whatever it is. A shown row's parent is
272    /// its nearest shown ancestor; the namespace's root keeps its parent,
273    /// which no row shows, as Linux shows it. Each hidden row is mapped to
274    /// its nearest shown ancestor ([`Self::shown_mount_id`]).
275    ///
276    /// This is a chosen fidelity trade, generalizing the one made for host
277    /// FUSE seed mounts (`crate::procfs::is_ephemeral_host_seed_mount`): a
278    /// hidden mount is real and traversable, and Linux omits no real mount
279    /// from these files. Hermit hides it because whether it exists is decided
280    /// by unrelated host activity between runs, and a row added or removed
281    /// renumbers every later row. Where Hermit is given an image root
282    /// (`--image`), every mount below that root is Hermit's own, so nothing is
283    /// hidden there. hermit-cli also leaves the `uid=` and `gid=` superblock
284    /// options out of the rows of the filesystems its plan mounts afresh,
285    /// which only the container's root can own: Linux prints a tmpfs's owner
286    /// as the initial user namespace sees it, so they would name the host
287    /// user who ran Hermit where the same mount by root names no one.
288    pub fn select(
289        namespace: &[ContainerMount],
290        installed: &BTreeSet<u64>,
291        base: &[(Vec<u8>, u64)],
292        never: &BTreeSet<u64>,
293    ) -> Self {
294        let by_id = namespace
295            .iter()
296            .map(|row| (row.raw_mount_id, row))
297            .collect::<BTreeMap<_, _>>();
298        let mut shown = installed
299            .iter()
300            .copied()
301            .filter(|id| by_id.contains_key(id))
302            .collect::<BTreeSet<_>>();
303        for (mountpoint, id) in base {
304            if !never.contains(id)
305                && by_id
306                    .get(id)
307                    .is_some_and(|row| row.mountpoint == *mountpoint)
308            {
309                shown.insert(*id);
310            }
311        }
312        for id in installed {
313            let mut visited = BTreeSet::new();
314            let mut current = by_id.get(id).copied();
315            while let Some(row) = current {
316                if !visited.insert(row.raw_mount_id) {
317                    break;
318                }
319                current = by_id.get(&row.raw_parent_id).copied();
320                if let Some(parent) = current
321                    && !never.contains(&parent.raw_mount_id)
322                {
323                    shown.insert(parent.raw_mount_id);
324                }
325            }
326        }
327        // The nearest shown mount above `row`, or `None` where its chain of
328        // parents leaves the namespace (or loops) first, with the raw ID it
329        // left by.
330        let nearest_shown = |row: &ContainerMount| -> Result<u64, u64> {
331            let mut visited = BTreeSet::from([row.raw_mount_id]);
332            let mut parent = row.raw_parent_id;
333            loop {
334                if shown.contains(&parent) {
335                    return Ok(parent);
336                }
337                match by_id.get(&parent) {
338                    Some(above) if visited.insert(parent) => parent = above.raw_parent_id,
339                    _ => return Err(parent),
340                }
341            }
342        };
343        let mut mounts = Vec::new();
344        let mut hidden_ancestors = Vec::new();
345        for row in namespace {
346            if shown.contains(&row.raw_mount_id) {
347                let mut row = row.clone();
348                row.raw_parent_id = match nearest_shown(&row) {
349                    Ok(parent) => parent,
350                    // The namespace's root, or a row whose chain loops: its
351                    // own parent, as the kernel wrote it.
352                    Err(_) => row.raw_parent_id,
353                };
354                mounts.push(row);
355            } else if let Ok(ancestor) = nearest_shown(row) {
356                hidden_ancestors.push((row.raw_mount_id, ancestor));
357            }
358        }
359        Self {
360            mounts,
361            hidden_ancestors,
362        }
363    }
364
365    /// The raw mount ID the guest is shown for a file on mount `raw_mount_id`:
366    /// the mount itself where the table shows it or it is no row of the
367    /// namespace (pipefs, sockfs and the like, which no `mountinfo` lists),
368    /// else its nearest shown ancestor.
369    pub fn shown_mount_id(&self, raw_mount_id: u64) -> u64 {
370        self.hidden_ancestors
371            .iter()
372            .find(|(hidden, _)| *hidden == raw_mount_id)
373            .map_or(raw_mount_id, |(_, ancestor)| *ancestor)
374    }
375
376    /// The raw mount IDs of the rows the table shows.
377    pub fn shown_mount_ids(&self) -> BTreeSet<u64> {
378        self.mounts.iter().map(|row| row.raw_mount_id).collect()
379    }
380
381    /// The table as the kernel would write `/proc/<pid>/mountinfo` with only
382    /// these rows: raw IDs, devices and peer groups, which Detcore's
383    /// mountinfo sanitizer then numbers for the guest.
384    pub fn mountinfo(&self) -> Vec<u8> {
385        let mut out = Vec::new();
386        for row in &self.mounts {
387            row.write_mountinfo(&mut out);
388        }
389        out
390    }
391
392    /// The table as Linux writes `/proc/<pid>/mounts` (`show_vfsmnt`): source,
393    /// mountpoint, type, options and two zeros per row, in the table's order,
394    /// with the mountpoints `rewrites` translate (Hermit's internal staging
395    /// prefixes) written as the guest sees them, as its `mountinfo` shows them.
396    pub fn mounts(&self, rewrites: &[MountInfoRootRewrite]) -> Vec<u8> {
397        let mut prefixes = rewrites
398            .iter()
399            .filter_map(|rewrite| {
400                Some((
401                    rewrite.raw_mountpoint_prefix.as_deref()?,
402                    rewrite.deterministic_mountpoint_prefix.as_deref()?,
403                ))
404            })
405            .collect::<Vec<_>>();
406        prefixes.sort_by_key(|(raw, _)| std::cmp::Reverse(raw.len()));
407        let mut out = Vec::new();
408        for row in &self.mounts {
409            out.extend_from_slice(&row.source);
410            out.push(b' ');
411            out.extend_from_slice(&rewrite_prefix(&row.mountpoint, &prefixes));
412            out.push(b' ');
413            out.extend_from_slice(&row.fstype);
414            out.push(b' ');
415            out.extend_from_slice(&row.mounts_options());
416            out.extend_from_slice(b" 0 0\n");
417        }
418        out
419    }
420}
421
422/// One installation of the container's mount plan, as hermit-cli recorded it
423/// while installing it, in the completed namespace before the guest starts.
424#[derive(Clone, Debug, Default, Eq, PartialEq)]
425pub struct Installation {
426    /// The raw mount IDs of the rows the mount operation created: those the
427    /// namespace listed after it and not before. A recursive bind creates a
428    /// row for each mount below its source as well.
429    pub ids: Vec<u64>,
430    /// The mountpoints, as the kernel escapes them, at which the operation
431    /// installed its mount: its target as named, as Hermit resolved it, and
432    /// as the kernel resolved it. A created row at one of these is the
433    /// installation's; one below one of these is a recursive bind's copy of
434    /// a mount below its source; any other created row is not admitted.
435    pub mountpoints: Vec<Vec<u8>>,
436    /// A bind's source directory, as the kernel escapes it (as named and as
437    /// resolved), whose mounts a recursive bind copies below the target.
438    pub sources: Vec<Vec<u8>>,
439    /// Whether the installation is a backend's own (DBT's statistics bind,
440    /// e9patch's overlay of the rewritten program): plumbing,
441    /// not part of the guest's machine, so never shown, and neither is a
442    /// recursive bind's copy of one.
443    pub backend: bool,
444}
445
446/// The rows of `namespace` the installations made, split into Hermit's (the
447/// plan's own: rule 1 of [`ContainerMountTable::select`]) and a backend's own.
448/// A created row counts only at, or below, a mountpoint its installation
449/// names, so no row a later mount's symbolic link redirects a path onto can be
450/// taken for an installation's. A recursive bind's copy of a mount below its
451/// source takes the owner of the row it copies: a backend's own mount staged
452/// below Hermit's private tmp directory stays a backend's in the bind of that
453/// directory over `/tmp`.
454pub fn owned_mounts(
455    namespace: &[ContainerMount],
456    installations: &[Installation],
457) -> (BTreeSet<u64>, BTreeSet<u64>) {
458    let by_id = namespace
459        .iter()
460        .map(|row| (row.raw_mount_id, row))
461        .collect::<BTreeMap<_, _>>();
462    let mut hermit = BTreeSet::new();
463    let mut backend = BTreeSet::new();
464    for installation in installations {
465        let created = installation.ids.iter().copied().collect::<BTreeSet<_>>();
466        for id in &installation.ids {
467            let Some(row) = by_id.get(id) else {
468                continue;
469            };
470            let owner_is_backend = if installation.is_at(row) {
471                installation.backend
472            } else if installation.below(row).is_some() {
473                // The row a recursive bind copied: below the source, the same
474                // filesystem and root, made before this installation.
475                let original = installation
476                    .original_of(namespace, row, |candidate| !created.contains(&candidate));
477                installation.backend
478                    || original.is_some_and(|original| backend.contains(&original.raw_mount_id))
479            } else {
480                continue;
481            };
482            if owner_is_backend {
483                backend.insert(*id);
484            } else {
485                hermit.insert(*id);
486            }
487        }
488    }
489    (hermit, backend)
490}
491
492impl Installation {
493    /// Whether `row` is mounted at one of the installation's mountpoints.
494    fn is_at(&self, row: &ContainerMount) -> bool {
495        self.mountpoints.contains(&row.mountpoint)
496    }
497
498    /// The rest of `row`'s mountpoint below one of the installation's (with
499    /// its leading `/`), where `row` is strictly below one.
500    fn below(&self, row: &ContainerMount) -> Option<Vec<u8>> {
501        self.mountpoints.iter().find_map(|mountpoint| {
502            if mountpoint.as_slice() == b"/" {
503                return (row.mountpoint.as_slice() != b"/").then(|| row.mountpoint.clone());
504            }
505            let rest = row.mountpoint.strip_prefix(mountpoint.as_slice())?;
506            (rest.first() == Some(&b'/')).then(|| rest.to_vec())
507        })
508    }
509
510    /// The row of `namespace` a recursive bind of this installation's source
511    /// copied as `row`: at the source plus `row`'s rest below the target, on
512    /// the same filesystem with the same root, and admitted by `eligible`
513    /// (by raw mount ID).
514    fn original_of<'a>(
515        &self,
516        namespace: &'a [ContainerMount],
517        row: &ContainerMount,
518        eligible: impl Fn(u64) -> bool,
519    ) -> Option<&'a ContainerMount> {
520        let rest = self.below(row)?;
521        self.sources.iter().find_map(|source| {
522            let path = if source.as_slice() == b"/" {
523                rest.clone()
524            } else {
525                let mut path = source.clone();
526                path.extend_from_slice(&rest);
527                path
528            };
529            namespace.iter().find(|candidate| {
530                candidate.raw_mount_id != row.raw_mount_id
531                    && eligible(candidate.raw_mount_id)
532                    && candidate.mountpoint == path
533                    && (candidate.major, candidate.minor, &candidate.root)
534                        == (row.major, row.minor, &row.root)
535            })
536        })
537    }
538}
539
540/// Assign each row the plan created to the installation that created it, for
541/// a plan installed as the namespace's last mounts, one mount operation after
542/// another, in a namespace whose propagation from the host was cut (made
543/// private) before the plan's first mount, so that no host row can follow
544/// it: `namespace` lists rows in creation order (the kernel's row order), so
545/// the plan's rows are its end, each installation's together and in plan
546/// order, each starting with the one row the operation created at its target
547/// (a recursive bind's copies of the mounts below its source follow it).
548/// Walking back from the last row, a row is
549/// - the first row of the latest installation still open at whose
550///   mountpoint it is, which closes that installation; or
551/// - a copy the latest open installation's recursive bind made, if it is
552///   below that installation's mountpoint and the row it copies exists; or
553/// - no installation's (a row a symbolic link redirected, a propagated copy),
554///   which is not admitted.
555///
556/// The walk ends at the first installation's row: every row before it is the
557/// host's. Returns whether it got there; if not, the plan's rows cannot be
558/// told from the host's. An installation with no mountpoint (a remount, which
559/// creates no row) takes none.
560pub fn assign_installed_rows(
561    namespace: &[ContainerMount],
562    installations: &mut [Installation],
563) -> bool {
564    if installations.is_empty() {
565        return true;
566    }
567    // Installations below `open` may still take rows.
568    let mut open = installations.len();
569    for row in namespace.iter().rev() {
570        let first_row = (0..open)
571            .rev()
572            .find(|&index| installations[index].is_at(row));
573        let copy = (0..open).rev().find(|&index| {
574            installations[index]
575                .original_of(namespace, row, |_| true)
576                .is_some()
577        });
578        // The later installation's, where the row could be either.
579        let closes = match (first_row, copy) {
580            (Some(index), Some(copy)) => (index > copy).then_some(index),
581            (first_row, None) => first_row,
582            (None, Some(_)) => None,
583        };
584        if let Some(index) = closes {
585            installations[index].ids.push(row.raw_mount_id);
586            if index == 0 {
587                return true;
588            }
589            open = index;
590        } else if let Some(index) = copy {
591            installations[index].ids.push(row.raw_mount_id);
592        }
593    }
594    false
595}
596
597/// Refuse a walk ([`assign_installed_rows`]) whose result does not show the
598/// plan as installed: every installation that creates a row (one with a
599/// mountpoint) must have been given a row at its own recorded target, and
600/// the row given to the first installation must be the container's own
601/// `/proc` (`is_plans_first_row`; a copy of the host's `/proc`, at the same
602/// path but earlier, would mean the walk ran past the plan into the host's
603/// rows). The reason names the first installation that fails.
604pub fn check_installed_rows(
605    namespace: &[ContainerMount],
606    installations: &[Installation],
607    is_plans_first_row: impl Fn(&ContainerMount) -> bool,
608) -> Result<(), String> {
609    let row_at_target = |installation: &Installation| {
610        installation.ids.iter().find_map(|id| {
611            namespace
612                .iter()
613                .find(|row| row.raw_mount_id == *id && installation.is_at(row))
614        })
615    };
616    for (index, installation) in installations.iter().enumerate() {
617        if installation.mountpoints.is_empty() {
618            continue;
619        }
620        let Some(row) = row_at_target(installation) else {
621            return Err(format!(
622                "the plan's mount {index}, at {}, has no row at its target",
623                String::from_utf8_lossy(&installation.mountpoints[0])
624            ));
625        };
626        if index == 0 && !is_plans_first_row(row) {
627            return Err(format!(
628                "the row taken for the plan's first mount, {} at {}, is not the container's own",
629                row.raw_mount_id,
630                String::from_utf8_lossy(&row.mountpoint)
631            ));
632        }
633    }
634    Ok(())
635}
636
637/// `field` with the longest of `prefixes`' raw prefixes it starts with, at a
638/// path component boundary, replaced by that prefix's guest-visible one, as
639/// Detcore's mountinfo sanitizer rewrites a mountpoint.
640fn rewrite_prefix(field: &[u8], prefixes: &[(&[u8], &[u8])]) -> Vec<u8> {
641    for (raw, deterministic) in prefixes {
642        let Some(suffix) = field.strip_prefix(*raw) else {
643            continue;
644        };
645        if suffix.is_empty() || suffix.first() == Some(&b'/') {
646            let mut rewritten = deterministic.to_vec();
647            rewritten.extend_from_slice(suffix);
648            return rewritten;
649        }
650    }
651    field.to_vec()
652}
653
654#[cfg(test)]
655mod tests {
656    use super::*;
657
658    /// A host namespace: its root, the base filesystems, two host mounts
659    /// outside the base set (`/data` and a FUSE checkout below it), a host
660    /// tmpfs at `/tmp`, and Hermit's mounts: `/etc/group`, a new `/proc` over
661    /// the host's, the private `/tmp` and a user mount below `/data`.
662    const HOST: &[u8] = b"\
66320 1 259:4 / / rw,relatime shared:1 - btrfs /dev/nvme0n1p4 rw,ssd,subvol=/\n\
66421 20 0:22 / /proc rw,nosuid,nodev,noexec,relatime shared:2 - proc proc rw\n\
66522 20 0:5 / /dev rw,nosuid master:3 - devtmpfs devtmpfs rw,size=4096k,mode=755\n\
66623 22 0:24 / /dev/pts rw,nosuid,noexec,relatime - devpts devpts rw,gid=5,mode=620,ptmxmode=000\n\
66724 20 0:25 / /sys ro,nosuid,nodev,noexec,relatime - sysfs sysfs rw\n\
66825 20 259:5 / /data rw,relatime - ext4 /dev/nvme1n1 rw\n\
66926 25 0:40 / /data/a rw,nosuid,nodev,relatime - fuse.edenfs edenfs rw,user_id=0\n\
67027 20 0:26 / /tmp rw,nosuid,nodev - tmpfs tmpfs rw,size=1024k\n\
67128 20 259:4 /tmp/tmpAbC/group /etc/group ro,relatime - btrfs /dev/nvme0n1p4 rw,ssd,subvol=/\n\
67229 21 0:41 / /proc rw,relatime - proc proc rw\n\
67330 27 0:26 /tmpAbC /tmp rw,nosuid,nodev - tmpfs tmpfs rw,size=1024k\n\
67431 25 0:42 / /data/work rw,relatime - tmpfs none rw\n";
675
676    /// The base mountpoints with the mount ID a descriptor held on each
677    /// reaches.
678    fn base(top_at: &[(&str, u64)]) -> Vec<(Vec<u8>, u64)> {
679        top_at
680            .iter()
681            .map(|(path, id)| (path.as_bytes().to_vec(), *id))
682            .collect()
683    }
684
685    fn host_table(contents: &[u8]) -> ContainerMountTable {
686        let rows = parse_container_mounts(contents).expect("the host table parses");
687        // The descriptors hermit-cli would hold: the plan's targets reach
688        // Hermit's mounts, and each base path reaches the top mount there
689        // (`/run`, `/dev/shm`, `/dev/mqueue` and `/sys/fs/cgroup` are not
690        // mounts here: their descriptors reach the mount containing them).
691        let installed = BTreeSet::from([28, 29, 30, 31]);
692        let base = base(&[
693            ("/", 20),
694            ("/proc", 29),
695            ("/dev", 22),
696            ("/dev/pts", 23),
697            ("/dev/shm", 22),
698            ("/dev/mqueue", 22),
699            ("/sys", 24),
700            ("/sys/fs/cgroup", 24),
701            ("/run", 20),
702        ]);
703        ContainerMountTable::select(&rows, &installed, &base, &BTreeSet::new())
704    }
705
706    #[test]
707    fn the_table_shows_hermits_mounts_the_base_set_and_their_ancestors_only() {
708        let table = host_table(HOST);
709        let shown = table
710            .mounts
711            .iter()
712            .map(|row| row.raw_mount_id)
713            .collect::<Vec<_>>();
714        // Not 26, a host FUSE checkout. 21 (the host's /proc, which Hermit's
715        // covers), 25 (/data, which holds Hermit's user mount) and 27 (the
716        // host's /tmp, which holds Hermit's private /tmp) are ancestors of
717        // Hermit's mounts.
718        assert_eq!(shown, [20, 21, 22, 23, 24, 25, 27, 28, 29, 30, 31]);
719        // Every shown row keeps its parent: each parent is shown.
720        let named = parse_container_mounts(HOST).unwrap();
721        for row in &table.mounts {
722            let kernel = named
723                .iter()
724                .find(|kernel| kernel.raw_mount_id == row.raw_mount_id)
725                .unwrap();
726            assert_eq!(row, kernel);
727        }
728        assert_eq!(table.hidden_ancestors, [(26, 25)]);
729    }
730
731    #[test]
732    fn a_shown_row_hangs_from_its_nearest_shown_ancestor() {
733        let rows = parse_container_mounts(HOST).unwrap();
734        // /dev/pts is a base mount, /dev here is not: /dev/pts hangs from the
735        // root, and the root keeps the parent no row shows.
736        let table = ContainerMountTable::select(
737            &rows,
738            &BTreeSet::new(),
739            &base(&[("/", 20), ("/dev/pts", 23)]),
740            &BTreeSet::new(),
741        );
742        let shown = table
743            .mounts
744            .iter()
745            .map(|row| (row.raw_mount_id, row.raw_parent_id))
746            .collect::<Vec<_>>();
747        assert_eq!(shown, [(20, 1), (23, 20)]);
748        assert_eq!(table.shown_mount_id(22), 20);
749        assert_eq!(table.shown_mount_id(31), 20);
750    }
751
752    #[test]
753    fn a_hidden_mount_reports_its_nearest_shown_ancestor() {
754        let table = host_table(HOST);
755        assert_eq!(
756            table.shown_mount_id(26),
757            25,
758            "the FUSE checkout under /data"
759        );
760        assert_eq!(
761            table.shown_mount_id(21),
762            21,
763            "the covered host /proc is shown"
764        );
765        assert_eq!(table.shown_mount_id(30), 30, "a shown mount is itself");
766        assert_eq!(table.shown_mount_id(7), 7, "pipefs is no row");
767    }
768
769    #[test]
770    fn the_rendering_is_a_pure_function_of_the_table() {
771        let table = host_table(HOST);
772        assert_eq!(table.mountinfo(), host_table(HOST).mountinfo());
773        assert_eq!(
774            table.mountinfo(),
775            b"\
77620 1 259:4 / / rw,relatime shared:1 - btrfs /dev/nvme0n1p4 rw,ssd,subvol=/\n\
77721 20 0:22 / /proc rw,nosuid,nodev,noexec,relatime shared:2 - proc proc rw\n\
77822 20 0:5 / /dev rw,nosuid master:3 - devtmpfs devtmpfs rw,size=4096k,mode=755\n\
77923 22 0:24 / /dev/pts rw,nosuid,noexec,relatime - devpts devpts rw,gid=5,mode=620,ptmxmode=000\n\
78024 20 0:25 / /sys ro,nosuid,nodev,noexec,relatime - sysfs sysfs rw\n\
78125 20 259:5 / /data rw,relatime - ext4 /dev/nvme1n1 rw\n\
78227 20 0:26 / /tmp rw,nosuid,nodev - tmpfs tmpfs rw,size=1024k\n\
78328 20 259:4 /tmp/tmpAbC/group /etc/group ro,relatime - btrfs /dev/nvme0n1p4 rw,ssd,subvol=/\n\
78429 21 0:41 / /proc rw,relatime - proc proc rw\n\
78530 27 0:26 /tmpAbC /tmp rw,nosuid,nodev - tmpfs tmpfs rw,size=1024k\n\
78631 25 0:42 / /data/work rw,relatime - tmpfs none rw\n"
787                .as_slice()
788        );
789        // The rendering round-trips through the parser every consumer uses.
790        assert_eq!(
791            parse_container_mounts(&table.mountinfo()).unwrap(),
792            table.mounts
793        );
794        // And the serialized table renders the same bytes.
795        let restored: ContainerMountTable =
796            serde_json::from_str(&serde_json::to_string(&table).unwrap()).unwrap();
797        assert_eq!(restored.mountinfo(), table.mountinfo());
798    }
799
800    /// The measured divergence: one host mount appeared between two runs,
801    /// around row 20 of about 100, and renumbered every row after it. A host
802    /// table with an extra row outside the rule, even one under a shown
803    /// mount, renders the same bytes, and only the extra row's own fdinfo
804    /// mapping is new.
805    #[test]
806    fn a_host_mount_outside_the_rule_changes_no_byte() {
807        let mut churned = Vec::new();
808        for line in HOST.split_inclusive(|byte| *byte == b'\n') {
809            churned.extend_from_slice(line);
810            if line.starts_with(b"25 ") {
811                churned.extend_from_slice(
812                    b"95 25 0:93 / /data/b rw,nosuid,nodev,relatime - \
813                      fuse.edenfs edenfs rw,user_id=1\n",
814                );
815                churned.extend_from_slice(
816                    b"96 20 0:94 / /run/user/1000 rw,nosuid,nodev,relatime - tmpfs tmpfs rw\n",
817                );
818            }
819        }
820        let quiet = host_table(HOST);
821        let churned = host_table(&churned);
822        assert_eq!(churned.mountinfo(), quiet.mountinfo());
823        assert_eq!(churned.mounts(&[]), quiet.mounts(&[]));
824        assert_eq!(churned.shown_mount_id(95), 25);
825        assert_eq!(churned.shown_mount_id(96), 20);
826    }
827
828    #[test]
829    fn a_base_mountpoint_counts_only_where_its_descriptor_reaches_a_mount_there() {
830        let rows = parse_container_mounts(HOST).unwrap();
831        // A descriptor on /sys/fs/cgroup reaching the /sys mount does not
832        // make /sys a mount at /sys/fs/cgroup, nor one named elsewhere.
833        let table = ContainerMountTable::select(
834            &rows,
835            &BTreeSet::new(),
836            &base(&[("/sys/fs/cgroup", 24), ("/data", 26)]),
837            &BTreeSet::new(),
838        );
839        assert!(table.mounts.is_empty(), "{:?}", table.mounts);
840        // Every row then maps to the nearest shown ancestor, of which there
841        // is none.
842        assert!(table.hidden_ancestors.is_empty());
843    }
844
845    /// An installation as hermit-cli records it: the rows it created, at
846    /// `mountpoints`, copying from `sources`.
847    fn installation(
848        ids: &[u64],
849        mountpoints: &[&str],
850        sources: &[&str],
851        backend: bool,
852    ) -> Installation {
853        Installation {
854            ids: ids.to_vec(),
855            mountpoints: mountpoints.iter().map(|m| m.as_bytes().to_vec()).collect(),
856            sources: sources.iter().map(|m| m.as_bytes().to_vec()).collect(),
857            backend,
858        }
859    }
860
861    fn shown(table: &ContainerMountTable) -> Vec<u64> {
862        table.mounts.iter().map(|row| row.raw_mount_id).collect()
863    }
864
865    /// Both reviews' case: `--mount bind,/srv/a:/opt/x` then
866    /// `--mount bind,/srv/b:/opt`, where `/srv/b/x` is a symbolic link to
867    /// `/data/foo`. Reopening `/opt/x` afterwards reaches the host's `/data`
868    /// mount (and resolves to `/data/foo`, a host mount too); the recorded
869    /// installations name only the two rows the binds made. Neither host row,
870    /// nor any later host mount at `/data/foo`, becomes Hermit's.
871    #[test]
872    fn a_later_bind_redirecting_a_target_admits_no_host_row() {
873        let rows = parse_container_mounts(
874            b"20 1 8:1 / / rw - ext4 /dev/root rw\n\
875              50 20 8:2 / /data rw - ext4 /dev/data rw\n\
876              51 50 0:40 / /data/foo rw - fuse.edenfs edenfs rw\n\
877              60 20 8:1 /srv/a /opt/x rw - ext4 /dev/root rw\n\
878              61 20 8:1 /srv/b /opt rw - ext4 /dev/root rw\n",
879        )
880        .unwrap();
881        let installations = [
882            installation(&[60], &["/opt/x"], &["/srv/a"], false),
883            installation(&[61], &["/opt"], &["/srv/b"], false),
884            // A recorded ID at a mountpoint its installation does not name is
885            // not admitted: no row a symbolic link reaches counts.
886            installation(&[51], &["/opt/x"], &[], false),
887        ];
888        let (hermit, backend) = owned_mounts(&rows, &installations);
889        assert_eq!(hermit, BTreeSet::from([60, 61]));
890        assert!(backend.is_empty());
891        let table = ContainerMountTable::select(&rows, &hermit, &[], &BTreeSet::new());
892        assert_eq!(shown(&table), [20, 60, 61]);
893        assert_eq!(table.shown_mount_id(50), 20);
894        assert_eq!(table.shown_mount_id(51), 20);
895    }
896
897    /// A recursive bind of `/src`, which holds the host mount `/src/sub`,
898    /// creates `/dst` and its copy `/dst/sub`: both are the installation's.
899    #[test]
900    fn a_recursive_bind_owns_the_copies_it_makes() {
901        let rows = parse_container_mounts(
902            b"20 1 8:1 / / rw - ext4 /dev/root rw\n\
903              40 20 8:3 / /src rw - ext4 /dev/src rw\n\
904              41 40 0:41 / /src/sub rw - tmpfs tmpfs rw\n\
905              70 20 8:3 / /dst rw - ext4 /dev/src rw\n\
906              71 70 0:41 / /dst/sub rw - tmpfs tmpfs rw\n",
907        )
908        .unwrap();
909        let (hermit, backend) = owned_mounts(
910            &rows,
911            &[installation(&[70, 71], &["/dst"], &["/src"], false)],
912        );
913        assert_eq!(hermit, BTreeSet::from([70, 71]));
914        assert!(backend.is_empty());
915        let table = ContainerMountTable::select(&rows, &hermit, &[], &BTreeSet::new());
916        assert_eq!(shown(&table), [20, 70, 71]);
917        assert_eq!(
918            table.shown_mount_id(41),
919            20,
920            "the host's /src/sub stays hidden"
921        );
922    }
923
924    /// The ids each installation gets, in walk order (last row first), and
925    /// whether the walk reached the first installation's row.
926    fn assigned(rows: &[ContainerMount], plan: &[Installation]) -> (Vec<Vec<u64>>, bool) {
927        let mut plan = plan.to_vec();
928        let found = assign_installed_rows(rows, &mut plan);
929        (
930            plan.into_iter()
931                .map(|installation| installation.ids)
932                .collect(),
933            found,
934        )
935    }
936
937    /// Two installations at one target (a user's bind, then e9patch's
938    /// overlay of the program on it) each get their own row, a remount (no
939    /// mountpoint: it creates no row) gets none, and the rows before the
940    /// plan's first (the host's copy of `/proc`, at the same path as the
941    /// plan's `/proc`) are no installation's.
942    #[test]
943    fn rows_at_one_target_go_to_their_installations_in_order() {
944        let rows = parse_container_mounts(
945            b"20 1 8:1 / / rw - ext4 /dev/root rw\n\
946              21 20 0:20 / /proc rw - proc proc rw\n\
947              50 21 0:50 / /proc rw - proc proc rw\n\
948              60 20 8:1 /p /tmp/prog rw - ext4 /dev/root rw\n\
949              61 60 8:1 /q /tmp/prog ro - ext4 /dev/root rw\n",
950        )
951        .unwrap();
952        let plan = [
953            installation(&[], &["/proc"], &[], false),
954            installation(&[], &["/tmp/prog"], &["/p"], false),
955            installation(&[], &["/tmp/prog"], &["/q"], true),
956            installation(&[], &[], &[], true),
957        ];
958        assert_eq!(
959            assigned(&rows, &plan),
960            (vec![vec![50], vec![60], vec![61], vec![]], true)
961        );
962    }
963
964    /// A recursive bind's copies (here, of the staged mount below Hermit's
965    /// private tmp directory, in its bind over `/tmp`) are the bind's, and
966    /// keep the owner of the row they copy.
967    #[test]
968    fn a_recursive_binds_copies_are_assigned_to_it() {
969        let rows = parse_container_mounts(
970            b"20 1 8:1 / / rw - ext4 /dev/root rw\n\
971              49 20 0:49 / /proc rw - proc proc rw\n\
972              50 20 0:60 / /scratch/t/x rw - tmpfs tmpfs rw\n\
973              51 20 8:1 /scratch/t /tmp rw - ext4 /dev/root rw\n\
974              52 51 0:60 / /tmp/x rw - tmpfs tmpfs rw\n",
975        )
976        .unwrap();
977        let plan = [
978            installation(&[], &["/proc"], &[], false),
979            installation(&[], &["/scratch/t/x"], &[], true),
980            installation(&[], &["/tmp"], &["/scratch/t"], false),
981        ];
982        let (plan_ids, found) = assigned(&rows, &plan);
983        assert!(found);
984        assert_eq!(plan_ids, [vec![49], vec![50], vec![52, 51]]);
985        let mut plan = plan.to_vec();
986        for (installation, ids) in plan.iter_mut().zip(plan_ids) {
987            installation.ids = ids;
988        }
989        let (hermit, backend) = owned_mounts(&rows, &plan);
990        assert_eq!(hermit, BTreeSet::from([49, 51]));
991        assert_eq!(
992            backend,
993            BTreeSet::from([50, 52]),
994            "a backend's mount stays the backend's in the copy"
995        );
996    }
997
998    /// A row stays its installation's where the kernel put it at install
999    /// time, whatever its target names later: a bind at /opt/x crossed the
1000    /// symbolic link x -> /data/foo in the first bind at /opt, so it was
1001    /// recorded at /data/foo, and a later bind that replaces /opt (whose x
1002    /// links elsewhere) does not drop it. A row at no recorded target is no
1003    /// installation's, and no host row (before the plan's first) is ever
1004    /// assigned, even one at a plan target's path.
1005    #[test]
1006    fn a_row_stays_where_its_install_time_target_was() {
1007        let rows = parse_container_mounts(
1008            b"20 1 8:1 / / rw - ext4 /dev/root rw\n\
1009              30 20 8:2 / /data rw - ext4 /dev/data rw\n\
1010              31 30 8:2 /x /opt/x rw - ext4 /dev/data rw\n\
1011              49 20 0:49 / /proc rw - proc proc rw\n\
1012              60 20 8:1 /srv/a /opt rw - ext4 /dev/root rw\n\
1013              61 30 8:1 /item /data/foo rw - ext4 /dev/root rw\n\
1014              62 20 8:1 /stray /stray rw - ext4 /dev/root rw\n\
1015              63 60 8:1 /srv/c /opt rw - ext4 /dev/root rw\n",
1016        )
1017        .unwrap();
1018        let plan = [
1019            installation(&[], &["/proc"], &[], false),
1020            installation(&[], &["/opt"], &[], false),
1021            installation(&[], &["/data/foo"], &[], false),
1022            installation(&[], &["/opt"], &[], false),
1023        ];
1024        let (plan_ids, found) = assigned(&rows, &plan);
1025        assert!(found);
1026        assert_eq!(plan_ids, [vec![49], vec![60], vec![61], vec![63]]);
1027        let mut plan = plan.to_vec();
1028        for (installation, ids) in plan.iter_mut().zip(plan_ids) {
1029            installation.ids = ids;
1030        }
1031        assert_eq!(
1032            check_installed_rows(&rows, &plan, |row| row.raw_mount_id == 49),
1033            Ok(())
1034        );
1035        let (hermit, _) = owned_mounts(&rows, &plan);
1036        let table = ContainerMountTable::select(&rows, &hermit, &[], &BTreeSet::new());
1037        assert!(shown(&table).contains(&61), "the genuine row stays visible");
1038        assert!(!shown(&table).contains(&62) && !shown(&table).contains(&31));
1039    }
1040
1041    /// A namespace whose plan rows lack the container's own /proc: the walk
1042    /// runs on into the host's rows and takes the host's /proc for it, and
1043    /// the check refuses that; a plan mount with no row at its target is
1044    /// refused too.
1045    #[test]
1046    fn a_walk_that_takes_the_hosts_proc_or_misses_a_mount_is_refused() {
1047        let rows = parse_container_mounts(
1048            b"20 1 8:1 / / rw - ext4 /dev/root rw\n\
1049              21 20 0:22 / /proc rw - proc proc rw\n\
1050              27 20 0:26 / /tmp rw - tmpfs tmpfs rw\n\
1051              70 27 8:1 /scratch/t /tmp rw - ext4 /dev/root rw\n",
1052        )
1053        .unwrap();
1054        let plan = [
1055            installation(&[], &["/proc"], &[], false),
1056            installation(&[], &["/tmp"], &[], false),
1057        ];
1058        let (plan_ids, found) = assigned(&rows, &plan);
1059        assert!(found, "the walk alone cannot tell");
1060        let mut walked = plan.to_vec();
1061        for (installation, ids) in walked.iter_mut().zip(plan_ids) {
1062            installation.ids = ids;
1063        }
1064        // Only a fresh procfs (device 0:4095 here) is the container's.
1065        let ours = |row: &ContainerMount| (row.major, row.minor) == (0, 4095);
1066        assert!(check_installed_rows(&rows, &walked, ours).is_err());
1067        let mut missing = walked.clone();
1068        missing[1].ids.clear();
1069        assert!(
1070            check_installed_rows(&rows, &missing, |_| true)
1071                .unwrap_err()
1072                .contains("no row at its target")
1073        );
1074    }
1075
1076    /// Without the plan's first row, the walk cannot tell the plan's rows
1077    /// from the host's, and says so.
1078    #[test]
1079    fn a_plan_whose_first_row_is_missing_is_refused() {
1080        let rows = parse_container_mounts(
1081            b"20 1 8:1 / / rw - ext4 /dev/root rw\n\
1082              60 20 8:1 /srv/b /opt rw - ext4 /dev/root rw\n",
1083        )
1084        .unwrap();
1085        let plan = [
1086            installation(&[], &["/proc"], &[], false),
1087            installation(&[], &["/opt"], &["/srv/b"], false),
1088        ];
1089        assert!(!assigned(&rows, &plan).1);
1090    }
1091
1092    /// A user's own tmpfs at `/test`, with a backend's tmpfs stacked on it:
1093    /// the user's is shown, the backend's is not, and a file on the
1094    /// backend's reports the user's, its nearest shown ancestor. Ownership is
1095    /// the installation's identity, not its path.
1096    #[test]
1097    fn a_user_mount_at_a_backend_target_stays_shown() {
1098        let rows = parse_container_mounts(
1099            b"20 1 8:1 / / rw - ext4 /dev/root rw\n\
1100              80 20 0:50 / /test rw - tmpfs tmpfs rw\n\
1101              81 80 0:51 / /test rw - tmpfs tmpfs rw\n",
1102        )
1103        .unwrap();
1104        let (hermit, backend) = owned_mounts(
1105            &rows,
1106            &[
1107                installation(&[80], &["/test"], &[], false),
1108                installation(&[81], &["/test"], &[], true),
1109            ],
1110        );
1111        assert_eq!(hermit, BTreeSet::from([80]));
1112        assert_eq!(backend, BTreeSet::from([81]));
1113        let table = ContainerMountTable::select(&rows, &hermit, &[], &backend);
1114        assert_eq!(shown(&table), [20, 80]);
1115        assert_eq!(table.shown_mount_id(81), 80);
1116    }
1117
1118    /// Hermit's `/proc`, covered by a later user bind at `/`, stays shown,
1119    /// as Linux lists every mount of the namespace.
1120    #[test]
1121    fn a_covered_hermit_proc_stays_shown() {
1122        let rows = parse_container_mounts(
1123            b"20 1 8:1 / / rw - ext4 /dev/root rw\n\
1124              21 20 0:22 / /proc rw - proc proc rw\n\
1125              90 21 0:4095 / /proc rw - proc proc rw\n\
1126              91 20 8:4 /guest-root / rw - ext4 /dev/other rw\n",
1127        )
1128        .unwrap();
1129        let plan = [
1130            installation(&[], &["/proc"], &[], false),
1131            installation(&[], &["/"], &["/guest-root"], false),
1132        ];
1133        let (plan_ids, found) = assigned(&rows, &plan);
1134        assert_eq!((plan_ids.clone(), found), (vec![vec![90], vec![91]], true));
1135        let mut plan = plan.to_vec();
1136        for (installation, ids) in plan.iter_mut().zip(plan_ids) {
1137            installation.ids = ids;
1138        }
1139        assert_eq!(
1140            check_installed_rows(&rows, &plan, |row| row.raw_mount_id == 90),
1141            Ok(())
1142        );
1143        let (hermit, _) = owned_mounts(&rows, &plan);
1144        assert_eq!(hermit, BTreeSet::from([90, 91]));
1145        let table = ContainerMountTable::select(&rows, &hermit, &[], &BTreeSet::new());
1146        assert_eq!(shown(&table), [20, 21, 90, 91]);
1147    }
1148
1149    /// A backend's bind staged below Hermit's private tmp directory, then
1150    /// copied by the recursive bind of that directory over `/tmp`: the copy
1151    /// is the backend's too, and the guest's `/tmp` is Hermit's.
1152    #[test]
1153    fn a_copy_of_a_backend_mount_is_the_backends() {
1154        let rows = parse_container_mounts(
1155            b"20 1 8:1 / / rw - ext4 /dev/root rw\n\
1156              27 20 0:26 / /tmp rw - tmpfs tmpfs rw\n\
1157              100 27 8:1 /stats /tmp/tmpX/.stats rw - ext4 /dev/root rw\n\
1158              101 27 0:26 /tmpX /tmp rw - tmpfs tmpfs rw\n\
1159              102 101 8:1 /stats /tmp/.stats rw - ext4 /dev/root rw\n",
1160        )
1161        .unwrap();
1162        let (hermit, backend) = owned_mounts(
1163            &rows,
1164            &[
1165                installation(&[100], &["/tmp/tmpX/.stats"], &["/stats"], true),
1166                installation(&[101, 102], &["/tmp"], &["/tmp/tmpX"], false),
1167            ],
1168        );
1169        assert_eq!(hermit, BTreeSet::from([101]));
1170        assert_eq!(backend, BTreeSet::from([100, 102]));
1171        let table = ContainerMountTable::select(&rows, &hermit, &[], &backend);
1172        assert_eq!(shown(&table), [20, 27, 101]);
1173        assert_eq!(table.shown_mount_id(102), 101);
1174    }
1175
1176    /// An ephemeral host FUSE seed is hidden by visibility alone: it stays in
1177    /// the topology, so a mount below it maps to its nearest shown ancestor,
1178    /// and a mount Hermit installed is never dropped by its name.
1179    #[test]
1180    fn a_seed_is_hidden_but_kept_in_the_topology() {
1181        let rows = parse_container_mounts(
1182            b"20 1 8:1 / / rw - ext4 /dev/root rw\n\
1183              110 20 0:60 / /mnt/x/0a-seed-chef-ns-1 rw - fuse.squashfuse_ll s rw\n\
1184              111 110 0:61 / /mnt/x/0a-seed-chef-ns-1/in rw - tmpfs tmpfs rw\n\
1185              112 20 0:62 / /opt/0b-seed-plan-ns-2 rw - fuse.squashfuse_ll s rw\n",
1186        )
1187        .unwrap();
1188        let installed = BTreeSet::from([112]);
1189        let never = BTreeSet::from([110]);
1190        let table = ContainerMountTable::select(&rows, &installed, &base(&[("/", 20)]), &never);
1191        assert_eq!(shown(&table), [20, 112]);
1192        assert_eq!(table.shown_mount_id(110), 20);
1193        assert_eq!(table.shown_mount_id(111), 20);
1194    }
1195
1196    #[test]
1197    fn proc_mounts_merges_the_options_as_show_vfsmnt_does() {
1198        let table = host_table(HOST);
1199        let mounts = table.mounts(&[]);
1200        let text = String::from_utf8(mounts).unwrap();
1201        let lines = text.lines().collect::<Vec<_>>();
1202        assert_eq!(
1203            lines[0],
1204            "/dev/nvme0n1p4 / btrfs rw,relatime,ssd,subvol=/ 0 0"
1205        );
1206        assert_eq!(
1207            lines[4], "sysfs /sys sysfs ro,nosuid,nodev,noexec,relatime 0 0",
1208            "a read-only mount of a writable superblock is ro"
1209        );
1210        let row = |mount_options: &[u8], super_options: &[u8]| ContainerMount {
1211            raw_mount_id: 1,
1212            raw_parent_id: 0,
1213            major: 0,
1214            minor: 1,
1215            root: b"/".to_vec(),
1216            mountpoint: b"/m".to_vec(),
1217            mount_options: mount_options.to_vec(),
1218            optional_fields: Vec::new(),
1219            fstype: b"ext4".to_vec(),
1220            source: b"/dev/x".to_vec(),
1221            super_options: super_options.to_vec(),
1222        };
1223        assert_eq!(
1224            row(b"rw,nosuid,noatime", b"rw,sync,lazytime,errors=remount-ro").mounts_options(),
1225            b"rw,sync,lazytime,nosuid,noatime,errors=remount-ro"
1226        );
1227        assert_eq!(
1228            row(b"rw,relatime", b"ro").mounts_options(),
1229            b"ro,relatime",
1230            "a writable mount of a read-only superblock is ro"
1231        );
1232    }
1233
1234    #[test]
1235    fn proc_mounts_shows_staged_mountpoints_where_the_guest_sees_them() {
1236        let table = ContainerMountTable {
1237            mounts: parse_container_mounts(
1238                b"40 30 0:26 /tmpAbC/x /tmp/tmpAbC/x rw - tmpfs tmpfs rw\n\
1239                  41 30 0:26 /tmpAbC/xy /tmp/tmpAbCdef rw - tmpfs tmpfs rw\n",
1240            )
1241            .unwrap(),
1242            hidden_ancestors: Vec::new(),
1243        };
1244        let rewrite = MountInfoRootRewrite {
1245            raw_mount_id: 30,
1246            deterministic_root: b"/".to_vec(),
1247            raw_root_prefix: None,
1248            deterministic_root_prefix: None,
1249            raw_mountpoint_prefix: Some(b"/tmp/tmpAbC".to_vec()),
1250            deterministic_mountpoint_prefix: Some(b"/tmp".to_vec()),
1251        };
1252        assert_eq!(
1253            table.mounts(&[rewrite]),
1254            b"tmpfs /tmp/x tmpfs rw 0 0\ntmpfs /tmp/tmpAbCdef tmpfs rw 0 0\n"
1255        );
1256    }
1257
1258    #[test]
1259    fn the_parser_refuses_what_parse_mountinfo_refuses() {
1260        assert_eq!(parse_container_mounts(b""), Some(Vec::new()));
1261        for malformed in [
1262            b"20 1 259:4 / / rw - btrfs\n".as_slice(),
1263            b"20 1 259:4 / / rw unknown:7 - btrfs /dev/x rw\n".as_slice(),
1264            b"20 1 259:4 / / rw - btrfs /dev/x rw\n20 1 259:4 / /a rw - btrfs /dev/x rw\n"
1265                .as_slice(),
1266        ] {
1267            assert_eq!(parse_container_mounts(malformed), None, "{malformed:?}");
1268        }
1269    }
1270}