xref: /freebsd/lib/libc/tests/gen/fts_regress_test.c (revision 670738a17568f2579b866878f39d2a824a386297)
1 /*
2  * Copyright (c) 2026 Jitendra Bhati
3  *
4  * SPDX-License-Identifier: BSD-2-Clause
5  */
6 
7 /*
8  * Regression tests for specific FreeBSD bug reports fixed in fts(3).
9  */
10 
11 #include <sys/stat.h>
12 #include <sys/time.h>
13 
14 #include <errno.h>
15 #include <fcntl.h>
16 #include <fts.h>
17 #include <pthread.h>
18 #include <stdbool.h>
19 #include <stdio.h>
20 #include <stdlib.h>
21 #include <string.h>
22 #include <time.h>
23 #include <unistd.h>
24 
25 #include <atf-c.h>
26 
27 /*
28  * Thrash function for file-based race tests: repeatedly creates and
29  * deletes a regular file at the given path.
30  */
31 static volatile bool race_stop;
32 
33 static void *
race_thrash(void * arg)34 race_thrash(void *arg)
35 {
36 	const char *path = arg;
37 
38 	while (!race_stop) {
39 		(void)close(creat(path, 0644));
40 		(void)unlink(path);
41 	}
42 	return (NULL);
43 }
44 
45 /*
46  * Thrash function for directory-based race tests: repeatedly removes
47  * and recreates a directory at the given path.
48  */
49 static void *
dir_thrash(void * arg)50 dir_thrash(void *arg)
51 {
52 	const char *path = arg;
53 
54 	while (!race_stop) {
55 		(void)rmdir(path);
56 		(void)mkdir(path, 0755);
57 	}
58 	return (NULL);
59 }
60 
61 /*
62  * PR 45723: A directory with read but no execute permission must be
63  * traversed.  Before the fix, fts_build() gave up silently when
64  * chdir() failed, producing no output at all.  The fix falls back to
65  * FTS_DONTCHDIR mode so the directory is still traversed using full
66  * relative paths.
67  *
68  * Requires an unprivileged user because root ignores permissions.
69  */
70 ATF_TC(read_no_exec_dir);
ATF_TC_HEAD(read_no_exec_dir,tc)71 ATF_TC_HEAD(read_no_exec_dir, tc)
72 {
73 	atf_tc_set_md_var(tc, "descr",
74 	    "directory with read but no execute is traversed via "
75 	    "FTS_DONTCHDIR fallback");
76 	atf_tc_set_md_var(tc, "require.user", "unprivileged");
77 }
ATF_TC_BODY(read_no_exec_dir,tc)78 ATF_TC_BODY(read_no_exec_dir, tc)
79 {
80 	char *paths[] = { "dir", NULL };
81 	FTS *fts;
82 	FTSENT *ent;
83 	bool saw_d, saw_file;
84 
85 	ATF_REQUIRE_EQ(0, mkdir("dir", 0755));
86 	ATF_REQUIRE_EQ(0, close(creat("dir/file", 0644)));
87 	ATF_REQUIRE_EQ(0, chmod("dir", 0400));
88 
89 	ATF_REQUIRE((fts = fts_open(paths, FTS_PHYSICAL, NULL)) != NULL);
90 
91 	/*
92 	 * Before the fix, zero entries were produced.  After the fix,
93 	 * fts falls back to FTS_DONTCHDIR and traverses using full paths.
94 	 * Verify the directory is not silently skipped.
95 	 */
96 	saw_d = false;
97 	saw_file = false;
98 	while ((ent = fts_read(fts)) != NULL) {
99 		if (ent->fts_info == FTS_D &&
100 		    strcmp(ent->fts_name, "dir") == 0)
101 			saw_d = true;
102 		if (strcmp(ent->fts_name, "file") == 0)
103 			saw_file = true;
104 	}
105 
106 	ATF_CHECK_MSG(saw_d,
107 	    "FTS_D not returned for directory with mode 0400");
108 	ATF_CHECK_MSG(saw_file,
109 	    "file inside mode 0400 directory was not visited");
110 
111 	ATF_REQUIRE_EQ_MSG(0, fts_close(fts), "fts_close(): %m");
112 }
113 
114 /*
115  * PR 196724: FTS_SLNONE must not be returned for a non-symlink.
116  *
117  * The fix ensures that FTS_SLNONE is only returned when lstat confirms
118  * the entry is actually a symlink.  Exercised by a time-bounded race
119  * where a background thread creates and deletes a regular file while
120  * fts traverses with FTS_LOGICAL.
121  */
122 ATF_TC(no_slnone_for_nonsymlink);
ATF_TC_HEAD(no_slnone_for_nonsymlink,tc)123 ATF_TC_HEAD(no_slnone_for_nonsymlink, tc)
124 {
125 	atf_tc_set_md_var(tc, "descr",
126 	    "FTS_SLNONE must not be returned for a non-symlink");
127 }
ATF_TC_BODY(no_slnone_for_nonsymlink,tc)128 ATF_TC_BODY(no_slnone_for_nonsymlink, tc)
129 {
130 	pthread_t thr;
131 	char *paths[] = { "dir", NULL };
132 	FTS *fts;
133 	FTSENT *ent;
134 	struct timespec start, now, elapsed;
135 
136 	ATF_REQUIRE_EQ(0, mkdir("dir", 0755));
137 	ATF_REQUIRE_EQ(0, symlink("nonexistent", "dir/dead"));
138 
139 	race_stop = false;
140 	ATF_REQUIRE_EQ(0, pthread_create(&thr, NULL, race_thrash,
141 	    __DECONST(void *, "dir/victim")));
142 
143 	clock_gettime(CLOCK_MONOTONIC, &start);
144 	for (;;) {
145 		clock_gettime(CLOCK_MONOTONIC, &now);
146 		timespecsub(&now, &start, &elapsed);
147 		if (elapsed.tv_sec >= 1)
148 			break;
149 		fts = fts_open(paths, FTS_LOGICAL, NULL);
150 		ATF_REQUIRE(fts != NULL);
151 		while ((ent = fts_read(fts)) != NULL) {
152 			if (ent->fts_info == FTS_SLNONE &&
153 			    ent->fts_statp->st_mode != 0 &&
154 			    !S_ISLNK(ent->fts_statp->st_mode))
155 				ATF_CHECK_MSG(0,
156 				    "FTS_SLNONE returned for non-symlink '%s'",
157 				    ent->fts_name);
158 		}
159 		fts_close(fts);
160 	}
161 
162 	race_stop = true;
163 	pthread_join(thr, NULL);
164 }
165 
166 /*
167  * PR 262038: fts_build() must detect readdir(2) errors and not treat
168  * them as end-of-directory.  The man page specifies that FTS_DNR must
169  * immediately follow FTS_D, in place of FTS_DP.
170  *
171  * Requires an unprivileged user because root ignores permissions.
172  */
173 ATF_TC(readdir_error_detected);
ATF_TC_HEAD(readdir_error_detected,tc)174 ATF_TC_HEAD(readdir_error_detected, tc)
175 {
176 	atf_tc_set_md_var(tc, "descr",
177 	    "readdir errors produce FTS_DNR with fts_errno set");
178 	atf_tc_set_md_var(tc, "require.user", "unprivileged");
179 }
ATF_TC_BODY(readdir_error_detected,tc)180 ATF_TC_BODY(readdir_error_detected, tc)
181 {
182 	char *paths[] = { "dir", NULL };
183 	FTS *fts;
184 	FTSENT *ent;
185 
186 	ATF_REQUIRE_EQ(0, mkdir("dir", 0755));
187 	ATF_REQUIRE_EQ(0, close(creat("dir/file", 0644)));
188 
189 	/*
190 	 * Mode 0100: execute only, no read.  chdir() succeeds but
191 	 * opendir/readdir fails.  fts must return FTS_D then FTS_DNR
192 	 * (not FTS_DP) per the man page.
193 	 */
194 	ATF_REQUIRE_EQ(0, chmod("dir", 0100));
195 
196 	ATF_REQUIRE((fts = fts_open(paths, FTS_PHYSICAL, NULL)) != NULL);
197 
198 	ATF_REQUIRE((ent = fts_read(fts)) != NULL);
199 	ATF_CHECK_EQ_MSG(FTS_D, ent->fts_info,
200 	    "expected FTS_D, got %d", ent->fts_info);
201 
202 	ATF_REQUIRE((ent = fts_read(fts)) != NULL);
203 	ATF_CHECK_EQ_MSG(FTS_DNR, ent->fts_info,
204 	    "expected FTS_DNR, got %d", ent->fts_info);
205 	ATF_CHECK_MSG(ent->fts_errno != 0,
206 	    "FTS_DNR must have non-zero fts_errno");
207 
208 	ATF_REQUIRE_EQ_MSG(NULL, fts_read(fts),
209 	    "expected NULL after FTS_DNR");
210 
211 	ATF_REQUIRE_EQ_MSG(0, fts_close(fts), "fts_close(): %m");
212 }
213 
214 /*
215  * SVN r246641: fts_safe_changedir() uses O_DIRECTORY to prevent a
216  * TOCTOU substitution attack where a directory is replaced with a
217  * non-directory between stat and open.  Exercised by a time-bounded
218  * race where a background thread repeatedly removes and recreates
219  * dir/sub while fts traverses.
220  */
221 ATF_TC(odirectory_changedir);
ATF_TC_HEAD(odirectory_changedir,tc)222 ATF_TC_HEAD(odirectory_changedir, tc)
223 {
224 	atf_tc_set_md_var(tc, "descr",
225 	    "fts_safe_changedir handles concurrent dir/file substitution");
226 }
ATF_TC_BODY(odirectory_changedir,tc)227 ATF_TC_BODY(odirectory_changedir, tc)
228 {
229 	pthread_t thr;
230 	char *paths[] = { "dir", NULL };
231 	FTS *fts;
232 	struct timespec start, now, elapsed;
233 
234 	ATF_REQUIRE_EQ(0, mkdir("dir", 0755));
235 	ATF_REQUIRE_EQ(0, mkdir("dir/sub", 0755));
236 	ATF_REQUIRE_EQ(0, close(creat("dir/sub/file", 0644)));
237 
238 	/*
239 	 * Background thread races to remove and recreate dir/sub as a
240 	 * directory.  With O_DIRECTORY the open fails safely if dir/sub
241 	 * is temporarily absent or replaced.
242 	 */
243 	race_stop = false;
244 	ATF_REQUIRE_EQ(0, pthread_create(&thr, NULL, dir_thrash,
245 	    __DECONST(void *, "dir/sub")));
246 
247 	clock_gettime(CLOCK_MONOTONIC, &start);
248 	for (;;) {
249 		clock_gettime(CLOCK_MONOTONIC, &now);
250 		timespecsub(&now, &start, &elapsed);
251 		if (elapsed.tv_sec >= 1)
252 			break;
253 		fts = fts_open(paths, FTS_PHYSICAL, NULL);
254 		ATF_REQUIRE(fts != NULL);
255 		while (fts_read(fts) != NULL)
256 			;
257 		fts_close(fts);
258 	}
259 
260 	race_stop = true;
261 	pthread_join(thr, NULL);
262 }
263 
264 /*
265  * SVN r261589: fts must not double-free when the directory tree is
266  * concurrently modified.  Exercised by a time-bounded race where a
267  * background thread creates and deletes a file during traversal.
268  */
269 ATF_TC(concurrent_modification);
ATF_TC_HEAD(concurrent_modification,tc)270 ATF_TC_HEAD(concurrent_modification, tc)
271 {
272 	atf_tc_set_md_var(tc, "descr",
273 	    "no crash when tree modified during traversal");
274 }
ATF_TC_BODY(concurrent_modification,tc)275 ATF_TC_BODY(concurrent_modification, tc)
276 {
277 	pthread_t thr;
278 	char *paths[] = { "dir", NULL };
279 	FTS *fts;
280 	struct timespec start, now, elapsed;
281 
282 	ATF_REQUIRE_EQ(0, mkdir("dir", 0755));
283 	ATF_REQUIRE_EQ(0, close(creat("dir/stable", 0644)));
284 
285 	race_stop = false;
286 	ATF_REQUIRE_EQ(0, pthread_create(&thr, NULL, race_thrash,
287 	    __DECONST(void *, "dir/victim")));
288 
289 	clock_gettime(CLOCK_MONOTONIC, &start);
290 	for (;;) {
291 		clock_gettime(CLOCK_MONOTONIC, &now);
292 		timespecsub(&now, &start, &elapsed);
293 		if (elapsed.tv_sec >= 1)
294 			break;
295 		fts = fts_open(paths, FTS_PHYSICAL, NULL);
296 		ATF_REQUIRE(fts != NULL);
297 		while (fts_read(fts) != NULL)
298 			;
299 		fts_close(fts);
300 	}
301 
302 	race_stop = true;
303 	pthread_join(thr, NULL);
304 }
305 
ATF_TP_ADD_TCS(tp)306 ATF_TP_ADD_TCS(tp)
307 {
308 	ATF_TP_ADD_TC(tp, read_no_exec_dir);
309 	ATF_TP_ADD_TC(tp, no_slnone_for_nonsymlink);
310 	ATF_TP_ADD_TC(tp, readdir_error_detected);
311 	ATF_TP_ADD_TC(tp, odirectory_changedir);
312 	ATF_TP_ADD_TC(tp, concurrent_modification);
313 
314 	return (atf_no_error());
315 }
316