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::{OsStr, 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
22const TMPFILESD: &str = "usr/lib/tmpfiles.d";
23const ETC_TMPFILESD: &str = "etc/tmpfiles.d";
24/// The path to the file we use for generation
25const BOOTC_GENERATED_PREFIX: &str = "bootc-autogenerated-var";
26
27/// The number of times we've generated a tmpfiles.d
28#[derive(Debug, Default)]
29struct BootcTmpfilesGeneration(u32);
30
31impl BootcTmpfilesGeneration {
32    fn increment(&mut self) {
33        // SAFETY: We shouldn't ever wrap here
34        self.0 = self.0.checked_add(1).unwrap();
35    }
36
37    fn path(&self) -> Utf8PathBuf {
38        format!("{TMPFILESD}/{BOOTC_GENERATED_PREFIX}-{}.conf", self.0).into()
39    }
40}
41
42/// An error when translating tmpfiles.d.
43#[derive(Debug, Error)]
44#[allow(missing_docs)]
45pub enum Error {
46    #[error("I/O error: {0}")]
47    Io(#[from] std::io::Error),
48    #[error("I/O (fmt) error")]
49    Fmt(#[from] std::fmt::Error),
50    #[error("I/O error on {path}: {err}")]
51    PathIo { path: PathBuf, err: std::io::Error },
52    #[error("User not found for id {0}")]
53    UserNotFound(uzers::uid_t),
54    #[error("Group not found for id {0}")]
55    GroupNotFound(uzers::gid_t),
56    #[error("Invalid non-UTF8 username: {uid} {name}")]
57    NonUtf8User { uid: uzers::uid_t, name: String },
58    #[error("Invalid non-UTF8 groupname: {gid} {name}")]
59    NonUtf8Group { gid: uzers::gid_t, name: String },
60    #[error("Missing {TMPFILESD}")]
61    MissingTmpfilesDir {},
62    #[error("Found /var/run as a non-symlink")]
63    FoundVarRunNonSymlink {},
64    #[error("Malformed tmpfiles.d")]
65    MalformedTmpfilesPath,
66    #[error("Malformed tmpfiles.d line {0}")]
67    MalformedTmpfilesEntry(String),
68    #[error("Unsupported regular file for tmpfiles.d {0}")]
69    UnsupportedRegfile(PathBuf),
70    #[error("Unsupported file of type {ty:?} for tmpfiles.d {path}")]
71    UnsupportedFile {
72        ty: rustix::fs::FileType,
73        path: PathBuf,
74    },
75}
76
77/// The type of Result.
78pub type Result<T> = std::result::Result<T, Error>;
79
80fn escape_path<W: std::fmt::Write>(path: &Path, out: &mut W) -> std::fmt::Result {
81    let path_bytes = path.as_os_str().as_bytes();
82    if path_bytes.is_empty() {
83        return Err(std::fmt::Error);
84    }
85
86    if let Ok(s) = path.as_os_str().as_str() {
87        if s.chars().all(|c| c.is_ascii_alphanumeric() || c == '/') {
88            return write!(out, "{s}");
89        }
90    }
91
92    for c in path_bytes.iter().copied() {
93        let is_special = c == b'\\';
94        let is_printable = c.is_ascii_alphanumeric() || c.is_ascii_punctuation();
95        if is_printable && !is_special {
96            out.write_char(c as char)?;
97        } else {
98            match c {
99                b'\\' => out.write_str(r"\\")?,
100                b'\n' => out.write_str(r"\n")?,
101                b'\t' => out.write_str(r"\t")?,
102                b'\r' => out.write_str(r"\r")?,
103                o => write!(out, "\\x{o:02x}")?,
104            }
105        }
106    }
107    std::fmt::Result::Ok(())
108}
109
110fn impl_unescape_path_until<I>(
111    src: &mut Peekable<I>,
112    buf: &mut Vec<u8>,
113    end_of_record_is_quote: bool,
114) -> Result<()>
115where
116    I: Iterator<Item = u8>,
117{
118    let should_take_next = |c: &u8| {
119        let c = *c;
120        if end_of_record_is_quote {
121            c != b'"'
122        } else {
123            !c.is_ascii_whitespace()
124        }
125    };
126    while let Some(c) = src.next_if(should_take_next) {
127        if c != b'\\' {
128            buf.push(c);
129            continue;
130        };
131        let Some(c) = src.next() else {
132            return Err(Error::MalformedTmpfilesPath);
133        };
134        let c = match c {
135            b'\\' => b'\\',
136            b'n' => b'\n',
137            b'r' => b'\r',
138            b't' => b'\t',
139            b'x' => {
140                let mut s = String::new();
141                s.push(src.next().ok_or(Error::MalformedTmpfilesPath)?.into());
142                s.push(src.next().ok_or(Error::MalformedTmpfilesPath)?.into());
143
144                u8::from_str_radix(&s, 16).map_err(|_| Error::MalformedTmpfilesPath)?
145            }
146            _ => return Err(Error::MalformedTmpfilesPath),
147        };
148        buf.push(c);
149    }
150    Ok(())
151}
152
153fn unescape_path<I>(src: &mut Peekable<I>) -> Result<PathBuf>
154where
155    I: Iterator<Item = u8>,
156{
157    let mut r = Vec::new();
158    if src.next_if_eq(&b'"').is_some() {
159        impl_unescape_path_until(src, &mut r, true)?;
160    } else {
161        impl_unescape_path_until(src, &mut r, false)?;
162    };
163    let r = OsString::from_vec(r);
164    Ok(PathBuf::from(r))
165}
166
167/// Canonicalize and escape a path value for tmpfiles.d
168/// At the current time the only canonicalization we do is remap /var/run -> /run.
169fn canonicalize_escape_path<W: std::fmt::Write>(path: &Path, out: &mut W) -> std::fmt::Result {
170    // systemd-tmpfiles complains loudly about writing to /var/run;
171    // ideally, all of the packages get fixed for this but...eh.
172    let path = if path.starts_with("/var/run") {
173        let rest = &path.as_os_str().as_bytes()[4..];
174        Path::new(OsStr::from_bytes(rest))
175    } else {
176        path
177    };
178    escape_path(path, out)
179}
180
181/// In tmpfiles.d we only handle directories and symlinks. Directories
182/// just have a mode, and symlinks just have a target.
183enum FileMeta {
184    Directory(Mode),
185    Symlink(PathBuf),
186}
187
188impl FileMeta {
189    fn from_fs(dir: &Dir, path: &Path) -> Result<Option<Self>> {
190        let meta = dir.symlink_metadata(path)?;
191        let ftype = meta.file_type();
192        let r = if ftype.is_dir() {
193            FileMeta::Directory(Mode::from_raw_mode(meta.mode()))
194        } else if ftype.is_symlink() {
195            let target = dir.read_link_contents(path)?;
196            FileMeta::Symlink(target)
197        } else {
198            return Ok(None);
199        };
200        Ok(Some(r))
201    }
202}
203
204/// Translate a filepath entry to an equivalent tmpfiles.d line.
205pub(crate) fn translate_to_tmpfiles_d(
206    abs_path: &Path,
207    meta: FileMeta,
208    username: &str,
209    groupname: &str,
210) -> Result<String> {
211    let mut bufwr = String::new();
212
213    let filetype_char = match &meta {
214        FileMeta::Directory(_) => 'd',
215        FileMeta::Symlink(_) => 'L',
216    };
217    write!(bufwr, "{filetype_char} ")?;
218    canonicalize_escape_path(abs_path, &mut bufwr)?;
219
220    match meta {
221        FileMeta::Directory(mode) => {
222            write!(bufwr, " {mode:04o} {username} {groupname} - -")?;
223        }
224        FileMeta::Symlink(target) => {
225            bufwr.push_str(" - - - - ");
226            canonicalize_escape_path(&target, &mut bufwr)?;
227        }
228    };
229
230    Ok(bufwr)
231}
232
233/// The result of a tmpfiles.d generation run
234#[derive(Debug, Default)]
235pub struct TmpfilesWrittenResult {
236    /// Set if we generated entries; this is the count and the path.
237    pub generated: Option<(NonZeroUsize, Utf8PathBuf)>,
238    /// Total number of unsupported files that were skipped
239    pub unsupported: usize,
240}
241
242/// Translate the content of `/var` underneath the target root to use tmpfiles.d.
243pub fn var_to_tmpfiles<U: uzers::Users, G: uzers::Groups>(
244    rootfs: &Dir,
245    users: &U,
246    groups: &G,
247) -> Result<TmpfilesWrittenResult> {
248    let (existing_tmpfiles, generation) = read_tmpfiles(rootfs)?;
249
250    // We should never have /var/run as a non-symlink. Don't recurse into it, it's
251    // a hard error.
252    if let Some(meta) = rootfs.symlink_metadata_optional("var/run")? {
253        if !meta.is_symlink() {
254            return Err(Error::FoundVarRunNonSymlink {});
255        }
256    }
257
258    // Require that the tmpfiles.d directory exists; it's part of systemd.
259    if !rootfs.try_exists(TMPFILESD)? {
260        return Err(Error::MissingTmpfilesDir {});
261    }
262
263    let mut entries = BTreeSet::new();
264    let mut prefix = PathBuf::from("/var");
265    let mut unsupported = Vec::new();
266    convert_path_to_tmpfiles_d_recurse(
267        &TmpfilesConvertConfig {
268            users,
269            groups,
270            rootfs,
271            existing: &existing_tmpfiles,
272            readonly: false,
273        },
274        &mut entries,
275        &mut unsupported,
276        &mut prefix,
277    )?;
278
279    // If there's no entries, don't write a file
280    let Some(entries_count) = NonZeroUsize::new(entries.len()) else {
281        return Ok(TmpfilesWrittenResult::default());
282    };
283
284    let path = generation.path();
285    // This should not exist
286    assert!(!rootfs.try_exists(&path)?);
287
288    rootfs.atomic_replace_with(&path, |bufwr| -> Result<()> {
289        let mode = Permissions::from_mode(0o644);
290        bufwr.get_mut().as_file_mut().set_permissions(mode)?;
291
292        for line in entries.iter() {
293            bufwr.write_all(line.as_bytes())?;
294            writeln!(bufwr)?;
295        }
296        if !unsupported.is_empty() {
297            let (samples, rest) = bootc_utils::iterator_split(unsupported.iter(), 5);
298            for elt in samples {
299                writeln!(bufwr, "# bootc ignored: {elt:?}")?;
300            }
301            let rest = rest.count();
302            if rest > 0 {
303                writeln!(bufwr, "# bootc ignored: ...and {rest} more")?;
304            }
305        }
306        Ok(())
307    })?;
308
309    Ok(TmpfilesWrittenResult {
310        generated: Some((entries_count, path)),
311        unsupported: unsupported.len(),
312    })
313}
314
315/// Configuration for recursive tmpfiles conversion
316struct TmpfilesConvertConfig<'a, U: uzers::Users, G: uzers::Groups> {
317    users: &'a U,
318    groups: &'a G,
319    rootfs: &'a Dir,
320    existing: &'a BTreeMap<PathBuf, String>,
321    readonly: bool,
322}
323
324/// Recursively explore target directory and translate content to tmpfiles.d entries. See
325/// `convert_var_to_tmpfiles_d` for more background.
326///
327/// This proceeds depth-first and progressively deletes translated subpaths as it goes.
328/// `prefix` is updated at each recursive step, so that in case of errors it can be
329/// used to pinpoint the faulty path.
330fn convert_path_to_tmpfiles_d_recurse<U: uzers::Users, G: uzers::Groups>(
331    config: &TmpfilesConvertConfig<'_, U, G>,
332    out_entries: &mut BTreeSet<String>,
333    out_unsupported: &mut Vec<PathBuf>,
334    prefix: &mut PathBuf,
335) -> Result<()> {
336    let relpath = prefix.strip_prefix("/").unwrap();
337    for subpath in config.rootfs.read_dir(relpath)? {
338        let subpath = subpath?;
339        let meta = subpath.metadata()?;
340        let fname = subpath.file_name();
341        prefix.push(fname);
342
343        let has_tmpfiles_entry = config.existing.contains_key(prefix);
344
345        // Translate this file entry.
346        if !has_tmpfiles_entry {
347            let entry = {
348                // SAFETY: We know this path is absolute
349                let relpath = prefix.strip_prefix("/").unwrap();
350                let Some(tmpfiles_meta) = FileMeta::from_fs(config.rootfs, &relpath)? else {
351                    out_unsupported.push(relpath.into());
352                    assert!(prefix.pop());
353                    continue;
354                };
355                let uid = meta.uid();
356                let gid = meta.gid();
357                let user = config
358                    .users
359                    .get_user_by_uid(meta.uid())
360                    .ok_or(Error::UserNotFound(uid))?;
361                let username = user.name();
362                let username: &str = username.to_str().ok_or_else(|| Error::NonUtf8User {
363                    uid,
364                    name: username.to_string_lossy().into_owned(),
365                })?;
366                let group = config
367                    .groups
368                    .get_group_by_gid(gid)
369                    .ok_or(Error::GroupNotFound(gid))?;
370                let groupname = group.name();
371                let groupname: &str = groupname.to_str().ok_or_else(|| Error::NonUtf8Group {
372                    gid,
373                    name: groupname.to_string_lossy().into_owned(),
374                })?;
375                translate_to_tmpfiles_d(&prefix, tmpfiles_meta, &username, &groupname)?
376            };
377            out_entries.insert(entry);
378        }
379
380        if meta.is_dir() {
381            // SAFETY: We know this path is absolute
382            let relpath = prefix.strip_prefix("/").unwrap();
383            // Avoid traversing mount points by default
384            if config.rootfs.open_dir_noxdev(relpath)?.is_some() {
385                convert_path_to_tmpfiles_d_recurse(config, out_entries, out_unsupported, prefix)?;
386                let relpath = prefix.strip_prefix("/").unwrap();
387                if !config.readonly {
388                    config.rootfs.remove_dir_all(relpath)?;
389                }
390            }
391        } else {
392            // SAFETY: We know this path is absolute
393            let relpath = prefix.strip_prefix("/").unwrap();
394            if !config.readonly {
395                config.rootfs.remove_file(relpath)?;
396            }
397        }
398        assert!(prefix.pop());
399    }
400    Ok(())
401}
402
403/// Convert /var for the current root to use systemd tmpfiles.d.
404#[allow(unsafe_code)]
405pub fn convert_var_to_tmpfiles_current_root() -> Result<TmpfilesWrittenResult> {
406    let rootfs = Dir::open_ambient_dir("/", cap_std::ambient_authority())?;
407
408    // See the docs for why this is unsafe
409    let usergroups = unsafe { uzers::cache::UsersSnapshot::new() };
410
411    var_to_tmpfiles(&rootfs, &usergroups, &usergroups)
412}
413
414/// The result of processing tmpfiles.d
415#[derive(Debug)]
416pub struct TmpfilesResult {
417    /// The resulting tmpfiles.d entries
418    pub tmpfiles: BTreeSet<String>,
419    /// Paths which could not be processed
420    pub unsupported: Vec<PathBuf>,
421}
422
423/// Convert /var for the current root to use systemd tmpfiles.d.
424#[allow(unsafe_code)]
425pub fn find_missing_tmpfiles_current_root() -> Result<TmpfilesResult> {
426    use uzers::cache::UsersSnapshot;
427
428    let rootfs = Dir::open_ambient_dir("/", cap_std::ambient_authority())?;
429
430    // See the docs for why this is unsafe
431    let usergroups = unsafe { UsersSnapshot::new() };
432
433    let existing_tmpfiles = read_tmpfiles(&rootfs)?.0;
434
435    let mut prefix = PathBuf::from("/var");
436    let mut tmpfiles = BTreeSet::new();
437    let mut unsupported = Vec::new();
438    convert_path_to_tmpfiles_d_recurse(
439        &TmpfilesConvertConfig {
440            users: &usergroups,
441            groups: &usergroups,
442            rootfs: &rootfs,
443            existing: &existing_tmpfiles,
444            readonly: true,
445        },
446        &mut tmpfiles,
447        &mut unsupported,
448        &mut prefix,
449    )?;
450    Ok(TmpfilesResult {
451        tmpfiles,
452        unsupported,
453    })
454}
455
456/// Read all tmpfiles.d entries from a single directory
457fn read_tmpfiles_from_dir(
458    rootfs: &Dir,
459    dir_path: &str,
460    generation: &mut BootcTmpfilesGeneration,
461) -> Result<BTreeMap<PathBuf, String>> {
462    let Some(tmpfiles_dir) = rootfs.open_dir_optional(dir_path)? else {
463        return Ok(Default::default());
464    };
465    let mut result = BTreeMap::new();
466    for entry in tmpfiles_dir.entries()? {
467        let entry = entry?;
468        let name = entry.file_name();
469        let (Some(stem), Some(extension)) =
470            (Path::new(&name).file_stem(), Path::new(&name).extension())
471        else {
472            continue;
473        };
474        if extension != "conf" {
475            continue;
476        }
477        if let Ok(s) = stem.as_str() {
478            if s.starts_with(BOOTC_GENERATED_PREFIX) {
479                generation.increment();
480            }
481        }
482        let r = BufReader::new(entry.open()?);
483        for line in r.lines() {
484            let line = line?;
485            if line.is_empty() || line.starts_with("#") {
486                continue;
487            }
488            let path = tmpfiles_entry_get_path(&line)?;
489            result.insert(path.to_owned(), line);
490        }
491    }
492    Ok(result)
493}
494
495/// Read all tmpfiles.d entries in the target directory, and return a mapping
496/// from (file path) => (single tmpfiles.d entry line)
497///
498/// This function reads from both `/usr/lib/tmpfiles.d` and `/etc/tmpfiles.d`,
499/// with `/etc` entries taking precedence (matching systemd's behavior).
500fn read_tmpfiles(rootfs: &Dir) -> Result<(BTreeMap<PathBuf, String>, BootcTmpfilesGeneration)> {
501    let mut generation = BootcTmpfilesGeneration::default();
502
503    // Read from /usr/lib/tmpfiles.d first (system/package-provided)
504    let mut result = read_tmpfiles_from_dir(rootfs, TMPFILESD, &mut generation)?;
505
506    // Read from /etc/tmpfiles.d and merge (user-provided, takes precedence)
507    let etc_result = read_tmpfiles_from_dir(rootfs, ETC_TMPFILESD, &mut generation)?;
508    // /etc entries override /usr/lib entries for the same path
509    result.extend(etc_result);
510
511    Ok((result, generation))
512}
513
514fn tmpfiles_entry_get_path(line: &str) -> Result<PathBuf> {
515    let err = || Error::MalformedTmpfilesEntry(line.to_string());
516    let mut it = line.as_bytes().iter().copied().peekable();
517    // Skip leading whitespace
518    while it.next_if(|c| c.is_ascii_whitespace()).is_some() {}
519    // Skip the file type
520    let mut found_ftype = false;
521    while it.next_if(|c| !c.is_ascii_whitespace()).is_some() {
522        found_ftype = true
523    }
524    if !found_ftype {
525        return Err(err());
526    }
527    // Skip trailing whitespace
528    while it.next_if(|c| c.is_ascii_whitespace()).is_some() {}
529    unescape_path(&mut it)
530}
531
532#[cfg(test)]
533mod tests {
534    use super::*;
535    use cap_std::fs::DirBuilder;
536    use cap_std_ext::cap_std::fs::DirBuilderExt as _;
537
538    #[test]
539    fn test_tmpfiles_entry_get_path() {
540        let cases = [
541            ("z /dev/kvm          0666 - kvm -", "/dev/kvm"),
542            ("d /run/lock/lvm 0700 root root -", "/run/lock/lvm"),
543            (
544                "a+      /var/lib/tpm2-tss/system/keystore   -    -    -     -           default:group:tss:rwx",
545                "/var/lib/tpm2-tss/system/keystore",
546            ),
547            (
548                "d \"/run/file with spaces/foo\" 0700 root root -",
549                "/run/file with spaces/foo",
550            ),
551            (
552                r#"d /spaces\x20\x20here/foo 0700 root root -"#,
553                "/spaces  here/foo",
554            ),
555        ];
556        for (input, expected) in cases {
557            let path = tmpfiles_entry_get_path(input).unwrap();
558            assert_eq!(path, Path::new(expected), "Input: {input}");
559        }
560    }
561
562    fn newroot() -> Result<cap_std_ext::cap_tempfile::TempDir> {
563        let root = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
564        root.create_dir_all(TMPFILESD)?;
565        Ok(root)
566    }
567
568    fn mock_userdb() -> uzers::mock::MockUsers {
569        let testuid = rustix::process::getuid();
570        let testgid = rustix::process::getgid();
571        let mut users = uzers::mock::MockUsers::with_current_uid(testuid.as_raw());
572        users.add_user(uzers::User::new(
573            testuid.as_raw(),
574            "testuser",
575            testgid.as_raw(),
576        ));
577        users.add_group(uzers::Group::new(testgid.as_raw(), "testgroup"));
578        users
579    }
580
581    #[test]
582    fn test_tmpfiles_d_translation() -> anyhow::Result<()> {
583        // Prepare a minimal rootfs as playground.
584        let rootfs = &newroot()?;
585        let userdb = &mock_userdb();
586
587        let mut db = DirBuilder::new();
588        db.recursive(true);
589        db.mode(0o755);
590
591        rootfs.write(
592            Path::new(TMPFILESD).join("systemd.conf"),
593            indoc::indoc! { r#"
594            d /var/lib 0755 - - -
595            d /var/lib/private 0700 root root -
596            d /var/log/private 0700 root root -
597        "#},
598        )?;
599
600        // Also test /etc/tmpfiles.d (user-provided configs)
601        rootfs.create_dir_all(ETC_TMPFILESD)?;
602        rootfs.write(
603            Path::new(ETC_TMPFILESD).join("user.conf"),
604            "d /var/lib/user 0755 root root - -\n",
605        )?;
606
607        // Add test content.
608        rootfs.ensure_dir_with("var/lib/systemd", &db)?;
609        rootfs.ensure_dir_with("var/lib/private", &db)?;
610        rootfs.ensure_dir_with("var/lib/nfs", &db)?;
611        rootfs.ensure_dir_with("var/lib/user", &db)?;
612        let global_rwx = Permissions::from_mode(0o777);
613        rootfs.ensure_dir_with("var/lib/test/nested", &db).unwrap();
614        rootfs.set_permissions("var/lib/test", global_rwx.clone())?;
615        rootfs.set_permissions("var/lib/test/nested", global_rwx)?;
616        rootfs.symlink("../", "var/lib/test/nested/symlink")?;
617        rootfs.symlink_contents("/var/lib/foo", "var/lib/test/absolute-symlink")?;
618
619        var_to_tmpfiles(rootfs, userdb, userdb).unwrap();
620
621        // This is the first run
622        let mut tmp_gen = BootcTmpfilesGeneration(0);
623        let autovar_path = &tmp_gen.path();
624        assert!(rootfs.try_exists(autovar_path).unwrap());
625        let entries: Vec<String> = rootfs
626            .read_to_string(autovar_path)
627            .unwrap()
628            .lines()
629            .map(|s| s.to_owned())
630            .collect();
631        let expected = &[
632            "L /var/lib/test/absolute-symlink - - - - /var/lib/foo",
633            "L /var/lib/test/nested/symlink - - - - ../",
634            "d /var/lib/nfs 0755 testuser testgroup - -",
635            "d /var/lib/systemd 0755 testuser testgroup - -",
636            "d /var/lib/test 0777 testuser testgroup - -",
637            "d /var/lib/test/nested 0777 testuser testgroup - -",
638        ];
639        similar_asserts::assert_eq!(entries, expected);
640        assert!(!rootfs.try_exists("var/lib").unwrap());
641
642        // Now pretend we're doing a layered container build, and so we need
643        // a new tmpfiles.d run
644        rootfs.create_dir_all("var/lib/gen2-test")?;
645        let w = var_to_tmpfiles(rootfs, userdb, userdb).unwrap();
646        let wg = w.generated.as_ref().unwrap();
647        assert_eq!(wg.0, NonZeroUsize::new(1).unwrap());
648        assert_eq!(w.unsupported, 0);
649        tmp_gen.increment();
650        let autovar_path = &tmp_gen.path();
651        assert_eq!(autovar_path, &wg.1);
652        assert!(rootfs.try_exists(autovar_path).unwrap());
653        Ok(())
654    }
655
656    /// Verify that we emit ignores for regular files
657    #[test]
658    fn test_log_regfile() -> anyhow::Result<()> {
659        // Prepare a minimal rootfs as playground.
660        let rootfs = &newroot()?;
661        let userdb = &mock_userdb();
662
663        rootfs.create_dir_all("var/log/dnf")?;
664        rootfs.write("var/log/dnf/dnf.log", b"some dnf log")?;
665        rootfs.create_dir_all("var/log/foo")?;
666        rootfs.write("var/log/foo/foo.log", b"some other log")?;
667
668        let tmp_gen = BootcTmpfilesGeneration(0);
669        var_to_tmpfiles(rootfs, userdb, userdb).unwrap();
670        let tmpfiles = rootfs.read_to_string(&tmp_gen.path()).unwrap();
671        let ignored = tmpfiles
672            .lines()
673            .filter(|line| line.starts_with("# bootc ignored"))
674            .count();
675        assert_eq!(ignored, 2);
676        Ok(())
677    }
678
679    #[test]
680    fn test_canonicalize_escape_path() {
681        let intact_cases = vec!["/", "/var", "/var/foo", "/run/foo"];
682        for entry in intact_cases {
683            let mut s = String::new();
684            canonicalize_escape_path(Path::new(entry), &mut s).unwrap();
685            similar_asserts::assert_eq!(&s, entry);
686        }
687
688        let quoting_cases = &[
689            ("/var/foo bar", r#"/var/foo\x20bar"#),
690            ("/var/run", "/run"),
691            ("/var/run/foo bar", r#"/run/foo\x20bar"#),
692        ];
693        for (input, expected) in quoting_cases {
694            let mut s = String::new();
695            canonicalize_escape_path(Path::new(input), &mut s).unwrap();
696            similar_asserts::assert_eq!(&s, expected);
697        }
698    }
699
700    #[test]
701    fn test_translate_to_tmpfiles_d() {
702        let path = Path::new(r#"/var/foo bar"#);
703        let username = "testuser";
704        let groupname = "testgroup";
705        {
706            // Directory
707            let meta = FileMeta::Directory(Mode::from_raw_mode(0o721));
708            let out = translate_to_tmpfiles_d(path, meta, username, groupname).unwrap();
709            let expected = r#"d /var/foo\x20bar 0721 testuser testgroup - -"#;
710            similar_asserts::assert_eq!(out, expected);
711        }
712        {
713            // Symlink
714            let meta = FileMeta::Symlink("/mytarget".into());
715            let out = translate_to_tmpfiles_d(path, meta, username, groupname).unwrap();
716            let expected = r#"L /var/foo\x20bar - - - - /mytarget"#;
717            similar_asserts::assert_eq!(out, expected);
718        }
719    }
720}