1use 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";
24const BOOTC_GENERATED_PREFIX: &str = "bootc-autogenerated-var";
26
27#[derive(Debug, Default)]
29struct BootcTmpfilesGeneration(u32);
30
31impl BootcTmpfilesGeneration {
32 fn increment(&mut self) {
33 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#[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
77pub 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
167fn canonicalize_escape_path<W: std::fmt::Write>(path: &Path, out: &mut W) -> std::fmt::Result {
170 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
181enum 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
204pub(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#[derive(Debug, Default)]
235pub struct TmpfilesWrittenResult {
236 pub generated: Option<(NonZeroUsize, Utf8PathBuf)>,
238 pub unsupported: usize,
240}
241
242pub 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 if let Some(meta) = rootfs.symlink_metadata_optional("var/run")? {
253 if !meta.is_symlink() {
254 return Err(Error::FoundVarRunNonSymlink {});
255 }
256 }
257
258 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 let Some(entries_count) = NonZeroUsize::new(entries.len()) else {
281 return Ok(TmpfilesWrittenResult::default());
282 };
283
284 let path = generation.path();
285 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
315struct 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
324fn 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 if !has_tmpfiles_entry {
347 let entry = {
348 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 let relpath = prefix.strip_prefix("/").unwrap();
383 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 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#[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 let usergroups = unsafe { uzers::cache::UsersSnapshot::new() };
410
411 var_to_tmpfiles(&rootfs, &usergroups, &usergroups)
412}
413
414#[derive(Debug)]
416pub struct TmpfilesResult {
417 pub tmpfiles: BTreeSet<String>,
419 pub unsupported: Vec<PathBuf>,
421}
422
423#[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 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
456fn 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
495fn read_tmpfiles(rootfs: &Dir) -> Result<(BTreeMap<PathBuf, String>, BootcTmpfilesGeneration)> {
501 let mut generation = BootcTmpfilesGeneration::default();
502
503 let mut result = read_tmpfiles_from_dir(rootfs, TMPFILESD, &mut generation)?;
505
506 let etc_result = read_tmpfiles_from_dir(rootfs, ETC_TMPFILESD, &mut generation)?;
508 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 while it.next_if(|c| c.is_ascii_whitespace()).is_some() {}
519 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 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 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 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 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 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 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 #[test]
658 fn test_log_regfile() -> anyhow::Result<()> {
659 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 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 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}