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