From 114ab01ecc2b006c7ce38e41bb9b4dac6c048cc0 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Sun, 9 Feb 2025 10:52:01 -0500 Subject: [PATCH] tmpfiles: New crate This adapts code rewritten from rpm-ostree to synthesize tmpfiles.d entries. Signed-off-by: Colin Walters --- Cargo.lock | 17 + Cargo.toml | 4 +- lib/Cargo.toml | 2 +- tmpfiles/Cargo.toml | 24 ++ tmpfiles/src/command.rs | 0 tmpfiles/src/lib.rs | 586 +++++++++++++++++++++++++++++++++++ tmpfiles/src/tracing_util.rs | 18 ++ 7 files changed, 649 insertions(+), 2 deletions(-) create mode 100644 tmpfiles/Cargo.toml create mode 100644 tmpfiles/src/command.rs create mode 100644 tmpfiles/src/lib.rs create mode 100644 tmpfiles/src/tracing_util.rs diff --git a/Cargo.lock b/Cargo.lock index 94ef39e80..6cff344be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -236,6 +236,23 @@ dependencies = [ "xshell", ] +[[package]] +name = "bootc-tmpfiles" +version = "0.1.0" +dependencies = [ + "anyhow", + "bootc-utils", + "camino", + "cap-std-ext", + "fn-error-context", + "indoc", + "rustix", + "similar-asserts", + "tempfile", + "thiserror 2.0.11", + "uzers", +] + [[package]] name = "bootc-utils" version = "0.0.0" diff --git a/Cargo.toml b/Cargo.toml index 90e155fc0..dcfe35364 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,8 @@ members = [ "utils", "blockdev", "xtask", - "tests-integration" + "tests-integration", + "tmpfiles" ] resolver = "2" @@ -59,6 +60,7 @@ similar-asserts = "1.5.0" static_assertions = "1.1.0" tempfile = "3.10.1" tracing = "0.1.40" +thiserror = "2.0.11" tokio = ">= 1.37.0" tokio-util = { features = ["io-util"], version = "0.7.10" } diff --git a/lib/Cargo.toml b/lib/Cargo.toml index bd195c080..52566063e 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -51,7 +51,7 @@ xshell = { version = "0.2.6", optional = true } uuid = { version = "1.8.0", features = ["v4"] } tini = "1.3.0" comfy-table = "7.1.1" -thiserror = "2.0.11" +thiserror = { workspace = true } [dev-dependencies] similar-asserts = { workspace = true } diff --git a/tmpfiles/Cargo.toml b/tmpfiles/Cargo.toml new file mode 100644 index 000000000..8e407220a --- /dev/null +++ b/tmpfiles/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "bootc-tmpfiles" +version = "0.1.0" +license = "MIT OR Apache-2.0" +edition = "2021" +publish = false + +[dependencies] +camino = { workspace = true } +fn-error-context = { workspace = true } +cap-std-ext = { version = "4" } +thiserror = { workspace = true } +tempfile = { workspace = true } +bootc-utils = { path = "../utils" } +rustix = { workspace = true } +uzers = "0.12" + +[dev-dependencies] +anyhow = { workspace = true } +indoc = { workspace = true } +similar-asserts = { workspace = true } + +[lints] +workspace = true diff --git a/tmpfiles/src/command.rs b/tmpfiles/src/command.rs new file mode 100644 index 000000000..e69de29bb diff --git a/tmpfiles/src/lib.rs b/tmpfiles/src/lib.rs new file mode 100644 index 000000000..6c4937465 --- /dev/null +++ b/tmpfiles/src/lib.rs @@ -0,0 +1,586 @@ +//! Parse and generate systemd tmpfiles.d entries. +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use std::collections::{BTreeMap, BTreeSet}; +use std::ffi::{OsStr, OsString}; +use std::fmt::Write as WriteFmt; +use std::io::{BufRead, BufReader, Write as StdWrite}; +use std::iter::Peekable; +use std::os::unix::ffi::{OsStrExt, OsStringExt}; +use std::path::{Path, PathBuf}; + +use cap_std::fs::MetadataExt; +use cap_std::fs::{Dir, Permissions, PermissionsExt}; +use cap_std_ext::cap_std; +use cap_std_ext::dirext::CapStdExtDirExt; +use rustix::fs::Mode; +use rustix::path::Arg; +use thiserror::Error; + +const TMPFILESD: &str = "usr/lib/tmpfiles.d"; +const BOOTC_GENERATED: &str = "bootc-autogenerated-var.conf"; + +/// An error when translating tmpfiles.d. +#[derive(Debug, Error)] +#[allow(missing_docs)] +pub enum Error { + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + #[error("I/O (fmt) error")] + Fmt(#[from] std::fmt::Error), + #[error("I/O error on {path}: {err}")] + PathIo { path: PathBuf, err: std::io::Error }, + #[error("User not found for id {0}")] + UserNotFound(uzers::uid_t), + #[error("Group not found for id {0}")] + GroupNotFound(uzers::gid_t), + #[error("Invalid non-UTF8 username: {uid} {name}")] + NonUtf8User { uid: uzers::uid_t, name: String }, + #[error("Invalid non-UTF8 groupname: {gid} {name}")] + NonUtf8Group { gid: uzers::gid_t, name: String }, + #[error("Missing {TMPFILESD}")] + MissingTmpfilesDir {}, + #[error("Found /var/run as a non-symlink")] + FoundVarRunNonSymlink {}, + #[error("Malformed tmpfiles.d")] + MalformedTmpfilesPath, + #[error("Malformed tmpfiles.d line {0}")] + MalformedTmpfilesEntry(String), + #[error("Unsupported; {0}")] + Unsupported(String), +} + +/// The type of Result. +pub type Result = std::result::Result; + +fn escape_path(path: &Path, out: &mut W) -> std::fmt::Result { + let path_bytes = path.as_os_str().as_bytes(); + if path_bytes.is_empty() { + return Err(std::fmt::Error); + } + + if let Some(s) = path.as_os_str().as_str().ok() { + if s.chars().all(|c| c.is_ascii_alphanumeric() || c == '/') { + return write!(out, "{s}"); + } + } + + for c in path_bytes.iter().copied() { + let is_special = c == b'\\'; + let is_printable = c.is_ascii_alphanumeric() || c.is_ascii_punctuation(); + if is_printable && !is_special { + out.write_char(c as char)?; + } else { + match c { + b'\\' => out.write_str(r"\\")?, + b'\n' => out.write_str(r"\n")?, + b'\t' => out.write_str(r"\t")?, + b'\r' => out.write_str(r"\r")?, + o => write!(out, "\\x{:02x}", o)?, + } + } + } + std::fmt::Result::Ok(()) +} + +fn impl_unescape_path_until( + src: &mut Peekable, + buf: &mut Vec, + end_of_record_is_quote: bool, +) -> Result<()> +where + I: Iterator, +{ + let should_take_next = |c: &u8| { + let c = *c; + if end_of_record_is_quote { + c != b'"' + } else { + !c.is_ascii_whitespace() + } + }; + while let Some(c) = src.next_if(should_take_next) { + if c != b'\\' { + buf.push(c); + continue; + }; + let Some(c) = src.next() else { + return Err(Error::MalformedTmpfilesPath); + }; + let c = match c { + b'\\' => b'\\', + b'n' => b'\n', + b'r' => b'\r', + b't' => b'\t', + b'x' => { + let mut s = String::new(); + s.push( + src.next() + .ok_or_else(|| Error::MalformedTmpfilesPath)? + .into(), + ); + s.push( + src.next() + .ok_or_else(|| Error::MalformedTmpfilesPath)? + .into(), + ); + + u8::from_str_radix(&s, 16).map_err(|_| Error::MalformedTmpfilesPath)? + } + _ => return Err(Error::MalformedTmpfilesPath), + }; + buf.push(c); + } + Ok(()) +} + +fn unescape_path(src: &mut Peekable) -> Result +where + I: Iterator, +{ + let mut r = Vec::new(); + if let Some(_) = src.next_if_eq(&b'"') { + impl_unescape_path_until(src, &mut r, true)?; + } else { + impl_unescape_path_until(src, &mut r, false)?; + }; + let r = OsString::from_vec(r); + Ok(PathBuf::from(r)) +} + +/// Canonicalize and escape a path value for tmpfiles.d +/// At the current time the only canonicalization we do is remap /var/run -> /run. +fn canonicalize_escape_path(path: &Path, out: &mut W) -> std::fmt::Result { + // systemd-tmpfiles complains loudly about writing to /var/run; + // ideally, all of the packages get fixed for this but...eh. + let path = if path.starts_with("/var/run") { + let rest = &path.as_os_str().as_bytes()[4..]; + Path::new(OsStr::from_bytes(rest)) + } else { + path + }; + escape_path(path, out) +} + +/// In tmpfiles.d we only handle directories and symlinks. Directories +/// just have a mode, and symlinks just have a target. +enum FileMeta { + Directory(Mode), + Symlink(PathBuf), +} + +impl FileMeta { + fn from_fs(dir: &Dir, path: &Path) -> Result { + let meta = dir.symlink_metadata(path)?; + let ftype = meta.file_type(); + let r = if ftype.is_dir() { + FileMeta::Directory(Mode::from_raw_mode(meta.mode())) + } else if ftype.is_symlink() { + let target = dir.read_link_contents(path)?; + FileMeta::Symlink(target) + } else { + return Err(Error::Unsupported(format!( + "path '{path:?}' has invalid type: {ftype:?}" + ))); + }; + Ok(r) + } +} + +/// Translate a filepath entry to an equivalent tmpfiles.d line. +pub(crate) fn translate_to_tmpfiles_d( + abs_path: &Path, + meta: FileMeta, + username: &str, + groupname: &str, +) -> Result { + let mut bufwr = String::new(); + + let filetype_char = match &meta { + FileMeta::Directory(_) => 'd', + FileMeta::Symlink(_) => 'L', + }; + write!(bufwr, "{} ", filetype_char)?; + canonicalize_escape_path(abs_path, &mut bufwr)?; + + match meta { + FileMeta::Directory(mode) => { + write!(bufwr, " {mode:04o} {username} {groupname} - -")?; + } + FileMeta::Symlink(target) => { + bufwr.push_str(" - - - - "); + canonicalize_escape_path(&target, &mut bufwr)?; + } + }; + + Ok(bufwr) +} + +/// Translate the content of `/var` underneath the target root to use tmpfiles.d. +pub fn var_to_tmpfiles( + rootfs: &Dir, + users: &U, + groups: &G, +) -> Result<()> { + let existing_tmpfiles = read_tmpfiles(rootfs)?; + + // We should never have /var/run as a non-symlink. Don't recurse into it, it's + // a hard error. + if let Some(meta) = rootfs.symlink_metadata_optional("var/run")? { + if !meta.is_symlink() { + return Err(Error::FoundVarRunNonSymlink {}); + } + } + + // Require that the tmpfiles.d directory exists; it's part of systemd. + if !rootfs.try_exists(TMPFILESD)? { + return Err(Error::MissingTmpfilesDir {}); + } + let mode = Permissions::from_mode(0o644); + rootfs.atomic_replace_with( + Path::new(TMPFILESD).join(BOOTC_GENERATED), + |bufwr| -> Result<()> { + bufwr.get_mut().as_file_mut().set_permissions(mode)?; + let mut prefix = PathBuf::from("/var"); + let mut entries = BTreeSet::new(); + convert_path_to_tmpfiles_d_recurse( + &mut entries, + users, + groups, + rootfs, + &existing_tmpfiles, + &mut prefix, + false, + )?; + for line in entries { + bufwr.write_all(line.as_bytes())?; + writeln!(bufwr)?; + } + Ok(()) + }, + )?; + + Ok(()) +} + +/// Recursively explore target directory and translate content to tmpfiles.d entries. See +/// `convert_var_to_tmpfiles_d` for more background. +/// +/// This proceeds depth-first and progressively deletes translated subpaths as it goes. +/// `prefix` is updated at each recursive step, so that in case of errors it can be +/// used to pinpoint the faulty path. +fn convert_path_to_tmpfiles_d_recurse( + out_entries: &mut BTreeSet, + users: &U, + groups: &G, + rootfs: &Dir, + existing: &BTreeMap, + prefix: &mut PathBuf, + readonly: bool, +) -> Result<()> { + let relpath = prefix.strip_prefix("/").unwrap(); + for subpath in rootfs.read_dir(relpath)? { + let subpath = subpath?; + let meta = subpath.metadata()?; + let fname = subpath.file_name(); + prefix.push(fname); + + if existing.contains_key(prefix) { + assert!(prefix.pop()); + continue; + } + + // Translate this file entry. + let entry = { + // SAFETY: We know this path is absolute + let relpath = prefix.strip_prefix("/").unwrap(); + let tmpfiles_meta = FileMeta::from_fs(rootfs, &relpath)?; + let uid = meta.uid(); + let gid = meta.gid(); + let user = users + .get_user_by_uid(meta.uid()) + .ok_or_else(|| Error::UserNotFound(uid))?; + let username = user.name(); + let username: &str = username.to_str().ok_or_else(|| Error::NonUtf8User { + uid, + name: username.to_string_lossy().into_owned(), + })?; + let group = groups + .get_group_by_gid(gid) + .ok_or_else(|| Error::GroupNotFound(gid))?; + let groupname = group.name(); + let groupname: &str = groupname.to_str().ok_or_else(|| Error::NonUtf8Group { + gid, + name: groupname.to_string_lossy().into_owned(), + })?; + translate_to_tmpfiles_d(&prefix, tmpfiles_meta, &username, &groupname)? + }; + out_entries.insert(entry); + + if meta.is_dir() { + convert_path_to_tmpfiles_d_recurse( + out_entries, + users, + groups, + rootfs, + existing, + prefix, + readonly, + )?; + // SAFETY: We know this path is absolute + let relpath = prefix.strip_prefix("/").unwrap(); + if !readonly { + rootfs.remove_dir_all(relpath)?; + } + } else { + // SAFETY: We know this path is absolute + let relpath = prefix.strip_prefix("/").unwrap(); + if !readonly { + rootfs.remove_file(relpath)?; + } + } + assert!(prefix.pop()); + } + Ok(()) +} + +/// Convert /var for the current root to use systemd tmpfiles.d. +#[allow(unsafe_code)] +pub fn convert_var_to_tmpfiles_current_root() -> Result<()> { + let rootfs = Dir::open_ambient_dir("/", cap_std::ambient_authority())?; + + // See the docs for why this is unsafe + let usergroups = unsafe { uzers::cache::UsersSnapshot::new() }; + + var_to_tmpfiles(&rootfs, &usergroups, &usergroups) +} + +/// Convert /var for the current root to use systemd tmpfiles.d. +#[allow(unsafe_code)] +pub fn find_missing_tmpfiles_current_root() -> Result> { + use uzers::cache::UsersSnapshot; + + let rootfs = Dir::open_ambient_dir("/", cap_std::ambient_authority())?; + + // See the docs for why this is unsafe + let usergroups = unsafe { UsersSnapshot::new() }; + + let existing_tmpfiles = read_tmpfiles(&rootfs)?; + + let mut prefix = PathBuf::from("/var"); + let mut entries = BTreeSet::new(); + convert_path_to_tmpfiles_d_recurse( + &mut entries, + &usergroups, + &usergroups, + &rootfs, + &existing_tmpfiles, + &mut prefix, + true, + )?; + Ok(entries) +} + +/// Read all tmpfiles.d entries in the target directory, and return a mapping +/// from (file path) => (single tmpfiles.d entry line) +fn read_tmpfiles(rootfs: &Dir) -> Result> { + let Some(tmpfiles_dir) = rootfs.open_dir_optional(TMPFILESD)? else { + return Ok(Default::default()); + }; + let mut result = BTreeMap::new(); + for entry in tmpfiles_dir.entries()? { + let entry = entry?; + let name = entry.file_name(); + let Some(extension) = Path::new(&name).extension() else { + continue; + }; + if extension != "conf" { + continue; + } + let r = BufReader::new(entry.open()?); + for line in r.lines() { + let line = line?; + if line.is_empty() || line.starts_with("#") { + continue; + } + let path = tmpfiles_entry_get_path(&line)?; + result.insert(path.to_owned(), line); + } + } + Ok(result) +} + +fn tmpfiles_entry_get_path(line: &str) -> Result { + let err = || Error::MalformedTmpfilesEntry(line.to_string()); + let mut it = line.as_bytes().iter().copied().peekable(); + // Skip leading whitespace + while let Some(_) = it.next_if(|c| c.is_ascii_whitespace()) {} + // Skip the file type + let mut found_ftype = false; + while let Some(_) = it.next_if(|c| !c.is_ascii_whitespace()) { + found_ftype = true + } + if !found_ftype { + return Err(err()); + } + // Skip trailing whitespace + while let Some(_) = it.next_if(|c| c.is_ascii_whitespace()) {} + unescape_path(&mut it) +} + +#[cfg(test)] +mod tests { + use super::*; + use cap_std::fs::DirBuilder; + use cap_std_ext::cap_std::fs::DirBuilderExt as _; + + #[test] + fn test_tmpfiles_entry_get_path() { + let cases = [ + ("z /dev/kvm 0666 - kvm -", "/dev/kvm"), + ("d /run/lock/lvm 0700 root root -", "/run/lock/lvm"), + ("a+ /var/lib/tpm2-tss/system/keystore - - - - default:group:tss:rwx", "/var/lib/tpm2-tss/system/keystore"), + ("d \"/run/file with spaces/foo\" 0700 root root -", "/run/file with spaces/foo"), + ( + r#"d /spaces\x20\x20here/foo 0700 root root -"#, + "/spaces here/foo", + ), + ]; + for (input, expected) in cases { + let path = tmpfiles_entry_get_path(input).unwrap(); + assert_eq!(path, Path::new(expected), "Input: {input}"); + } + } + + fn newroot() -> Result { + let root = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?; + root.create_dir_all(TMPFILESD)?; + Ok(root) + } + + fn mock_userdb() -> uzers::mock::MockUsers { + let testuid = rustix::process::getuid(); + let testgid = rustix::process::getgid(); + let mut users = uzers::mock::MockUsers::with_current_uid(testuid.as_raw()); + users.add_user(uzers::User::new( + testuid.as_raw(), + "testuser", + testgid.as_raw(), + )); + users.add_group(uzers::Group::new(testgid.as_raw(), "testgroup")); + users + } + + #[test] + fn test_tmpfiles_d_translation() -> anyhow::Result<()> { + // Prepare a minimal rootfs as playground. + let rootfs = &newroot()?; + let userdb = &mock_userdb(); + + let mut db = DirBuilder::new(); + db.recursive(true); + db.mode(0o755); + + rootfs.write( + Path::new(TMPFILESD).join("systemd.conf"), + indoc::indoc! { r#" + d /var/lib/private 0700 root root - + d /var/log/private 0700 root root - + "#}, + )?; + + // Add test content. + rootfs.ensure_dir_with("var/lib/systemd", &db)?; + rootfs.ensure_dir_with("var/lib/private", &db)?; + rootfs.ensure_dir_with("var/lib/nfs", &db)?; + let global_rwx = Permissions::from_mode(0o777); + rootfs.ensure_dir_with("var/lib/test/nested", &db).unwrap(); + rootfs.set_permissions("var/lib/test", global_rwx.clone())?; + rootfs.set_permissions("var/lib/test/nested", global_rwx)?; + rootfs.symlink("../", "var/lib/test/nested/symlink")?; + rootfs.symlink_contents("/var/lib/foo", "var/lib/test/absolute-symlink")?; + + var_to_tmpfiles(rootfs, userdb, userdb).unwrap(); + + let autovar_path = &Path::new(TMPFILESD).join(BOOTC_GENERATED); + assert!(!rootfs.try_exists("var/lib").unwrap()); + assert!(rootfs.try_exists(autovar_path).unwrap()); + let entries: Vec = rootfs + .read_to_string(autovar_path) + .unwrap() + .lines() + .map(|s| s.to_owned()) + .collect(); + let expected = &[ + "L /var/lib/test/absolute-symlink - - - - /var/lib/foo", + "L /var/lib/test/nested/symlink - - - - ../", + "d /var/lib 0755 testuser testgroup - -", + "d /var/lib/nfs 0755 testuser testgroup - -", + "d /var/lib/systemd 0755 testuser testgroup - -", + "d /var/lib/test 0777 testuser testgroup - -", + "d /var/lib/test/nested 0777 testuser testgroup - -", + ]; + similar_asserts::assert_eq!(entries, expected); + + Ok(()) + } + + /// Verify that we error out if we encounter a regular file + #[test] + fn test_err_regfile() -> anyhow::Result<()> { + // Prepare a minimal rootfs as playground. + let rootfs = &newroot()?; + let userdb = &mock_userdb(); + + rootfs.create_dir_all("var/log/dnf")?; + rootfs.write("var/log/dnf/dnf.log", b"some dnf log")?; + + match var_to_tmpfiles(rootfs, userdb, userdb) { + Ok(()) => unreachable!(), + Err(e) => assert!(matches!(e, Error::Unsupported(_))), + } + Ok(()) + } + + #[test] + fn test_canonicalize_escape_path() { + let intact_cases = vec!["/", "/var", "/var/foo", "/run/foo"]; + for entry in intact_cases { + let mut s = String::new(); + canonicalize_escape_path(Path::new(entry), &mut s).unwrap(); + similar_asserts::assert_eq!(&s, entry); + } + + let quoting_cases = &[ + ("/var/foo bar", r#"/var/foo\x20bar"#), + ("/var/run", "/run"), + ("/var/run/foo bar", r#"/run/foo\x20bar"#), + ]; + for (input, expected) in quoting_cases { + let mut s = String::new(); + canonicalize_escape_path(Path::new(input), &mut s).unwrap(); + similar_asserts::assert_eq!(&s, expected); + } + } + + #[test] + fn test_translate_to_tmpfiles_d() { + let path = Path::new(r#"/var/foo bar"#); + let username = "testuser"; + let groupname = "testgroup"; + { + // Directory + let meta = FileMeta::Directory(Mode::from_raw_mode(0o721)); + let out = translate_to_tmpfiles_d(path, meta, username, groupname).unwrap(); + let expected = r#"d /var/foo\x20bar 0721 testuser testgroup - -"#; + similar_asserts::assert_eq!(out, expected); + } + { + // Symlink + let meta = FileMeta::Symlink("/mytarget".into()); + let out = translate_to_tmpfiles_d(path, meta, username, groupname).unwrap(); + let expected = r#"L /var/foo\x20bar - - - - /mytarget"#; + similar_asserts::assert_eq!(out, expected); + } + } +} diff --git a/tmpfiles/src/tracing_util.rs b/tmpfiles/src/tracing_util.rs new file mode 100644 index 000000000..c6a7cf94e --- /dev/null +++ b/tmpfiles/src/tracing_util.rs @@ -0,0 +1,18 @@ +//! Helpers related to tracing, used by main entrypoints + +/// Initialize tracing with the default configuration. +pub fn initialize_tracing() { + // Don't include timestamps and such because they're not really useful and + // too verbose, and plus several log targets such as journald will already + // include timestamps. + let format = tracing_subscriber::fmt::format() + .without_time() + .with_target(false) + .compact(); + // Log to stderr by default + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .event_format(format) + .with_writer(std::io::stderr) + .init(); +}