install: add --compare (-C) option to install file only when necessary
authorKamil Dudka <kdudka@redhat.com>
Tue, 17 Feb 2009 12:16:54 +0000 (13:16 +0100)
committerJim Meyering <meyering@redhat.com>
Tue, 17 Feb 2009 13:12:44 +0000 (14:12 +0100)
* src/install.c (have_same_content): New function to compare files
content.
(extra_mode): New function checking for non-permission bits in mode.
(need_copy): New function to check if copy is necessary.
(main): Handle new option --compare (-C).
(copy_file): Skip file copying if not necessary.
(usage): Show new option --compare (-C) in --help.
* tests/install/install-C: Basic tests for install --compare (-C).
* tests/install/install-C-root: Tests requiring root privileges.
* tests/install/install-C-selinux: Tests requiring SELinux.
* tests/Makefile.am: Add new tests for install --compare (-C).
* doc/coreutils.texi: Document new install option --compare (-C).
* NEWS: Mention the change.

NEWS
doc/coreutils.texi
src/install.c
tests/Makefile.am
tests/install/install-C [new file with mode: 0755]
tests/install/install-C-root [new file with mode: 0755]
tests/install/install-C-selinux [new file with mode: 0755]

diff --git a/NEWS b/NEWS
index 9de4f25..4f80813 100644 (file)
--- a/NEWS
+++ b/NEWS
@@ -16,6 +16,11 @@ GNU coreutils NEWS                                    -*- outline -*-
   dd accepts iflag=cio and oflag=cio to open the file in CIO (concurrent I/O)
   mode where this feature is available.
 
+  install accepts a new option, --compare (-C): compare each pair of source
+  and destination files, and if the destination has identical content and
+  any specified owner, group, permissions, and possibly SELinux context, then
+  do not modify the destination at all.
+
   ls --color now highlights hard linked files, too
 
   stat -f recognizes the Lustre file system type
index 57497e9..ba1e74e 100644 (file)
@@ -2123,6 +2123,14 @@ The program accepts the following options.  Also see @ref{Common options}.
 
 @table @samp
 
+@item -C
+@itemx --compare
+@opindex -C
+@opindex --compare
+Compare each pair of source and destination files, and if the destination has
+identical content and any specified owner, group, permissions, and possibly
+SELinux context, then do not modify the destination at all.
+
 @item -c
 @itemx --crown-margin
 @opindex -c
index 9bf9eee..669fbea 100644 (file)
@@ -31,6 +31,7 @@
 #include "cp-hash.h"
 #include "copy.h"
 #include "filenamecat.h"
+#include "full-read.h"
 #include "mkancesdirs.h"
 #include "mkdir-p.h"
 #include "modechange.h"
@@ -125,6 +126,9 @@ static mode_t dir_mode = DEFAULT_MODE;
    or S_ISGID bits.  */
 static mode_t dir_mode_bits = CHMOD_MODE_BITS;
 
+/* Compare files before installing (-C) */
+static bool copy_only_if_needed;
+
 /* If true, strip executable files after copying them. */
 static bool strip_files;
 
@@ -145,6 +149,7 @@ enum
 static struct option const long_options[] =
 {
   {"backup", optional_argument, NULL, 'b'},
+  {"compare", no_argument, NULL, 'C'},
   {GETOPT_SELINUX_CONTEXT_OPTION_DECL},
   {"directory", no_argument, NULL, 'd'},
   {"group", required_argument, NULL, 'g'},
@@ -167,6 +172,107 @@ static struct option const long_options[] =
   {NULL, 0, NULL, 0}
 };
 
