From: Pádraig Brady Date: Wed, 22 Jul 2026 18:23:26 +0000 (+0100) Subject: chcon,chgrp,chmod,chown,du,ls: ignore missing files while traversing X-Git-Url: http://git.ipfire.org/gitweb/?a=commitdiff_plain;h=30866b30e2d32b7cc8e2eea3719aae995ffccbd3;p=thirdparty%2Fcoreutils.git 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 for missing 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 --- diff --git a/NEWS b/NEWS index 73c7af9d1b..76354e9e9c 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 91b78148d0..4ab2d75701 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 8b57172707..68f37e1810 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 e5e355fdb8..21f1e3d97a 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 9b80633711..eefb24f05a 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 0000000000..cb007bca9d --- /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 . */ + +#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 d7d6ae5096..db86cb5ea7 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 3d583b85f1..a4be6fcea0 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 d0f2e0c44d..e96127d41f 100644 --- a/src/system.h +++ b/src/system.h @@ -83,6 +83,11 @@ # 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; } + #include #include "version.h" diff --git a/tests/chcon/traversal-missing.sh b/tests/chcon/traversal-missing.sh new file mode 100755 index 0000000000..7286cc8fdf --- /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 . + +. "${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; 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 18bf798618..9414dbf9da 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 0000000000..7352331002 --- /dev/null +++ b/tests/misc/traversal-missing.sh @@ -0,0 +1,74 @@ +#!/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 . + +. "${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 $expected_status || fail=1 + if test $expected_status -eq 0; then + test ! -s err || { cat err; fail=1; } + else + test -s err || fail=1 + fi +} + +expected_status=0 +for errnum in ENOENT EIO; do + test $errnum = EIO && expected_status=1 + 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