diff --git a/mk/tests.mk b/mk/tests.mk index 75b6cb17..8b4b72f0 100644 --- a/mk/tests.mk +++ b/mk/tests.mk @@ -25,7 +25,8 @@ ELFUSE_HOST_NOFILE_MIN ?= $(shell bash "$(CURDIR)/tests/test-config.sh" --host-n test-config \ test-mremap-tail-emfile \ test-proctitle-host test-proctitle-low-stack \ - test-sysroot-procfs-exec test-timeout-disable test-fuse-alpine \ + test-sysroot-procfs-exec test-sysroot-fd-magiclink \ + test-timeout-disable test-fuse-alpine \ test-sysroot-nofollow test-sysroot-chdir test-sysroot-symlink-escape \ test-sysroot-dotdot test-sysroot-openat2-walk \ test-sysroot-inotify-names test-sysroot-exec-names \ @@ -212,6 +213,7 @@ check: $(ELFUSE_BIN) $(TEST_DEPS) check-syscall-coverage test-config \ $(call run-lane,test-proctitle-low-stack,proctitle low-stack regression) $(call run-lane,test-busybox,busybox applet validation) $(call run-lane,test-sysroot-procfs-exec,sysroot procfs exec validation) + $(call run-lane,test-sysroot-fd-magiclink,fd magic link resolution) $(call run-lane,test-getdents64-overlong,getdents64 overlong-UTF-8 dirent skip) $(call run-lane,test-sysroot-host-fallback,sysroot host-fallback validation) $(call run-lane,test-sysroot-tmp-remove,sysroot /tmp remove/rename consistency) @@ -1010,6 +1012,15 @@ test-sysroot-procfs-exec: $(ELFUSE_BIN) $(BUILD_DIR)/test-procfs-exec cp $(BUILD_DIR)/test-procfs-exec "$$tmpdir/bin/test-procfs-exec"; \ $(ELFUSE_BIN) --sysroot "$$tmpdir" "$$tmpdir/bin/test-procfs-exec" +## Magic link (/proc/self/fd/, /dev/fd/) resolution. Runs in a throwaway +## sysroot so the fixtures it creates at the guest root land in the tmpdir. +test-sysroot-fd-magiclink: $(ELFUSE_BIN) $(BUILD_DIR)/test-fd-magiclink + @tmpdir=$$(mktemp -d); \ + trap 'rm -rf "$$tmpdir"' EXIT; \ + mkdir -p "$$tmpdir/bin"; \ + cp $(BUILD_DIR)/test-fd-magiclink "$$tmpdir/bin/test-fd-magiclink"; \ + $(ELFUSE_BIN) --sysroot "$$tmpdir" "$$tmpdir/bin/test-fd-magiclink" + test-timeout-disable: $(ELFUSE_BIN) $(TEST_HELLO_DEP) @$(ELFUSE_BIN) --timeout 0 $(TEST_DIR)/test-hello > /dev/null diff --git a/src/runtime/procemu.c b/src/runtime/procemu.c index 6023786d..c395da6e 100644 --- a/src/runtime/procemu.c +++ b/src/runtime/procemu.c @@ -814,14 +814,12 @@ static int proc_parse_fd_index(const char *path, size_t prefix_len, int errno_on_invalid) { - char *endp; - long n = strtol(path + prefix_len, &endp, 10); - if (endp == path + prefix_len || *endp != '\0' || n < 0 || - n >= FD_TABLE_SIZE) { + int n = path_parse_proc_name(path + prefix_len); + if (n < 0 || n >= FD_TABLE_SIZE) { errno = errno_on_invalid; return -1; } - return (int) n; + return n; } /* Map a guest /dev/shm/ path to its host backing path, and gate the name. diff --git a/src/syscall/fs.c b/src/syscall/fs.c index 45975396..0c6fdad5 100644 --- a/src/syscall/fs.c +++ b/src/syscall/fs.c @@ -2734,6 +2734,19 @@ int64_t sys_fchmodat(guest_t *g, return 0; } + /* An fd magic link names the descriptor's file, and Linux resolves it + * inside the syscall. Act on the descriptor so nothing can redirect the + * chmod between resolution and use; see path_fd_magiclink_dup(). + */ + if (!(flags & LINUX_AT_SYMLINK_NOFOLLOW)) { + int magic_fd = path_fd_magiclink_dup(path); + if (magic_fd >= 0) { + int mrc = fchmod(magic_fd, mode); + close_keep_errno(magic_fd); + return mrc < 0 ? linux_errno() : 0; + } + } + path_translation_t tx; if (path_translate_at(dirfd, path, path_tr_nofollow(flags & LINUX_AT_SYMLINK_NOFOLLOW), @@ -2898,6 +2911,24 @@ int64_t sys_fchownat(guest_t *g, return out; } + /* Same reasoning as the fd magic link branch in sys_fchmodat: act on the + * descriptor, not on a pathname resolved from it a moment earlier. + */ + if (!(flags & LINUX_AT_SYMLINK_NOFOLLOW)) { + int magic_fd = path_fd_magiclink_dup(path); + if (magic_fd >= 0) { + int host_rc = fchown(magic_fd, owner, group); + int saved_errno = errno; + struct stat host_st; + const struct stat *st_ptr = + fstat(magic_fd, &host_st) == 0 ? &host_st : NULL; + errno = saved_errno; + int64_t out = chown_result(host_rc, st_ptr, owner, group); + close_keep_errno(magic_fd); + return out; + } + } + path_translation_t tx; if (path_translate_at(dirfd, path, path_tr_nofollow(flags & LINUX_AT_SYMLINK_NOFOLLOW), @@ -3040,6 +3071,19 @@ int64_t sys_utimensat(guest_t *g, host_fd_ref_close(&dir_ref); return rc; } + + /* Same reasoning as the fd magic link branch in sys_fchmodat: act on + * the descriptor, not on a pathname resolved from it a moment earlier. + */ + if (!(flags & LINUX_AT_SYMLINK_NOFOLLOW)) { + int magic_fd = path_fd_magiclink_dup(path); + if (magic_fd >= 0) { + int mrc = futimens(magic_fd, times_gva ? ts : NULL); + close_keep_errno(magic_fd); + host_fd_ref_close(&dir_ref); + return mrc < 0 ? linux_errno() : 0; + } + } rc = reject_unsupported_fuse_path_op(&tx); if (rc != INT64_MIN) { host_fd_ref_close(&dir_ref); diff --git a/src/syscall/path.c b/src/syscall/path.c index abe7f56e..54365283 100644 --- a/src/syscall/path.c +++ b/src/syscall/path.c @@ -23,6 +23,7 @@ #include "syscall/fuse.h" #include "proved/pathdepth.h" +#include "syscall/internal.h" /* fd_to_host_dup */ #include "syscall/path.h" #include "syscall/proc.h" @@ -200,6 +201,146 @@ static int path_check_relative_sysroot_containment(guest_fd_t dirfd, char *host_out, size_t host_outsz); +int path_parse_proc_name(const char *name) +{ + if (!name || !*name) + return -1; + /* Linux rejects a leading zero on any name longer than one character, so + * "0" names descriptor 0 but "00" and "03" name nothing. + */ + if (name[0] == '0' && name[1] != '\0') + return -1; + + long n = 0; + for (const char *p = name; *p; p++) { + if (*p < '0' || *p > '9') + return -1; + n = n * 10 + (*p - '0'); + if (n > INT_MAX) + return -1; + } + return (int) n; +} + +/* Parse an absolute fd magic link to the guest descriptor it names. This + * accepts + * "/proc/self/fd/", the equivalent spelling with this process's own pid, and + * the /dev aliases Linux exposes as symlinks to procfs. + * + * Linux makes that a magic symlink, so a path-based syscall against it acts on + * the file the descriptor holds. It is the standard way to reach a file through + * an fd when no f*() variant applies -- systemd's fchmod_opath() chmods + * /proc/self/fd/ precisely because fchmod() rejects O_PATH descriptors, and + * reads ENOENT there as "this fd is not valid" (reporting EBADF) rather than as + * a missing file. + * + * Returns the guest descriptor, or -1 when the path is not that shape. + */ +static int parse_fd_magiclink(const char *path) +{ + const char *rest = NULL; + + if (strncmp(path, "/proc/", 6) == 0) { + rest = path + 6; + if (!strncmp(rest, "self/", 5)) { + rest += 5; + } else { + /* The pid component gets the same strict rules as the fd leaf: + * Linux resolves /proc/ through name_to_int as well, so + * "/proc/+1234/fd/3" names nothing there even when 1234 is this + * process. A component too long for the buffer is not a pid either. + */ + const char *slash = strchr(rest, '/'); + if (!slash) + return -1; + char pid_name[16]; + if (path_component_copy(pid_name, sizeof(pid_name), rest, + (size_t) (slash - rest)) < 0) + return -1; + if (path_parse_proc_name(pid_name) != (int) proc_get_pid()) + return -1; + rest = slash + 1; + } + + if (strncmp(rest, "fd/", 3) != 0) + return -1; + rest += 3; + } else if (strncmp(path, "/dev/fd/", 8) == 0) { + rest = path + 8; + } else if (!strcmp(path, "/dev/stdin")) { + rest = "0"; + } else if (!strcmp(path, "/dev/stdout")) { + rest = "1"; + } else if (!strcmp(path, "/dev/stderr")) { + rest = "2"; + } else { + return -1; + } + + /* Only a bare descriptor number names the file itself. Anything trailing + * ("/proc/self/fd/3/x" or "/dev/fd/3/x") walks through it, which the host + * path cannot express here, and a leaf Linux would not accept as a procfs + * fd name is not this shape at all. + */ + return path_parse_proc_name(rest); +} + +int path_fd_magiclink_dup(const char *path) +{ + int fd = parse_fd_magiclink(path); + if (fd < 0) + return -1; + + /* Only descriptors whose host fd is the object itself. A FUSE or synthetic + * fd is served by an emulation layer rather than by the host file behind + * it, so an f*() call would act on the wrong thing; those keep the path + * form and the intercepts that go with it. + */ + fd_entry_t snap; + if (!fd_snapshot(fd, &snap)) + return -1; + if (snap.type != FD_REGULAR && snap.type != FD_DIR && + snap.type != FD_PATH && snap.type != FD_STDIO) + return -1; + + /* dup under fd_lock: a sibling vCPU closing this slot would otherwise + * leave the number free for the next open to claim. + */ + return fd_to_host_dup(fd); +} + +/* Resolve an absolute fd magic link to the host path its descriptor is open on. + * + * Returns 1 and fills out on success, 0 when the path is not that shape or the + * descriptor has no host path (a pipe, socket, or anonymous fd, where F_GETPATH + * fails and the caller's own /proc intercepts remain the right answer). + * + * Callers that can act on a descriptor should prefer path_fd_magiclink_dup(): + * a pathname taken here and used later is a TOCTOU, since a rename or an + * unlink-and-recreate in between leaves it naming a different inode, where + * Linux resolves the link inside the syscall and cannot be redirected. + */ +static int resolve_fd_magiclink_host_path(const char *path, + char *out, + size_t outsz) +{ + int host_fd = path_fd_magiclink_dup(path); + if (host_fd < 0) + return 0; + + char resolved[MAXPATHLEN]; + int rc = fcntl(host_fd, F_GETPATH, resolved); + close(host_fd); + if (rc < 0) + return 0; + + size_t len = strlen(resolved); + if (len >= outsz) + return 0; + memcpy(out, resolved, len + 1); + return 1; +} + int path_translate_at(guest_fd_t dirfd, const char *path, unsigned int flags, @@ -265,6 +406,39 @@ int path_translate_at(guest_fd_t dirfd, return 0; } + /* Only host_path moves; guest_path and intercept_path keep the /proc + * spelling. open, stat and readlink never reach host_path for these paths: + * proc_intercept_open dups the descriptor, proc_intercept_stat fstats it, + * and proc_intercept_readlink reports its path, and none of the three fall + * through to the host on a fd magic link that names an open slot (a + * closed one fails as EBADF rather than falling through). What this changes + * is every other follow-style operation -- chmod, chown, utimensat, + * truncate, access -- which now acts on the file the descriptor holds, the + * way Linux does when it resolves the magic link. + * + * Returning before sysroot resolution is not a containment claim about the + * path: F_GETPATH reports where the descriptor's file actually lives, which + * is regularly outside the sysroot -- an emulated character device, a + * /dev/shm backing file, inherited stdio. Re-resolving one of those as a + * guest path would be wrong, since it is already a host path. Nothing is + * widened by it either: the guest holds the descriptor, so this reaches + * only what it could already reach through it. + * + * Follow-style only. Linux resolves the link for an operation that follows + * the final component and acts on the link itself otherwise, so a no-follow + * or create-style caller -- unlinkat, renameat, chmod with + * AT_SYMLINK_NOFOLLOW -- must not be handed the descriptor's file, or + * unlinkat("/proc/self/fd/") would delete it instead of failing on the + * /proc entry. + */ + if (tx->guest_path[0] == '/' && + !(flags & (PATH_TR_NOFOLLOW | PATH_TR_CREATE)) && + resolve_fd_magiclink_host_path(tx->guest_path, tx->host_buf, + sizeof(tx->host_buf))) { + tx->host_path = tx->host_buf; + return 0; + } + unsigned int lookup_flags = flags; if (path_has_trailing_slash(tx->guest_path)) lookup_flags &= ~PATH_TR_NOFOLLOW; diff --git a/src/syscall/path.h b/src/syscall/path.h index 4c1190d1..b1a35902 100644 --- a/src/syscall/path.h +++ b/src/syscall/path.h @@ -268,3 +268,29 @@ int path_openat2_crosses_mount(guest_fd_t dirfd, * symlink-driven crossings that the string-only precheck misses by design. */ int path_openat2_check_fd_xdev(int guest_fd, int start_class); + +/* Parse a numeric procfs component the way Linux's name_to_int() does: decimal + * digits only, so no sign, no leading whitespace, and no leading zero unless + * the name is "0" itself. The kernel runs both the pid and the fd component + * through it, so both get the same rules here. strtol() accepts all three + * spellings, which made "/proc/self/fd/+3", "/proc/self/fd/03", "/proc/self/fd/ + * 3" and the matching pid forms resolve here while Linux reports ENOENT for + * each. + * + * Returns the value, or -1 when the name is not that shape. The caller applies + * its own upper bound and errno. + */ +int path_parse_proc_name(const char *name); + +/* An owned dup of the descriptor an absolute fd magic link names + * ("/proc/self/fd/", the own-pid spelling, "/dev/fd/", "/dev/std*"), or + * -1 when the path is not that shape or its descriptor is not backed by a plain + * host object. The caller closes it. + * + * Metadata syscalls should act on this rather than on the translated pathname. + * Linux resolves the magic link inside the syscall, so nothing can redirect it; + * resolving to a pathname and operating on it a moment later can land on a + * different inode if the file is renamed, or unlinked and recreated, in + * between. + */ +int path_fd_magiclink_dup(const char *path); diff --git a/tests/test-fd-magiclink.c b/tests/test-fd-magiclink.c new file mode 100644 index 00000000..8f728d5f --- /dev/null +++ b/tests/test-fd-magiclink.c @@ -0,0 +1,296 @@ +/* + * fd magic link resolution regression test + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Covers the paths Linux serves through a procfs magic symlink + * ("/proc/self/fd/", the own-pid spelling, "/dev/fd/"): + * 1. A follow-style metadata call acts on the file the descriptor holds. + * 2. It follows the descriptor, not a pathname resolved from it: the call + * still lands after the file has been unlinked, which no path can reach. + * 3. A no-follow or create-style call does NOT act on that file, so + * unlink("/proc/self/fd/") cannot delete it. + * 4. Only names Linux's name_to_int() accepts resolve: no sign, no leading + * whitespace, no leading zero beyond "0" itself. + * 5. open/stat/readlink keep answering from the existing /proc intercepts. + */ + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "test-harness.h" + +int passes = 0, fails = 0; + +#define TEST_FILE "/fd-magiclink.tmp" + +/* Mode of the file behind fd, or (mode_t) -1. */ +static mode_t fd_mode(int fd) +{ + struct stat st; + if (fstat(fd, &st) < 0) + return (mode_t) -1; + return st.st_mode & 07777; +} + +static int make_test_file(mode_t mode) +{ + unlink(TEST_FILE); + int fd = open(TEST_FILE, O_RDWR | O_CREAT | O_TRUNC, mode); + if (fd < 0) + return -1; + /* open() honours umask, so set the mode the test actually asked for. */ + if (fchmod(fd, mode) < 0) { + close(fd); + return -1; + } + return fd; +} + +/* "/proc/self/fd/" and friends, built for the fd the caller holds. */ +static const char *magic(const char *prefix, int fd) +{ + static char buf[128]; + snprintf(buf, sizeof(buf), "%s%d", prefix, fd); + return buf; +} + +static void check_follow_style_ops(void) +{ + int fd = make_test_file(0600); + if (fd < 0) { + TEST("fd magiclink: fixture"); + FAIL("could not create test file"); + return; + } + + TEST("chmod through magic link"); + EXPECT_TRUE( + chmod(magic("/proc/self/fd/", fd), 0644) == 0 && fd_mode(fd) == 0644, + "chmod did not reach the descriptor's file"); + + TEST("chmod through /dev/fd"); + EXPECT_TRUE(chmod(magic("/dev/fd/", fd), 0600) == 0 && fd_mode(fd) == 0600, + "/dev/fd spelling did not resolve"); + + TEST("chmod through own-pid spelling"); + char pid_prefix[64]; + snprintf(pid_prefix, sizeof(pid_prefix), "/proc/%d/fd/", (int) getpid()); + EXPECT_TRUE(chmod(magic(pid_prefix, fd), 0640) == 0 && fd_mode(fd) == 0640, + "own-pid spelling did not resolve"); + + /* A no-op ownership change exercises the chown path without needing any + * privilege the test may not hold. + */ + TEST("chown through magic link"); + EXPECT_TRUE(chown(magic("/proc/self/fd/", fd), (uid_t) -1, (gid_t) -1) == 0, + "chown did not reach the descriptor's file"); + + TEST("utimensat through magic link"); + struct timespec ts[2] = {{.tv_sec = 1000000000, .tv_nsec = 0}, + {.tv_sec = 1000000000, .tv_nsec = 0}}; + struct stat st; + EXPECT_TRUE(utimensat(AT_FDCWD, magic("/proc/self/fd/", fd), ts, 0) == 0 && + fstat(fd, &st) == 0 && st.st_mtime == 1000000000, + "utimensat did not reach the descriptor's file"); + + close(fd); + unlink(TEST_FILE); +} + +/* The property a resolved pathname cannot have: once the file is unlinked no + * path names it, so a call that still lands proves it followed the descriptor. + */ +static void check_follows_descriptor_not_path(void) +{ + int fd = make_test_file(0600); + if (fd < 0) { + TEST("fd magiclink: unlinked fixture"); + FAIL("could not create test file"); + return; + } + if (unlink(TEST_FILE) < 0) { + TEST("fd magiclink: unlinked fixture"); + FAIL("could not unlink test file"); + close(fd); + return; + } + + TEST("chmod on unlinked held file"); + EXPECT_TRUE( + chmod(magic("/proc/self/fd/", fd), 0644) == 0 && fd_mode(fd) == 0644, + "chmod did not follow the descriptor past unlink"); + + TEST("unlinked file has no links"); + struct stat st; + EXPECT_TRUE(fstat(fd, &st) == 0 && st.st_nlink == 0, + "fixture still had a name, so the check proved nothing"); + + close(fd); +} + +/* unlink and rename do not follow the final component, so on Linux they act on + * the procfs entry and never on the file the descriptor holds. + */ +static void check_no_follow_ops_spare_the_target(void) +{ + int fd = make_test_file(0600); + if (fd < 0) { + TEST("fd magiclink: nofollow fixture"); + FAIL("could not create test file"); + return; + } + + TEST("unlink through magic link refused"); + EXPECT_TRUE(unlink(magic("/proc/self/fd/", fd)) < 0, + "unlink through a magic link was accepted"); + + TEST("unlink left the target in place"); + struct stat st; + EXPECT_TRUE(stat(TEST_FILE, &st) == 0, + "unlink through a magic link deleted the target"); + + TEST("rename through magic link refused"); + EXPECT_TRUE(rename(magic("/proc/self/fd/", fd), "/fd-magiclink.moved") < 0, + "rename through a magic link was accepted"); + + TEST("rename left the target in place"); + EXPECT_TRUE(stat(TEST_FILE, &st) == 0, + "rename through a magic link moved the target"); + + TEST("chmod AT_SYMLINK_NOFOLLOW spares the target"); + mode_t before = fd_mode(fd); + fchmodat(AT_FDCWD, magic("/proc/self/fd/", fd), 0755, AT_SYMLINK_NOFOLLOW); + EXPECT_TRUE(fd_mode(fd) == before, + "a nofollow chmod reached the descriptor's file"); + + close(fd); + unlink(TEST_FILE); + unlink("/fd-magiclink.moved"); +} + +/* Linux resolves both the pid and the fd component through name_to_int(), which + * rejects a sign, leading whitespace, and a leading zero past "0" itself. + */ +static void check_strict_name_parsing(void) +{ + int fd = make_test_file(0600); + if (fd < 0) { + TEST("fd magiclink: name fixture"); + FAIL("could not create test file"); + return; + } + + /* Spelled under /proc, where a name this parser rejects stops here. The + * /dev/fd equivalents are deliberately left out: a rejected name there + * falls through to generic path resolution, whose answer depends on what + * the sysroot's own /dev holds rather than on the parser under test. + */ + static const char *const bad_fd_prefixes[] = { + "/proc/self/fd/+", + "/proc/self/fd/0", + "/proc/self/fd/ ", + }; + bool all_rejected = true; + for (size_t i = 0; i < sizeof(bad_fd_prefixes) / sizeof(*bad_fd_prefixes); + i++) { + if (chmod(magic(bad_fd_prefixes[i], fd), 0755) == 0) + all_rejected = false; + } + TEST("malformed fd names rejected"); + EXPECT_TRUE(all_rejected && fd_mode(fd) == 0600, + "a sign, space or leading zero resolved a descriptor"); + + TEST("trailing garbage rejected"); + char buf[128]; + snprintf(buf, sizeof(buf), "/proc/self/fd/%dx", fd); + EXPECT_TRUE(chmod(buf, 0755) < 0 && fd_mode(fd) == 0600, + "a non-numeric suffix resolved a descriptor"); + + TEST("walking through a magic link rejected"); + snprintf(buf, sizeof(buf), "/proc/self/fd/%d/child", fd); + EXPECT_TRUE(chmod(buf, 0755) < 0 && fd_mode(fd) == 0600, + "a path below a magic link resolved"); + + TEST("malformed pid names rejected"); + bool pid_rejected = true; + snprintf(buf, sizeof(buf), "/proc/+%d/fd/%d", (int) getpid(), fd); + if (chmod(buf, 0755) == 0) + pid_rejected = false; + snprintf(buf, sizeof(buf), "/proc/0%d/fd/%d", (int) getpid(), fd); + if (chmod(buf, 0755) == 0) + pid_rejected = false; + EXPECT_TRUE(pid_rejected && fd_mode(fd) == 0600, + "a signed or zero-padded pid resolved"); + + close(fd); + unlink(TEST_FILE); +} + +/* open, stat and readlink are served by the /proc intercepts, and the magic + * link rewrite must not have taken them over. + */ +static void check_intercepts_unchanged(void) +{ + int fd = make_test_file(0600); + if (fd < 0) { + TEST("fd magiclink: intercept fixture"); + FAIL("could not create test file"); + return; + } + if (write(fd, "payload", 7) != 7) { + TEST("fd magiclink: intercept fixture"); + FAIL("could not write test payload"); + close(fd); + return; + } + + TEST("open through magic link reopens"); + int reopened = open(magic("/proc/self/fd/", fd), O_RDONLY); + char buf[16] = {0}; + /* Seek explicitly: elfuse serves this open by duplicating the descriptor, + * so the result shares the original file offset rather than starting at 0 + * the way a fresh open on Linux would. + */ + bool read_ok = reopened >= 0 && lseek(reopened, 0, SEEK_SET) == 0 && + read(reopened, buf, sizeof(buf) - 1) == 7 && + !strcmp(buf, "payload"); + if (reopened >= 0) + close(reopened); + EXPECT_TRUE(read_ok, "open through a magic link did not reopen the file"); + + TEST("stat through magic link"); + struct stat st; + EXPECT_TRUE(stat(magic("/proc/self/fd/", fd), &st) == 0 && st.st_size == 7, + "stat through a magic link did not describe the file"); + + TEST("readlink reports the target"); + char link[512]; + ssize_t n = readlink(magic("/proc/self/fd/", fd), link, sizeof(link) - 1); + if (n > 0) + link[n] = '\0'; + EXPECT_TRUE(n > 0 && strstr(link, "fd-magiclink.tmp") != NULL, + "readlink did not report the descriptor's file"); + + close(fd); + unlink(TEST_FILE); +} + +int main(void) +{ + check_follow_style_ops(); + check_follows_descriptor_not_path(); + check_no_follow_ops_spare_the_target(); + check_strict_name_parsing(); + check_intercepts_unchanged(); + + SUMMARY("test-fd-magiclink"); + return fails > 0 ? 1 : 0; +}