git: 9aed7a774551 - main - fts: add fts_openat() API

From: Alan Somers <asomers_at_FreeBSD.org>
Date: Tue, 18 Aug 2026 00:22:59 UTC
The branch main has been updated by asomers:

URL: https://cgit.FreeBSD.org/src/commit/?id=9aed7a7745512ca098cc2b247cef61ad57d63204

commit 9aed7a7745512ca098cc2b247cef61ad57d63204
Author:     Jitendra Bhati <bhatijitendra2022@gmail.com>
AuthorDate: 2026-06-12 20:48:17 +0000
Commit:     Alan Somers <asomers@FreeBSD.org>
CommitDate: 2026-08-18 00:17:45 +0000

    fts: add fts_openat() API
    
    Add fts_openat() as a new entry point for fts(3).
    
    When dirfd is AT_FDCWD the behaviour is identical to fts_open().
    Passing a pre-opened directory fd allows fts traversal inside
    Capsicum capability mode where path-based operations are not
    permitted.
    
    Capability mode users should use fts_parent->fts_dirfd + fts_name with
    openat(2) to access files.
    
    Reviewed by:    asomers
    Relnotes:       yes
    Sponsored by:   Google LLC (GSoC 2026)
    Pull Request:   https://github.com/freebsd/freebsd-src/pull/2273
---
 include/fts.h                        |   4 +-
 lib/libc/gen/Makefile.inc            |   1 +
 lib/libc/gen/Symbol.map              |   1 +
 lib/libc/gen/fts.3                   |  34 ++++-
 lib/libc/gen/fts.c                   |  68 ++++++++--
 lib/libc/tests/gen/Makefile          |   1 +
 lib/libc/tests/gen/fts_openat_test.c | 250 +++++++++++++++++++++++++++++++++++
 7 files changed, 346 insertions(+), 13 deletions(-)

