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}