The branch main has been updated by asomers: URL: https://cgit.FreeBSD.org/src/commit/?id=b45654c6a4d3b2a322b5787e3233b1b22ef4d128
commit b45654c6a4d3b2a322b5787e3233b1b22ef4d128 Author: Jitendra Bhati <[email protected]> AuthorDate: 2026-05-26 11:23:53 +0000 Commit: Alan Somers <[email protected]> CommitDate: 2026-06-05 19:57:30 +0000 fts: add misc fts traversal tests Extend fts_misc_test.c with additional test cases: - FTS_NOCHDIR with absolute paths allows application chdir freely - fts_name is always NUL-terminated with correct fts_namelen - FTS_D/FTS_DP are paired and fts_level increments correctly - FTSENT fts_errno/fts_dev/fts_ino/fts_nlink are correct - circular symlink loop under FTS_PHYSICAL terminates - cycle via symlink under FTS_LOGICAL yields FTS_DC - fts_close after root deletion must not crash - fts_close after root rename restores CWD (SVN r77497) - FTS_NOCHDIR + empty directory does not corrupt path (SVN r49772) - FTS_NS entry has non-zero fts_errno - FTS_XDEV and FTS_WHITEOUT stubbed pending mount setup Sponsored by: Google LLC (GSoC 2026) Reviewed by: asomers, jillest MFC after: 2 weeks Pull Request: https://github.com/freebsd/freebsd-src/pull/2248 --- lib/libc/tests/gen/fts_misc_test.c | 521 ++++++++++++++++++++++++++++++++++++- 1 file changed, 520 insertions(+), 1 deletion(-) diff --git a/lib/libc/tests/gen/fts_misc_test.c b/lib/libc/tests/gen/fts_misc_test.c index 91640078f63c..95593e26095c 100644 --- a/lib/libc/tests/gen/fts_misc_test.c +++ b/lib/libc/tests/gen/fts_misc_test.c @@ -1,16 +1,23 @@ -/*- +/* * Copyright (c) 2025 Klara, Inc. + * Copyright (c) 2026 Jitendra Bhati * * SPDX-License-Identifier: BSD-2-Clause */ +#include <sys/mount.h> +#include <sys/param.h> #include <sys/stat.h> +#include <sys/syslimits.h> +#include <sys/uio.h> +#include <errno.h> #include <fcntl.h> #include <fts.h> #include <stdbool.h> #include <stdio.h> #include <stdlib.h> +#include <string.h> #include <unistd.h> #include <atf-c.h> @@ -69,10 +76,522 @@ ATF_TC_BODY(fts_unrdir_nochdir, tc) }); } +/* + * With FTS_NOCHDIR and absolute paths, the application may call chdir(2) + * freely between fts_read() calls without corrupting the traversal. + */ +ATF_TC(nochdir_app_can_chdir); +ATF_TC_HEAD(nochdir_app_can_chdir, tc) +{ + atf_tc_set_md_var(tc, "descr", + "FTS_NOCHDIR: application chdir between reads does not " + "corrupt traversal"); +} +ATF_TC_BODY(nochdir_app_can_chdir, tc) +{ + char *cwd, *abspath; + char *paths[2]; + char pwd[PATH_MAX]; + FTS *fts; + FTSENT *ent; + int entries; + + cwd = malloc(PATH_MAX); + ATF_REQUIRE(cwd != NULL); + abspath = malloc(PATH_MAX * 2); + ATF_REQUIRE(abspath != NULL); + + ATF_REQUIRE(getcwd(cwd, PATH_MAX) != NULL); + ATF_REQUIRE_EQ(0, mkdir("dir", 0755)); + ATF_REQUIRE_EQ(0, close(creat("dir/a", 0644))); + ATF_REQUIRE_EQ(0, close(creat("dir/b", 0644))); + + snprintf(abspath, PATH_MAX * 2, "%s/dir", cwd); + paths[0] = abspath; + paths[1] = NULL; + + ATF_REQUIRE((fts = fts_open(paths, FTS_PHYSICAL | FTS_NOCHDIR, + fts_lexical_compar)) != NULL); + + /* + * Chdir to root once after fts_open() but before fts_read(). + * With FTS_NOCHDIR, fts must not call chdir() itself, so the + * process CWD must remain "/" throughout the traversal. + */ + ATF_REQUIRE_EQ(0, chdir("/")); + + entries = 0; + while ((ent = fts_read(fts)) != NULL) { + ATF_REQUIRE(getcwd(pwd, sizeof(pwd)) != NULL); + ATF_CHECK_STREQ_MSG("/", pwd, + "PWD changed during FTS_NOCHDIR traversal"); + entries++; + } + ATF_CHECK_EQ_MSG(0, errno, + "traversal ended with errno %d", errno); + + /* FTS_D dir, FTS_F a, FTS_F b, FTS_DP dir = 4 entries */ + ATF_CHECK_EQ_MSG(4, entries, + "expected 4 entries, got %d", entries); + + ATF_REQUIRE_EQ_MSG(0, fts_close(fts), "fts_close(): %m"); + free(cwd); + free(abspath); +} + +/* + * fts_name is always NUL-terminated and fts_namelen always equals + * strlen(fts_name), regardless of traversal options or entry type. + */ +ATF_TC(name_nul_terminated); +ATF_TC_HEAD(name_nul_terminated, tc) +{ + atf_tc_set_md_var(tc, "descr", + "fts_name is always NUL-terminated with correct fts_namelen"); +} +ATF_TC_BODY(name_nul_terminated, tc) +{ + char *paths[] = { "root", NULL }; + FTS *fts; + FTSENT *ent; + + ATF_REQUIRE_EQ(0, mkdir("root", 0755)); + ATF_REQUIRE_EQ(0, mkdir("root/sub", 0755)); + ATF_REQUIRE_EQ(0, close(creat("root/sub/file.c", 0644))); + ATF_REQUIRE_EQ(0, symlink("file.c", "root/sub/link")); + + ATF_REQUIRE((fts = fts_open(paths, FTS_PHYSICAL, + fts_lexical_compar)) != NULL); + + while ((ent = fts_read(fts)) != NULL) { + ATF_CHECK_EQ_MSG(strlen(ent->fts_name), ent->fts_namelen, + "fts_namelen %zu != strlen(fts_name) %zu for '%s'", + ent->fts_namelen, strlen(ent->fts_name), ent->fts_name); + } + + ATF_REQUIRE_EQ_MSG(0, fts_close(fts), "fts_close(): %m"); +} + +/* + * Every FTS_D must be paired with exactly one FTS_DP. fts_level must + * be FTS_ROOTLEVEL (0) for the root, incrementing by one per level. + */ +ATF_TC(prepost_order_and_levels); +ATF_TC_HEAD(prepost_order_and_levels, tc) +{ + atf_tc_set_md_var(tc, "descr", + "FTS_D/FTS_DP are paired and fts_level increments correctly"); +} +ATF_TC_BODY(prepost_order_and_levels, tc) +{ + char *paths[] = { "top", NULL }; + FTS *fts; + FTSENT *ent; + static const int stack_depth = 32; + struct { + const char *name; + long level; + } stack[32]; + int depth; + + ATF_REQUIRE_EQ(0, mkdir("top", 0755)); + ATF_REQUIRE_EQ(0, mkdir("top/mid", 0755)); + ATF_REQUIRE_EQ(0, mkdir("top/mid/bot", 0755)); + ATF_REQUIRE_EQ(0, close(creat("top/mid/bot/leaf", 0644))); + + ATF_REQUIRE((fts = fts_open(paths, FTS_PHYSICAL, + fts_lexical_compar)) != NULL); + + depth = 0; + while ((ent = fts_read(fts)) != NULL) { + if (ent->fts_info == FTS_D) { + ATF_REQUIRE_MSG(depth < stack_depth, + "stack overflow in test"); + stack[depth].name = ent->fts_name; + stack[depth].level = ent->fts_level; + depth++; + } else if (ent->fts_info == FTS_DP) { + ATF_REQUIRE_MSG(depth > 0, + "FTS_DP without matching FTS_D"); + depth--; + ATF_CHECK_STREQ(stack[depth].name, ent->fts_name); + ATF_CHECK_EQ(stack[depth].level, ent->fts_level); + } + + if (ent->fts_info == FTS_D || ent->fts_info == FTS_DP || + ent->fts_info == FTS_F) { + if (strcmp(ent->fts_name, "top") == 0) + ATF_CHECK_EQ(FTS_ROOTLEVEL, ent->fts_level); + else if (strcmp(ent->fts_name, "mid") == 0) + ATF_CHECK_EQ(1, ent->fts_level); + else if (strcmp(ent->fts_name, "bot") == 0) + ATF_CHECK_EQ(2, ent->fts_level); + else if (strcmp(ent->fts_name, "leaf") == 0) + ATF_CHECK_EQ(3, ent->fts_level); + } + } + ATF_CHECK_EQ_MSG(0, depth, + "%d unmatched FTS_D entries at end", depth); + + ATF_REQUIRE_EQ_MSG(0, fts_close(fts), "fts_close(): %m"); +} + +/* + * FTSENT fields fts_errno, fts_dev, fts_ino, and fts_nlink must be + * consistent with what stat(2) returns for successfully visited entries. + */ +ATF_TC(ftsent_fields); +ATF_TC_HEAD(ftsent_fields, tc) +{ + atf_tc_set_md_var(tc, "descr", + "FTSENT fts_errno/fts_dev/fts_ino/fts_nlink are correct"); +} +ATF_TC_BODY(ftsent_fields, tc) +{ + char *paths[] = { "dir", NULL }; + FTS *fts; + FTSENT *ent; + struct stat sb; + + ATF_REQUIRE_EQ(0, mkdir("dir", 0755)); + ATF_REQUIRE_EQ(0, close(creat("dir/file", 0644))); + + ATF_REQUIRE((fts = fts_open(paths, FTS_PHYSICAL, + fts_lexical_compar)) != NULL); + + while ((ent = fts_read(fts)) != NULL) { + ATF_CHECK_EQ_MSG(0, ent->fts_errno, + "fts_errno != 0 for '%s'", ent->fts_name); + + if (ent->fts_info == FTS_D) { + ATF_REQUIRE_EQ_MSG(0, + stat(ent->fts_accpath, &sb), + "stat(%s): %m", ent->fts_accpath); + ATF_CHECK_EQ(sb.st_dev, ent->fts_dev); + ATF_CHECK_EQ(sb.st_ino, ent->fts_ino); + /* + * "dir" has exactly two links: one from its + * parent and one from its own "." entry. + */ + ATF_CHECK_EQ_MSG(2, ent->fts_nlink, + "expected fts_nlink == 2 for '%s', got %ju", + ent->fts_name, (uintmax_t)ent->fts_nlink); + } + } + + ATF_REQUIRE_EQ_MSG(0, fts_close(fts), "fts_close(): %m"); +} + +/* + * Under FTS_PHYSICAL, symlinks are never followed, so a circular + * symlink loop cannot cause infinite recursion. Both symlinks are + * returned as FTS_SL and traversal terminates. + */ +ATF_TC(symlink_loop_physical); +ATF_TC_HEAD(symlink_loop_physical, tc) +{ + atf_tc_set_md_var(tc, "descr", + "circular symlink loop under FTS_PHYSICAL terminates"); +} +ATF_TC_BODY(symlink_loop_physical, tc) +{ + char *paths[] = { "dir", NULL }; + FTS *fts; + FTSENT *ent; + int entries; + + ATF_REQUIRE_EQ(0, mkdir("dir", 0755)); + ATF_REQUIRE_EQ(0, symlink("b", "dir/a")); + ATF_REQUIRE_EQ(0, symlink("a", "dir/b")); + + ATF_REQUIRE((fts = fts_open(paths, FTS_PHYSICAL, + fts_lexical_compar)) != NULL); + + entries = 0; + while ((ent = fts_read(fts)) != NULL) { + ATF_CHECK_MSG( + ent->fts_info == FTS_D || + ent->fts_info == FTS_DP || + ent->fts_info == FTS_SL, + "unexpected fts_info %d for '%s'", + ent->fts_info, ent->fts_name); + ATF_REQUIRE_MSG(++entries < 100, + "traversal exceeded 100 entries, probable infinite loop"); + } + + ATF_REQUIRE_EQ_MSG(0, fts_close(fts), "fts_close(): %m"); +} + +/* + * Cycle detection via dev/ino comparison under FTS_LOGICAL: following + * a symlink that points back to an ancestor must produce FTS_DC rather + * than infinite recursion. + */ +ATF_TC(cycle_detection); +ATF_TC_HEAD(cycle_detection, tc) +{ + atf_tc_set_md_var(tc, "descr", + "cycle via symlink under FTS_LOGICAL yields FTS_DC"); +} +ATF_TC_BODY(cycle_detection, tc) +{ + char *paths[] = { "dir", NULL }; + FTS *fts; + FTSENT *ent; + int saw_dc, entries; + + ATF_REQUIRE_EQ(0, mkdir("dir", 0755)); + ATF_REQUIRE_EQ(0, symlink("..", "dir/up")); + + ATF_REQUIRE((fts = fts_open(paths, FTS_LOGICAL, + fts_lexical_compar)) != NULL); + + saw_dc = 0; + entries = 0; + while ((ent = fts_read(fts)) != NULL) { + if (ent->fts_info == FTS_DC) + saw_dc = 1; + ATF_REQUIRE_MSG(++entries < 100, + "traversal exceeded 100 entries, probable infinite loop"); + } + ATF_CHECK_MSG(saw_dc != 0, + "expected FTS_DC entry for the cycle"); + + ATF_REQUIRE_EQ_MSG(0, fts_close(fts), "fts_close(): %m"); +} + +/* + * fts_close() after the root directory has been deleted must not crash. + */ +ATF_TC(close_after_root_deleted); +ATF_TC_HEAD(close_after_root_deleted, tc) +{ + atf_tc_set_md_var(tc, "descr", + "fts_close after root deletion must not crash"); +} +ATF_TC_BODY(close_after_root_deleted, tc) +{ + char *orig_cwd, *final_cwd; + char *paths[] = { "dir", NULL }; + FTS *fts; + + orig_cwd = malloc(PATH_MAX); + ATF_REQUIRE(orig_cwd != NULL); + final_cwd = malloc(PATH_MAX); + ATF_REQUIRE(final_cwd != NULL); + + ATF_REQUIRE(getcwd(orig_cwd, PATH_MAX) != NULL); + ATF_REQUIRE_EQ(0, mkdir("dir", 0755)); + ATF_REQUIRE_EQ(0, close(creat("dir/file", 0644))); + + ATF_REQUIRE((fts = fts_open(paths, FTS_PHYSICAL, NULL)) != NULL); + + /* Read first entry then delete the tree. */ + ATF_REQUIRE(fts_read(fts) != NULL); + ATF_REQUIRE_EQ(0, unlink("dir/file")); + ATF_REQUIRE_EQ(0, rmdir("dir")); + + /* + * Drain traversal -- errors are expected after deletion + * but fts_read() must not crash. + */ + while (fts_read(fts) != NULL) + ; + + /* fts_close() must not crash regardless of return value. */ + (void)fts_close(fts); + + /* + * After fts_close(), the process CWD must be restored to + * the original directory even though the traversal root + * was deleted mid-traversal. + */ + ATF_REQUIRE(getcwd(final_cwd, PATH_MAX) != NULL); + ATF_CHECK_STREQ_MSG(orig_cwd, final_cwd, + "CWD after fts_close should be '%s', got '%s'", + orig_cwd, final_cwd); + + free(orig_cwd); + free(final_cwd); +} + +/* + * fts_close() after the root has been renamed must restore the process + * CWD to the original directory. + * Regression test for SVN r77497. + */ +ATF_TC(close_after_root_moved); +ATF_TC_HEAD(close_after_root_moved, tc) +{ + atf_tc_set_md_var(tc, "descr", + "fts_close after root rename restores CWD (SVN r77497)"); +} +ATF_TC_BODY(close_after_root_moved, tc) +{ + char *orig_cwd, *final_cwd; + char *paths[] = { "dir", NULL }; + FTS *fts; + + orig_cwd = malloc(PATH_MAX); + ATF_REQUIRE(orig_cwd != NULL); + final_cwd = malloc(PATH_MAX); + ATF_REQUIRE(final_cwd != NULL); + + ATF_REQUIRE(getcwd(orig_cwd, PATH_MAX) != NULL); + ATF_REQUIRE_EQ(0, mkdir("dir", 0755)); + ATF_REQUIRE_EQ(0, close(creat("dir/file", 0644))); + + ATF_REQUIRE((fts = fts_open(paths, FTS_PHYSICAL, NULL)) != NULL); + + /* Read first entry then rename the root mid-traversal. */ + ATF_REQUIRE(fts_read(fts) != NULL); + ATF_REQUIRE_EQ(0, rename("dir", "dir_moved")); + + while (fts_read(fts) != NULL) + ; + + /* fts_close() must not crash. */ + (void)fts_close(fts); + + ATF_REQUIRE(getcwd(final_cwd, PATH_MAX) != NULL); + ATF_CHECK_STREQ_MSG(orig_cwd, final_cwd, + "CWD after fts_close should be '%s', got '%s'", + orig_cwd, final_cwd); + + free(orig_cwd); + free(final_cwd); +} +/* + * FTS_NOCHDIR with an empty terminal directory must not corrupt the + * path buffer for subsequent entries. + * Regression test for SVN r49772. + */ +ATF_TC(nochdir_empty_terminal_dir); +ATF_TC_HEAD(nochdir_empty_terminal_dir, tc) +{ + atf_tc_set_md_var(tc, "descr", + "FTS_NOCHDIR + empty directory does not corrupt path " + "(SVN r49772)"); +} +ATF_TC_BODY(nochdir_empty_terminal_dir, tc) +{ + ATF_REQUIRE_EQ(0, mkdir("parent", 0755)); + ATF_REQUIRE_EQ(0, mkdir("parent/empty", 0755)); + ATF_REQUIRE_EQ(0, close(creat("parent/sibling", 0644))); + + fts_test(tc, &(struct fts_testcase){ + (char *[]){ "parent", NULL }, + FTS_PHYSICAL | FTS_NOCHDIR, + (struct fts_expect[]){ + { FTS_D, "parent", "parent" }, + { FTS_D, "empty", "parent/empty" }, + { FTS_DP, "empty", "parent/empty" }, + { FTS_F, "sibling", "parent/sibling" }, + { FTS_DP, "parent", "parent" }, + { 0 } + }, + }); +} + +/* + * A nonexistent path yields FTS_NS with fts_errno set to a non-zero + * value identifying why stat(2) failed. + */ +ATF_TC(ns_errno_set); +ATF_TC_HEAD(ns_errno_set, tc) +{ + atf_tc_set_md_var(tc, "descr", + "FTS_NS entry has non-zero fts_errno"); +} +ATF_TC_BODY(ns_errno_set, tc) +{ + char *paths[] = { "nonexistent", NULL }; + FTS *fts; + FTSENT *ent; + + ATF_REQUIRE((fts = fts_open(paths, FTS_PHYSICAL, NULL)) != NULL); + + ent = fts_read(fts); + ATF_REQUIRE(ent != NULL); + ATF_CHECK_EQ(FTS_NS, ent->fts_info); + ATF_CHECK_MSG(ent->fts_errno != 0, + "FTS_NS entry must have non-zero fts_errno"); + + ATF_REQUIRE_EQ_MSG(0, fts_close(fts), "fts_close(): %m"); +} + +/* + * FTS_XDEV prevents traversal from crossing mount points. + * Mount a tmpfs on a subdirectory and verify fts does not + * descend into it when FTS_XDEV is set. + */ +ATF_TC_WITH_CLEANUP(xdev); +ATF_TC_HEAD(xdev, tc) +{ + atf_tc_set_md_var(tc, "descr", + "FTS_XDEV does not cross mount points"); + atf_tc_set_md_var(tc, "require.user", "root"); +} +ATF_TC_BODY(xdev, tc) +{ + struct iovec iov[4]; + char *paths[] = { "dir", NULL }; + FTS *fts; + FTSENT *ent; + bool crossed; + + ATF_REQUIRE_EQ(0, mkdir("dir", 0755)); + ATF_REQUIRE_EQ(0, mkdir("dir/mnt", 0755)); + ATF_REQUIRE_EQ(0, close(creat("dir/file", 0644))); + + iov[0].iov_base = (void *)"fstype"; + iov[0].iov_len = sizeof("fstype"); + iov[1].iov_base = (void *)"tmpfs"; + iov[1].iov_len = sizeof("tmpfs"); + iov[2].iov_base = (void *)"fspath"; + iov[2].iov_len = sizeof("fspath"); + iov[3].iov_base = (void *)"dir/mnt"; + iov[3].iov_len = sizeof("dir/mnt"); + + if (nmount(iov, 4, 0) != 0) + atf_tc_skip("could not mount tmpfs: %s", strerror(errno)); + + ATF_REQUIRE_EQ(0, close(creat("dir/mnt/inside", 0644))); + + ATF_REQUIRE((fts = fts_open(paths, FTS_PHYSICAL | FTS_XDEV, + fts_lexical_compar)) != NULL); + + crossed = false; + while ((ent = fts_read(fts)) != NULL) { + if (strcmp(ent->fts_name, "inside") == 0) + crossed = true; + } + ATF_CHECK_MSG(!crossed, + "FTS_XDEV must not descend into tmpfs mount point"); + + ATF_REQUIRE_EQ_MSG(0, fts_close(fts), "fts_close(): %m"); +} +ATF_TC_CLEANUP(xdev, tc) +{ + (void)unmount("dir/mnt", 0); +} + ATF_TP_ADD_TCS(tp) { fts_check_debug(); ATF_TP_ADD_TC(tp, fts_unrdir); ATF_TP_ADD_TC(tp, fts_unrdir_nochdir); + ATF_TP_ADD_TC(tp, nochdir_app_can_chdir); + ATF_TP_ADD_TC(tp, name_nul_terminated); + ATF_TP_ADD_TC(tp, prepost_order_and_levels); + ATF_TP_ADD_TC(tp, ftsent_fields); + ATF_TP_ADD_TC(tp, symlink_loop_physical); + ATF_TP_ADD_TC(tp, cycle_detection); + ATF_TP_ADD_TC(tp, close_after_root_deleted); + ATF_TP_ADD_TC(tp, close_after_root_moved); + ATF_TP_ADD_TC(tp, nochdir_empty_terminal_dir); + ATF_TP_ADD_TC(tp, ns_errno_set); + ATF_TP_ADD_TC(tp, xdev); + return (atf_no_error()); }
