Skip to main content

bootc_tmpfiles/
lib.rs

1//! Parse and generate systemd tmpfiles.d entries.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4use std::collections::{BTreeMap, BTreeSet};
5use std::ffi::OsString;
6use std::fmt::Write as WriteFmt;
7use std::io::{BufRead, BufReader, Write as StdWrite};
8use std::iter::Peekable;
9use std::num::NonZeroUsize;
10use std::os::unix::ffi::{OsStrExt, OsStringExt};
11use std::path::{Path, PathBuf};
12
13use camino::Utf8PathBuf;
14use cap_std::fs::MetadataExt;
15use cap_std::fs::{Dir, Permissions, PermissionsExt};
16use cap_std_ext::cap_std;
17use cap_std_ext::dirext::CapStdExtDirExt;
18use rustix::fs::Mode;
19use rustix::path::Arg;
20use thiserror::Error;
21
22mod path_resolution;
23use path_resolution::{PathIdentity, PathResolver};
24
25const TMPFILESD: &str = "usr/lib/tmpfiles.d";
26const ETC_TMPFILESD: &str = "etc/tmpfiles.d";
27/// The path to the file we use for generation
28const BOOTC_GENERATED_PREFIX: &str = "bootc-autogenerated-var";
29
30/// The number of times we've generated a tmpfiles.d
31#[derive(Debug, Default)]
32struct BootcTmpfilesGeneration(u32);
33
34impl BootcTmpfilesGeneration {
35    fn increment(&mut self) {
36        // SAFETY: We shouldn't ever wrap here
37        self.0 = self.0.checked_add(1).unwrap();
38    }
39
40    fn path(&self) -> Utf8PathBuf {
41        format!("{TMPFILESD}/{BOOTC_GENERATED_PREFIX}-{}.conf", self.0).into()
42    }
43}
44
45/// An error when translating tmpfiles.d.
46#[derive(Debug, Error)]
47#[allow(missing_docs)]
48pub enum Error {
49    #[error("I/O error: {0}")]
50    Io(#[from] std::io::Error),
51    #[error("I/O (fmt) error")]
52    Fmt(#[from] std::fmt::Error),
53    #[error("I/O error on {path}: {err}")]
54    PathIo { path: PathBuf, err: std::io::Error },
55    #[error("User not found for id {0}")]
56    UserNotFound(uzers::uid_t),
57    #[error("Group not found for id {0}")]
58    GroupNotFound(uzers::gid_t),
59    #[error("Invalid non-UTF8 username: {uid} {name}")]
60    NonUtf8User { uid: uzers::uid_t, name: String },
61    #[error("Invalid non-UTF8 groupname: {gid} {name}")]
62    NonUtf8Group { gid: uzers::gid_t, name: String },
63    #[error("Missing {TMPFILESD}")]
64    MissingTmpfilesDir {},
65    #[error("Found /var/run as a non-symlink")]
66    FoundVarRunNonSymlink {},
67    #[error("Malformed tmpfiles.d")]
68    MalformedTmpfilesPath,
69    #[error("Malformed tmpfiles.d line {0}")]
70    MalformedTmpfilesEntry(String),
71    #[error("Unsupported regular file for tmpfiles.d {0}")]
72    UnsupportedRegfile(PathBuf),
73    #[error("Unsupported file of type {ty:?} for tmpfiles.d {path}")]
74    UnsupportedFile {
75        ty: rustix::fs::FileType,
76        path: PathBuf,
77    },
78}
79
80/// The type of Result.
81pub type Result<T> = std::result::Result<T, Error>;
82
83fn escape_path<W: std::fmt::Write>(path: &Path, out: &mut W) -> std::fmt::Result {
84    let path_bytes = path.as_os_str().as_bytes();
85    if path_bytes.is_empty() {
86        return Err(std::fmt::Error);
87    }
88
89    if let Ok(s) = path.as_os_str().as_str() {
90        if s.chars().all(|c| c.is_ascii_alphanumeric() || c == '/') {
91            return write!(out, "{s}");
92        }
93    }
94
95    for c in path_bytes.iter().copied() {
96        let is_special = c == b'\\';
97        let is_printable = c.is_ascii_alphanumeric() || c.is_ascii_punctuation();
98        if is_printable && !is_special {
99            out.write_char(c as char)?;
100        } else {
101            match c {
102                b'\\' => out.write_str(r"\\")?,
103                b'\n' => out.write_str(r"\n")?,
104                b'\t' => out.write_str(r"\t")?,
105                b'\r' => out.write_str(r"\r")?,
106                o => write!(out, "\\x{o:02x}")?,
107            }
108        }
109    }
110    std::fmt::Result::Ok(())
111}
112
113fn impl_unescape_path_until<I>(
114    src: &mut Peekable<I>,
115    buf: &mut Vec<u8>,
116    end_of_record_is_quote: bool,
117) -> Result<()>
118where
119    I: Iterator<Item = u8>,
120{
121    let should_take_next = |c: &u8| {
122        let c = *c;
123        if end_of_record_is_quote {
124            c != b'"'
125        } else {
126            !c.is_ascii_whitespace()
127        }
128    };
129    while let Some(c) = src.next_if(should_take_next) {
130        if c != b'\\' {
131            buf.push(c);
132            continue;
133        };
134        let Some(c) = src.next() else {
135            return Err(Error::MalformedTmpfilesPath);
136        };
137        let c = match c {
138            b'\\' => b'\\',
139            b'n' => b'\n',
140            b'r' => b'\r',
141            b't' => b'\t',
142            b'x' => {
143                let mut s = String::new();
144                s.push(src.next().ok_or(Error::MalformedTmpfilesPath)?.into());
145                s.push(src.next().ok_or(Error::MalformedTmpfilesPath)?.into());
146
147                u8::from_str_radix(&s, 16).map_err(|_| Error::MalformedTmpfilesPath)?
148            }
149            _ => return Err(Error::MalformedTmpfilesPath),
150        };
151        buf.push(c);
152    }
153    Ok(())
154}
155
156fn unescape_path<I>(src: &mut Peekable<I>) -> Result<PathBuf>
157where
158    I: Iterator<Item = u8>,
159{
160    let mut r = Vec::new();
161    if src.next_if_eq(&b'"').is_some() {
162        impl_unescape_path_until(src, &mut r, true)?;
163    } else {
164        impl_unescape_path_until(src, &mut r, false)?;
165    };
166    let r = OsString::from_vec(r);
167    Ok(PathBuf::from(r))
168}
169
170/// Canonicalize and escape a path value for tmpfiles.d.
171///
172/// This performs one canonicalization: remapping `/var/run` -> `/run`. This
173/// is hardcoded because `/var/run` is itself required to be a symlink
174/// pointing *out* to the real `/run` (see `Error::FoundVarRunNonSymlink`).
175/// The walker never recurses into `/var/run`, so this rewrite only ever
176/// applies to the top-level entry itself.
177fn canonicalize_escape_path<W: std::fmt::Write>(path: &Path, out: &mut W) -> std::fmt::Result {
178    if let Ok(rest) = path.strip_prefix("/var/run") {
179        let mut rewritten = PathBuf::from("/run");
180        if !rest.as_os_str().is_empty() {
181            rewritten.push(rest);
182        }
183        return escape_path(&rewritten, out);
184    }
185    escape_path(path, out)
186}
187
188/// In tmpfiles.d we only handle directories and symlinks. Directories
189/// just have a mode, and symlinks just have a target.
190enum FileMeta {
191    Directory(Mode),
192    Symlink(PathBuf),
193}
194
195impl FileMeta {
196    fn from_fs(dir: &Dir, path: &Path) -> Result<Option<Self>> {
197        let meta = dir.symlink_metadata(path)?;
198        let ftype = meta.file_type();
199        let r = if ftype.is_dir() {
200            FileMeta::Directory(Mode::from_raw_mode(meta.mode()))
201        } else if ftype.is_symlink() {
202            let target = dir.read_link_contents(path)?;
203            FileMeta::Symlink(target)
204        } else {
205            return Ok(None);
206        };
207        Ok(Some(r))
208    }
209}
210
211/// Translate a filepath entry to an equivalent tmpfiles.d line.
212pub(crate) fn translate_to_tmpfiles_d(
213    abs_path: &Path,
214    meta: FileMeta,
215    username: &str,
216    groupname: &str,
217) -> Result<String> {
218    let mut bufwr = String::new();
219
220    let filetype_char = match &meta {
221        FileMeta::Directory(_) => 'd',
222        FileMeta::Symlink(_) => 'L',
223    };
224    write!(bufwr, "{filetype_char} ")?;
225    canonicalize_escape_path(abs_path, &mut bufwr)?;
226
227    match meta {
228        FileMeta::Directory(mode) => {
229            write!(bufwr, " {mode:04o} {username} {groupname} - -")?;
230        }
231        FileMeta::Symlink(target) => {
232            bufwr.push_str(" - - - - ");
233            canonicalize_escape_path(&target, &mut bufwr)?;
234        }
235    };
236
237    Ok(bufwr)
238}
239
240/// The result of a tmpfiles.d generation run
241#[derive(Debug, Default)]
242pub struct TmpfilesWrittenResult {
243    /// Set if we generated entries; this is the count and the path.
244    pub generated: Option<(NonZeroUsize, Utf8PathBuf)>,
245    /// Total number of unsupported files that were skipped
246    pub unsupported: usize,
247}
248
249/// Translate the content of `/var` underneath the target root to use tmpfiles.d.
250pub fn var_to_tmpfiles<U: uzers::Users, G: uzers::Groups>(
251    rootfs: &Dir,
252    users: &U,
253    groups: &G,
254) -> Result<TmpfilesWrittenResult> {
255    let (existing_tmpfiles, generation) = read_tmpfiles(rootfs)?;
256
257    // We should never have /var/run as a non-symlink. Don't recurse into it, it's
258    // a hard error.
259    if let Some(meta) = rootfs.symlink_metadata_optional("var/run")? {
260        if !meta.is_symlink() {
261            return Err(Error::FoundVarRunNonSymlink {});
262        }
263    }
264
265    // Require that the tmpfiles.d directory exists; it's part of systemd.
266    if !rootfs.try_exists(TMPFILESD)? {
267        return Err(Error::MissingTmpfilesDir {});
268    }
269
270    let mut entries = BTreeSet::new();
271    let mut prefix = PathBuf::from("/var");
272    let mut unsupported = Vec::new();
273    let var_identity = dir_dev_ino(rootfs, "var")?;
274    convert_path_to_tmpfiles_d_recurse(
275        &TmpfilesConvertConfig {
276            users,
277            groups,
278            rootfs,
279            existing: &existing_tmpfiles,
280            readonly: false,
281        },
282        &mut entries,
283        &mut unsupported,
284        &mut prefix,
285        var_identity,
286    )?;
287
288    // If there's no entries, don't write a file
289    let Some(entries_count) = NonZeroUsize::new(entries.len()) else {
290        return Ok(TmpfilesWrittenResult::default());
291    };
292
293    let path = generation.path();
294    // This should not exist
295    assert!(!rootfs.try_exists(&path)?);
296
297    rootfs.atomic_replace_with(&path, |bufwr| -> Result<()> {
298        let mode = Permissions::from_mode(0o644);
299        bufwr.get_mut().as_file_mut().set_permissions(mode)?;
300
301        for line in entries.iter() {
302            bufwr.write_all(line.as_bytes())?;
303            writeln!(bufwr)?;
304        }
305        if !unsupported.is_empty() {
306            let (samples, rest) = bootc_utils::iterator_split(unsupported.iter(), 5);
307            for elt in samples {
308                writeln!(bufwr, "# bootc ignored: {elt:?}")?;
309            }
310            let rest = rest.count();
311            if rest > 0 {
312                writeln!(bufwr, "# bootc ignored: ...and {rest} more")?;
313            }
314        }
315        Ok(())
316    })?;
317
318    Ok(TmpfilesWrittenResult {
319        generated: Some((entries_count, path)),
320        unsupported: unsupported.len(),
321    })
322}
323
324/// The `(dev, ino)` identity of `path` (relative to `dir`), used as the
325/// "parent" half of a [`PathIdentity`] lookup key.
326fn dir_dev_ino(dir: &Dir, path: &str) -> Result<(u64, u64)> {
327    let meta = dir.metadata(path)?;
328    Ok((meta.dev(), meta.ino()))
329}
330
331/// Configuration for recursive tmpfiles conversion
332struct TmpfilesConvertConfig<'a, U: uzers::Users, G: uzers::Groups> {
333    users: &'a U,
334    groups: &'a G,
335    rootfs: &'a Dir,
336    /// tmpfiles.d entries already declared for `/var`, keyed by the
337    /// *physical identity* of their resolved location (see
338    /// `PathResolver::resolve_parent_identity`), matching what this walker
339    /// actually encounters on disk.
340    existing: &'a BTreeMap<PathIdentity, String>,
341    readonly: bool,
342}
343
344/// Recursively explore target directory and translate content to tmpfiles.d entries. See
345/// `convert_var_to_tmpfiles_d` for more background.
346///
347/// This proceeds depth-first and progressively deletes translated subpaths as it goes.
348/// `prefix` is updated at each recursive step, so that in case of errors it can be
349/// used to pinpoint the faulty path.
350fn convert_path_to_tmpfiles_d_recurse<U: uzers::Users, G: uzers::Groups>(
351    config: &TmpfilesConvertConfig<'_, U, G>,
352    out_entries: &mut BTreeSet<String>,
353    out_unsupported: &mut Vec<PathBuf>,
354    prefix: &mut PathBuf,
355    dir_identity: (u64, u64),
356) -> Result<()> {
357    let relpath = prefix.strip_prefix("/").unwrap();
358    for subpath in config.rootfs.read_dir(relpath)? {
359        let subpath = subpath?;
360        let meta = subpath.metadata()?;
361        let fname = subpath.file_name();
362        prefix.push(&fname);
363
364        // `existing` is keyed by physical identity (see
365        // `PathResolver::resolve_parent_identity`), matching what we
366        // encounter here directly -- no alias lookup needed.
367        let key = PathIdentity::new(dir_identity.0, dir_identity.1, fname);
368        let has_tmpfiles_entry = config.existing.contains_key(&key);
369
370        // Translate this file entry.
371        if !has_tmpfiles_entry {
372            let entry = {
373                // SAFETY: We know this path is absolute
374                let relpath = prefix.strip_prefix("/").unwrap();
375                let Some(tmpfiles_meta) = FileMeta::from_fs(config.rootfs, &relpath)? else {
376                    out_unsupported.push(relpath.into());
377                    assert!(prefix.pop());
378                    continue;
379                };
380                let uid = meta.uid();
381                let gid = meta.gid();
382                let user = config
383                    .users
384                    .get_user_by_uid(meta.uid())
385                    .ok_or(Error::UserNotFound(uid))?;
386                let username = user.name();
387                let username: &str = username.to_str().ok_or_else(|| Error::NonUtf8User {
388                    uid,
389                    name: username.to_string_lossy().into_owned(),
390                })?;
391                let group = config
392                    .groups
393                    .get_group_by_gid(gid)
394                    .ok_or(Error::GroupNotFound(gid))?;
395                let groupname = group.name();
396                let groupname: &str = groupname.to_str().ok_or_else(|| Error::NonUtf8Group {
397                    gid,
398                    name: groupname.to_string_lossy().into_owned(),
399                })?;
400                translate_to_tmpfiles_d(&prefix, tmpfiles_meta, &username, &groupname)?
401            };
402            out_entries.insert(entry);
403        }
404
405        if meta.is_dir() {
406            // SAFETY: We know this path is absolute
407            let relpath = prefix.strip_prefix("/").unwrap();
408            // Avoid traversing mount points by default
409            if let Some(subdir) = config.rootfs.open_dir_noxdev(relpath)? {
410                // Reuse the fd we just opened (to decide whether to recurse)
411                // to also cheaply get this subdirectory's own identity, so
412                // we only pay for one `fstat` per *directory*, not per file.
413                let sub_meta = subdir.dir_metadata()?;
414                convert_path_to_tmpfiles_d_recurse(
415                    config,
416                    out_entries,
417                    out_unsupported,
418                    prefix,
419                    (sub_meta.dev(), sub_meta.ino()),
420                )?;
421                let relpath = prefix.strip_prefix("/").unwrap();
422                if !config.readonly {
423                    config.rootfs.remove_dir_all(relpath)?;
424                }
425            }
426        } else {
427            // SAFETY: We know this path is absolute
428            let relpath = prefix.strip_prefix("/").unwrap();
429            if !config.readonly {
430                config.rootfs.remove_file(relpath)?;
431            }
432        }
433        assert!(prefix.pop());
434    }
435    Ok(())
436}
437
438/// Convert /var for the current root to use systemd tmpfiles.d.
439#[allow(unsafe_code)]
440pub fn convert_var_to_tmpfiles_current_root() -> Result<TmpfilesWrittenResult> {
441    let rootfs = Dir::open_ambient_dir("/", cap_std::ambient_authority())?;
442
443    // See the docs for why this is unsafe
444    let usergroups = unsafe { uzers::cache::UsersSnapshot::new() };
445
446    var_to_tmpfiles(&rootfs, &usergroups, &usergroups)
447}
448
449/// The result of processing tmpfiles.d
450#[derive(Debug)]
451pub struct TmpfilesResult {
452    /// The resulting tmpfiles.d entries
453    pub tmpfiles: BTreeSet<String>,
454    /// Paths which could not be processed
455    pub unsupported: Vec<PathBuf>,
456}
457
458/// Convert /var for the current root to use systemd tmpfiles.d.
459#[allow(unsafe_code)]
460pub fn find_missing_tmpfiles_current_root() -> Result<TmpfilesResult> {
461    use uzers::cache::UsersSnapshot;
462
463    let rootfs = Dir::open_ambient_dir("/", cap_std::ambient_authority())?;
464
465    // See the docs for why this is unsafe
466    let usergroups = unsafe { UsersSnapshot::new() };
467
468    let existing_tmpfiles = read_tmpfiles(&rootfs)?.0;
469
470    let mut prefix = PathBuf::from("/var");
471    let mut tmpfiles = BTreeSet::new();
472    let mut unsupported = Vec::new();
473    let var_identity = dir_dev_ino(&rootfs, "var")?;
474    convert_path_to_tmpfiles_d_recurse(
475        &TmpfilesConvertConfig {
476            users: &usergroups,
477            groups: &usergroups,
478            rootfs: &rootfs,
479            existing: &existing_tmpfiles,
480            readonly: true,
481        },
482        &mut tmpfiles,
483        &mut unsupported,
484        &mut prefix,
485        var_identity,
486    )?;
487    Ok(TmpfilesResult {
488        tmpfiles,
489        unsupported,
490    })
491}
492
493/// Read all tmpfiles.d entries from a single directory. Declared paths are
494/// canonicalized against `rootfs` (see `PathResolver`) so the returned map is
495/// keyed by physical identity.
496fn read_tmpfiles_from_dir(
497    rootfs: &Dir,
498    dir_path: &str,
499    resolver: &PathResolver,
500    generation: &mut BootcTmpfilesGeneration,
501) -> Result<BTreeMap<PathIdentity, String>> {
502    let Some(tmpfiles_dir) = rootfs.open_dir_optional(dir_path)? else {
503        return Ok(Default::default());
504    };
505    let mut result = BTreeMap::new();
506    for entry in tmpfiles_dir.entries()? {
507        let entry = entry?;
508        let name = entry.file_name();
509        let (Some(stem), Some(extension)) =
510            (Path::new(&name).file_stem(), Path::new(&name).extension())
511        else {
512            continue;
513        };
514        if extension != "conf" {
515            continue;
516        }
517        if let Ok(s) = stem.as_str() {
518            if s.starts_with(BOOTC_GENERATED_PREFIX) {
519                generation.increment();
520            }
521        }
522        let r = BufReader::new(entry.open()?);
523        for line in r.lines() {
524            let line = line?;
525            if line.is_empty() || line.starts_with("#") {
526                continue;
527            }
528            let path = tmpfiles_entry_get_path(&line)?;
529            // Canonicalize against the physical rootfs so `existing` is keyed
530            // by the identity of what the `/var` walker actually encounters
531            // on disk (e.g. `/root/.ssh` -> the identity of
532            // `/var/roothome/.ssh`), rather than the raw declared path. See
533            // `PathResolver`.
534            let Some(identity) = resolver.resolve_parent_identity(&path)? else {
535                // Nothing can physically exist beneath a missing parent
536                // directory, so the `/var` walker will never need to match
537                // this declared entry anyway.
538                continue;
539            };
540            result.insert(identity, line);
541        }
542    }
543    Ok(result)
544}
545
546/// Read all tmpfiles.d entries in the target directory, and return a mapping
547/// from (file path) => (single tmpfiles.d entry line)
548///
549/// This function reads from both `/usr/lib/tmpfiles.d` and `/etc/tmpfiles.d`,
550/// with `/etc` entries taking precedence (matching systemd's behavior).
551fn read_tmpfiles(
552    rootfs: &Dir,
553) -> Result<(BTreeMap<PathIdentity, String>, BootcTmpfilesGeneration)> {
554    let mut generation = BootcTmpfilesGeneration::default();
555    let resolver = PathResolver::new(rootfs)?;
556
557    // Read from /usr/lib/tmpfiles.d first (system/package-provided)
558    let mut result = read_tmpfiles_from_dir(rootfs, TMPFILESD, &resolver, &mut generation)?;
559
560    // Read from /etc/tmpfiles.d and merge (user-provided, takes precedence)
561    let etc_result = read_tmpfiles_from_dir(rootfs, ETC_TMPFILESD, &resolver, &mut generation)?;
562    // /etc entries override /usr/lib entries for the same path
563    result.extend(etc_result);
564
565    Ok((result, generation))
566}
567
568fn tmpfiles_entry_get_path(line: &str) -> Result<PathBuf> {
569    let err = || Error::MalformedTmpfilesEntry(line.to_string());
570    let mut it = line.as_bytes().iter().copied().peekable();
571    // Skip leading whitespace
572    while it.next_if(|c| c.is_ascii_whitespace()).is_some() {}
573    // Skip the file type
574    let mut found_ftype = false;
575    while it.next_if(|c| !c.is_ascii_whitespace()).is_some() {
576        found_ftype = true
577    }
578    if !found_ftype {
579        return Err(err());
580    }
581    // Skip trailing whitespace
582    while it.next_if(|c| c.is_ascii_whitespace()).is_some() {}
583    unescape_path(&mut it)
584}
585
586#[cfg(test)]
587mod tests {
588    use super::*;
589    use cap_std::fs::DirBuilder;
590    use cap_std_ext::cap_std::fs::DirBuilderExt as _;
591
592    #[test]
593    fn test_tmpfiles_entry_get_path() {
594        let cases = [
595            ("z /dev/kvm          0666 - kvm -", "/dev/kvm"),
596            ("d /run/lock/lvm 0700 root root -", "/run/lock/lvm"),
597            (
598                "a+      /var/lib/tpm2-tss/system/keystore   -    -    -     -           default:group:tss:rwx",
599                "/var/lib/tpm2-tss/system/keystore",
600            ),
601            (
602                "d \"/run/file with spaces/foo\" 0700 root root -",
603                "/run/file with spaces/foo",
604            ),
605            (
606                r#"d /spaces\x20\x20here/foo 0700 root root -"#,
607                "/spaces  here/foo",
608            ),
609        ];
610        for (input, expected) in cases {
611            let path = tmpfiles_entry_get_path(input).unwrap();
612            assert_eq!(path, Path::new(expected), "Input: {input}");
613        }
614    }
615
616    fn newroot() -> Result<cap_std_ext::cap_tempfile::TempDir> {
617        let root = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
618        root.create_dir_all(TMPFILESD)?;
619        Ok(root)
620    }
621
622    fn mock_userdb() -> uzers::mock::MockUsers {
623        let testuid = rustix::process::getuid();
624        let testgid = rustix::process::getgid();
625        let mut users = uzers::mock::MockUsers::with_current_uid(testuid.as_raw());
626        users.add_user(uzers::User::new(
627            testuid.as_raw(),
628            "testuser",
629            testgid.as_raw(),
630        ));
631        users.add_group(uzers::Group::new(testgid.as_raw(), "testgroup"));
632        users
633    }
634
635    #[test]
636    fn test_tmpfiles_d_translation() -> anyhow::Result<()> {
637        // Prepare a minimal rootfs as playground.
638        let rootfs = &newroot()?;
639        let userdb = &mock_userdb();
640
641        let mut db = DirBuilder::new();
642        db.recursive(true);
643        db.mode(0o755);
644
645        rootfs.write(
646            Path::new(TMPFILESD).join("systemd.conf"),
647            indoc::indoc! { r#"
648            d /var/lib 0755 - - -
649            d /var/lib/private 0700 root root -
650            d /var/log/private 0700 root root -
651        "#},
652        )?;
653
654        // Also test /etc/tmpfiles.d (user-provided configs)
655        rootfs.create_dir_all(ETC_TMPFILESD)?;
656        rootfs.write(
657            Path::new(ETC_TMPFILESD).join("user.conf"),
658            "d /var/lib/user 0755 root root - -\n",
659        )?;
660
661        // Add test content.
662        rootfs.ensure_dir_with("var/lib/systemd", &db)?;
663        rootfs.ensure_dir_with("var/lib/private", &db)?;
664        rootfs.ensure_dir_with("var/lib/nfs", &db)?;
665        rootfs.ensure_dir_with("var/lib/user", &db)?;
666        let global_rwx = Permissions::from_mode(0o777);
667        rootfs.ensure_dir_with("var/lib/test/nested", &db).unwrap();
668        rootfs.set_permissions("var/lib/test", global_rwx.clone())?;
669        rootfs.set_permissions("var/lib/test/nested", global_rwx)?;
670        rootfs.symlink("../", "var/lib/test/nested/symlink")?;
671        rootfs.symlink_contents("/var/lib/foo", "var/lib/test/absolute-symlink")?;
672
673        var_to_tmpfiles(rootfs, userdb, userdb).unwrap();
674
675        // This is the first run
676        let mut tmp_gen = BootcTmpfilesGeneration(0);
677        let autovar_path = &tmp_gen.path();
678        assert!(rootfs.try_exists(autovar_path).unwrap());
679        let entries: Vec<String> = rootfs
680            .read_to_string(autovar_path)
681            .unwrap()
682            .lines()
683            .map(|s| s.to_owned())
684            .collect();
685        let expected = &[
686            "L /var/lib/test/absolute-symlink - - - - /var/lib/foo",
687            "L /var/lib/test/nested/symlink - - - - ../",
688            "d /var/lib/nfs 0755 testuser testgroup - -",
689            "d /var/lib/systemd 0755 testuser testgroup - -",
690            "d /var/lib/test 0777 testuser testgroup - -",
691            "d /var/lib/test/nested 0777 testuser testgroup - -",
692        ];
693        similar_asserts::assert_eq!(entries, expected);
694        assert!(!rootfs.try_exists("var/lib").unwrap());
695
696        // Now pretend we're doing a layered container build, and so we need
697        // a new tmpfiles.d run
698        rootfs.create_dir_all("var/lib/gen2-test")?;
699        let w = var_to_tmpfiles(rootfs, userdb, userdb).unwrap();
700        let wg = w.generated.as_ref().unwrap();
701        assert_eq!(wg.0, NonZeroUsize::new(1).unwrap());
702        assert_eq!(w.unsupported, 0);
703        tmp_gen.increment();
704        let autovar_path = &tmp_gen.path();
705        assert_eq!(autovar_path, &wg.1);
706        assert!(rootfs.try_exists(autovar_path).unwrap());
707        Ok(())
708    }
709
710    /// Verify that a physical path under /var is recognized as covered by a
711    /// tmpfiles.d entry declared via its conventional alias, e.g. systemd's
712    /// `provision.conf` declares `/root/.ssh` even though on stateless-root
713    /// systems `/root` is a symlink to the physical `/var/roothome`. The
714    /// symlink is resolved against the real rootfs at match time, so this
715    /// works for whatever alias happens to be present there.
716    #[test]
717    fn test_tmpfiles_d_root_alias() -> anyhow::Result<()> {
718        // Prepare a minimal rootfs as playground.
719        let rootfs = &newroot()?;
720        let userdb = &mock_userdb();
721
722        rootfs.write(
723            Path::new(TMPFILESD).join("systemd.conf"),
724            indoc::indoc! { r#"
725            d /var/roothome 0700 root root -
726            d /root/.ssh 0700 root root -
727        "#},
728        )?;
729
730        rootfs.create_dir_all("var/roothome/.ssh")?;
731        rootfs.symlink("var/roothome", "root")?;
732
733        let w = var_to_tmpfiles(rootfs, userdb, userdb).unwrap();
734        assert_eq!(w.unsupported, 0);
735        assert!(w.generated.is_none());
736
737        Ok(())
738    }
739
740    /// A second, differently-named alias scenario (using an absolute symlink
741    /// target this time), proving resolution isn't special-cased to `/root`.
742    #[test]
743    fn test_tmpfiles_d_home_alias() -> anyhow::Result<()> {
744        // Prepare a minimal rootfs as playground.
745        let rootfs = &newroot()?;
746        let userdb = &mock_userdb();
747
748        rootfs.write(
749            Path::new(TMPFILESD).join("systemd.conf"),
750            indoc::indoc! { r#"
751            d /var/home 0755 root root -
752            d /home/testuser 0700 testuser testuser -
753        "#},
754        )?;
755
756        rootfs.create_dir_all("var/home/testuser")?;
757        rootfs.symlink_contents("/var/home", "home")?;
758
759        let w = var_to_tmpfiles(rootfs, userdb, userdb).unwrap();
760        assert_eq!(w.unsupported, 0);
761        assert!(w.generated.is_none());
762
763        Ok(())
764    }
765
766    /// Like `test_tmpfiles_d_root_alias`, but the symlink is nested two
767    /// levels deep under /var instead of being a top-level entry, proving
768    /// `PathResolver::resolve_parent_identity` resolves symlinks anywhere
769    /// along the path, not just at the top level.
770    #[test]
771    fn test_tmpfiles_d_nested_symlink() -> anyhow::Result<()> {
772        // Prepare a minimal rootfs as playground.
773        let rootfs = &newroot()?;
774        let userdb = &mock_userdb();
775
776        rootfs.write(
777            Path::new(TMPFILESD).join("systemd.conf"),
778            indoc::indoc! { r#"
779            d /var/lib 0755 root root -
780            d /var/lib/machines 0755 root root -
781            L /var/lib/portables - - - - machines
782            d /var/lib/portables/myimage 0755 root root -
783        "#},
784        )?;
785
786        rootfs.create_dir_all("var/lib/machines/myimage")?;
787        rootfs.symlink("machines", "var/lib/portables")?;
788
789        let w = var_to_tmpfiles(rootfs, userdb, userdb).unwrap();
790        assert_eq!(w.unsupported, 0);
791        assert!(w.generated.is_none());
792
793        Ok(())
794    }
795
796    /// Verify that we emit ignores for regular files
797    #[test]
798    fn test_log_regfile() -> anyhow::Result<()> {
799        // Prepare a minimal rootfs as playground.
800        let rootfs = &newroot()?;
801        let userdb = &mock_userdb();
802
803        rootfs.create_dir_all("var/log/dnf")?;
804        rootfs.write("var/log/dnf/dnf.log", b"some dnf log")?;
805        rootfs.create_dir_all("var/log/foo")?;
806        rootfs.write("var/log/foo/foo.log", b"some other log")?;
807
808        let tmp_gen = BootcTmpfilesGeneration(0);
809        var_to_tmpfiles(rootfs, userdb, userdb).unwrap();
810        let tmpfiles = rootfs.read_to_string(&tmp_gen.path()).unwrap();
811        let ignored = tmpfiles
812            .lines()
813            .filter(|line| line.starts_with("# bootc ignored"))
814            .count();
815        assert_eq!(ignored, 2);
816        Ok(())
817    }
818
819    #[test]
820    fn test_canonicalize_escape_path() {
821        let intact_cases = vec!["/", "/var", "/var/foo", "/run/foo"];
822        for entry in intact_cases {
823            let mut s = String::new();
824            canonicalize_escape_path(Path::new(entry), &mut s).unwrap();
825            similar_asserts::assert_eq!(&s, entry);
826        }
827
828        // The /var/run -> /run rewrite remains hardcoded.
829        let quoting_cases = &[
830            ("/var/foo bar", r#"/var/foo\x20bar"#),
831            ("/var/run", "/run"),
832            ("/var/run/foo bar", r#"/run/foo\x20bar"#),
833        ];
834        for (input, expected) in quoting_cases {
835            let mut s = String::new();
836            canonicalize_escape_path(Path::new(input), &mut s).unwrap();
837            similar_asserts::assert_eq!(&s, expected);
838        }
839    }
840
841    #[test]
842    fn test_translate_to_tmpfiles_d() {
843        let path = Path::new(r#"/var/foo bar"#);
844        let username = "testuser";
845        let groupname = "testgroup";
846        {
847            // Directory
848            let meta = FileMeta::Directory(Mode::from_raw_mode(0o721));
849            let out = translate_to_tmpfiles_d(path, meta, username, groupname).unwrap();
850            let expected = r#"d /var/foo\x20bar 0721 testuser testgroup - -"#;
851            similar_asserts::assert_eq!(out, expected);
852        }
853        {
854            // Symlink
855            let meta = FileMeta::Symlink("/mytarget".into());
856            let out = translate_to_tmpfiles_d(path, meta, username, groupname).unwrap();
857            let expected = r#"L /var/foo\x20bar - - - - /mytarget"#;
858            similar_asserts::assert_eq!(out, expected);
859        }
860    }
861}