Hi,

I attach a patch for the "install" utility: "install -C" was still
calling chown/chmod on the destination even when need_copy() had
already determined it matches (mode, owner, group, content), which
could needlessly fail, e.g. on an immutable file.

This is my first patch to coreutils, so apologies for any mistakes in
the submission process -- happy to fix anything you point out.

Thanks,
Mateusz Nosek
From c762fc651e78dc01aa2bc370882283db414e127d Mon Sep 17 00:00:00 2001
From: Mateusz Nosek <[email protected]>
Date: Tue, 14 Jul 2026 23:50:28 +0200
Subject: [PATCH] install: skip chown/chmod when -C finds nothing to change

"install -C" (--compare) has always called chown/chmod on the
destination even after determining that copying was unnecessary,
ever since --compare was added in commit v7.0-182-gdac5f12c6.
need_copy() already verifies that the destination's mode, owner, and
group match before skipping the copy, so re-applying them is
redundant, and can needlessly fail on a destination that cannot be
chown/chmod'd even though nothing would actually change, e.g. one
marked immutable with chattr +i.
[bug introduced in coreutils-7.1]

* src/install.c (change_attributes): Move the setdefaultfilecon call
out to the caller, decoupling it from chown/chmod success.
(install_file_in_file): Only call change_attributes when the copy
was actually performed, i.e. copy_status != COPY_SKIPPED.
* tests/install/install-C-immutable.sh: New test.
* tests/local.mk (all_root_tests): Add it.
---
 src/install.c                        | 13 ++++--
 tests/install/install-C-immutable.sh | 60 ++++++++++++++++++++++++++++
 tests/local.mk                       |  1 +
 3 files changed, 70 insertions(+), 4 deletions(-)
 create mode 100755 tests/install/install-C-immutable.sh

diff --git a/src/install.c b/src/install.c
index bccfe606f..296cc01d0 100644
--- a/src/install.c
+++ b/src/install.c
@@ -457,9 +457,6 @@ change_attributes (char const *name, int dirfd, char const *relname)
   else
     ok = true;
 
-  if (use_default_selinux_context)
-    setdefaultfilecon (name);
-
   return ok;
 }
 
@@ -737,7 +734,15 @@ install_file_in_file (char const *from, char const *to,
                                  || ! S_ISREG (from_sb.st_mode))
       && ! change_timestamps (&from_sb, to, to_dirfd, to_relname))
     return false;
-  return change_attributes (to, to_dirfd, to_relname);
+
+  if (copy_status != COPY_SKIPPED
+      && ! change_attributes (to, to_dirfd, to_relname))
+    return false;
+
+  if (use_default_selinux_context)
+    setdefaultfilecon (to);
+
+  return true;
 }
 
 /* Create any missing parent directories of TO,
diff --git a/tests/install/install-C-immutable.sh b/tests/install/install-C-immutable.sh
new file mode 100755
index 000000000..cc1a88621
--- /dev/null
+++ b/tests/install/install-C-immutable.sh
@@ -0,0 +1,60 @@
+#!/bin/sh
+# Ensure "install -C" does not attempt to chown/chmod an unchanged,
+# immutable destination file.
+# Requires root access to do chattr +i, as well as a file system
+# that supports the immutable attribute (e.g., ext2/3/4, xfs, btrfs).
+
+# 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_ ginstall
+require_root_
+skip_if_setgid_
+skip_if_nondefault_group_
+
+# These simple one-file operations are expected to work even in the
+# presence of this bug, and we need them to set up the rest of the test.
+chattr_i_works=1
+touch f
+chattr +i f 2>/dev/null || chattr_i_works=0
+rm f 2>/dev/null
+test -f f || chattr_i_works=0
+chattr -i f 2>/dev/null || chattr_i_works=0
+rm f 2>/dev/null || chattr_i_works=0
+test -f f && chattr_i_works=0
+
+if test $chattr_i_works = 0; then
+  skip_ "chattr +i doesn't work on this file system"
+fi
+
+u1=1
+g1=1
+
+echo test > a || framework_failure_
+
+# Install once to create the destination, then make it immutable.
+ginstall -Cv -m0644 -o$u1 -g$g1 a b || framework_failure_
+chattr +i b || framework_failure_
+
+# A second "-C" install with an identical source, mode, owner and group
+# must be a no-op: it must not attempt to chown/chmod the destination,
+# since that would fail on an immutable file even though nothing about
+# it would actually change.
+ginstall -Cv -m0644 -o$u1 -g$g1 a b || fail=1
+
+chattr -i b
+
+Exit $fail
diff --git a/tests/local.mk b/tests/local.mk
index e2afbe134..18bf79861 100644
--- a/tests/local.mk
+++ b/tests/local.mk
@@ -121,6 +121,7 @@ all_root_tests =				\
   tests/du/bind-mount-dir-cycle.sh		\
   tests/du/bind-mount-dir-cycle-v2.sh		\
   tests/id/setgid.sh				\
+  tests/install/install-C-immutable.sh		\
   tests/install/install-C-root.sh		\
   tests/ls/capability.sh			\
   tests/ls/no-cap.sh				\
-- 
2.47.3

Reply via email to