diff --git a/src/shared/install-file.c b/src/shared/install-file.c new file mode 100644 index 0000000000..664f028ad1 --- /dev/null +++ b/src/shared/install-file.c @@ -0,0 +1,269 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ + +#include + +#include "btrfs-util.h" +#include "chattr-util.h" +#include "errno-util.h" +#include "fd-util.h" +#include "fs-util.h" +#include "install-file.h" +#include "missing_syscall.h" +#include "rm-rf.h" + +int fs_make_very_read_only(int fd) { + struct stat st; + int r; + + assert(fd >= 0); + + /* Tries to make the specified fd "comprehensively" read-only. Primary usecase for this is OS images, + * i.e. either loopback files or larger directory hierarchies. Depending on the inode type and + * backing file system this means something different: + * + * 1. If the fd refers to a btrfs subvolume we'll mark it read-only as a whole + * 2. If the fd refers to any other directory we'll set the FS_IMMUTABLE_FL flag on it + * 3. If the fd refers to a regular file we'll drop the w bits. + * 4. If the fd refers to a block device, use BLKROSET to set read-only state + * + * You might wonder why not drop the x bits for directories. That's because we want to guarantee that + * everything "inside" the image remains largely the way it is, in case you mount it. And since the + * mode of the root dir of the image is pretty visible we don't want to modify it. btrfs subvol flags + * and the FS_IMMUTABLE_FL otoh are much less visible. Changing the mode of regular files should be + * OK though, since after all this is supposed to be used for disk images, i.e. the fs in the disk + * image doesn't make the mode of the loopback file it is stored in visible. */ + + if (fstat(fd, &st) < 0) + return -errno; + + switch (st.st_mode & S_IFMT) { + + case S_IFDIR: + if (btrfs_might_be_subvol(&st)) { + r = btrfs_subvol_set_read_only_fd(fd, true); + if (r >= 0) + return 0; + + if (!ERRNO_IS_NOT_SUPPORTED(r) && r != -EINVAL) + return r; + } + + r = chattr_fd(fd, FS_IMMUTABLE_FL, FS_IMMUTABLE_FL, NULL); + if (r < 0) + return r; + + break; + + case S_IFREG: + if ((st.st_mode & 0222) != 0) + if (fchmod(fd, st.st_mode & 07555) < 0) + return -errno; + + break; + + case S_IFBLK: { + int ro = 1; + + if (ioctl(fd, BLKROSET, &ro) < 0) + return -errno; + + break; + } + + default: + return -EBADFD; + } + + return 0; +} + +static int unlinkat_maybe_dir(int dirfd, const char *pathname) { + + /* Invokes unlinkat() for regular files first, and if this fails with EISDIR tries again with + * AT_REMOVEDIR */ + + if (unlinkat(dirfd, pathname, 0) < 0) { + if (errno != EISDIR) + return -errno; + + if (unlinkat(dirfd, pathname, AT_REMOVEDIR) < 0) + return -errno; + } + + return 0; +} + +int install_file(int source_atfd, const char *source_name, + int target_atfd, const char *target_name, + InstallFileFlags flags) { + + _cleanup_close_ int rofd = -1; + int r; + + /* Moves a file or directory tree into place, with some bells and whistles: + * + * 1. Optionally syncs before/after to ensure file installation can be used as barrier + * 2. Optionally marks the file/directory read-only using fs_make_very_read_only() + * 3. Optionally operates in replacing or in non-replacing mode. + * 4. If it replaces will remove the old tree if needed. + */ + + assert(source_atfd >= 0 || source_atfd == AT_FDCWD); + assert(source_name); + assert(target_atfd >= 0 || target_atfd == AT_FDCWD); + + /* If target_name is specified as NULL no renaming takes place. Instead it is assumed the file is + * already in place, and only the syncing/read-only marking shall be applied. Note that with + * target_name=NULL and flags=0 this call is a NOP */ + + if ((flags & (INSTALL_FSYNC|INSTALL_FSYNC_FULL|INSTALL_SYNCFS|INSTALL_READ_ONLY)) != 0) { + _cleanup_close_ int pfd = -1; + struct stat st; + + /* Open an O_PATH fd for the source if we need to sync things or mark things read only. */ + + pfd = openat(source_atfd, source_name, O_PATH|O_CLOEXEC|O_NOFOLLOW); + if (pfd < 0) + return -errno; + + if (fstat(pfd, &st) < 0) + return -errno; + + switch (st.st_mode & S_IFMT) { + + case S_IFREG: { + _cleanup_close_ int regfd = -1; + + regfd = fd_reopen(pfd, O_RDONLY|O_CLOEXEC); + if (regfd < 0) + return regfd; + + if ((flags & (INSTALL_FSYNC_FULL|INSTALL_SYNCFS)) != 0) { + /* If this is just a regular file (as oppose to a fully populated directory) + * let's downgrade INSTALL_SYNCFS to INSTALL_FSYNC_FULL, after all this is + * going to be a single inode we install */ + r = fsync_full(regfd); + if (r < 0) + return r; + } else if (flags & INSTALL_FSYNC) { + if (fsync(regfd) < 0) + return -errno; + } + + if (flags & INSTALL_READ_ONLY) + rofd = TAKE_FD(regfd); + + break; + } + + case S_IFDIR: { + _cleanup_close_ int dfd = -1; + + dfd = fd_reopen(pfd, O_RDONLY|O_DIRECTORY|O_CLOEXEC); + if (dfd < 0) + return dfd; + + if (flags & INSTALL_SYNCFS) { + if (syncfs(dfd) < 0) + return -errno; + } else if (flags & INSTALL_FSYNC_FULL) { + r = fsync_full(dfd); + if (r < 0) + return r; + } else if (flags & INSTALL_FSYNC) { + if (fsync(dfd) < 0) + return -errno; + } + + if (flags & INSTALL_READ_ONLY) + rofd = TAKE_FD(dfd); + + break; + } + + default: + /* Other inodes: char/block device inodes, fifos, symlinks, sockets don't need + * syncing themselves, as they only exist in the directory, and have no contents on + * disk */ + + if (target_name && (flags & (INSTALL_FSYNC_FULL|INSTALL_SYNCFS)) != 0) { + r = fsync_directory_of_file(pfd); + if (r < 0) + return r; + } + + break; + } + } + + if (target_name) { + /* Rename the file */ + + if (flags & INSTALL_REPLACE) { + /* First, try a simple renamat(), maybe that's enough */ + if (renameat(source_atfd, source_name, target_atfd, target_name) < 0) { + _cleanup_close_ int dfd = -1; + + if (!IN_SET(errno, EEXIST, ENOTDIR, ENOTEMPTY, EISDIR, EBUSY)) + return -errno; + + /* Hmm, the target apparently existed already. Let's try to use + * RENAME_EXCHANGE. But let's first open the inode if it's a directory, so + * that we can later remove its contents if it's a directory. Why do this + * before the rename()? Mostly because if we have trouble opening the thing + * we want to know before we start actually modifying the file system. */ + + dfd = openat(target_atfd, target_name, O_RDONLY|O_DIRECTORY|O_CLOEXEC, 0); + if (dfd < 0 && errno != ENOTDIR) + return -errno; + + if (renameat2(source_atfd, source_name, target_atfd, target_name, RENAME_EXCHANGE) < 0) { + + if (!ERRNO_IS_NOT_SUPPORTED(errno) && errno != EINVAL) + return -errno; + + /* The exchange didn't work, let's remove the target first, and try again */ + + if (dfd >= 0) + (void) rm_rf_children(TAKE_FD(dfd), REMOVE_PHYSICAL|REMOVE_SUBVOLUME|REMOVE_CHMOD, NULL); + + r = unlinkat_maybe_dir(target_atfd, target_name); + if (r < 0) + return log_debug_errno(r, "Failed to remove target directory: %m"); + + if (renameat(source_atfd, source_name, target_atfd, target_name) < 0) + return -errno; + } else { + /* The exchange worked, hence let's remove the source (i.e. the old target) */ + if (dfd >= 0) + (void) rm_rf_children(TAKE_FD(dfd), REMOVE_PHYSICAL|REMOVE_SUBVOLUME|REMOVE_CHMOD, NULL); + + r = unlinkat_maybe_dir(source_atfd, source_name); + if (r < 0) + return log_debug_errno(r, "Failed to remove replaced target directory: %m"); + } + } + } else { + r = rename_noreplace(source_atfd, source_name, target_atfd, target_name); + if (r < 0) + return r; + } + } + + if (rofd >= 0) { + r = fs_make_very_read_only(rofd); + if (r < 0) + return r; + } + + if ((flags & (INSTALL_FSYNC_FULL|INSTALL_SYNCFS)) != 0) { + if (target_name) + r = fsync_parent_at(target_atfd, target_name); + else + r = fsync_parent_at(source_atfd, source_name); + if (r < 0) + return r; + } + + return 0; +} diff --git a/src/shared/install-file.h b/src/shared/install-file.h new file mode 100644 index 0000000000..c37254fa72 --- /dev/null +++ b/src/shared/install-file.h @@ -0,0 +1,14 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ +#pragma once + +int fs_make_very_read_only(int fd); + +typedef enum InstallFileFlags { + INSTALL_REPLACE = 1 << 0, /* Replace an existing inode */ + INSTALL_READ_ONLY = 1 << 1, /* Call fs_make_very_read_only() to make the inode comprehensively read-only */ + INSTALL_FSYNC = 1 << 2, /* fsync() file contents before moving file in */ + INSTALL_FSYNC_FULL = 1 << 3, /* like INSTALL_FSYNC, but also fsync() parent dir before+after moving file in */ + INSTALL_SYNCFS = 1 << 4, /* syncfs() before moving file in, fsync() parent dir after moving file in */ +} InstallFileFlags; + +int install_file(int source_atfd, const char *source_name, int target_atfd, const char *target_name, InstallFileFlags flags); diff --git a/src/shared/meson.build b/src/shared/meson.build index 1f741f7417..7eb71b287c 100644 --- a/src/shared/meson.build +++ b/src/shared/meson.build @@ -152,6 +152,8 @@ shared_sources = files(''' import-util.c import-util.h initreq.h + install-file.c + install-file.h install-printf.c install-printf.h install.c diff --git a/src/test/meson.build b/src/test/meson.build index e9204b150d..ad226418e2 100644 --- a/src/test/meson.build +++ b/src/test/meson.build @@ -215,6 +215,8 @@ tests += [ [['src/test/test-fs-util.c']], + [['src/test/test-install-file.c']], + [['src/test/test-umask-util.c']], [['src/test/test-proc-cmdline.c']], diff --git a/src/test/test-install-file.c b/src/test/test-install-file.c new file mode 100644 index 0000000000..55d77ac236 --- /dev/null +++ b/src/test/test-install-file.c @@ -0,0 +1,72 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ + +#include "fileio.h" +#include "install-file.h" +#include "path-util.h" +#include "rm-rf.h" +#include "tests.h" +#include "tmpfile-util.h" +#include "umask-util.h" + +static void test_install_file(void) { + _cleanup_(rm_rf_physical_and_freep) char *p = NULL; + _cleanup_free_ char *a = NULL, *b = NULL, *c = NULL; + struct stat stat1, stat2; + + log_info("/* %s */", __func__); + + assert_se(mkdtemp_malloc(NULL, &p) >= 0); + assert_se(a = path_join(p, "foo")); + assert_se(b = path_join(p, "bar")); + + RUN_WITH_UMASK(0077) + assert_se(write_string_file(a, "wups", WRITE_STRING_FILE_CREATE) >= 0); + + assert_se(lstat(a, &stat1) >= 0); + assert_se(S_ISREG(stat1.st_mode)); + + assert_se(install_file(AT_FDCWD, a, AT_FDCWD, b, 0) >= 0); + assert_se(install_file(AT_FDCWD, b, AT_FDCWD, a, INSTALL_FSYNC) >= 0); + + assert_se(write_string_file(b, "ttss", WRITE_STRING_FILE_CREATE) >= 0); + assert_se(install_file(AT_FDCWD, a, AT_FDCWD, b, INSTALL_FSYNC_FULL) == -EEXIST); + assert_se(install_file(AT_FDCWD, a, AT_FDCWD, b, INSTALL_FSYNC_FULL|INSTALL_REPLACE) >= 0); + + assert_se(stat(b, &stat2) >= 0); + assert_se(stat1.st_dev == stat2.st_dev); + assert_se(stat1.st_ino == stat2.st_ino); + assert_se((stat2.st_mode & 0222) != 0); /* writable */ + + assert_se(install_file(AT_FDCWD, b, AT_FDCWD, a, INSTALL_FSYNC_FULL|INSTALL_REPLACE|INSTALL_READ_ONLY) >= 0); + + assert_se(stat(a, &stat2) >= 0); + assert_se(stat1.st_dev == stat2.st_dev); + assert_se(stat1.st_ino == stat2.st_ino); + assert_se((stat2.st_mode & 0222) == 0); /* read-only */ + + assert_se(mkdir(b, 0755) >= 0); + assert_se(c = path_join(b, "dir")); + assert_se(mkdir(c, 0755) >= 0); + free(c); + assert_se(c = path_join(b, "reg")); + assert_se(mknod(c, S_IFREG|0755, 0) >= 0); + free(c); + assert_se(c = path_join(b, "fifo")); + assert_se(mknod(c, S_IFIFO|0755, 0) >= 0); + + assert_se(install_file(AT_FDCWD, b, AT_FDCWD, a, INSTALL_FSYNC_FULL) == -EEXIST); + assert_se(install_file(AT_FDCWD, b, AT_FDCWD, a, INSTALL_FSYNC_FULL|INSTALL_REPLACE) == 0); + + assert_se(write_string_file(b, "ttss", WRITE_STRING_FILE_CREATE) >= 0); + + assert_se(install_file(AT_FDCWD, b, AT_FDCWD, a, INSTALL_FSYNC_FULL) == -EEXIST); + assert_se(install_file(AT_FDCWD, b, AT_FDCWD, a, INSTALL_FSYNC_FULL|INSTALL_REPLACE) == 0); +} + +int main(int argc, char *argv[]) { + test_setup_logging(LOG_INFO); + + test_install_file(); + + return 0; +}