Skip to main content

bootc_tmpfiles/
path_resolution.rs

1//! Generic primitives for resolving a path against real symlinks in a
2//! `cap_std::fs::Dir`-rooted filesystem.
3
4use std::ffi::OsString;
5use std::os::unix::fs::MetadataExt as _;
6use std::path::Path;
7
8use cap_std::fs::{Dir, MetadataExt as _};
9use cap_std_ext::RootDir;
10use cap_std_ext::cap_std;
11
12use crate::{Error, Result};
13
14/// The physical identity of a resolved path: the `(dev, ino)` of its
15/// (symlink-resolved) parent directory, paired with its own leaf filename.
16///
17/// The leaf is never dereferenced (see [`PathResolver::resolve_parent_identity`]),
18/// so it's kept as a plain filename rather than being folded into the parent
19/// identity.
20#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
21pub(crate) struct PathIdentity {
22    parent_dev: u64,
23    parent_ino: u64,
24    leaf: OsString,
25}
26
27impl PathIdentity {
28    pub(crate) fn new(parent_dev: u64, parent_ino: u64, leaf: OsString) -> Self {
29        Self {
30            parent_dev,
31            parent_ino,
32            leaf,
33        }
34    }
35}
36
37/// Resolves the symlinks in the *parent* (intermediate) components of a
38/// declared tmpfiles.d path against the physical rootfs, so that it matches
39/// what the `/var` walker in `convert_path_to_tmpfiles_d_recurse` actually
40/// encounters on disk.
41///
42/// For example, systemd's `provision.conf` declares `d /root/.ssh ...`, but
43/// on a stateless/immutable-root system `/root` is a symlink to the physical
44/// `/var/roothome`, so the walker only ever sees `/var/roothome/.ssh`.
45/// Resolving `/root/.ssh` here to the *identity* of `/var/roothome/.ssh`
46/// lets a plain map lookup recognize it as covered, without the walker
47/// having to do a bidirectional alias lookup at every node.
48///
49/// The **leaf** (final) component of a path is never dereferenced, even if
50/// it happens to be a symlink itself: it's the thing being declared by the
51/// tmpfiles.d line, may not exist yet, and its own target is irrelevant to
52/// resolving its *location*. Only the parent directory is ever opened.
53///
54/// Resolution of the parent is delegated entirely to the kernel via
55/// `openat2(RESOLVE_IN_ROOT)` (through [`cap_std_ext::RootDir`]), which
56/// correctly and safely handles arbitrary symlink chains, absolute symlink
57/// targets (chroot-style reinterpreted against the rootfs, e.g. a top-level
58/// `/home -> /var/home`), and symlink loops (surfaced as a standard `ELOOP`
59/// I/O error), without this crate needing to reimplement any of that itself.
60///
61/// Since the kernel has already done the real work of resolving the parent
62/// down to a live, open file descriptor, the result is reported as that
63/// descriptor's `(dev, ino)` identity (via `fstat`). Querying identity
64/// directly on an already-open fd is kernel-authoritative and immune to
65/// concurrent path mutations (renames of ancestors, etc.).
66///
67/// If any component of the parent doesn't exist, `Ok(None)` is returned:
68/// nothing can physically exist beneath a missing directory either, so the
69/// `/var` walker will never encounter (and thus never need to match) such a
70/// path in the first place.
71pub(crate) struct PathResolver {
72    root_dir: RootDir,
73    /// The `(dev, ino)` identity of the rootfs root itself, captured once so
74    /// that resolving a bare top-level entry (whose "parent" is the rootfs
75    /// root, which can't itself be a symlink) doesn't need a separate lookup.
76    root_dev: u64,
77    root_ino: u64,
78}
79
80impl PathResolver {
81    pub(crate) fn new(rootfs: &Dir) -> Result<Self> {
82        let root_dir = RootDir::new(rootfs, ".")?;
83        let root_meta = rootfs.dir_metadata()?;
84        Ok(Self {
85            root_dir,
86            root_dev: root_meta.dev(),
87            root_ino: root_meta.ino(),
88        })
89    }
90
91    /// See the module-level docs on [`PathResolver`] for the full contract.
92    pub(crate) fn resolve_parent_identity(&self, path: &Path) -> Result<Option<PathIdentity>> {
93        let to_err = |err| Error::PathIo {
94            path: path.to_owned(),
95            err,
96        };
97
98        let relpath = path.strip_prefix("/").unwrap_or(path);
99        // The leaf is never dereferenced. Paths with no leaf at all (i.e.
100        // "/", or an empty path) fall through to an empty leaf below; such a
101        // path can never match a real entry encountered by the `/var`
102        // walker, so this is harmless.
103        let leaf = relpath.file_name().map(OsString::from).unwrap_or_default();
104        // A bare top-level entry (e.g. "/root") has no parent component to
105        // resolve; the rootfs root itself can't be a symlink, so it (and the
106        // "no leaf at all" case above) resolve directly against the cached
107        // rootfs-root identity.
108        let parent = relpath.parent().unwrap_or_else(|| Path::new(""));
109        if parent.as_os_str().is_empty() {
110            return Ok(Some(PathIdentity::new(self.root_dev, self.root_ino, leaf)));
111        }
112
113        let parent_file = match self.root_dir.open_optional(parent) {
114            Ok(Some(f)) => f,
115            Ok(None) => return Ok(None),
116            // Tolerate inaccessible paths (EACCES in rootless containers).
117            Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => return Ok(None),
118            Err(e) => return Err(to_err(e)),
119        };
120        let parent_meta = parent_file.metadata().map_err(to_err)?;
121        Ok(Some(PathIdentity::new(
122            parent_meta.dev(),
123            parent_meta.ino(),
124            leaf,
125        )))
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use cap_std::fs::PermissionsExt as _;
132
133    use super::*;
134
135    fn newroot() -> Result<cap_std_ext::cap_tempfile::TempDir> {
136        cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority()).map_err(Error::Io)
137    }
138
139    /// Create a chain of `n` symlinks named `{prefix}0..{prefix}(n-1)`, each
140    /// pointing to the next, with the last one pointing to a real `target`
141    /// directory (which must already exist).
142    fn make_symlink_chain(rootfs: &Dir, prefix: &str, n: u32) -> Result<()> {
143        for i in 0..n {
144            let target = if i + 1 < n {
145                format!("{prefix}{}", i + 1)
146            } else {
147                "target".to_string()
148            };
149            rootfs.symlink(target, format!("{prefix}{i}"))?;
150        }
151        Ok(())
152    }
153
154    /// Compute the identity that `resolve_parent_identity` *should* return
155    /// for a given `input` path, by directly `fstat`-ing the real, known
156    /// physical location (`expected_parent_relpath`, or `""` for the rootfs
157    /// root itself) in the test fixture. This lets the test assert two
158    /// dynamically-derived values against each other, rather than hardcoding
159    /// meaningless raw `(dev, ino)` numbers.
160    fn expected_identity(rootfs: &Dir, input: &str, expected_parent_relpath: &str) -> PathIdentity {
161        let meta = if expected_parent_relpath.is_empty() {
162            rootfs.dir_metadata().unwrap()
163        } else {
164            rootfs.metadata(expected_parent_relpath).unwrap()
165        };
166        let leaf = Path::new(input).file_name().unwrap_or_default().to_owned();
167        PathIdentity::new(meta.dev(), meta.ino(), leaf)
168    }
169
170    #[test]
171    fn test_resolve_parent_identity() -> anyhow::Result<()> {
172        let rootfs = &newroot()?;
173
174        // No symlinks involved: the path resolves to its own real parent.
175        rootfs.create_dir_all("var/lib/plain")?;
176        // The conventional top-level `/root -> var/roothome` alias.
177        rootfs.create_dir_all("var/roothome/.ssh")?;
178        rootfs.symlink("var/roothome", "root")?;
179        // A symlink nested two levels deep under /var.
180        rootfs.create_dir_all("var/lib/machines")?;
181        rootfs.symlink("machines", "var/lib/portables")?;
182        // An absolute-target top-level symlink (e.g. the conventional
183        // `/home -> /var/home`), proving absolute targets are correctly
184        // chroot-reinterpreted against the rootfs rather than rejected.
185        rootfs.create_dir_all("var/home")?;
186        rootfs.symlink_contents("/var/home", "home")?;
187        // A leaf that is itself a symlink; it must never be dereferenced.
188        rootfs.symlink("var/roothome", "leaf-is-a-symlink")?;
189        // A symlink pointing exactly at the rootfs root, with further
190        // pending components after it that *do* exist, so the successful
191        // resolution path is actually exercised.
192        rootfs.symlink_contents("/", "root-link")?;
193        rootfs.create_dir_all("foo/bar")?;
194        // A chain that switches from a relative target to an absolute one
195        // midway through resolution.
196        rootfs.create_dir_all("c")?;
197        rootfs.symlink("b", "a")?;
198        rootfs.symlink_contents("/c", "b")?;
199        // A dangling intermediate symlink: the target itself doesn't exist
200        // (as opposed to a plain missing path component).
201        rootfs.symlink_contents("/var/nonexistent-target", "var/dangling")?;
202        // A real symlink hop, followed by a path that doesn't exist only
203        // *after* the hop.
204        rootfs.create_dir_all("real")?;
205        rootfs.symlink("real", "alias")?;
206
207        // `expected_parent`, when `Some`, is the rootfs-relative path of the
208        // real physical directory the parent should resolve to (`""` for
209        // the rootfs root itself). `None` means the parent can't be
210        // resolved at all (some component missing), so `Ok(None)` is
211        // expected.
212        let cases: &[(&str, Option<&str>)] = &[
213            // Identity: no symlinks anywhere in the path.
214            ("/var/lib/plain/file", Some("var/lib/plain")),
215            // A single top-level symlink.
216            ("/root/.ssh", Some("var/roothome")),
217            // A symlink nested more than one level deep.
218            ("/var/lib/portables/myimage", Some("var/lib/machines")),
219            // An absolute-target top-level symlink.
220            ("/home/testuser", Some("var/home")),
221            // A dangling/missing intermediate component: nothing on disk,
222            // so no identity can be resolved.
223            ("/nonexistent/deep/path", None),
224            // The leaf itself is a symlink, but must not be dereferenced;
225            // its parent is the rootfs root itself.
226            ("/leaf-is-a-symlink", Some("")),
227            // A symlink pointing exactly at the root, with pending
228            // components after it that fully exist.
229            ("/root-link/foo/bar", Some("foo")),
230            // A relative-then-absolute symlink chain.
231            ("/a/x", Some("c")),
232            // A dangling intermediate symlink: the parent can't be fully
233            // resolved (its target doesn't exist). This is harmless: no
234            // real, physically-walked path can ever match `None`.
235            ("/var/dangling/sub/leaf", None),
236            // A real symlink hop, then a missing component that only
237            // becomes apparent after following it: likewise unresolved.
238            ("/alias/missing/leaf", None),
239        ];
240        let resolver = PathResolver::new(rootfs)?;
241        for (input, expected_parent) in cases.iter().copied() {
242            let resolved = resolver.resolve_parent_identity(Path::new(input))?;
243            let expected = expected_parent.map(|relpath| expected_identity(rootfs, input, relpath));
244            assert_eq!(resolved, expected, "input: {input}");
245        }
246
247        Ok(())
248    }
249
250    #[test]
251    fn test_resolve_parent_identity_symlink_loop() -> anyhow::Result<()> {
252        let rootfs = &newroot()?;
253        rootfs.create_dir_all("var")?;
254
255        // A mutual A <-> B loop.
256        rootfs.symlink_contents("/var/b", "var/a")?;
257        rootfs.symlink_contents("/var/a", "var/b")?;
258        let resolver = PathResolver::new(rootfs)?;
259        let err = resolver
260            .resolve_parent_identity(Path::new("/var/a/file"))
261            .unwrap_err();
262        assert!(matches!(err, Error::PathIo { .. }), "{err:?}");
263
264        // A symlink pointing directly at itself.
265        rootfs.symlink("self", "var/self")?;
266        let err = resolver
267            .resolve_parent_identity(Path::new("/var/self/file"))
268            .unwrap_err();
269        assert!(matches!(err, Error::PathIo { .. }), "{err:?}");
270
271        Ok(())
272    }
273
274    #[test]
275    fn test_resolve_parent_identity_permission_denied() -> anyhow::Result<()> {
276        if rustix::process::getuid().is_root() {
277            return Ok(());
278        }
279        let rootfs = &newroot()?;
280        rootfs.create_dir("noaccess")?;
281        rootfs.set_permissions("noaccess", cap_std::fs::Permissions::from_mode(0o000))?;
282        let resolver = PathResolver::new(rootfs)?;
283        let resolved = resolver.resolve_parent_identity(Path::new("/noaccess/child/leaf"))?;
284        assert_eq!(resolved, None, "EACCES parent should resolve to None");
285
286        // Restore permissions so the temp dir can be cleaned up.
287        rootfs.set_permissions("noaccess", cap_std::fs::Permissions::from_mode(0o755))?;
288        Ok(())
289    }
290
291    #[test]
292    fn test_resolve_parent_identity_chain() -> anyhow::Result<()> {
293        let rootfs = &newroot()?;
294        rootfs.create_dir_all("target")?;
295        // The leaf needs to actually exist, since a chain hop only succeeds
296        // once the *entire* parent (the whole chain) resolves.
297        rootfs.create_dir_all("target/leaf")?;
298
299        // A reasonably long chain of symlinks resolves correctly end to end,
300        // proving multi-hop chains are followed (loop detection itself is
301        // the kernel's job and is covered separately).
302        const CHAIN_LEN: u32 = 25;
303        make_symlink_chain(rootfs, "chain", CHAIN_LEN)?;
304        let resolver = PathResolver::new(rootfs)?;
305        let resolved = resolver.resolve_parent_identity(Path::new("/chain0/leaf/subpath"))?;
306        let expected = expected_identity(rootfs, "/chain0/leaf/subpath", "target/leaf");
307        assert_eq!(resolved, Some(expected));
308
309        Ok(())
310    }
311}