diff --git a/include/fts.h b/include/fts.h
index 7d01b7a195c7..4f16ad029a30 100644
--- a/include/fts.h
+++ b/include/fts.h
@@ -1,4 +1,4 @@
-/*-
+/*
  * SPDX-License-Identifier: BSD-3-Clause
  *
  * Copyright (c) 1989, 1993
@@ -148,6 +148,8 @@ FTS	*fts_get_stream(FTSENT *);
 #define	 fts_get_stream(ftsent)	((ftsent)->fts_fts)
 FTS	*fts_open(char * const *, int,
 	    int (*)(const FTSENT * const *, const FTSENT * const *));
+FTS	*fts_openat(int, char * const *, int,
+	    int (*)(const FTSENT * const *, const FTSENT * const *));
 #ifdef __BLOCKS__
 FTS	*fts_open_b(char * const *, int,
 	    int (^)(const FTSENT * const *, const FTSENT * const *));
diff --git a/lib/libc/gen/Makefile.inc b/lib/libc/gen/Makefile.inc
index 582e519df0e5..f60425f27318 100644
--- a/lib/libc/gen/Makefile.inc
+++ b/lib/libc/gen/Makefile.inc
@@ -389,6 +389,7 @@ MLINKS+=frexp.3 frexpf.3 \
 MLINKS+=fts.3 fts_children.3 \
 	fts.3 fts_close.3 \
 	fts.3 fts_open.3 \
+	fts.3 fts_openat.3 \
 	fts.3 fts_read.3 \
 	fts.3 fts_set.3 \
 	fts.3 fts_set_clientptr.3 \
diff --git a/lib/libc/gen/Symbol.map b/lib/libc/gen/Symbol.map
index 8c4853737064..ff3653945296 100644
--- a/lib/libc/gen/Symbol.map
+++ b/lib/libc/gen/Symbol.map
@@ -476,6 +476,7 @@ FBSD_1.9 {
 	fts_read;
 	fts_set;
 	fts_set_clientptr;
+	fts_openat;
 	posix_spawn_file_actions_addchdir;
 	posix_spawn_file_actions_addfchdir;
 	posix_spawnattr_getexecfd_np;
diff --git a/lib/libc/gen/fts.3 b/lib/libc/gen/fts.3
index eb204a0dd3ac..94f10323543d 100644
--- a/lib/libc/gen/fts.3
+++ b/lib/libc/gen/fts.3
@@ -25,7 +25,7 @@
 .\" OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
 .\" SUCH DAMAGE.
 .\"
-.Dd August 9, 2026
+.Dd August 13, 2026
 .Dt FTS 3
 .Os
 .Sh NAME
@@ -38,6 +38,8 @@
 .Ft FTS *
 .Fn fts_open "char * const *path_argv" "int options" "int (*compar)(const FTSENT * const *, const FTSENT * const *)"
 .Ft FTS *
+.Fn fts_openat "int dirfd" "char * const *path_argv" "int options" "int (*compar)(const FTSENT * const *, const FTSENT * const *)"
+.Ft FTS *
 .Fn fts_open_b "char * const *path_argv" "int options" "int (^compar)(const FTSENT * const *, const FTSENT * const *)"
 .Ft FTSENT *
 .Fn fts_read "FTS *ftsp"
@@ -621,6 +623,36 @@ except that it takes a block pointer instead of a function pointer.
 The block is copied before
 .Fn fts_open_b
 returns, so the original can safely go out of scope or be released.
+.Ss Fn fts_openat
+The
+.Fn fts_openat
+function is identical to
+.Fn fts_open
+except that it accepts a file descriptor
+.Fa dirfd
+as its first argument.
+If
+.Fa dirfd
+is
+.Dv AT_FDCWD ,
+the behaviour is identical to
+.Fn fts_open .
+Otherwise,
+.Fa dirfd
+must be an open file descriptor referring to a directory,
+and the traversal is rooted there.
+This allows
+.Fn fts_openat
+to be used inside Capsicum capability mode
+.Pq Xr capsicum 4 ,
+where path-based operations are not permitted.
+The
+.Fn fts_openat
+function duplicates
+.Fa dirfd
+internally, so the caller may close it after
+.Fn fts_openat
+returns.
 .Ss Fn fts_read
 The
 .Fn fts_read
diff --git a/lib/libc/gen/fts.c b/lib/libc/gen/fts.c
index 66c5f62d9a8f..090250e433ef 100644
--- a/lib/libc/gen/fts.c
+++ b/lib/libc/gen/fts.c
@@ -155,6 +155,10 @@ __fts_open(FTS *sp, char * const *argv, int rootfd)
 	if ((parent = fts_alloc(sp, "", 0)) == NULL)
 		goto mem2;
 	parent->fts_level = FTS_ROOTPARENTLEVEL;
+	parent->fts_dirfd = AT_FDCWD;
+
+	if (rootfd != AT_FDCWD)
+		parent->fts_dirfd = rootfd;
 
 	/* Shush, GCC. */
 	tmp = NULL;
@@ -169,11 +173,20 @@ __fts_open(FTS *sp, char * const *argv, int rootfd)
 		p->fts_accpath = p->fts_name;
 		p->fts_info = fts_stat(sp, p,
 		    ISSET(FTS_COMFOLLOWDIR) ? -1 : ISSET(FTS_COMFOLLOW),
-		    -1);
+		    rootfd == AT_FDCWD ? -1 : rootfd);
 
 		/* Command-line "." and ".." are real directories. */
 		if (p->fts_info == FTS_DOT)
 			p->fts_info = FTS_D;
+		if (p->fts_info == FTS_D) {
+			if (strcmp(p->fts_name, ".") == 0)
+				p->fts_dirfd = rootfd != AT_FDCWD ?
+				    _dup(rootfd) :
+				    _open(".", O_RDONLY | O_DIRECTORY | O_CLOEXEC);
+			else
+				p->fts_dirfd = _openat(rootfd, p->fts_name,
+				    O_RDONLY | O_DIRECTORY | O_CLOEXEC);
+		}
 
 		/*
 		 * If comparison routine supplied, traverse in sorted
@@ -213,10 +226,16 @@ __fts_open(FTS *sp, char * const *argv, int rootfd)
 	 * descriptor we run anyway, just more slowly.  We use _openat rather
 	 * than _dup because rootfd may be AT_FDCWD, not a real descriptor.
 	 */
