Skip to main content

detcore_model/
procfs.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//! Types and parsers shared by procfs producers and consumers.
10
11use std::collections::BTreeSet;
12
13/// Identity-bearing fields from one Linux `/proc/*/mountinfo` row.
14#[derive(Clone, Debug, Eq, PartialEq)]
15pub struct MountInfoRow {
16    pub raw_mount_id: u64,
17    pub raw_parent_id: u64,
18    pub raw_device: u64,
19    pub root: Vec<u8>,
20    pub raw_peer_groups: Vec<u64>,
21}
22
23// AUTONOMOUS-BOT-IMPLEMENTED
24// TODO-HUMAN-REVIEW(PR-873): Review private mount-root normalization.
25pub const MOUNT_PEER_PREFIXES: [&[u8]; 3] = [b"shared:", b"master:", b"propagate_from:"];
26
27/// Whether captured mount-identity provenance is well formed: the
28/// namespace order names each raw mount ID once, and the assignment order
29/// numbers each raw ID once and names raw 0 only where the namespace order
30/// does (an fdinfo zero is the anonymous class and is never numbered).
31/// Detcore's mount-ID pool accepts exactly these, and a producer that hands
32/// provenance to a backend without a second capture checks them first.
33pub fn mount_identity_provenance_is_well_formed(order: &[u64], assignment: &[u64]) -> bool {
34    let mut rows = std::collections::BTreeSet::new();
35    if !order.iter().all(|raw| rows.insert(*raw)) {
36        return false;
37    }
38    if assignment.contains(&0) && !rows.contains(&0) {
39        return false;
40    }
41    let mut numbered = std::collections::BTreeSet::new();
42    assignment.iter().all(|raw| numbered.insert(*raw))
43}
44
45/// True for one ephemeral host FUSE seed mount row in `/proc/<pid>/mountinfo`
46/// grammar: filesystem type `fuse.squashfuse_ll` and a mount point whose last
47/// path component is a host seed name, `<hex>-seed-<seed>-ns-<digits>`, where
48/// `<seed>` is a non-empty run of ASCII letters, digits, `_` and `-`.
49///
50/// The host's squashfuse infrastructure creates these mounts, normally at
51/// `/mnt/xarfuse/uid-<uid>/<seed name>`, either one per host process (seed
52/// `nspid<digits>_cgpid<digits>`) or one per named host tool (for example
53/// seed `chef`, `fb-pcie-error-log` or `devserver-cleanup_hg_cache`). Those
54/// rows are other processes' runtime state imported into the guest namespace
55/// by shared mount propagation: they are not created by Hermit, by the guest
56/// session, or by any ancestor the guest can name, and they appear, disappear
57/// and are remounted under new mount IDs asynchronously as unrelated host
58/// processes and tool runs start and stop (named-tool seeds were observed
59/// living four to five seconds, and the `chef` seed was remounted under a new
60/// mount ID). Passing their membership through made `/proc/<pid>/mountinfo`
61/// (and the length of every read of it) a host-timing observation: in the
62/// `procfs-sanitized-paths` divergence, one seed row changed the tail `read`
63/// length between two strict runs.
64///
65/// This is a chosen determinism fidelity trade. Seed rows are real mounts in
66/// the guest namespace (imported by shared propagation and traversable), and
67/// Linux omits no real mount from mountinfo. Hermit nevertheless excludes the
68/// class, long-lived named seeds included, because its membership is owned by
69/// unrelated host processes and changes asynchronously, the same scope choice
70/// `DETERMINISM_ARGUMENT.md` makes for other changing host inputs. The class is
71/// decided by the seed name, not by the directory it is shown under, so other
72/// SquashFUSE mounts (for example a long-lived `/mnt/xarfuse/stable-release`)
73/// stay visible, and a guest whose root makes the displayed path
74/// `/xarfuse/uid-<uid>/<seed>` still excludes the same rows the launch-time
75/// capture excluded. Other host mount churn, such as logind's
76/// `/run/user/<uid>` tmpfs mounts, is not in this class and stays visible.
77///
78/// Only mountinfo rows are classified. `/proc/<pid>/mounts` is passed through
79/// unchanged: <https://github.com/rrnewton/hermit/issues/3719>.
80pub fn is_ephemeral_host_seed_mount(line: &[u8]) -> bool {
81    let fields: Vec<&[u8]> = line.split(|byte| *byte == b' ').collect();
82    let Some(separator) = fields.iter().position(|field| *field == b"-") else {
83        return false;
84    };
85    let mount_point = fields.get(4).copied().unwrap_or_default();
86    let fs_type = fields.get(separator + 1).copied().unwrap_or_default();
87    is_ephemeral_host_seed_fields(mount_point, fs_type)
88}
89
90/// [`is_ephemeral_host_seed_mount`] for a row already split into its
91/// mountpoint and filesystem type (fields 5 and 9).
92pub fn is_ephemeral_host_seed_fields(mount_point: &[u8], fs_type: &[u8]) -> bool {
93    let name = mount_point
94        .rsplit(|byte| *byte == b'/')
95        .next()
96        .unwrap_or_default();
97    fs_type == b"fuse.squashfuse_ll" && is_host_seed_name(name)
98}
99
100/// Drop the rows `is_ephemeral_host_seed_mount` classifies from raw mountinfo
101/// contents, keeping every other row, newline included, in its original order.
102/// Detcore's guest-read capture, Detcore's fdinfo `mnt_id` capture and
103/// hermit-cli's launch-time identity capture all use this one filter, so they
104/// agree on the guest mount membership.
105pub fn exclude_ephemeral_host_seed_mounts(contents: &[u8]) -> Vec<u8> {
106    let mut out = Vec::with_capacity(contents.len());
107    for line in contents.split_inclusive(|byte| *byte == b'\n') {
108        let body = line.strip_suffix(b"\n").unwrap_or(line);
109        if !is_ephemeral_host_seed_mount(body) {
110            out.extend_from_slice(line);
111        }
112    }
113    out
114}
115
116/// `<hex>-seed-<seed>-ns-<digits>`, with a non-empty hex run, a non-empty
117/// seed of ASCII letters, digits, `_` and `-`, and a non-empty digit run.
118fn is_host_seed_name(name: &[u8]) -> bool {
119    fn parse(name: &[u8]) -> Option<()> {
120        let hex = name
121            .iter()
122            .take_while(|byte| byte.is_ascii_hexdigit())
123            .count();
124        let rest = name
125            .get(hex..)
126            .filter(|_| hex > 0)?
127            .strip_prefix(b"-seed-")?;
128        let digits = rest
129            .iter()
130            .rev()
131            .take_while(|byte| byte.is_ascii_digit())
132            .count();
133        let seed = rest
134            .get(..rest.len().checked_sub(digits).filter(|_| digits > 0)?)?
135            .strip_suffix(b"-ns-")?;
136        (!seed.is_empty()
137            && seed
138                .iter()
139                .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')))
140        .then_some(())
141    }
142    parse(name).is_some()
143}
144
145/// Whether every visible mount ID occurs once and in the same relative order
146/// as the producer-captured namespace.
147pub fn mount_ids_are_ordered_subset(visible: &[u64], captured: &[u64]) -> bool {
148    let mut seen = BTreeSet::new();
149    if !visible.iter().all(|raw| seen.insert(*raw)) {
150        return false;
151    }
152    let mut captured = captured.iter();
153    visible
154        .iter()
155        .all(|raw| captured.by_ref().any(|candidate| candidate == raw))
156}
157
158fn decimal(field: &[u8]) -> Option<u64> {
159    if field.is_empty() || field.iter().any(|byte| !byte.is_ascii_digit()) {
160        return None;
161    }
162    std::str::from_utf8(field).ok()?.parse().ok()
163}
164
165fn mount_peer_group(field: &[u8]) -> Option<Option<u64>> {
166    for prefix in MOUNT_PEER_PREFIXES {
167        if let Some(raw) = field.strip_prefix(prefix) {
168            return Some(Some(decimal(raw)?));
169        }
170    }
171    Some(None)
172}
173
174fn parse_mountinfo_row(line: &[u8]) -> Option<MountInfoRow> {
175    let fields = line.split(|byte| *byte == b' ').collect::<Vec<_>>();
176    if fields.iter().any(|field| field.is_empty()) {
177        return None;
178    }
179    let separator = fields.iter().position(|field| *field == b"-")?;
180    if separator < 6 || separator + 4 != fields.len() {
181        return None;
182    }
183    let mut device = fields[2].split(|byte| *byte == b':');
184    let major = u32::try_from(decimal(device.next()?)?).ok()?;
185    let minor = u32::try_from(decimal(device.next()?)?).ok()?;
186    if device.next().is_some() {
187        return None;
188    }
189    let mut raw_peer_groups = Vec::new();
190    for field in &fields[6..separator] {
191        if let Some(raw) = mount_peer_group(field)? {
192            raw_peer_groups.push(raw);
193        } else if field
194            .iter()
195            .position(|byte| *byte == b':')
196            .is_some_and(|separator| decimal(&field[separator + 1..]).is_some())
197        {
198            // Unknown numeric optional fields can carry mount-namespace
199            // identity just like the peer-group fields above. Passing one
200            // through would expose an unsanitized host identifier, so refuse
201            // until its Linux semantics and deterministic mapping are known.
202            return None;
203        }
204    }
205    Some(MountInfoRow {
206        raw_mount_id: decimal(fields[0])?,
207        raw_parent_id: decimal(fields[1])?,
208        raw_device: libc::makedev(major, minor),
209        root: fields[3].to_vec(),
210        raw_peer_groups,
211    })
212}
213
214/// Strictly parse a Linux `/proc/*/mountinfo` snapshot.
215///
216/// Empty files are valid empty snapshots. Malformed rows and duplicate mount
217/// IDs are rejected so every producer and consumer accepts the same grammar.
218pub fn parse_mountinfo(contents: &[u8]) -> Option<Vec<MountInfoRow>> {
219    if contents.is_empty() {
220        return Some(Vec::new());
221    }
222    let body = contents.strip_suffix(b"\n").unwrap_or(contents);
223    if body.is_empty() {
224        return None;
225    }
226    let rows = body
227        .split(|byte| *byte == b'\n')
228        .map(parse_mountinfo_row)
229        .collect::<Option<Vec<_>>>()?;
230    let mut seen = BTreeSet::new();
231    rows.iter()
232        .all(|row| seen.insert(row.raw_mount_id))
233        .then_some(rows)
234}
235
236/// Parse the one decimal `mnt_id` field required by Linux fdinfo.
237///
238/// Missing, duplicate, malformed, signed, or out-of-range values are rejected.
239/// Keeping this byte parser below both `hermit-cli` and `detcore` prevents the
240/// container capture path from accepting input the guest-visible sanitizer
241/// would later refuse.
242pub fn parse_fdinfo_mount_id(contents: &[u8]) -> Option<u64> {
243    let mut mount_id = None;
244    for line in contents.split(|byte| *byte == b'\n') {
245        let Some(value) = line.strip_prefix(b"mnt_id:") else {
246            continue;
247        };
248        let value = value
249            .strip_prefix(b"\t")
250            .or_else(|| value.strip_prefix(b" "))?;
251        if value.is_empty() || value.iter().any(|byte| !byte.is_ascii_digit()) || mount_id.is_some()
252        {
253            return None;
254        }
255        let text = std::str::from_utf8(value).ok()?;
256        mount_id = Some(text.parse().ok()?);
257    }
258    mount_id
259}
260
261#[cfg(test)]
262mod tests {
263    use super::exclude_ephemeral_host_seed_mounts;
264    use super::is_ephemeral_host_seed_mount;
265    use super::mount_ids_are_ordered_subset;
266    use super::parse_fdinfo_mount_id;
267    use super::parse_mountinfo;
268
269    #[test]
270    fn fdinfo_mount_id_is_strict_and_unique() {
271        assert_eq!(parse_fdinfo_mount_id(b"pos:\t0\nmnt_id:\t37\n"), Some(37));
272        assert_eq!(parse_fdinfo_mount_id(b"mnt_id: 0\n"), Some(0));
273        for malformed in [
274            b"pos:\t0\n".as_slice(),
275            b"mnt_id:\tbad\nmnt_id:\t37\n".as_slice(),
276            b"mnt_id:\t37\nmnt_id:\t38\n".as_slice(),
277            b"mnt_id:\t37 trailing\n".as_slice(),
278            b"mnt_id:\t18446744073709551616\n".as_slice(),
279            b"mnt_id:37\n".as_slice(),
280        ] {
281            assert_eq!(parse_fdinfo_mount_id(malformed), None, "{malformed:?}");
282        }
283    }
284
285    #[test]
286    fn mountinfo_parser_accepts_empty_and_refuses_malformed_or_duplicate_rows() {
287        assert_eq!(parse_mountinfo(b""), Some(Vec::new()));
288        assert_eq!(parse_mountinfo(b"\n"), None);
289        let row = b"37 1 8:1 / / rw shared:9 - ext4 /dev/root rw\n";
290        let parsed = parse_mountinfo(row).expect("valid mountinfo row");
291        assert_eq!(parsed.len(), 1);
292        assert_eq!(parsed[0].raw_mount_id, 37);
293        assert_eq!(parsed[0].raw_parent_id, 1);
294        assert_eq!(parsed[0].root, b"/");
295        assert_eq!(parsed[0].raw_peer_groups, [9]);
296        assert!(parse_mountinfo(b"37 1 bad / / rw - ext4 /dev/root rw\n").is_none());
297        assert!(
298            parse_mountinfo(b"37 1 8:1 / / rw unbindable nosymfollow - ext4 /dev/root rw\n")
299                .is_some(),
300            "known bare optional flags must remain accepted"
301        );
302        assert!(
303            parse_mountinfo(b"37 1 8:1 / / rw future_peer:19 - ext4 /dev/root rw\n").is_none(),
304            "unknown numeric optional fields must fail closed"
305        );
306        assert!(
307            parse_mountinfo(b"37 1 8:1 / / rw future_flag:value - ext4 /dev/root rw\n").is_some(),
308            "unknown nonnumeric flags carry no raw numeric identity"
309        );
310        let duplicate = [row.as_slice(), row.as_slice()].concat();
311        assert!(parse_mountinfo(&duplicate).is_none());
312    }
313
314    #[test]
315    fn mount_id_subset_requires_unique_members_in_captured_order() {
316        assert!(mount_ids_are_ordered_subset(&[], &[10, 20, 30]));
317        assert!(mount_ids_are_ordered_subset(&[10, 30], &[10, 20, 30]));
318        assert!(!mount_ids_are_ordered_subset(&[30, 10], &[10, 20, 30]));
319        assert!(!mount_ids_are_ordered_subset(&[10, 99], &[10, 20, 30]));
320        assert!(!mount_ids_are_ordered_subset(&[10, 10], &[10, 20, 30]));
321    }
322
323    const SEED: &[u8] = b"76 1 0:50 / /mnt/xarfuse/uid-1000/e62a203d-seed-nspid4026531836_cgpid16161-ns-4026531832 rw,nosuid,nodev,relatime master:48 - fuse.squashfuse_ll squashfuse_ll rw,user_id=1000,group_id=100,allow_other";
324
325    /// The class is the seed name on a SquashFUSE mount, not the directory:
326    /// a long-lived SquashFUSE mount under the same prefix stays, a changed
327    /// guest root that shortens the displayed path still excludes the seed,
328    /// and a seed-named mount of another filesystem type stays.
329    #[test]
330    fn ephemeral_host_seed_mount_class_is_the_seed_name() {
331        assert!(is_ephemeral_host_seed_mount(SEED));
332        assert!(!is_ephemeral_host_seed_mount(
333            b"77 1 0:51 / /mnt/xarfuse/stable-release rw,relatime - fuse.squashfuse_ll squashfuse_ll rw"
334        ));
335        assert!(is_ephemeral_host_seed_mount(
336            b"76 1 0:50 / /xarfuse/uid-1/e62a203d-seed-nspid4026531836_cgpid16161-ns-4026531832 rw - fuse.squashfuse_ll squashfuse_ll rw"
337        ));
338        assert!(!is_ephemeral_host_seed_mount(
339            b"76 1 0:50 / /mnt/xarfuse/uid-1/e62a203d-seed-nspid4026531836_cgpid16161-ns-4026531832 rw - tmpfs none rw"
340        ));
341        for near_miss in [
342            b"76 1 0:50 / /var/releases/www rw - fuse.squashfuse_ll squashfuse_ll rw".as_slice(),
343            b"76 1 0:50 / /mnt/xarfuse/uid-1/e62a203d-seed-nspid1_cgpid2-ns-3x rw - fuse.squashfuse_ll squashfuse_ll rw".as_slice(),
344            b"76 1 0:50 / /mnt/xarfuse/uid-1/-seed-nspid1_cgpid2-ns-3 rw - fuse.squashfuse_ll squashfuse_ll rw".as_slice(),
345            b"76 1 0:50 / /mnt/xarfuse/uid-1/e62a203d-seed--ns-3 rw - fuse.squashfuse_ll squashfuse_ll rw".as_slice(),
346            b"76 1 0:50 / /mnt/xarfuse/uid-1/e62a203d-seed-chef-ns- rw - fuse.squashfuse_ll squashfuse_ll rw".as_slice(),
347            b"76 1 0:50 / /mnt/xarfuse/uid-1/e62a203d-seed-a.b-ns-3 rw - fuse.squashfuse_ll squashfuse_ll rw".as_slice(),
348            b"76 1 0:50 / /mnt/xarfuse/uid-1/e62a203d-chef-ns-3 rw - fuse.squashfuse_ll squashfuse_ll rw".as_slice(),
349            b"76 1 0:50 / /mnt/xarfuse/uid-1/e62a203d-seed-nspid1_cgpid2-ns-3/sub rw - fuse.squashfuse_ll squashfuse_ll rw".as_slice(),
350            b"18 1 0:21 / /proc rw,nosuid - proc proc rw".as_slice(),
351            b"squashfuse_ll /mnt/xarfuse/uid-1/e62a203d-seed-nspid1_cgpid2-ns-3 fuse.squashfuse_ll rw 0 0".as_slice(),
352        ] {
353            assert!(!is_ephemeral_host_seed_mount(near_miss), "{near_miss:?}");
354        }
355    }
356
357    /// Named-tool seeds are the same class as per-process seeds. The rows are
358    /// copied from a 2026-10-04 host mount monitor (two lived four to five
359    /// seconds; `chef` was remounted under a new mount ID); the host is
360    /// recorded in docs/TESTING_ENVIRONMENTS.md under "Named measurement hosts".
361    /// A long-lived SquashFUSE mount and a seed-named mount of another type stay.
362    #[test]
363    fn named_host_seeds_are_excluded() {
364        for named_seed in [
365            b"683 2845 0:111 / /mnt/xarfuse/uid-0/8cce7402-seed-fb-pcie-error-log-ns-4026531832 rw,nosuid,nodev,relatime shared:3548 - fuse.squashfuse_ll squashfuse_ll rw,user_id=0,group_id=0".as_slice(),
366            b"405 2845 0:52 / /mnt/xarfuse/uid-0/de637de8-seed-devserver-cleanup_hg_cache-ns-4026531832 rw,nosuid,nodev,relatime shared:246 - fuse.squashfuse_ll squashfuse_ll rw,user_id=0,group_id=0,allow_other".as_slice(),
367            b"401 2845 0:52 / /mnt/xarfuse/uid-0/06178150-seed-chef-ns-4026531832 rw,nosuid,nodev,relatime shared:246 - fuse.squashfuse_ll squashfuse_ll rw,user_id=0,group_id=0,allow_other".as_slice(),
368        ] {
369            assert!(is_ephemeral_host_seed_mount(named_seed), "{named_seed:?}");
370        }
371        for kept in [
372            b"77 2845 0:51 / /mnt/xarfuse/stable-release rw,relatime - fuse.squashfuse_ll squashfuse_ll rw".as_slice(),
373            b"401 2845 0:52 / /mnt/xarfuse/uid-0/06178150-seed-chef-ns-4026531832 rw,nosuid,nodev,relatime shared:246 - tmpfs tmpfs rw".as_slice(),
374        ] {
375            assert!(!is_ephemeral_host_seed_mount(kept), "{kept:?}");
376        }
377    }
378
379    #[test]
380    fn seed_filter_drops_only_seed_rows_and_keeps_order() {
381        let contents = [
382            b"18 1 0:21 / /proc rw - proc proc rw\n".as_slice(),
383            SEED,
384            b"\n401 1 0:52 / /mnt/xarfuse/uid-0/06178150-seed-chef-ns-4026531832 rw - fuse.squashfuse_ll squashfuse_ll rw".as_slice(),
385            b"\n100 1 0:70 / /test rw - tmpfs none rw\n".as_slice(),
386            b"77 1 0:51 / /mnt/xarfuse/stable-release rw - fuse.squashfuse_ll squashfuse_ll rw"
387                .as_slice(),
388        ]
389        .concat();
390        assert_eq!(
391            exclude_ephemeral_host_seed_mounts(&contents),
392            b"18 1 0:21 / /proc rw - proc proc rw\n100 1 0:70 / /test rw - tmpfs none rw\n77 1 0:51 / /mnt/xarfuse/stable-release rw - fuse.squashfuse_ll squashfuse_ll rw"
393        );
394    }
395}