From 513d3ce6eed7617ed51c1bdebcad415e2ab960ee Mon Sep 17 00:00:00 2001 From: Kir Kolyshkin Date: Fri, 31 Jul 2026 19:17:19 -0700 Subject: [PATCH 1/5] chroot_realpath: keep separator when expanding a relative symlink When the last resolved component is a relative symlink, the code backs up over it with while (*(--new_path) != '/'); leaving new_path pointing at the separator itself, so the expanded symlink target overwrites it. "/dir/link" with "link" pointing to "file" resolves to "/dirfile" instead of "/dir/file", and "/link" resolves to "link". Top level symlinks happen to survive since safe_openat_fallback() strips the rootfs prefix and then consumes leading slashes, but any relative symlink below the first level resolves to a path that does not exist, and the open fails with ENOENT. Keep the separator after backing up over the component. This is only reachable through the safe_openat() fallback, i.e. on kernels without openat2(2) or when a seccomp filter blocks it. Add unit tests for symlink expansion, which was not covered at all. Signed-off-by: Kir Kolyshkin Co-Authored-By: Claude Opus 5 --- src/libcrun/chroot_realpath.c | 8 +- tests/tests_libcrun_chroot_realpath.c | 132 +++++++++++++++++++++++++- 2 files changed, 137 insertions(+), 3 deletions(-) diff --git a/src/libcrun/chroot_realpath.c b/src/libcrun/chroot_realpath.c index deefb576f8..5a94bff3c5 100644 --- a/src/libcrun/chroot_realpath.c +++ b/src/libcrun/chroot_realpath.c @@ -157,9 +157,13 @@ char *chroot_realpath(const char *chroot, const char *path, char resolved_path[] new_path = got_path_root; *new_path++ = '/'; } - else - /* Otherwise back up over this component. */ + else { + /* Otherwise back up over this component, keeping the + separator, so that the expanded symlink is not + concatenated to the parent directory name. */ while (*(--new_path) != '/'); + new_path++; + } /* Safe sex check. */ if (strlen(path) + n >= PATH_MAX - 2) { __set_errno(ENAMETOOLONG); diff --git a/tests/tests_libcrun_chroot_realpath.c b/tests/tests_libcrun_chroot_realpath.c index e06dd2d2ed..ff8f899a97 100644 --- a/tests/tests_libcrun_chroot_realpath.c +++ b/tests/tests_libcrun_chroot_realpath.c @@ -20,6 +20,10 @@ #include #include #include +#include +#include +#include +#include typedef int (*test) (); @@ -213,6 +217,131 @@ test_deep_path () return 0; } +/* Create a temporary tree used by the symlink tests: + + $root/file a regular file + $root/link -> file (relative, top level) + $root/dir/file a regular file + $root/dir/link -> file (relative, nested) + $root/dir/up -> ../file (relative, with "..") + $root/dir/abs -> /file (absolute, i.e. $root/file) + + Return 0 on success and 77 to skip the test when the tree cannot be + created. */ +static int +make_symlink_tree (char *root, size_t root_size) +{ + char path[PATH_MAX]; + char *tmpdir; + int fd; + + if (snprintf (root, root_size, "%s/crun-chroot-realpath-XXXXXX", + getenv ("TMPDIR") ? getenv ("TMPDIR") : "/tmp") + >= (int) root_size) + return 77; + + tmpdir = mkdtemp (root); + if (tmpdir == NULL) + return 77; + + snprintf (path, sizeof (path), "%s/file", root); + fd = creat (path, 0600); + if (fd < 0) + return 77; + close (fd); + + snprintf (path, sizeof (path), "%s/link", root); + if (symlink ("file", path) < 0) + return 77; + + snprintf (path, sizeof (path), "%s/dir", root); + if (mkdir (path, 0700) < 0) + return 77; + + snprintf (path, sizeof (path), "%s/dir/file", root); + fd = creat (path, 0600); + if (fd < 0) + return 77; + close (fd); + + snprintf (path, sizeof (path), "%s/dir/link", root); + if (symlink ("file", path) < 0) + return 77; + + snprintf (path, sizeof (path), "%s/dir/up", root); + if (symlink ("../file", path) < 0) + return 77; + + snprintf (path, sizeof (path), "%s/dir/abs", root); + if (symlink ("/file", path) < 0) + return 77; + + return 0; +} + +static void +cleanup_symlink_tree (const char *root) +{ + char path[PATH_MAX]; + const char *files[] = { "dir/abs", "dir/up", "dir/link", "dir/file", "dir", "link", "file", NULL }; + size_t i; + + for (i = 0; files[i]; i++) + { + snprintf (path, sizeof (path), "%s/%s", root, files[i]); + if (remove (path) < 0) + continue; + } + rmdir (root); +} + +/* Expanding a symlink must not drop the separator between the parent + directory and the symlink target. */ +static int +test_symlinks () +{ + struct + { + const char *path; + const char *expected; + } cases[] = { + { "/link", "/file" }, + { "/dir/link", "/dir/file" }, + { "/dir/up", "/file" }, + { "/dir/abs", "/file" }, + { NULL, NULL }, + }; + char root[PATH_MAX]; + char resolved[PATH_MAX]; + char expected[PATH_MAX]; + int ret = 0; + size_t i; + + ret = make_symlink_tree (root, sizeof (root)); + if (ret != 0) + return ret; + + for (i = 0; cases[i].path; i++) + { + char *result = chroot_realpath (root, cases[i].path, resolved); + if (result == NULL) + { + ret = -1; + break; + } + + snprintf (expected, sizeof (expected), "%s%s", root, cases[i].expected); + if (strcmp (resolved, expected) != 0) + { + ret = -1; + break; + } + } + + cleanup_symlink_tree (root); + return ret; +} + static void run_and_print_test_result (const char *name, int id, test t) { @@ -235,7 +364,7 @@ int main () { int id = 1; - printf ("1..10\n"); + printf ("1..11\n"); RUN_TEST (test_null_chroot); RUN_TEST (test_empty_chroot); RUN_TEST (test_root_chroot); @@ -246,5 +375,6 @@ main () RUN_TEST (test_simple_path); RUN_TEST (test_trailing_slash); RUN_TEST (test_deep_path); + RUN_TEST (test_symlinks); return 0; } From 3d6aba8a38bbe719276009890298cbf895f5fed6 Mon Sep 17 00:00:00 2001 From: Kir Kolyshkin Date: Fri, 31 Jul 2026 19:17:30 -0700 Subject: [PATCH 2/5] utils: honor O_NOFOLLOW in the safe_openat fallback chroot_realpath() resolves the last component as well, so the fallback used when openat2(2) is not available silently follows a symlink even when the caller passed O_NOFOLLOW. A "dest-nofollow" bind mount whose destination is a symlink is then created on the symlink target instead of on the symlink itself, and a dangling destination symlink makes the container fail to start. When O_NOFOLLOW is set, resolve only the parent directory and let openat(2) deal with the last component. A trailing '/' still forces the symlink to be resolved, as the kernel does. While at it, drop the rootfs prefix only when it is actually present: chroot_realpath() returns the path unchanged when rootfs is "/", and the unconditional "path_in_chroot += rootfs_len" removed the first character of the path. With the parent directory now possibly being the empty string, that also read past the end of the buffer. Document that dirfd must be a file descriptor for rootfs itself. Signed-off-by: Kir Kolyshkin Co-Authored-By: Claude Opus 5 --- src/libcrun/utils.c | 55 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 51 insertions(+), 4 deletions(-) diff --git a/src/libcrun/utils.c b/src/libcrun/utils.c index 07dd1f6f62..1a03b013e9 100644 --- a/src/libcrun/utils.c +++ b/src/libcrun/utils.c @@ -332,23 +332,70 @@ close_and_replace (int *oldfd, int newfd) /* Defined in chroot_realpath.c */ char *chroot_realpath (const char *chroot, const char *path, char resolved_path[]); +/* DIRFD must be a file descriptor for ROOTFS itself: PATH is resolved + against ROOTFS and the result is then opened relatively to DIRFD. */ static int safe_openat_fallback (int dirfd, const char *rootfs, const char *path, int flags, int mode, libcrun_error_t *err) { + cleanup_free char *parent_path = NULL; + const char *last_component = NULL; + const char *orig_path = path; const char *path_in_chroot; cleanup_close int fd = -1; + char resolved[PATH_MAX]; char buffer[PATH_MAX]; size_t rootfs_len = strlen (rootfs); int ret; + /* chroot_realpath resolves the last component as well, so when O_NOFOLLOW + is requested resolve only the parent directory and let openat(2) deal + with the last component, otherwise a symlink would be followed even + though the caller asked not to. */ + if (flags & O_NOFOLLOW) + { + char *sep; + + parent_path = xstrdup (path); + sep = strrchr (parent_path, '/'); + if (sep == NULL) + { + /* No parent directory, the entire path is the last component. */ + last_component = path; + parent_path[0] = '\0'; + } + else if (sep[1] != '\0') + { + *sep = '\0'; + last_component = path + (sep - parent_path) + 1; + } + /* A trailing '/' forces the symlink to be resolved anyway, so in that + case keep resolving the entire path. */ + + if (last_component) + path = parent_path; + } + path_in_chroot = chroot_realpath (rootfs, path, buffer); if (path_in_chroot == NULL) - return crun_make_error (err, errno, "cannot resolve `%s` under rootfs", path); + return crun_make_error (err, errno, "cannot resolve `%s` under rootfs", orig_path); - path_in_chroot += rootfs_len; + /* When rootfs is "/", chroot_realpath returns the path unchanged, so drop + the prefix only when it is really there. */ + if (strncmp (path_in_chroot, rootfs, rootfs_len) == 0) + path_in_chroot += rootfs_len; path_in_chroot = consume_slashes (path_in_chroot); + if (last_component) + { + ret = snprintf (resolved, sizeof (resolved), "%s%s%s", path_in_chroot, + path_in_chroot[0] == '\0' ? "" : "/", last_component); + if (UNLIKELY (ret >= (int) sizeof (resolved))) + return crun_make_error (err, ENAMETOOLONG, "resolve `%s` under rootfs", orig_path); + + path_in_chroot = resolved; + } + /* If the path is empty we are at the root, dup the dirfd itself. */ if (path_in_chroot[0] == '\0') { @@ -360,11 +407,11 @@ safe_openat_fallback (int dirfd, const char *rootfs, const char *path, int flags ret = openat (dirfd, path_in_chroot, flags, mode); if (UNLIKELY (ret < 0)) - return crun_make_error (err, errno, "open `%s`", path); + return crun_make_error (err, errno, "open `%s`", orig_path); fd = ret; - ret = check_fd_under_path (rootfs, rootfs_len, fd, path, err); + ret = check_fd_under_path (rootfs, rootfs_len, fd, orig_path, err); if (UNLIKELY (ret < 0)) return ret; From a4194a3c2eed87c13331ee6f0fd5ebf4b14ccbe3 Mon Sep 17 00:00:00 2001 From: Kir Kolyshkin Date: Fri, 31 Jul 2026 19:17:40 -0700 Subject: [PATCH 3/5] tests: run the test suite with openat2 blocked The safe_openat() fallback is only used on kernels older than 5.6 or where a seccomp filter blocks openat2(2), so it is never exercised by CI, and it silently diverged from the openat2 path. Add tests/no_openat2, a small helper installing a seccomp filter that makes openat2 fail with ENOSYS and then executing its arguments. The filter is inherited across exec and by every child, so pointing TESTS_ENVIRONMENT at it runs the whole suite through the fallback. Add a "make check-no-openat2" target using it, and run it in CI. Signed-off-by: Kir Kolyshkin Co-Authored-By: Claude Opus 5 --- .github/workflows/test.yaml | 15 ++++++ Makefile.am | 16 +++++- tests/no_openat2.c | 104 ++++++++++++++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 tests/no_openat2.c diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 9e2a28cc1e..7f7beb830b 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -68,6 +68,7 @@ jobs: include: - test: disable-systemd - test: check + - test: check-no-openat2 - test: enable-shared - test: embedded-blake3 - test: system-blake3 @@ -129,6 +130,20 @@ jobs: echo run tests as rootless in a user namespace unshare -r make check ASAN_OPTIONS=detect_leaks=false || (cat test-suite.log; exit 1) ;; + check-no-openat2) + # Run the test suite with openat2(2) forced to fail with + # ENOSYS, so that the safe_openat() fallback used on kernels + # older than 5.6 is exercised. GitHub runners always have a + # much newer kernel, so this path is otherwise never tested. + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + ./configure --enable-embedded-blake3 --disable-dl + make + # Only the root run: without CAP_SYS_ADMIN the helper has to + # set NO_NEW_PRIVS to install its seccomp filter, and that is + # inherited by the test containers. + echo run tests as root, openat2 blocked + sudo make check-no-openat2 ASAN_OPTIONS=detect_leaks=false || (cat test-suite.log; exit 1) + ;; podman) sudo mkdir -p /var/lib/containers /var/tmp sudo docker build -t crun-podman tests/podman diff --git a/Makefile.am b/Makefile.am index bc2e40d9b2..8e2cef8f80 100644 --- a/Makefile.am +++ b/Makefile.am @@ -204,7 +204,7 @@ noinst_PROGRAMS = crun endif if BUILD_TESTS -check_PROGRAMS = tests/init $(UNIT_TESTS) tests/tests_libcrun_fuzzer +check_PROGRAMS = tests/init $(UNIT_TESTS) tests/tests_libcrun_fuzzer tests/no_openat2 TESTS_LDADD = libcrun_testing.la $(FOUND_LIBS) $(JSON_C_LIBS) @@ -213,6 +213,10 @@ tests_init_LDFLAGS = -static-libgcc -all-static tests_init_CFLAGS = -g -O2 tests_init_SOURCES = tests/init.c +tests_no_openat2_LDADD = +tests_no_openat2_CFLAGS = -g -O2 +tests_no_openat2_SOURCES = tests/no_openat2.c + tests_tests_libcrun_utils_CFLAGS = -I $(abs_top_builddir)/libocispec/src -I $(abs_top_srcdir)/libocispec/src $(JSON_C_CFLAGS) -I $(abs_top_builddir)/src -I $(abs_top_srcdir)/src tests_tests_libcrun_utils_SOURCES = tests/tests_libcrun_utils.c tests_tests_libcrun_utils_LDADD = $(TESTS_LDADD) @@ -327,6 +331,14 @@ PYTHON_TESTS = tests/test_capabilities.py \ if BUILD_TESTS TESTS = $(PYTHON_TESTS) $(UNIT_TESTS) + +# Run the whole test suite with openat2(2) forced to fail with ENOSYS. This +# exercises the safe_openat() fallback used on kernels older than 5.6 and +# wherever a seccomp filter blocks openat2, which is otherwise never reached +# on a modern kernel. Run it as root: without CAP_SYS_ADMIN the helper must +# set NO_NEW_PRIVS, which the test containers inherit. +check-no-openat2: tests/no_openat2$(EXEEXT) + $(MAKE) $(AM_MAKEFLAGS) check TESTS_ENVIRONMENT='$(abs_top_builddir)/tests/no_openat2$(EXEEXT)' endif .version: @@ -526,4 +538,4 @@ clean-local: coverage-clean # Coverage targets must not run in parallel due to race conditions in .gcda file writes .NOTPARALLEL: coverage-reset coverage-check coverage-html coverage-xml coverage-summary coverage-multi-env -.PHONY: coverity sync generate-rust-bindings generate-signals.c generate-mount_flags.c clang-format shellcheck coverage-clean coverage-reset coverage-check coverage-html coverage-xml coverage-summary coverage-multi-env +.PHONY: check-no-openat2 coverity sync generate-rust-bindings generate-signals.c generate-mount_flags.c clang-format shellcheck coverage-clean coverage-reset coverage-check coverage-html coverage-xml coverage-summary coverage-multi-env diff --git a/tests/no_openat2.c b/tests/no_openat2.c new file mode 100644 index 0000000000..32543c84c2 --- /dev/null +++ b/tests/no_openat2.c @@ -0,0 +1,104 @@ +/* + * crun - OCI runtime written in C + * + * Copyright (C) 2026 crun Authors + * crun is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2.1 of the License, or + * (at your option) any later version. + * + * crun is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with crun. If not, see . + */ + +/* Install a seccomp filter that makes openat2(2) fail with ENOSYS, then exec + the given command. The filter is inherited across exec() and by every + child process, so running the test suite under this helper: + + ./tests/no_openat2 make check + + exercises the safe_openat() fallback path used on kernels older than 5.6, + as well as on any kernel where a seccomp filter blocks openat2. + + Note that without CAP_SYS_ADMIN the filter can only be installed after + NO_NEW_PRIVS is set, and NO_NEW_PRIVS is inherited by the containers + created by the test suite; tests looking at noNewPrivileges fail in that + case. For this reason the test suite is run under this helper only as + root. */ + +#define _GNU_SOURCE + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef __NR_openat2 +# define __NR_openat2 437 +#endif + +#ifndef SECCOMP_SET_MODE_FILTER +# define SECCOMP_SET_MODE_FILTER 1 +#endif + +static int +install_filter (void) +{ + struct sock_filter filter[] = { + /* Load the syscall number. */ + BPF_STMT (BPF_LD | BPF_W | BPF_ABS, offsetof (struct seccomp_data, nr)), + /* If it is not openat2, allow it. */ + BPF_JUMP (BPF_JMP | BPF_JEQ | BPF_K, __NR_openat2, 0, 1), + BPF_STMT (BPF_RET | BPF_K, SECCOMP_RET_ERRNO | (ENOSYS & SECCOMP_RET_DATA)), + BPF_STMT (BPF_RET | BPF_K, SECCOMP_RET_ALLOW), + }; + struct sock_fprog prog = { + .len = (unsigned short) (sizeof (filter) / sizeof (filter[0])), + .filter = filter, + }; + + if (syscall (__NR_seccomp, SECCOMP_SET_MODE_FILTER, 0, &prog) == 0) + return 0; + + /* Without CAP_SYS_ADMIN the filter can only be installed after + NO_NEW_PRIVS is set. */ + if (errno != EACCES) + return -1; + + if (prctl (PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) < 0) + return -1; + + return syscall (__NR_seccomp, SECCOMP_SET_MODE_FILTER, 0, &prog); +} + +int +main (int argc, char **argv) +{ + if (argc < 2) + { + fprintf (stderr, "usage: %s COMMAND [ARGS...]\n", argv[0]); + return 2; + } + + if (install_filter () < 0) + { + fprintf (stderr, "%s: cannot install seccomp filter: %s\n", argv[0], strerror (errno)); + return 2; + } + + execvp (argv[1], argv + 1); + + fprintf (stderr, "%s: exec `%s`: %s\n", argv[0], argv[1], strerror (errno)); + return 127; +} From 8616e498573a724426a34d26cbe089a04e5f8ed6 Mon Sep 17 00:00:00 2001 From: Kir Kolyshkin Date: Fri, 31 Jul 2026 19:25:09 -0700 Subject: [PATCH 4/5] utils: allow "/" as rootfs in safe_openat check_fd_under_path() requires a '/' right after the rootfs prefix, so when the rootfs is "/" it rejects every path: for "/proc" the character after the prefix is 'p', and the open fails with "target `/proc` not under the directory `/`". This is reached through the safe_openat() fallback, i.e. on kernels without openat2(2), for containers using the host root, as done for the mounts set up by libcrun_container_enter_cgroup_ns() and by the krun handler. Return early when the rootfs is "/" or not set: every path is under it, so there is nothing to verify. Signed-off-by: Kir Kolyshkin Co-Authored-By: Claude Opus 5 --- src/libcrun/utils.c | 5 +++++ tests/tests_libcrun_utils.c | 31 +++++++++++++++++++++++++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/libcrun/utils.c b/src/libcrun/utils.c index 1a03b013e9..b519af79f3 100644 --- a/src/libcrun/utils.c +++ b/src/libcrun/utils.c @@ -308,6 +308,11 @@ check_fd_under_path (const char *rootfs, size_t rootfslen, int fd, const char *f char link[PATH_MAX]; int ret; + /* Every path is under "/", there is nothing to verify. The check below + would reject any path since it expects a '/' right after the rootfs. */ + if (rootfslen == 0 || (rootfslen == 1 && rootfs[0] == '/')) + return 0; + get_proc_self_fd_path (fdpath, fd); ret = TEMP_FAILURE_RETRY (readlink (fdpath, link, sizeof (link))); if (UNLIKELY (ret < 0)) diff --git a/tests/tests_libcrun_utils.c b/tests/tests_libcrun_utils.c index d0b692b0a3..b9cacb8b5e 100644 --- a/tests/tests_libcrun_utils.c +++ b/tests/tests_libcrun_utils.c @@ -16,6 +16,8 @@ * along with crun. If not, see . */ +#define _GNU_SOURCE + #include #include #include @@ -762,6 +764,30 @@ test_format_default_id_mapping () return 0; } +/* safe_openat must work when the rootfs is "/", both through openat2 and + through the fallback used when openat2 is not available. */ +static int +test_safe_openat_root () +{ + libcrun_error_t err = NULL; + int rootfd, fd; + + rootfd = open ("/", O_PATH | O_CLOEXEC); + if (rootfd < 0) + return -1; + + fd = safe_openat (rootfd, "/", "proc/self/status", O_RDONLY | O_CLOEXEC, 0, &err); + close (rootfd); + if (fd < 0) + { + crun_error_release (&err); + return -1; + } + close (fd); + + return 0; +} + static void run_and_print_test_result (const char *name, int id, test t) { @@ -785,9 +811,9 @@ main () { int id = 1; #ifdef HAVE_SYSTEMD - printf ("1..18\n"); + printf ("1..19\n"); #else - printf ("1..15\n"); + printf ("1..16\n"); #endif RUN_TEST (test_crun_path_exists); RUN_TEST (test_write_read_file); @@ -804,6 +830,7 @@ main () RUN_TEST (test_crun_ensure_directory); RUN_TEST (test_channel_fd_pair_no_busy_loop_on_blocked_output); RUN_TEST (test_format_default_id_mapping); + RUN_TEST (test_safe_openat_root); #ifdef HAVE_SYSTEMD RUN_TEST (test_parse_sd_array); RUN_TEST (test_get_scope_path); From 3b78daae0139e1de18c12349498518fc257261fc Mon Sep 17 00:00:00 2001 From: Kir Kolyshkin Date: Fri, 31 Jul 2026 19:25:58 -0700 Subject: [PATCH 5/5] utils: do not dereference a NULL rootfs in safe_openat The krun handler passes a NULL rootfs together with AT_FDCWD for containers without a rootfs of their own, and libcrun_create_dev() and libkrun_read_vm_config() then call safe_openat() with it. The fallback used when openat2(2) is not available calls strlen() on it and crashes, and the empty path case passes it to open() directly. Treat a NULL or empty rootfs as "/", which is what chroot_realpath() already does. Signed-off-by: Kir Kolyshkin Co-Authored-By: Claude Opus 5 --- src/libcrun/utils.c | 14 +++++++++----- tests/tests_libcrun_utils.c | 28 ++++++++++++++++++++++++++-- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/src/libcrun/utils.c b/src/libcrun/utils.c index b519af79f3..fabb85ce6d 100644 --- a/src/libcrun/utils.c +++ b/src/libcrun/utils.c @@ -350,7 +350,7 @@ safe_openat_fallback (int dirfd, const char *rootfs, const char *path, int flags cleanup_close int fd = -1; char resolved[PATH_MAX]; char buffer[PATH_MAX]; - size_t rootfs_len = strlen (rootfs); + size_t rootfs_len = is_empty_string (rootfs) ? 0 : strlen (rootfs); int ret; /* chroot_realpath resolves the last component as well, so when O_NOFOLLOW @@ -385,9 +385,9 @@ safe_openat_fallback (int dirfd, const char *rootfs, const char *path, int flags if (path_in_chroot == NULL) return crun_make_error (err, errno, "cannot resolve `%s` under rootfs", orig_path); - /* When rootfs is "/", chroot_realpath returns the path unchanged, so drop - the prefix only when it is really there. */ - if (strncmp (path_in_chroot, rootfs, rootfs_len) == 0) + /* When rootfs is "/" or not set, chroot_realpath returns the path + unchanged, so drop the prefix only when it is really there. */ + if (rootfs_len > 0 && strncmp (path_in_chroot, rootfs, rootfs_len) == 0) path_in_chroot += rootfs_len; path_in_chroot = consume_slashes (path_in_chroot); @@ -406,7 +406,7 @@ safe_openat_fallback (int dirfd, const char *rootfs, const char *path, int flags { ret = dup (dirfd); if (UNLIKELY (ret < 0)) - return crun_make_error (err, errno, "dup `%s`", rootfs); + return crun_make_error (err, errno, "dup `%s`", rootfs_len ? rootfs : "/"); return ret; } @@ -436,6 +436,10 @@ safe_openat (int dirfd, const char *rootfs, const char *path, int flags, int mod { cleanup_close int fd = -1; + /* A container without a rootfs of its own uses the host root. */ + if (is_empty_string (rootfs)) + rootfs = "/"; + fd = open (rootfs, flags, mode); if (UNLIKELY (fd < 0)) return crun_make_error (err, errno, "open `%s`", rootfs); diff --git a/tests/tests_libcrun_utils.c b/tests/tests_libcrun_utils.c index b9cacb8b5e..fdc71255e2 100644 --- a/tests/tests_libcrun_utils.c +++ b/tests/tests_libcrun_utils.c @@ -788,6 +788,29 @@ test_safe_openat_root () return 0; } +/* A NULL rootfs is used for containers without a rootfs of their own. */ +static int +test_safe_openat_null_rootfs () +{ + libcrun_error_t err = NULL; + int rootfd, fd; + + rootfd = open ("/", O_PATH | O_CLOEXEC); + if (rootfd < 0) + return -1; + + fd = safe_openat (rootfd, NULL, "proc/self/status", O_RDONLY | O_CLOEXEC, 0, &err); + close (rootfd); + if (fd < 0) + { + crun_error_release (&err); + return -1; + } + close (fd); + + return 0; +} + static void run_and_print_test_result (const char *name, int id, test t) { @@ -811,9 +834,9 @@ main () { int id = 1; #ifdef HAVE_SYSTEMD - printf ("1..19\n"); + printf ("1..20\n"); #else - printf ("1..16\n"); + printf ("1..17\n"); #endif RUN_TEST (test_crun_path_exists); RUN_TEST (test_write_read_file); @@ -831,6 +854,7 @@ main () RUN_TEST (test_channel_fd_pair_no_busy_loop_on_blocked_output); RUN_TEST (test_format_default_id_mapping); RUN_TEST (test_safe_openat_root); + RUN_TEST (test_safe_openat_null_rootfs); #ifdef HAVE_SYSTEMD RUN_TEST (test_parse_sd_array); RUN_TEST (test_get_scope_path);