On 20/07/2026 18:12, Paul Holzinger via GNU coreutils Bug Reports wrote:
Hi,I noticed that when chown'ing a directory tree recursively with many files sometimes chown will exit 1 and report ENOENT errors. This happens when looping over the directory results from readdir() and in a parallel another process removes a file before the code then calls stat(), chmod() or whatever syscalls the command has to do for the given filename. Since such race is expected the coreutils should not treat this as error and just ignore them instead. That way a parallel removal of a file will not make the commands return exit code 1 and thus possibility break scripts. Of course this should only apply to files or directories under the main path given as input, i.e. chmod -R dest should still fail with ENOENT when dest does not exists. Below is a reproducer for the commands, might needs to run a few times until it triggers the race condition, I am not sure if there are more commands in coreutils which are affected by this.
Thanks for the repro, it works well. I agree, these commands shouldn't warn or fail for files being added, replaced, removed in the tree being traversed. I also notice that du is affected. The attached should hopefully address this issue. thanks, Padraig
From 94875b4f1bab47ba5ac8cc234fe70d215dcfb9d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A1draig=20Brady?= <[email protected]> Date: Wed, 22 Jul 2026 19:23:26 +0100 Subject: [PATCH] chcon,chgrp,chmod,chown,du,ls: ignore missing files while traversing * src/fts-missing.h: Add helper routines to determine if an FTS entry is skippable. I.e., that is traversed and not specified on the command line. * src/chcon.c (process_file): Setup to ignore skippable files. (change_file_context): Ignore missing files if appropriate. * src/chmod.c (process_file): Setup to ignore skippable files. Ignore missing files if appropriate. (mode_changed): Ignore missing files if appropriate. * src/chown-core.c (change_file_owner): Setup to ignore skippable files. Ignore missing files if appropriate. * src/du.c (process_file): Likewise. * src/ls.c (print_dir): Ignore missing files if appropriate. (get_link_name): Likewise. (gobble_file): Likewise. Also handle the dangling symlink case. * src/system.h (ignorable_traversal_errno): Match ENOENT and ENOTDIR, for missing and replaced files. * tests/chcon/traversal-missing.sh: A new test. * tests/misc/traversal-missing.sh: Likewise. * tests/local.mk: Reference the new tests. * NEWS: Mention the bug fix. Fixes https://bugs.gnu.org/81444 --- NEWS | 4 ++ src/chcon.c | 20 +++++++--- src/chmod.c | 29 ++++++++++++-- src/chown-core.c | 24 ++++++++--- src/du.c | 8 ++++ src/fts-missing.h | 39 ++++++++++++++++++ src/local.mk | 1 + src/ls.c | 62 +++++++++++++++++++++-------- src/system.h | 8 ++++ tests/chcon/traversal-missing.sh | 48 ++++++++++++++++++++++ tests/local.mk | 2 + tests/misc/traversal-missing.sh | 68 ++++++++++++++++++++++++++++++++ 12 files changed, 283 insertions(+), 30 deletions(-) create mode 100644 src/fts-missing.h create mode 100755 tests/chcon/traversal-missing.sh create mode 100755 tests/misc/traversal-missing.sh diff --git a/NEWS b/NEWS index 73c7af9d1..76354e9e9 100644 --- a/NEWS +++ b/NEWS @@ -4,6 +4,10 @@ GNU coreutils NEWS -*- outline -*- ** Bug fixes + 'chcon', 'chgrp', 'chmod', 'chown', 'du', 'ls' which traverse hierarchies with + -R, no longer fail merely because files may be being removed in parallel. + [This bug was present in "the beginning".] + 'comm - -' no longer closes standard input twice. Previously it would mistakenly exit with a nonzero status. [This bug was present in "the beginning".] diff --git a/src/chcon.c b/src/chcon.c index 91b78148d..4ab2d7570 100644 --- a/src/chcon.c +++ b/src/chcon.c @@ -22,6 +22,7 @@ #include "system.h" #include "dev-ino.h" +#include "fts-missing.h" #include "ignore-value.h" #include "quote.h" #include "root-dev-ino.h" @@ -137,7 +138,7 @@ compute_context_from_mask (char const *context, context_t *ret) Return 0 if successful, 1 if errors occurred. */ static int -change_file_context (int fd, char const *file) +change_file_context (int fd, char const *file, bool ignore_missing) { char *file_context = NULL; context_t context IF_LINT (= NULL); @@ -152,6 +153,8 @@ change_file_context (int fd, char const *file) if (status < 0 && errno != ENODATA) { + if (ignore_missing && ignorable_traversal_errno (errno)) + return 0; error (0, errno, _("failed to get security context of %s"), quoteaf (file)); return 1; @@ -188,9 +191,12 @@ change_file_context (int fd, char const *file) if (fail) { - errors = 1; - error (0, errno, _("failed to change context of %s to %s"), - quoteaf_n (0, file), quote_n (1, context_string)); + if (! (ignore_missing && ignorable_traversal_errno (errno))) + { + errors = 1; + error (0, errno, _("failed to change context of %s to %s"), + quoteaf_n (0, file), quote_n (1, context_string)); + } } } @@ -214,6 +220,10 @@ process_file (FTS *fts, FTSENT *ent) char const *file = ent->fts_accpath; const struct stat *file_stats = ent->fts_statp; bool ok = true; + bool ignore_missing = ignore_missing_fts_entry (ent); + + if (ignore_missing && ignorable_fts_error (ent)) + return true; switch (ent->fts_info) { @@ -295,7 +305,7 @@ process_file (FTS *fts, FTSENT *ent) printf (_("changing security context of %s\n"), quoteaf (file_full_name)); - if (change_file_context (fts->fts_cwd_fd, file) != 0) + if (change_file_context (fts->fts_cwd_fd, file, ignore_missing) != 0) ok = false; } diff --git a/src/chmod.c b/src/chmod.c index 8b5717270..68f37e181 100644 --- a/src/chmod.c +++ b/src/chmod.c @@ -25,6 +25,7 @@ #include "assure.h" #include "dev-ino.h" #include "filemode.h" +#include "fts-missing.h" #include "ignore-value.h" #include "modechange.h" #include "quote.h" @@ -125,8 +126,10 @@ static struct option const long_options[] = static bool mode_changed (int dir_fd, char const *file, char const *file_full_name, - mode_t old_mode, mode_t new_mode) + mode_t old_mode, mode_t new_mode, bool ignore_missing, + bool *file_missing) { + *file_missing = false; if (new_mode & (S_ISUID | S_ISGID | S_ISVTX)) { /* The new mode contains unusual bits that the call to chmod may @@ -136,7 +139,9 @@ mode_changed (int dir_fd, char const *file, char const *file_full_name, if (fstatat (dir_fd, file, &new_stats, 0) != 0) { - if (! force_silent) + *file_missing = (ignore_missing + && ignorable_traversal_errno (errno)); + if (! *file_missing && ! force_silent) error (0, errno, _("getting new attributes of %s"), quoteaf (file_full_name)); return false; @@ -215,6 +220,10 @@ process_file (FTS *fts, FTSENT *ent) struct change_status ch = {0}; ch.status = CH_NO_STAT; struct stat stat_buf; + bool ignore_missing = ignore_missing_fts_entry (ent); + + if (ignore_missing && ignorable_fts_error (ent)) + return true; switch (ent->fts_info) { @@ -267,6 +276,9 @@ process_file (FTS *fts, FTSENT *ent) { if (fstatat (fts->fts_cwd_fd, file, &stat_buf, 0) != 0) { + if (ignore_missing + && ignorable_traversal_errno (errno)) + return true; if (! force_silent) error (0, errno, _("cannot dereference %s"), quoteaf (file_full_name)); @@ -314,6 +326,9 @@ process_file (FTS *fts, FTSENT *ent) ch.status = CH_SUCCEEDED; else { + if (ignore_missing + && ignorable_traversal_errno (errno)) + return true; if (! is_ENOTSUP (errno)) { if (! force_silent) @@ -328,10 +343,16 @@ process_file (FTS *fts, FTSENT *ent) if (verbosity != V_off) { + bool file_missing; if (ch.status == CH_SUCCEEDED && !mode_changed (fts->fts_cwd_fd, file, file_full_name, - ch.old_mode, ch.new_mode)) - ch.status = CH_NO_CHANGE_REQUESTED; + ch.old_mode, ch.new_mode, ignore_missing, + &file_missing)) + { + if (file_missing) + return true; + ch.status = CH_NO_CHANGE_REQUESTED; + } if (ch.status == CH_SUCCEEDED || verbosity == V_high) describe_change (file_full_name, &ch); diff --git a/src/chown-core.c b/src/chown-core.c index e5e355fdb..21f1e3d97 100644 --- a/src/chown-core.c +++ b/src/chown-core.c @@ -25,6 +25,7 @@ #include "system.h" #include "assure.h" #include "chown-core.h" +#include "fts-missing.h" #include "ignore-value.h" #include "root-dev-ino.h" #include "xfts.h" @@ -284,6 +285,10 @@ change_file_owner (FTS *fts, FTSENT *ent, char const *file_full_name = ent->fts_path; char const *file = ent->fts_accpath; bool ok = true; + bool ignore_missing = ignore_missing_fts_entry (ent); + + if (ignore_missing && ignorable_fts_error (ent)) + return true; switch (ent->fts_info) { @@ -381,6 +386,9 @@ change_file_owner (FTS *fts, FTSENT *ent, { if (fstatat (fts->fts_cwd_fd, file, &stat_buf, 0) != 0) { + if (ignore_missing + && ignorable_traversal_errno (errno)) + return true; if (! chopt->force_silent) error (0, errno, _("cannot dereference %s"), quoteaf (file_full_name)); @@ -469,11 +477,17 @@ change_file_owner (FTS *fts, FTSENT *ent, by some other user and operating on files in a directory where M has write access. */ - if (do_chown && !ok && ! chopt->force_silent) - error (0, errno, (uid != (uid_t) -1 - ? _("changing ownership of %s") - : _("changing group of %s")), - quoteaf (file_full_name)); + if (do_chown && !ok) + { + if (ignore_missing + && ignorable_traversal_errno (errno)) + return true; + if (! chopt->force_silent) + error (0, errno, (uid != (uid_t) -1 + ? _("changing ownership of %s") + : _("changing group of %s")), + quoteaf (file_full_name)); + } } if (chopt->verbosity != V_off) diff --git a/src/du.c b/src/du.c index 9b8063371..eefb24f05 100644 --- a/src/du.c +++ b/src/du.c @@ -32,6 +32,7 @@ #include "assure.h" #include "di-set.h" #include "exclude.h" +#include "fts-missing.h" #include "human.h" #include "mountlist.h" #include "quote.h" @@ -554,6 +555,10 @@ process_file (FTS *fts, FTSENT *ent) char const *file = ent->fts_path; const struct stat *sb = ent->fts_statp; int info = ent->fts_info; + bool ignore_missing = ignore_missing_fts_entry (ent); + + if (ignore_missing && ignorable_fts_error (ent)) + return true; if (info == FTS_DNR) { @@ -574,6 +579,9 @@ process_file (FTS *fts, FTSENT *ent) MAYBE_UNUSED FTSENT const *e = fts_read (fts); affirm (e == ent); info = ent->fts_info; + + if (ignore_missing && ignorable_fts_error (ent)) + return true; } if (info == FTS_NS || info == FTS_SLNONE) diff --git a/src/fts-missing.h b/src/fts-missing.h new file mode 100644 index 000000000..cb007bca9 --- /dev/null +++ b/src/fts-missing.h @@ -0,0 +1,39 @@ +/* fts-missing.h -- helpers for files missing during an FTS traversal. + + Copyright (C) 2026 Free Software Foundation, Inc. + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see <https://www.gnu.org/licenses/>. */ + +#ifndef FTS_MISSING_H +# define FTS_MISSING_H + +# include "fts_.h" + +static inline bool +ignore_missing_fts_entry (FTSENT const *ent) +{ + return (FTS_ROOTLEVEL < ent->fts_level + && ent->fts_info != FTS_SL + && ent->fts_info != FTS_SLNONE); +} + +static inline bool +ignorable_fts_error (FTSENT const *ent) +{ + return (ignorable_traversal_errno (ent->fts_errno) + && (ent->fts_info == FTS_NS || ent->fts_info == FTS_ERR + || ent->fts_info == FTS_DNR)); +} + +#endif /* FTS_MISSING_H */ diff --git a/src/local.mk b/src/local.mk index d7d6ae509..db86cb5ea 100644 --- a/src/local.mk +++ b/src/local.mk @@ -50,6 +50,7 @@ noinst_HEADERS = \ src/find-mount-point.h \ src/fs.h \ src/fs-is-local.h \ + src/fts-missing.h \ src/group-list.h \ src/ioblksize.h \ src/iopoll.h \ diff --git a/src/ls.c b/src/ls.c index 3d583b85f..a4be6fcea 100644 --- a/src/ls.c +++ b/src/ls.c @@ -279,7 +279,7 @@ static void attach (char *dest, char const *dirname, char const *name); static void clear_files (void); static void extract_dirs_from_files (char const *dirname, bool command_line_arg); -static void get_link_name (char const *filename, struct fileinfo *f, +static bool get_link_name (char const *filename, struct fileinfo *f, bool command_line_arg); static void indent (size_t from, size_t to); static idx_t calculate_columns (bool by_columns); @@ -2961,7 +2961,8 @@ print_dir (char const *name, char const *realname, bool command_line_arg) dirp = opendir (name); if (!dirp) { - file_failure (command_line_arg, _("cannot open directory %s"), name); + if (command_line_arg || ! ignorable_traversal_errno (errno)) + file_failure (command_line_arg, _("cannot open directory %s"), name); return; } @@ -2975,8 +2976,9 @@ print_dir (char const *name, char const *realname, bool command_line_arg) ? fstat_for_ino (fd, &dir_stat) : stat_for_ino (name, &dir_stat)) < 0) { - file_failure (command_line_arg, - _("cannot determine device and inode of %s"), name); + if (! ignorable_traversal_errno (errno)) + file_failure (command_line_arg, + _("cannot determine device and inode of %s"), name); closedir (dirp); return; } @@ -3008,7 +3010,8 @@ print_dir (char const *name, char const *realname, bool command_line_arg) if (print_hyperlink) { absolute_name = canonicalize_filename_mode (name, CAN_MISSING); - if (! absolute_name) + if (! absolute_name + && ! ignorable_traversal_errno (errno)) file_failure (command_line_arg, _("error canonicalizing %s"), name); } @@ -3064,12 +3067,9 @@ print_dir (char const *name, char const *realname, bool command_line_arg) int err = errno; if (err == 0) break; - /* Some readdir()s do not absorb ENOENT (dir deleted but open). - This bug was fixed in glibc 2.3 (2002). */ -#if ! (2 < __GLIBC__ + (3 <= __GLIBC_MINOR__)) - if (err == ENOENT) + /* Ignore errors indicating that the directory was removed. */ + if (ignorable_traversal_errno (err)) break; -#endif file_failure (command_line_arg, _("reading directory %s"), name); if (err != EOVERFLOW) break; @@ -3391,7 +3391,9 @@ gobble_file (char const *name, enum filetype type, ino_t inode, { f->absolute_name = canonicalize_filename_mode (full_name, CAN_MISSING); - if (! f->absolute_name) + if (! f->absolute_name + && (command_line_arg + || ! ignorable_traversal_errno (errno))) file_failure (command_line_arg, _("error canonicalizing %s"), full_name); } @@ -3439,6 +3441,22 @@ gobble_file (char const *name, enum filetype type, ino_t inode, if (err != 0) { + if (! command_line_arg && ignorable_traversal_errno (errno)) + { + int stat_errno = errno; + struct stat linkstat; + bool dangling_link = (do_deref + && (type == symbolic_link + || type == unknown) + && do_lstat (full_name, &linkstat) == 0); + errno = stat_errno; + if (! dangling_link) + { + free_ent (f); + return 0; + } + } + /* Failure to stat a command line argument leads to an exit status of 2. For other files, stat failure provokes an exit status of 1. */ @@ -3483,7 +3501,8 @@ gobble_file (char const *name, enum filetype type, ino_t inode, Also if a file is removed while we're reading ACL info, ACL_T_UNKNOWN is sufficient indication for that edge case. */ bool cannot_access_acl = n < 0 - && (errno == EACCES || errno == ENOENT); + && (errno == EACCES + || ignorable_traversal_errno (errno)); f->acl_type = (!have_scontext && !have_acl ? (cannot_access_acl ? ACL_T_UNKNOWN : ACL_T_NONE) @@ -3501,6 +3520,7 @@ gobble_file (char const *name, enum filetype type, ino_t inode, isn't on the right type of file system. I.e., a getfilecon failure isn't in the same class as a stat failure. */ if (print_scontext && ai.scontext_err + && ! ignorable_traversal_errno (ai.scontext_err) && (! (is_ENOTSUP (ai.scontext_err) || ai.scontext_err == ENODATA))) error (0, ai.scontext_err, "%s", quotef (full_name)); @@ -3522,7 +3542,11 @@ gobble_file (char const *name, enum filetype type, ino_t inode, { struct stat linkstats; - get_link_name (full_name, f, command_line_arg); + if (! get_link_name (full_name, f, command_line_arg)) + { + free_ent (f); + return 0; + } /* Use the slower quoting path for this entry, though don't update CWD_SOME_QUOTED since alignment not affected. */ @@ -3648,13 +3672,19 @@ is_linked_directory (const struct fileinfo *f) into the LINKNAME field of 'f'. COMMAND_LINE_ARG indicates whether FILENAME is a command-line argument. */ -static void +static bool get_link_name (char const *filename, struct fileinfo *f, bool command_line_arg) { f->linkname = areadlink_with_size (filename, f->stat.st_size); if (f->linkname == NULL) - file_failure (command_line_arg, _("cannot read symbolic link %s"), - filename); + { + if (! command_line_arg + && ignorable_traversal_errno (errno)) + return false; + file_failure (command_line_arg, _("cannot read symbolic link %s"), + filename); + } + return true; } /* Return true if the last component of NAME is '.' or '..' diff --git a/src/system.h b/src/system.h index d0f2e0c44..89ce2a8aa 100644 --- a/src/system.h +++ b/src/system.h @@ -83,6 +83,14 @@ # define ENODATA (-1) #endif +/* Return true if ERRNUM indicates that a file disappeared during a + directory traversal. */ +static inline bool +ignorable_traversal_errno (int errnum) +{ + return errnum == ENOENT || errnum == ENOTDIR; +} + #include <stdlib.h> #include "version.h" diff --git a/tests/chcon/traversal-missing.sh b/tests/chcon/traversal-missing.sh new file mode 100755 index 000000000..356d7ce4d --- /dev/null +++ b/tests/chcon/traversal-missing.sh @@ -0,0 +1,48 @@ +#!/bin/sh +# Test ignoring traversal races using strace fault injection. + +# Copyright (C) 2026 Free Software Foundation, Inc. + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. + +. "${srcdir=.}/tests/init.sh"; path_prepend_ ./src +print_ver_ chcon +require_strace_ getxattr + +mkdir d || framework_failure_ +touch d/foo || framework_failure_ +xattrs=getxattr,lgetxattr,setxattr,lsetxattr + +(cd d && chcon -R -t user_tmp_t . > /dev/null 2>&1) \ + || skip_ 'chcon does not work on the test file system' + +# Skip if strace does not support path-filtered syscall injection. +strace --quiet=all -o /dev/null -P /proc/self/fd/4/foo \ + -e inject=$xattrs:error=ENOENT true 2> /dev/null \ + || skip_ 'strace does not support the required options and syscalls' + +for errnum in ENOENT ENOTDIR; do + rm -f err trace + (cd d && strace --quiet=all -o ../trace -P /proc/self/fd/4/foo \ + -e inject=$xattrs:error=$errnum chcon -R -t user_tmp_t . \ + > /dev/null 2> ../err) + status=$? + + grep ' (INJECTED)' trace > /dev/null 2>&1 \ + || skip_ 'strace did not inject the requested failure' + test $status -eq 0 || fail=1 + test ! -s err || { cat err; fail=1; } +done + +Exit $fail diff --git a/tests/local.mk b/tests/local.mk index 18bf79861..9414dbf9d 100644 --- a/tests/local.mk +++ b/tests/local.mk @@ -185,6 +185,7 @@ all_tests = \ tests/misc/io-errors.sh \ tests/misc/read-errors.sh \ tests/misc/responsive.sh \ + tests/misc/traversal-missing.sh \ tests/misc/warning-errors.sh \ tests/misc/write-errors.sh \ tests/tail/basic-seek.sh \ @@ -297,6 +298,7 @@ all_tests = \ tests/pwd/argument.sh \ tests/pwd/pwd-option.sh \ tests/chcon/chcon-fail.sh \ + tests/chcon/traversal-missing.sh \ tests/misc/coreutils.sh \ tests/cut/cut.pl \ tests/cut/mb-non-utf8.sh \ diff --git a/tests/misc/traversal-missing.sh b/tests/misc/traversal-missing.sh new file mode 100755 index 000000000..1fa7dd888 --- /dev/null +++ b/tests/misc/traversal-missing.sh @@ -0,0 +1,68 @@ +#!/bin/sh +# Test ignoring traversal races using strace fault injection. + +# Copyright (C) 2026 Free Software Foundation, Inc. + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. + +. "${srcdir=.}/tests/init.sh"; path_prepend_ ./src +print_ver_ ls chmod chown chgrp du +require_strace_ stat + +mkdir d || framework_failure_ +touch d/foo || framework_failure_ +uid=$(id -u) || framework_failure_ +gid=$(id -g) || framework_failure_ + +stats='stat' +# List other _file name_ stat functions to increase coverage. +other_stats='statx lstat stat64 lstat64 newfstatat fstatat64' +for stat in $other_stats; do + strace -qe "$stat" true > /dev/null 2>&1 && + stats="$stats,$stat" +done + +chmods=chmod,fchmodat,fchmodat2 +chowns=chown,lchown,fchownat + +# Skip if strace does not support path-filtered syscall injection. +strace --quiet=all -o /dev/null -P foo \ + -e inject=$stats:error=ENOENT true 2> /dev/null \ + || skip_ 'strace does not support the required options and syscalls' + +run_injected_ () +{ + path=$1 + syscalls=$2 + shift 2 + rm -f err trace + (cd d && strace --quiet=all -o ../trace -P "$path" \ + -e inject="$syscalls:error=$errnum" "$@" > /dev/null 2> ../err) + status=$? + + grep ' (INJECTED)' trace > /dev/null 2>&1 \ + || skip_ 'strace did not inject the requested failure' + test $status -eq 0 || fail=1 + test ! -s err || { cat err; fail=1; } +} + +for errnum in ENOENT ENOTDIR; do + run_injected_ foo "$stats" ls -l . + run_injected_ foo "$stats" du -a . + run_injected_ foo "$chmods" chmod -R 0700 . + run_injected_ foo "$chowns" chown -R "$uid" . + run_injected_ foo "$chowns" chgrp -R "$gid" . +done + +Exit $fail -- 2.55.0