+/* Compare content of opened files using file descriptors A_FD and B_FD. Return
+   true if files are equal. */
+static bool
+have_same_content (int a_fd, int b_fd)
+{
+  enum { CMP_BLOCK_SIZE = 4096 };
+  static char a_buff[CMP_BLOCK_SIZE];
+  static char b_buff[CMP_BLOCK_SIZE];
+
+  size_t size;
+  while (0 < (size = full_read (a_fd, a_buff, sizeof a_buff))) {
+    if (size != full_read (b_fd, b_buff, sizeof b_buff))
+      return false;
+
+    if (memcmp (a_buff, b_buff, size) != 0)
+      return false;
+  }
+
+  return size == 0;
+}
+
+/* Return true for mode with non-permission bits. */
+static bool
+extra_mode (mode_t input)
+{
+  const mode_t mask = ~S_IRWXUGO & ~S_IFMT;
+  return input & mask;
+}
+
+/* Return true if copy of file SRC_NAME to file DEST_NAME is necessary. */
+static bool
+need_copy (const char *src_name, const char *dest_name,
+          const struct cp_options *x)
+{
+  struct stat src_sb, dest_sb;
+  int src_fd, dest_fd;
+  bool content_match;
+
+  if (extra_mode (mode))
+    return true;
+
+  /* compare files using stat */
+  if (lstat (src_name, &src_sb) != 0)
+    return true;
+
+  if (lstat (dest_name, &dest_sb) != 0)
+    return true;
+
+  if (!S_ISREG (src_sb.st_mode) || !S_ISREG (dest_sb.st_mode)
+      || extra_mode (src_sb.st_mode) || extra_mode (dest_sb.st_mode))
+    return true;
+
+  if (src_sb.st_size != dest_sb.st_size
+      || (dest_sb.st_mode & CHMOD_MODE_BITS) != mode
+      || dest_sb.st_uid != (owner_id == (uid_t) -1 ? getuid () : owner_id)
+      || dest_sb.st_gid != (group_id == (gid_t) -1 ? getgid () : group_id))
+    return true;
+
+  /* compare SELinux context if preserving */
+  if (selinux_enabled && x->preserve_security_context)
+    {
+      security_context_t file_scontext = NULL;
+      security_context_t to_scontext = NULL;
+      bool scontext_match;
+
+      if (getfilecon (src_name, &file_scontext) == -1)
+       return true;
+
+      if (getfilecon (dest_name, &to_scontext) == -1)
+       {
+         freecon (file_scontext);
+         return true;
+       }
+
+      scontext_match = STREQ (file_scontext, to_scontext);
+
+      freecon (file_scontext);
+      freecon (to_scontext);
+      if (!scontext_match)
+       return true;
+    }
+
+  /* compare files content */
+  src_fd = open (src_name, O_RDONLY);
+  if (src_fd < 0)
+    return true;
+
+  dest_fd = open (dest_name, O_RDONLY);
+  if (dest_fd < 0)
+    {
+      close (src_fd);
+      return true;
+    }
+
+  content_match = have_same_content (src_fd, dest_fd);
+
+  close (src_fd);
+  close (dest_fd);
+  return !content_match;
+}
+
 static void
 cp_option_init (struct cp_options *x)
 {
@@ -361,7 +467,7 @@ main (int argc, char **argv)
      we'll actually use backup_suffix_string.  */
   backup_suffix_string = getenv ("SIMPLE_BACKUP_SUFFIX");
 
-  while ((optc = getopt_long (argc, argv, "bcsDdg:m:o:pt:TvS:Z:", long_options,
+  while ((optc = getopt_long (argc, argv, "bcCsDdg:m:o:pt:TvS:Z:", long_options,
                              NULL)) != -1)
     {
       switch (optc)
@@ -373,6 +479,9 @@ main (int argc, char **argv)
          break;
        case 'c':
          break;
+       case 'C':
+         copy_only_if_needed = true;
+         break;
        case 's':
          strip_files = true;
 #ifdef SIGCHLD
@@ -529,6 +638,24 @@ main (int argc, char **argv)
     error (0, 0, _("WARNING: ignoring --strip-program option as -s option was "
                   "not specified"));
 
+  if (copy_only_if_needed && x.preserve_timestamps)
+    {
+      error (0, 0, _("options --compare (-C) and --preserve-timestamps are "
+                    "mutually exclusive"));
+      usage (EXIT_FAILURE);
+    }
+
+  if (copy_only_if_needed && strip_files)
+    {
+      error (0, 0, _("options --compare (-C) and --strip are mutually "
+                    "exclusive"));
+      usage (EXIT_FAILURE);
+    }
+
+  if (copy_only_if_needed && extra_mode (mode))
+    error (0, 0, _("the --compare (-C) option is ignored when you"
+                  " specify a mode with non-permission bits"));
+
   get_ids ();
 
   if (dir_arg)
@@ -645,6 +772,9 @@ copy_file (const char *from, const char *to, const struct cp_options *x)
 {
   bool copy_into_self;
 
+  if (copy_only_if_needed && !need_copy (from, to, x))
+    return true;
+
   /* Allow installing from non-regular files like /dev/null.
      Charles Karney reported that some Sun version of install allows that
      and that sendmail's installation process relies on the behavior.
@@ -835,6 +965,8 @@ Mandatory arguments to long options are mandatory for short options too.\n\
       --backup[=CONTROL]  make a backup of each existing destination file\n\
   -b                  like --backup but does not accept an argument\n\
   -c                  (ignored)\n\
+  -C, --compare       compare each pair of source and destination files, and\n\
+                        in some cases, do not modify the destination at all\n\
   -d, --directory     treat all arguments as directory names; create all\n\
                         components of the specified directories\n\
 "), stdout);
index 024eb48..07e9473 100644 (file)
@@ -25,6 +25,7 @@ root_tests =                                  \
   cp/preserve-gid                              \
   cp/special-bits                              \
   dd/skip-seek-past-dev                                \
+  install/install-C-root                       \
   ls/capability                                        \
   ls/nameless-uid                              \
   misc/chcon                                   \
@@ -318,6 +319,8 @@ TESTS =                                             \
   install/basic-1                              \
   install/create-leading                       \
   install/d-slashdot                           \
+  install/install-C                            \
+  install/install-C-selinux                    \
   install/strip-program                                \
   install/trap                                 \
   ln/backup-1                                  \
diff --git a/tests/install/install-C b/tests/install/install-C
new file mode 100755 (executable)
index 0000000..129286d
--- /dev/null
@@ -0,0 +1,94 @@
+#!/bin/sh
+# Ensure "install -C" works. (basic tests)
+
+# Copyright (C) 2008 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 <http://www.gnu.org/licenses/>.
+
+if test "$VERBOSE" = yes; then
+  set -x
+  ginstall --version
+fi
+
+. $srcdir/test-lib.sh
+
+mode1=0644
+mode2=0755
+mode3=1755
+
+fail=0
+
+echo test > a || framework_failure
+echo "\`a' -> \`b'" > out_installed_first
+echo "removed \`b'
+\`a' -> \`b'" > out_installed_second
+> out_empty
+
+# destination file does not exist
+ginstall -Cv -m$mode1 a b > out || fail=1
+compare out out_installed_first || fail=1
+
+# destination file exists
+ginstall -Cv -m$mode1 a b > out || fail=1
+compare out out_empty || fail=1
+
+# destination file exists (long option)
+ginstall -v --compare -m$mode1 a b > out || fail=1
+compare out out_empty || fail=1
+
+# destination file exists but -C is not given
+ginstall -v -m$mode1 a b > out || fail=1
+compare out out_installed_second || fail=1
+
+# option -C ignored if any non-permission mode should be set
+ginstall -Cv -m$mode3 a b > out || fail=1
+compare out out_installed_second || fail=1
+ginstall -Cv -m$mode3 a b > out || fail=1
+compare out out_installed_second || fail=1
+
+# files are not regular files
+ln -s a c || framework_failure
+ln -s b d || framework_failure
+ginstall -Cv -m$mode1 c d > out || fail=1
+echo "removed \`d'
+\`c' -> \`d'" > out_installed_second_cd
+compare out out_installed_second_cd || fail=1
+
+# destination file exists but content differs
+echo test1 > a || framework_failure
+ginstall -Cv -m$mode1 a b > out || fail=1
+compare out out_installed_second || fail=1
+ginstall -Cv -m$mode1 a b > out || fail=1
+compare out out_empty || fail=1
+
+# destination file exists but content differs (same size)
+echo test2 > a || framework_failure
+ginstall -Cv -m$mode1 a b > out || fail=1
+compare out out_installed_second || fail=1
+ginstall -Cv -m$mode1 a b > out || fail=1
+compare out out_empty || fail=1
+
+# destination file exists but mode differs
+ginstall -Cv -m$mode2 a b > out || fail=1
+compare out out_installed_second || fail=1
+ginstall -Cv -m$mode2 a b > out || fail=1
+compare out out_empty || fail=1
+
+# options -C and --preserve-timestamps are mutually exclusive
+ginstall -C --preserve-timestamps a b && fail=1
+
+# options -C and --strip are mutually exclusive
+ginstall -C --strip --strip-program=echo a b && fail=1
+
+Exit $fail
diff --git a/tests/install/install-C-root b/tests/install/install-C-root
new file mode 100755 (executable)
index 0000000..1a07dbe
--- /dev/null
@@ -0,0 +1,80 @@
+#!/bin/sh
+# Ensure "install -C" compares owner and group.
+
+# Copyright (C) 2008 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 <http://www.gnu.org/licenses/>.
+
+if test "$VERBOSE" = yes; then
+  set -x
+  ginstall --version
+fi
+
+. $srcdir/test-lib.sh
+require_root_
+
+u1=1
+u2=2
+g1=1
+g2=2
+
+fail=0
+
+echo test > a || framework_failure
+echo "\`a' -> \`b'" > out_installed_first
+echo "removed \`b'
+\`a' -> \`b'" > out_installed_second
+> out_empty
+
+# destination file does not exist
+ginstall -Cv -o$u1 -g$g1 a b > out || fail=1
+compare out out_installed_first || fail=1
+
+# destination file exists
+ginstall -Cv -o$u1 -g$g1 a b > out || fail=1
+compare out out_empty || fail=1
+
+# destination file exists but -C is not given
+ginstall -v -o$u1 -g$g1 a b > out || fail=1
+compare out out_installed_second || fail=1
+
+# destination file exists but owner differs
+ginstall -Cv -o$u2 -g$g1 a b > out || fail=1
+compare out out_installed_second || fail=1
+ginstall -Cv -o$u2 -g$g1 a b > out || fail=1
+compare out out_empty || fail=1
+
+# destination file exists but group differs
+ginstall -Cv -o$u2 -g$g2 a b > out || fail=1
+compare out out_installed_second || fail=1
+ginstall -Cv -o$u2 -g$g2 a b > out || fail=1
+compare out out_empty || fail=1
+
+# destination file exists but owner differs from getuid ()
+ginstall -Cv -o$u2 a b > out || fail=1
+compare out out_installed_second || fail=1
+ginstall -Cv a b > out || fail=1
+compare out out_installed_second || fail=1
+ginstall -Cv a b > out || fail=1
+compare out out_empty || fail=1
+
+# destination file exists but group differs from getgid ()
+ginstall -Cv -g$g2 a b > out || fail=1
+compare out out_installed_second || fail=1
+ginstall -Cv a b > out || fail=1
+compare out out_installed_second || fail=1
+ginstall -Cv a b > out || fail=1
+compare out out_empty || fail=1
+
+Exit $fail
diff --git a/tests/install/install-C-selinux b/tests/install/install-C-selinux
new file mode 100755 (executable)
index 0000000..d1d9540
--- /dev/null
@@ -0,0 +1,56 @@
+#!/bin/sh
+# Ensure "install -C" compares SELinux context.
+
+# Copyright (C) 2008 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 <http://www.gnu.org/licenses/>.
+
+if test "$VERBOSE" = yes; then
+  set -x
+  ginstall --version
+fi
+
+. $srcdir/test-lib.sh
+require_selinux_
+
+fail=0
+
+echo test > a || framework_failure
+chcon -u system_u a || skip_test_ "chcon doesn't work"
+
+echo "\`a' -> \`b'" > out_installed_first
+echo "removed \`b'
+\`a' -> \`b'" > out_installed_second
+> out_empty
+
+# destination file does not exist
+ginstall -Cv --preserve-context a b > out || fail=1
+compare out out_installed_first || fail=1
+
+# destination file exists
+ginstall -Cv --preserve-context a b > out || fail=1
+compare out out_empty || fail=1
+
+# destination file exists but -C is not given
+ginstall -v --preserve-context a b > out || fail=1
+compare out out_installed_second || fail=1
+
+# destination file exists but SELinux context differs
+chcon -u unconfined_u a || skip_test_ "chcon doesn't work"
+ginstall -Cv --preserve-context a b > out || fail=1
+compare out out_installed_second || fail=1
+ginstall -Cv --preserve-context a b > out || fail=1
+compare out out_empty || fail=1
+
+Exit $fail