-	if (!ISSET(FTS_NOCHDIR) &&
-	    (sp->fts_rfd = _openat(rootfd, ".", O_RDONLY |
-	    O_CLOEXEC, 0)) < 0)
-		SET(FTS_NOCHDIR);
+
+	if (!ISSET(FTS_NOCHDIR)) {
+		if (rootfd != AT_FDCWD)
+			sp->fts_rfd = _dup(rootfd);
+		if (sp->fts_rfd < 0)
+			SET(FTS_NOCHDIR);
+		else if ((sp->fts_rfd =
+			_open(".", O_RDONLY | O_CLOEXEC, 0)) < 0)
+			SET(FTS_NOCHDIR);
+	}
 	return (sp);
 
 mem3:	fts_lfree(root);
@@ -229,9 +248,17 @@ mem1:	free(sp);
 FTS *
 fts_open(char * const *argv, int options,
     int (*compar)(const FTSENT * const *, const FTSENT * const *))
+{
+	return (fts_openat(AT_FDCWD, argv, options, compar));
+}
+
+FTS *
+fts_openat(int dirfd, char * const *argv, int options,
+    int (*compar)(const FTSENT * const *, const FTSENT * const *))
 {
 	struct _fts_private *priv;
 	FTS *sp;
+	int rootfd;
 
 	/* Options check. */
 	if (options & ~FTS_OPTIONMASK) {
@@ -239,7 +266,7 @@ fts_open(char * const *argv, int options,
 		return (NULL);
 	}
 
-	/* fts_open() requires at least one path */
+	/* fts_openat() requires at least one path */
 	if (*argv == NULL) {
 		errno = EINVAL;
 		return (NULL);
@@ -251,10 +278,15 @@ fts_open(char * const *argv, int options,
 	sp = &priv->ftsp_fts;
 	sp->fts_compar = compar;
 	sp->fts_options = options;
+	if (dirfd == AT_FDCWD)
+		rootfd = AT_FDCWD;
+	else if ((rootfd = _dup(dirfd)) < 0) {
+		free(priv);
+		return (NULL);
+	}
+	return (__fts_open(sp, argv, rootfd));
 
-	return (__fts_open(sp, argv, AT_FDCWD));
 }
-
 #ifdef __BLOCKS__
 FTS *
 fts_open_b(char * const *argv, int options,
@@ -781,7 +813,16 @@ fts_build(FTS *sp, int type)
 		oflag = DTF_NODUP;
 	else
 		oflag = DTF_HIDEW | DTF_NODUP;
-	if ((dirp = __opendir2(cur->fts_accpath, oflag)) == NULL) {
+	if (cur->fts_dirfd >= 0) {
+		int fd;
+		fd = _openat(cur->fts_dirfd, ".", O_RDONLY | O_DIRECTORY | O_CLOEXEC);
+		if (fd >= 0)
+			dirp = fdopendir(fd);
+		else
+			dirp = __opendir2(cur->fts_accpath, oflag);
+	} else
+		dirp = __opendir2(cur->fts_accpath, oflag);
+	if (dirp == NULL) {
 		if (type == BREAD) {
 			cur->fts_info = FTS_DNR;
 			cur->fts_errno = errno;
@@ -789,7 +830,8 @@ fts_build(FTS *sp, int type)
 		return (NULL);
 	}
 
-	cur->fts_dirfd = _dup(_dirfd(dirp));
+	if (cur->fts_dirfd < 0)
+		cur->fts_dirfd = _dup(_dirfd(dirp));
 
 	/*
 	 * In the FTS_PHYSICAL | FTS_NOSTAT case, we want to avoid calling
@@ -927,8 +969,12 @@ mem1:				saved_errno = errno;
 
 		p->fts_level = level;
 		p->fts_parent = sp->fts_cur;
+		if (dp->d_type == DT_DIR) {
+			p->fts_dirfd = _openat(_dirfd(dirp),
+			p->fts_name,
+			O_RDONLY | O_DIRECTORY | O_CLOEXEC);
+		}
 		p->fts_pathlen = len + dnamlen;
-
 		if (dp->d_type == DT_WHT)
 			p->fts_flags |= FTS_ISW;
 
diff --git a/lib/libc/tests/gen/Makefile b/lib/libc/tests/gen/Makefile
index e451fbea644c..1e26e46e8ddb 100644
--- a/lib/libc/tests/gen/Makefile
+++ b/lib/libc/tests/gen/Makefile
@@ -16,6 +16,7 @@ ATF_TESTS_C+=		fts_blocks_test
 ATF_TESTS_C+=		fts_children_test
 ATF_TESTS_C+=		fts_misc_test
 ATF_TESTS_C+=		fts_open_test
+ATF_TESTS_C+=		fts_openat_test
 ATF_TESTS_C+=		fts_options_test
 ATF_TESTS_C+=		fts_regress_test
 ATF_TESTS_C+=		fts_set_test
diff --git a/lib/libc/tests/gen/fts_openat_test.c b/lib/libc/tests/gen/fts_openat_test.c
new file mode 100644
index 000000000000..f6b6806d216f
--- /dev/null
+++ b/lib/libc/tests/gen/fts_openat_test.c
@@ -0,0 +1,250 @@
+/*
+ * Copyright (c) 2026 Jitendra Bhati
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+/*
+ * Basic tests for fts_openat().  When called with AT_FDCWD the
+ * behaviour must be identical to fts_open().
+ */
+
+#include <sys/stat.h>
+
+#include <limits.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <stdbool.h>
+#include <fcntl.h>
+#include <errno.h>
+#include <fts.h>
+#include <string.h>
+#include <unistd.h>
+#include <sys/capsicum.h>
+
+#include <atf-c.h>
+
+#define	FTS_TEST_MAXENTRIES 64
+
+static int
+fts_lexical_compar(const FTSENT * const *a, const FTSENT * const *b)
+{
+	return (strcmp((*a)->fts_name, (*b)->fts_name));
+}
+
+/*
+ * fts_openat(AT_FDCWD, ...) must behave identically to fts_open().
+ */
+ATF_TC(atfdcwd_matches_fts_open);
+ATF_TC_HEAD(atfdcwd_matches_fts_open, tc)
+{
+	atf_tc_set_md_var(tc, "descr",
+	    "fts_openat(AT_FDCWD) behaves identically to fts_open");
+}
+
+ATF_TC_BODY(atfdcwd_matches_fts_open, tc)
+{
+	char *cwd, *abspath;
+	char *paths[2];
+	FTS *fts;
+	FTSENT *ent;
+
+	int *info1, *info2;
+	char (*names1)[NAME_MAX + 1], (*names2)[NAME_MAX + 1];
+	int n1, n2, i;
+	
+	ATF_REQUIRE((info1 = malloc(FTS_TEST_MAXENTRIES *
+            sizeof(*info1))) != NULL);
+        ATF_REQUIRE((info2 = malloc(FTS_TEST_MAXENTRIES *
+            sizeof(*info2))) != NULL);
+        ATF_REQUIRE((names1 = malloc(FTS_TEST_MAXENTRIES *
+            sizeof(*names1))) != NULL);
+        ATF_REQUIRE((names2 = malloc(FTS_TEST_MAXENTRIES *
+            sizeof(*names2))) != NULL);
+
+	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, mkdir("dir/sub", 0755));
+	ATF_REQUIRE_EQ(0, close(creat("dir/sub/file", 0644)));
+	ATF_REQUIRE_EQ(0, close(creat("dir/other", 0644)));
+
+	snprintf(abspath, PATH_MAX * 2, "%s/dir", cwd);
+	paths[0] = abspath;
+	paths[1] = NULL;
+
+	/* Collect fts_open results. */
+	ATF_REQUIRE((fts = fts_open(paths, FTS_PHYSICAL,
+	    fts_lexical_compar)) != NULL);
+	for (n1 = 0;
+	    (ent = fts_read(fts)) != NULL && n1 < FTS_TEST_MAXENTRIES;
+	    n1++) {
+		info1[n1] = ent->fts_info;
+		strlcpy(names1[n1], ent->fts_name, NAME_MAX + 1);
+	}
+	ATF_REQUIRE_EQ_MSG(0, fts_close(fts), "fts_close: %m");
+
+	/* Collect fts_openat results. */
+	ATF_REQUIRE((fts = fts_openat(AT_FDCWD, paths, FTS_PHYSICAL,
+	    fts_lexical_compar)) != NULL);
+	for (n2 = 0;
+	    (ent = fts_read(fts)) != NULL && n2 < FTS_TEST_MAXENTRIES;
+	    n2++) {
+		info2[n2] = ent->fts_info;
+		strlcpy(names2[n2], ent->fts_name, NAME_MAX + 1);
+	}
+	ATF_REQUIRE_EQ_MSG(0, fts_close(fts), "fts_close: %m");
+
+	/* Compare. */
+	ATF_CHECK_EQ_MSG(n1, n2,
+	    "entry count mismatch: fts_open=%d fts_openat=%d", n1, n2);
+	for (i = 0; i < n1 && i < n2; i++) {
+		ATF_CHECK_EQ_MSG(info1[i], info2[i],
+		    "fts_info mismatch at entry %d: "
+		    "fts_open=%d fts_openat=%d name=%s",
+		    i, info1[i], info2[i], names1[i]);
+		ATF_CHECK_STREQ_MSG(names1[i], names2[i],
+		    "fts_name mismatch at entry %d: "
+		    "fts_open='%s' fts_openat='%s'",
+		    i, names1[i], names2[i]);
+	}
+
+	free(cwd);
+	free(abspath);
+	free(info1);
+	free(info2);
+	free(names1);
+	free(names2);
+}
+
+/*
+ * fts_openat() with a real dirfd must work in Capsicum capability mode.
+ */
+ATF_TC(openat_capsicum);
+ATF_TC_HEAD(openat_capsicum, tc)
+{
+	atf_tc_set_md_var(tc, "descr",
+	    "fts_openat() with dirfd works in Capsicum capability mode");
+}
+ATF_TC_BODY(openat_capsicum, tc)
+{
+	char *paths[] = { ".", NULL };
+	FTS *fts;
+	FTSENT *ent;
+	int dirfd;
+	bool saw_file = false, saw_sub = false;
+
+	if (!feature_present("security_capabilities") ||
+	    !feature_present("security_capability_mode"))
+		atf_tc_skip("Capsicum not available");
+
+	ATF_REQUIRE_EQ(0, mkdir("dir", 0755));
+	ATF_REQUIRE_EQ(0, mkdir("dir/sub", 0755));
+	ATF_REQUIRE_EQ(0, close(creat("dir/sub/file", 0644)));
+	ATF_REQUIRE_EQ(0, close(creat("dir/other", 0644)));
+
+	ATF_REQUIRE((dirfd = open("dir", O_RDONLY | O_DIRECTORY)) >= 0);
+	ATF_REQUIRE_EQ(0, cap_enter());
+	ATF_REQUIRE((fts = fts_openat(dirfd, paths,
+	    FTS_PHYSICAL | FTS_NOCHDIR, NULL)) != NULL);
+
+	while ((ent = fts_read(fts)) != NULL) {
+		if (ent->fts_info == FTS_DP)
+			continue;
+		if (strcmp(ent->fts_name, "sub") == 0 &&
+		    ent->fts_info == FTS_D)
+			saw_sub = true;
+		if (strcmp(ent->fts_name, "file") == 0 &&
+		    ent->fts_info == FTS_F)
+			saw_file = true;
+	}
+
+	ATF_CHECK_MSG(saw_sub, "must have visited 'sub' directory");
+	ATF_CHECK_MSG(saw_file, "must have visited 'file'");
+	ATF_REQUIRE_EQ_MSG(0, fts_close(fts), "fts_close(): %m");
+}
+
+/*
+ * Demonstrate the intended use of fts_dirfd: use
+ * fts_parent->fts_dirfd + fts_name to access files without
+ * relying on path-based operations.
+ */
+ATF_TC(fts_dirfd_openat);
+ATF_TC_HEAD(fts_dirfd_openat, tc)
+{
+	atf_tc_set_md_var(tc, "descr",
+	    "fts_parent->fts_dirfd + fts_name can be used with openat(2)");
+}
+
+ATF_TC_BODY(fts_dirfd_openat, tc)
+{
+	char *paths[] = { "dir1", "dir2", NULL };
+	FTS *fts;
+	FTSENT *ent;
+	struct stat sb_path, sb_dirfd;
+	int dirfd;
+	int nvisited = 0;
+
+	ATF_REQUIRE_EQ(0, mkdir("dir1", 0755));
+	ATF_REQUIRE_EQ(0, mkdir("dir1/sub", 0755));
+	ATF_REQUIRE_EQ(0, close(creat("dir1/sub/file", 0644)));
+	ATF_REQUIRE_EQ(0, mkdir("dir2", 0755));
+	ATF_REQUIRE_EQ(0, close(creat("dir2/file2", 0644)));
+
+	/*
+	 * Open the current working directory as dirfd. fts_openat
+	 * stores it in parent->fts_dirfd, allowing children to use
+	 * fts_parent->fts_dirfd + fts_name with openat(2).
+	 */
+	ATF_REQUIRE((dirfd = open(".", O_RDONLY | O_DIRECTORY)) >= 0);
+	ATF_REQUIRE((fts = fts_openat(dirfd, paths,
+	    FTS_PHYSICAL, NULL)) != NULL);
+	close(dirfd);
+
+	while ((ent = fts_read(fts)) != NULL) {
+		if (ent->fts_info == FTS_DP)
+			continue;
+		if (ent->fts_level == FTS_ROOTLEVEL)
+			continue;
+
+		ATF_REQUIRE_MSG(ent->fts_parent->fts_dirfd >= 0,
+		    "fts_parent->fts_dirfd must be valid for '%s'",
+		    ent->fts_name);
+
+		ATF_REQUIRE_EQ_MSG(0,
+		    fstatat(ent->fts_parent->fts_dirfd, ent->fts_name,
+		    &sb_dirfd, AT_SYMLINK_NOFOLLOW),
+		    "fstatat(fts_parent->fts_dirfd, '%s') failed: %m",
+		    ent->fts_name);
+
+		ATF_REQUIRE_EQ_MSG(0,
+		    lstat(ent->fts_accpath, &sb_path),
+		    "lstat('%s') failed: %m", ent->fts_accpath);
+
+		ATF_CHECK_EQ_MSG(sb_path.st_ino, sb_dirfd.st_ino,
+		    "inode mismatch for '%s': accpath=%ju dirfd=%ju",
+		    ent->fts_name,
+		    (uintmax_t)sb_path.st_ino,
+		    (uintmax_t)sb_dirfd.st_ino);
+
+		nvisited++;
+	}
+
+	ATF_REQUIRE_EQ_MSG(0, fts_close(fts), "fts_close(): %m");
+
+	/* dir1/sub, dir1/sub/file, dir2/file2 = 3 entries */
+	ATF_CHECK_EQ_MSG(3, nvisited,
+	    "expected 3 entries, got %d", nvisited);
+}
+
+ATF_TP_ADD_TCS(tp)
+{
+	ATF_TP_ADD_TC(tp, atfdcwd_matches_fts_open);
+	ATF_TP_ADD_TC(tp, openat_capsicum);
+	ATF_TP_ADD_TC(tp, fts_dirfd_openat);
+	return (atf_no_error());
+}