git: 7bf81e39d830 - main - ls: check fts_children() for errors that may not surface otherwise

From: Kyle Evans <kevans_at_FreeBSD.org>
Date: Wed, 11 Feb 2026 19:56:48 UTC
The branch main has been updated by kevans:

URL: https://cgit.FreeBSD.org/src/commit/?id=7bf81e39d83087dc7f984077b5eed5a48df794d4

commit 7bf81e39d83087dc7f984077b5eed5a48df794d4
Author:     Kyle Evans <kevans@FreeBSD.org>
AuthorDate: 2026-02-11 19:55:55 +0000
Commit:     Kyle Evans <kevans@FreeBSD.org>
CommitDate: 2026-02-11 19:56:37 +0000

    ls: check fts_children() for errors that may not surface otherwise
    
    In particular, if one simply does a non-recursive `ls` on a directory
    that is not accessible, there are some classes of errors that may cause
    it to fail that wouldn't be surfaced unless we do an fts_read() that
    will recurse into the inaccessible directory.  Catch those kinds of
    errors here since we cannot expect to an FTS_ERR/FTS_DNR entry to follow
    up on them.
    
    PR:             287451
    Reviewed by:    kib
    Discusssed with:        des
    Differential Revision:  https://reviews.freebsd.org/D51056
---
 bin/ls/ls.c              | 17 +++++++++++++++++
 bin/ls/tests/ls_tests.sh | 30 ++++++++++++++++++++++++++++++
 2 files changed, 47 insertions(+)

diff --git a/bin/ls/ls.c b/bin/ls/ls.c
index b3d0a508d714..c33d4d38c359 100644
--- a/bin/ls/ls.c
+++ b/bin/ls/ls.c
@@ -707,6 +707,23 @@ traverse(int argc, char *argv[], int options)
 				output = 1;
 			}
 			chp = fts_children(ftsp, ch_options);
+			if (chp == NULL && errno != 0) {
+				warn("%s", p->fts_path);
+				rval = 1;
+
+				/*
+				 * Avoid further errors on this entry.  We won't
+				 * always get an FTS_ERR/FTS_DNR for errors
+				 * in fts_children(), because opendir could
+				 * have failed early on and that only flags an
+				 * error for fts_read() when we try to recurse
+				 * into it.  We catch both the non-recursive and
+				 * the recursive case here.
+				 */
+				(void)fts_set(ftsp, p, FTS_SKIP);
+				break;
+			}
+
 			display(p, chp, options);
 
 			if (!f_recursive && chp != NULL)
diff --git a/bin/ls/tests/ls_tests.sh b/bin/ls/tests/ls_tests.sh
index c732b60b21a4..be662b75695d 100755
--- a/bin/ls/tests/ls_tests.sh
+++ b/bin/ls/tests/ls_tests.sh
@@ -476,6 +476,35 @@ b_flag_body()
 	atf_check -e empty -o match:'y\\vz' -s exit:0 ls -b
 }
 
+atf_test_case childerr
+childerr_head()
+{
+	atf_set "descr" "Verify that fts_children() in pre-order errors are checked"
+	atf_set "require.user" "unprivileged"
+}
+
+childerr_body()
+{
+	atf_check mkdir -p root/dir root/edir
+	atf_check touch root/c
+
+	# Check that listing an empty directory hasn't regressed into being
+	# called an error.
+	atf_check -o match:"total 0" -e empty ls -l root/dir
+
+	atf_check chmod 0 root/dir
+
+	# If we did not abort after fts_children() properly, then stdout would
+	# have an output of the total files enumerated (0).  Thus, assert that
+	# it's empty and that we see the correct error on stderr.
+	atf_check -s not-exit:0 -e match:"Permission denied" ls -l root/dir
+
+	# Now ensure that we didn't just stop there, we printed out a directory
+	# that would've been enumerated later.
+	atf_check -s not-exit:0 -o match:"^root/edir" \
+	    -e match:"Permission denied" ls -lR root
+}
+
 atf_test_case d_flag
 d_flag_head()
 {
@@ -971,6 +1000,7 @@ atf_init_test_cases()
 	#atf_add_test_case Z_flag
 	atf_add_test_case a_flag
 	atf_add_test_case b_flag
+	atf_add_test_case childerr
 	#atf_add_test_case c_flag
 	atf_add_test_case d_flag
 	atf_add_test_case f_flag