Subject: [PATCH] Add 'nofollow' flag to set-file-times
From: Paul Eggert <[email-masked]>
To: [email-masked]
Cc: Paul Eggert <[email-masked]>
Date: Mon, 24 Feb 2020 18:35:30 -0800
List-Id: <debbugs-submit.debbugs.gnu.org>

This is a companion to the recent set-file-modes patch.
It adds support for a 'nofollow' flag to set-file-times.
Like the set-file-modes patch, it needs work in the w32 port.
* admin/merge-gnulib (GNULIB_MODULES): Add futimens, utimensat.
Remove utimens.
* doc/lispref/files.texi (Changing Files):
* etc/NEWS: Mention the change.
* lib/gnulib.mk.in, m4/gnulib-comp.m4: Regenerate.
* lisp/files.el (copy-directory):
* lisp/gnus/gnus-cloud.el (gnus-cloud-replace-file):
* lisp/net/tramp-adb.el (tramp-adb-handle-copy-file):
* lisp/net/tramp-smb.el (tramp-smb-handle-copy-file):
* lisp/tar-mode.el (tar-copy):
* test/lisp/filenotify-tests.el (file-notify-test03-events):
* test/lisp/files-tests.el:
(files-tests-file-name-non-special-set-file-times):
* test/lisp/net/tramp-tests.el (tramp-test22-file-times):
When setting file times, avoid following symbolic links
when the file is not supposed to be a symbolic link.
* lib/futimens.c, lib/utimensat.c, m4/futimens.m4, m4/utimensat.m4:
New files, copied from Gnulib.
* lisp/gnus/gnus-cloud.el (gnus-cloud-replace-file):
When creating a file that is not supposed to exist already,
use the excl flag to check this.
* lisp/net/tramp-adb.el (tramp-adb-handle-set-file-times):
* lisp/net/tramp-sh.el (tramp-sh-handle-set-file-times):
* lisp/net/tramp-sudoedit.el (tramp-sudoedit-handle-set-file-times):
Accept an optional FLAG arg that is currently ignored,
and add a FIXME comment for it.
* lisp/net/tramp-gvfs.el (tramp-gvfs-handle-set-file-times):
* src/fileio.c (Fset_file_times):
Support an optional FLAG arg.
* src/fileio.c (Fcopy_file): Use futimens instead of set_file_times,
as it’s simpler and is a POSIX API.
* src/sysdep.c (set_file_times): Move from here ...
* src/w32.c (set_file_times): ... to here, and make it static,
since it is now used only in w32.c.  Presumably w32.c should also
add support for futimens and utimensat (the POSIX APIs, which
Emacs now uses) and it can remove fdutimens (the Gnulib API,
which Emacs no longer uses).
---
 admin/merge-gnulib            |   4 +-
 doc/lispref/files.texi        |  10 ++-
 etc/NEWS                      |   4 +-
 lib/futimens.c                |  37 ++++++++
 lib/gnulib.mk.in              |  28 +++++-
 lib/utimensat.c               | 160 ++++++++++++++++++++++++++++++++++
 lisp/files.el                 |   7 +-
 lisp/gnus/gnus-cloud.el       |   4 +-
 lisp/net/tramp-adb.el         |   6 +-
 lisp/net/tramp-gvfs.el        |   4 +-
 lisp/net/tramp-sh.el          |   3 +-
 lisp/net/tramp-smb.el         |   3 +-
 lisp/net/tramp-sudoedit.el    |   3 +-
 lisp/tar-mode.el              |   2 +-
 m4/futimens.m4                |  65 ++++++++++++++
 m4/gnulib-comp.m4             |  38 +++++++-
 m4/utimensat.m4               |  69 +++++++++++++++
 src/fileio.c                  |  51 +++++------
 src/sysdep.c                  |  15 ----
 src/systime.h                 |   3 -
 src/w32.c                     |  15 ++++
 test/lisp/filenotify-tests.el |   8 +-
 test/lisp/files-tests.el      |   4 +-
 test/lisp/net/tramp-tests.el  |   3 +-
 24 files changed, 476 insertions(+), 70 deletions(-)
 create mode 100644 lib/futimens.c
 create mode 100644 lib/utimensat.c
 create mode 100644 m4/futimens.m4
 create mode 100644 m4/utimensat.m4

diff --git a/admin/merge-gnulib b/admin/merge-gnulib
index 557119441e..768e5051f0 100755
--- a/admin/merge-gnulib
+++ b/admin/merge-gnulib
@@ -34,7 +34,7 @@ GNULIB_MODULES=
   d-type diffseq dosname double-slash-root dtoastr dtotimespec dup2
   environ execinfo explicit_bzero faccessat
   fchmodat fcntl fcntl-h fdopendir
-  filemode filevercmp flexmember fpieee fstatat fsusage fsync
+  filemode filevercmp flexmember fpieee fstatat fsusage fsync futimens
   getloadavg getopt-gnu gettime gettimeofday gitlog-to-changelog
   ieee754-h ignore-value intprops largefile lstat
   manywarnings memmem-simple mempcpy memrchr minmax mkostemp mktime nstrftime
@@ -43,7 +43,7 @@ GNULIB_MODULES=
   sig2str socklen stat-time std-gnu11 stdalign stddef stdio
   stpcpy strnlen strtoimax symlink sys_stat sys_time
   tempname time time_r time_rz timegm timer-time timespec-add timespec-sub
-  update-copyright unlocked-io utimens
+  update-copyright unlocked-io utimensat
   vla warnings
 '
 
diff --git a/doc/lispref/files.texi b/doc/lispref/files.texi
index a69a4e5dd3..b3ad9b9964 100644
--- a/doc/lispref/files.texi
+++ b/doc/lispref/files.texi
@@ -1909,11 +1909,19 @@ Changing Files
 all.
 @end defun
 
-@defun set-file-times filename &optional time
+@defun set-file-times filename &optional time flag
 This function sets the access and modification times of @var{filename}
 to @var{time}.  The return value is @code{t} if the times are successfully
 set, otherwise it is @code{nil}.  @var{time} defaults to the current
 time and must be a time value (@pxref{Time of Day}).
+
+By default this function follows symbolic links.  However, if the
+optional argument @var{flag} is the symbol @code{nofollow}, this
+function does not follow @var{filename} if it is a symbolic link;
+this can help prevent inadvertently changing the times of a file
+somewhere else.  On platforms that do not support changing times
+on a symbolic link, this function signals an error when @var{filename}
+is a symbolic link and @var{flag} is @code{nofollow}.
 @end defun
 
 @defun set-file-extended-attributes filename attribute-alist
diff --git a/etc/NEWS b/etc/NEWS
index 93b90d47d3..3158799987 100644
--- a/etc/NEWS
+++ b/etc/NEWS
@@ -204,8 +204,8 @@ called when the function object is garbage-collected.  Use
 'set_function_finalizer' to set the finalizer and
 'get_function_finalizer' to retrieve it.
 
-** 'file-modes' and 'set-file-modes' now have an optional argument
-specifying whether to follow symbolic links.
+** 'file-modes', 'set-file-modes', and 'set-file-times' now have an
+optional argument specifying whether to follow symbolic links.
 
 ** 'parse-time-string' can now parse ISO 8601 format strings,
 such as "2020-01-15T16:12:21-08:00".
diff --git a/lib/futimens.c b/lib/futimens.c
new file mode 100644
index 0000000000..83fb27cb6a
--- /dev/null
+++ b/lib/futimens.c
@@ -0,0 +1,37 @@
+/* Set the access and modification time of an open fd.
+   Copyright (C) 2009-2020 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/>.  */
+
+/* written by Eric Blake */
+
+#include <config.h>
+
+#include <sys/stat.h>
+
+#include "utimens.h"
+
+/* Set the access and modification timestamps of FD to be
+   TIMESPEC[0] and TIMESPEC[1], respectively.
+   Fail with ENOSYS on systems without futimes (or equivalent).
+   If TIMESPEC is null, set the timestamps to the current time.
+   Return 0 on success, -1 (setting errno) on failure.  */
+int
+futimens (int fd, struct timespec const times[2])
+{
+  /* fdutimens also works around bugs in native futimens, when running
+     with glibc compiled against newer headers but on a Linux kernel
+     older than 2.6.32.  */
+  return fdutimens (fd, NULL, times);
+}
diff --git a/lib/gnulib.mk.in b/lib/gnulib.mk.in
index d4dc6a3df3..e90d2e3904 100644
--- a/lib/gnulib.mk.in
+++ b/lib/gnulib.mk.in
@@ -106,6 +106,7 @@
 #  fstatat \
 #  fsusage \
 #  fsync \
+#  futimens \
 #  getloadavg \
 #  getopt-gnu \
 #  gettime \
@@ -155,7 +156,7 @@
 #  timespec-sub \
 #  unlocked-io \
 #  update-copyright \
-#  utimens \
+#  utimensat \
 #  vla \
 #  warnings
 
@@ -1087,6 +1088,7 @@ gl_GNULIB_ENABLED_lchmod = @gl_GNULIB_ENABLED_lchmod@
 gl_GNULIB_ENABLED_malloca = @gl_GNULIB_ENABLED_malloca@
 gl_GNULIB_ENABLED_open = @gl_GNULIB_ENABLED_open@
 gl_GNULIB_ENABLED_strtoll = @gl_GNULIB_ENABLED_strtoll@
+gl_GNULIB_ENABLED_utimens = @gl_GNULIB_ENABLED_utimens@
 gl_LIBOBJS = @gl_LIBOBJS@
 gl_LTLIBOBJS = @gl_LTLIBOBJS@
 gltests_LIBOBJS = @gltests_LIBOBJS@
@@ -1733,6 +1735,17 @@ EXTRA_libgnu_a_SOURCES += fsync.c
 endif
 ## end   gnulib module fsync
 
+## begin gnulib module futimens
+ifeq (,$(OMIT_GNULIB_MODULE_futimens))
+
+
+EXTRA_DIST += futimens.c
+
+EXTRA_libgnu_a_SOURCES += futimens.c
+
+endif
+## end   gnulib module futimens
+
 ## begin gnulib module getdtablesize
 ifeq (,$(OMIT_GNULIB_MODULE_getdtablesize))
 
@@ -3375,13 +3388,26 @@ endif
 ## begin gnulib module utimens
 ifeq (,$(OMIT_GNULIB_MODULE_utimens))
 
+ifneq (,$(gl_GNULIB_ENABLED_utimens))
 libgnu_a_SOURCES += utimens.c
 
+endif
 EXTRA_DIST += utimens.h
 
 endif
 ## end   gnulib module utimens
 
+## begin gnulib module utimensat
+ifeq (,$(OMIT_GNULIB_MODULE_utimensat))
+
+
+EXTRA_DIST += at-func.c utimensat.c
+
+EXTRA_libgnu_a_SOURCES += at-func.c utimensat.c
+
+endif
+## end   gnulib module utimensat
+
 ## begin gnulib module verify
 ifeq (,$(OMIT_GNULIB_MODULE_verify))
 
diff --git a/lib/utimensat.c b/lib/utimensat.c
new file mode 100644
index 0000000000..63788d5648
--- /dev/null
+++ b/lib/utimensat.c
@@ -0,0 +1,160 @@
+/* Set the access and modification time of a file relative to directory fd.
+   Copyright (C) 2009-2020 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/>.  */
+
+/* written by Eric Blake */
+
+#include <config.h>
+
+/* Specification.  */
+#include <sys/stat.h>
+
+#include <errno.h>
+#include <fcntl.h>
+#include <stdlib.h>
+
+#include "stat-time.h"
+#include "timespec.h"
+#include "utimens.h"
+
+#if HAVE_UTIMENSAT
+
+# undef utimensat
+
+/* If we have a native utimensat, but are compiling this file, then
+   utimensat was defined to rpl_utimensat by our replacement
+   sys/stat.h.  We assume the native version might fail with ENOSYS,
+   or succeed without properly affecting ctime (as is the case when
+   using newer glibc but older Linux kernel).  In this scenario,
+   rpl_utimensat checks whether the native version is usable, and
+   local_utimensat provides the fallback manipulation.  */
+
+static int local_utimensat (int, char const *, struct timespec const[2], int);
+# define AT_FUNC_NAME local_utimensat
+
+/* Like utimensat, but work around native bugs.  */
+
+int
+rpl_utimensat (int fd, char const *file, struct timespec const times[2],
+               int flag)
+{
+# if defined __linux__ || defined __sun
+  struct timespec ts[2];
+# endif
+
+  /* See comments in utimens.c for details.  */
+  static int utimensat_works_really; /* 0 = unknown, 1 = yes, -1 = no.  */
+  if (0 <= utimensat_works_really)
+    {
+      int result;
+# if defined __linux__ || defined __sun
+      struct stat st;
+      /* As recently as Linux kernel 2.6.32 (Dec 2009), several file
+         systems (xfs, ntfs-3g) have bugs with a single UTIME_OMIT,
+         but work if both times are either explicitly specified or
+         UTIME_NOW.  Work around it with a preparatory [l]stat prior
+         to calling utimensat; fortunately, there is not much timing
+         impact due to the extra syscall even on file systems where
+         UTIME_OMIT would have worked.
+
+         The same bug occurs in Solaris 11.1 (Apr 2013).
+
+         FIXME: Simplify this in 2024, when these file system bugs are
+         no longer common on Gnulib target platforms.  */
+      if (times && (times[0].tv_nsec == UTIME_OMIT
+                    || times[1].tv_nsec == UTIME_OMIT))
+        {
+          if (fstatat (fd, file, &st, flag))
+            return -1;
+          if (times[0].tv_nsec == UTIME_OMIT && times[1].tv_nsec == UTIME_OMIT)
+            return 0;
+          if (times[0].tv_nsec == UTIME_OMIT)
+            ts[0] = get_stat_atime (&st);
+          else
+            ts[0] = times[0];
+          if (times[1].tv_nsec == UTIME_OMIT)
+            ts[1] = get_stat_mtime (&st);
+          else
+            ts[1] = times[1];
+          times = ts;
+        }
+#  ifdef __hppa__
+      /* Linux kernel 2.6.22.19 on hppa does not reject invalid tv_nsec
+         values.  */
+      else if (times
+               && ((times[0].tv_nsec != UTIME_NOW
+                    && ! (0 <= times[0].tv_nsec
+                          && times[0].tv_nsec < TIMESPEC_HZ))
+                   || (times[1].tv_nsec != UTIME_NOW
+                       && ! (0 <= times[1].tv_nsec
+                             && times[1].tv_nsec < TIMESPEC_HZ))))
+        {
+          errno = EINVAL;
+          return -1;
+        }
+#  endif
+# endif
+      result = utimensat (fd, file, times, flag);
+      /* Linux kernel 2.6.25 has a bug where it returns EINVAL for
+         UTIME_NOW or UTIME_OMIT with non-zero tv_sec, which
+         local_utimensat works around.  Meanwhile, EINVAL for a bad
+         flag is indeterminate whether the native utimensat works, but
+         local_utimensat will also reject it.  */
+      if (result == -1 && errno == EINVAL && (flag & ~AT_SYMLINK_NOFOLLOW))
+        return result;
+      if (result == 0 || (errno != ENOSYS && errno != EINVAL))
+        {
+          utimensat_works_really = 1;
+          return result;
+        }
+    }
+  /* No point in trying openat/futimens, since on Linux, futimens is
+     implemented with the same syscall as utimensat.  Only avoid the
+     native utimensat due to an ENOSYS failure; an EINVAL error was
+     data-dependent, and the next caller may pass valid data.  */
+  if (0 <= utimensat_works_really && errno == ENOSYS)
+    utimensat_works_really = -1;
+  return local_utimensat (fd, file, times, flag);
+}
+
+#else /* !HAVE_UTIMENSAT */
+
+# define AT_FUNC_NAME utimensat
+
+#endif /* !HAVE_UTIMENSAT */
+
+/* Set the access and modification timestamps of FILE to be
+   TIMESPEC[0] and TIMESPEC[1], respectively; relative to directory
+   FD.  If flag is AT_SYMLINK_NOFOLLOW, change the times of a symlink,
+   or fail with ENOSYS if not possible.  If TIMESPEC is null, set the
+   timestamps to the current time.  If possible, do it without
+   changing the working directory.  Otherwise, resort to using
+   save_cwd/fchdir, then utimens/restore_cwd.  If either the save_cwd
+   or the restore_cwd fails, then give a diagnostic and exit nonzero.
+   Return 0 on success, -1 (setting errno) on failure.  */
+
+/* AT_FUNC_NAME is now utimensat or local_utimensat.  */
+#define AT_FUNC_F1 lutimens
+#define AT_FUNC_F2 utimens
+#define AT_FUNC_USE_F1_COND AT_SYMLINK_NOFOLLOW
+#define AT_FUNC_POST_FILE_PARAM_DECLS , struct timespec const ts[2], int flag
+#define AT_FUNC_POST_FILE_ARGS        , ts
+#include "at-func.c"
+#undef AT_FUNC_NAME
+#undef AT_FUNC_F1
+#undef AT_FUNC_F2
+#undef AT_FUNC_USE_F1_COND
+#undef AT_FUNC_POST_FILE_PARAM_DECLS
+#undef AT_FUNC_POST_FILE_ARGS
diff --git a/lisp/files.el b/lisp/files.el
index 2e7694d767..8ce0187f5b 100644
--- a/lisp/files.el
+++ b/lisp/files.el
@@ -5944,9 +5944,10 @@ copy-directory
       ;; Set directory attributes.
       (let ((modes (file-modes directory))
 	    (times (and keep-time (file-attribute-modification-time
-				   (file-attributes directory)))))
-	(if modes (set-file-modes newname modes (unless follow 'nofollow)))
-	(if times (set-file-times newname times))))))
+				   (file-attributes directory))))
+	    (follow-flag (unless follow 'nofollow)))
+	(if modes (set-file-modes newname modes follow-flag))
+	(if times (set-file-times newname times follow-flag))))))
 
 
 ;; At time of writing, only info uses this.
diff --git a/lisp/gnus/gnus-cloud.el b/lisp/gnus/gnus-cloud.el
index 4d8764bacc..da6231d733 100644
--- a/lisp/gnus/gnus-cloud.el
+++ b/lisp/gnus/gnus-cloud.el
@@ -285,8 +285,8 @@ gnus-cloud-replace-file
     (insert new-contents)
     (when (file-exists-p file-name)
       (rename-file file-name (car (find-backup-file-name file-name))))
-    (write-region (point-min) (point-max) file-name)
-    (set-file-times file-name (parse-iso8601-time-string date))))
+    (write-region (point-min) (point-max) file-name nil nil nil 'excl)
+    (set-file-times file-name (parse-iso8601-time-string date) 'nofollow)))
 
 (defun gnus-cloud-file-covered-p (file-name)
   (let ((matched nil))
diff --git a/lisp/net/tramp-adb.el b/lisp/net/tramp-adb.el
index 96ef95dbe3..5d73bd9758 100644
--- a/lisp/net/tramp-adb.el
+++ b/lisp/net/tramp-adb.el
@@ -674,8 +674,9 @@ tramp-adb-handle-set-file-modes
     (tramp-flush-file-properties v localname)
     (tramp-adb-send-command-and-check v (format "chmod %o %s" mode localname))))
 
-(defun tramp-adb-handle-set-file-times (filename &optional time)
+(defun tramp-adb-handle-set-file-times (filename &optional time flag)
   "Like `set-file-times' for Tramp files."
+  flag ;; FIXME: Support 'nofollow'.
   (with-parsed-tramp-file-name filename nil
     (tramp-flush-file-properties v localname)
     (let ((time (if (or (null time)
@@ -777,7 +778,8 @@ tramp-adb-handle-copy-file
       (set-file-times
        newname
        (tramp-compat-file-attribute-modification-time
-	(file-attributes filename))))))
+	(file-attributes filename))
+       (unless ok-if-already-exists 'nofollow)))))
 
 (defun tramp-adb-handle-rename-file
   (filename newname &optional ok-if-already-exists)
diff --git a/lisp/net/tramp-gvfs.el b/lisp/net/tramp-gvfs.el
index 79835804bc..39ffa2fb76 100644
--- a/lisp/net/tramp-gvfs.el
+++ b/lisp/net/tramp-gvfs.el
@@ -1571,7 +1571,7 @@ tramp-gvfs-handle-set-file-modes
      (tramp-gvfs-url-file-name (tramp-make-tramp-file-name v))
      "unix::mode" (number-to-string mode))))
 
-(defun tramp-gvfs-handle-set-file-times (filename &optional time)
+(defun tramp-gvfs-handle-set-file-times (filename &optional time flag)
   "Like `set-file-times' for Tramp files."
   (with-parsed-tramp-file-name filename nil
     (tramp-flush-file-properties v localname)
@@ -1582,7 +1582,7 @@ tramp-gvfs-handle-set-file-times
 	       (current-time)
 	     time)))
       (tramp-gvfs-send-command
-       v "gvfs-set-attribute" "-t" "uint64"
+       v "gvfs-set-attribute" (if flag "-nt" "-t") "uint64"
        (tramp-gvfs-url-file-name (tramp-make-tramp-file-name v))
        "time::modified" (format-time-string "%s" time)))))
 
diff --git a/lisp/net/tramp-sh.el b/lisp/net/tramp-sh.el
index f31d361588..9eee72da1e 100644
--- a/lisp/net/tramp-sh.el
+++ b/lisp/net/tramp-sh.el
@@ -1489,11 +1489,12 @@ tramp-sh-handle-set-file-modes
      (format "chmod %o %s" mode (tramp-shell-quote-argument localname))
      "Error while changing file's mode %s" filename)))
 
-(defun tramp-sh-handle-set-file-times (filename &optional time)
+(defun tramp-sh-handle-set-file-times (filename &optional time flag)
   "Like `set-file-times' for Tramp files."
   (with-parsed-tramp-file-name filename nil
     (when (tramp-get-remote-touch v)
       (tramp-flush-file-properties v localname)
+      flag ;; FIXME: Support 'nofollow'.
       (let ((time
 	     (if (or (null time)
 		     (tramp-compat-time-equal-p time tramp-time-doesnt-exist)
diff --git a/lisp/net/tramp-smb.el b/lisp/net/tramp-smb.el
index 95505ea101..af4f17de53 100644
--- a/lisp/net/tramp-smb.el
+++ b/lisp/net/tramp-smb.el
@@ -619,7 +619,8 @@ tramp-smb-handle-copy-file
       (set-file-times
        newname
        (tramp-compat-file-attribute-modification-time
-	(file-attributes filename))))))
+	(file-attributes filename))
+       (unless ok-if-already-exists 'nofollow)))))
 
 (defun tramp-smb-handle-delete-directory (directory &optional recursive _trash)
   "Like `delete-directory' for Tramp files."
diff --git a/lisp/net/tramp-sudoedit.el b/lisp/net/tramp-sudoedit.el
index 4654d633fa..a9e4916b2f 100644
--- a/lisp/net/tramp-sudoedit.el
+++ b/lisp/net/tramp-sudoedit.el
@@ -522,10 +522,11 @@ tramp-sudoedit-handle-file-system-info
 		     (string-to-number (match-string 2)))
 		  (string-to-number (match-string 3)))))))))
 
-(defun tramp-sudoedit-handle-set-file-times (filename &optional time)
+(defun tramp-sudoedit-handle-set-file-times (filename &optional time flag)
   "Like `set-file-times' for Tramp files."
   (with-parsed-tramp-file-name filename nil
     (tramp-flush-file-properties v localname)
+    flag ;; FIXME: Support 'nofollow'.
     (let ((time
 	   (if (or (null time)
 		   (tramp-compat-time-equal-p time tramp-time-doesnt-exist)
diff --git a/lisp/tar-mode.el b/lisp/tar-mode.el
index 97d883eebd..a3c1715b1e 100644
--- a/lisp/tar-mode.el
+++ b/lisp/tar-mode.el
@@ -1056,7 +1056,7 @@ tar-copy
 	(write-region start end to-file nil nil nil t))
       (when (and tar-copy-preserve-time
                  date)
-        (set-file-times to-file date)))
+	(set-file-times to-file date 'nofollow)))
     (message "Copied tar entry %s to %s" name to-file)))
 
 (defun tar-new-entry (filename &optional index)
diff --git a/m4/futimens.m4 b/m4/futimens.m4
new file mode 100644
index 0000000000..dc5cfa9411
--- /dev/null
+++ b/m4/futimens.m4
@@ -0,0 +1,65 @@
+# serial 8
+# See if we need to provide futimens replacement.
+
+dnl Copyright (C) 2009-2020 Free Software Foundation, Inc.
+dnl This file is free software; the Free Software Foundation
+dnl gives unlimited permission to copy and/or distribute it,
+dnl with or without modifications, as long as this notice is preserved.
+
+# Written by Eric Blake.
+
+AC_DEFUN([gl_FUNC_FUTIMENS],
+[
+  AC_REQUIRE([gl_SYS_STAT_H_DEFAULTS])
+  AC_REQUIRE([AC_CANONICAL_HOST]) dnl for cross-compiles
+  AC_REQUIRE([gl_USE_SYSTEM_EXTENSIONS])
+  AC_CHECK_FUNCS_ONCE([futimens])
+  if test $ac_cv_func_futimens = no; then
+    HAVE_FUTIMENS=0
+  else
+    AC_CACHE_CHECK([whether futimens works],
+      [gl_cv_func_futimens_works],
+      [AC_RUN_IFELSE([AC_LANG_PROGRAM([[
+#include <fcntl.h>
+#include <sys/stat.h>
+#include <unistd.h>
+#include <errno.h>
+]], [[struct timespec ts[2];
+      int fd = creat ("conftest.file", 0600);
+      struct stat st;
+      if (fd < 0) return 1;
+      ts[0].tv_sec = 1;
+      ts[0].tv_nsec = UTIME_OMIT;
+      ts[1].tv_sec = 1;
+      ts[1].tv_nsec = UTIME_NOW;
+      errno = 0;
+      if (futimens (AT_FDCWD, NULL) == 0) return 2;
+      if (errno != EBADF) return 3;
+      if (futimens (fd, ts)) return 4;
+      sleep (1);
+      ts[0].tv_nsec = UTIME_NOW;
+      ts[1].tv_nsec = UTIME_OMIT;
+      if (futimens (fd, ts)) return 5;
+      if (fstat (fd, &st)) return 6;
+      if (st.st_ctime < st.st_atime) return 7;
+      ]])],
+         [gl_cv_func_futimens_works=yes],
+         [gl_cv_func_futimens_works=no],
+         [case "$host_os" in
+                           # Guess no on glibc systems.
+            *-gnu* | gnu*) gl_cv_func_futimens_works="guessing no" ;;
+                           # Guess no on musl systems.
+            *-musl*)       gl_cv_func_futimens_works="guessing no" ;;
+                           # Guess yes otherwise.
+            *)             gl_cv_func_futimens_works="guessing yes" ;;
+          esac
+         ])
+      rm -f conftest.file])
+    case "$gl_cv_func_futimens_works" in
+      *yes) ;;
+      *)
+        REPLACE_FUTIMENS=1
+        ;;
+    esac
+  fi
+])
diff --git a/m4/gnulib-comp.m4 b/m4/gnulib-comp.m4
index 1465ce811b..3228aa42b5 100644
--- a/m4/gnulib-comp.m4
+++ b/m4/gnulib-comp.m4
@@ -95,6 +95,7 @@ AC_DEFUN
   # Code from module fstatat:
   # Code from module fsusage:
   # Code from module fsync:
+  # Code from module futimens:
   # Code from module getdtablesize:
   # Code from module getgroups:
   # Code from module getloadavg:
@@ -179,6 +180,7 @@ AC_DEFUN
   # Code from module unlocked-io:
   # Code from module update-copyright:
   # Code from module utimens:
+  # Code from module utimensat:
   # Code from module vararrays:
   # Code from module verify:
   # Code from module vla:
@@ -297,6 +299,11 @@ AC_DEFUN
     gl_PREREQ_FSYNC
   fi
   gl_UNISTD_MODULE_INDICATOR([fsync])
+  gl_FUNC_FUTIMENS
+  if test $HAVE_FUTIMENS = 0 || test $REPLACE_FUTIMENS = 1; then
+    AC_LIBOBJ([futimens])
+  fi
+  gl_SYS_STAT_MODULE_INDICATOR([futimens])
   gl_GETLOADAVG
   if test $HAVE_GETLOADAVG = 0; then
     AC_LIBOBJ([getloadavg])
@@ -466,7 +473,11 @@ AC_DEFUN
   gl_TIMESPEC
   gl_UNISTD_H
   gl_FUNC_GLIBC_UNLOCKED_IO
-  gl_UTIMENS
+  gl_FUNC_UTIMENSAT
+  if test $HAVE_UTIMENSAT = 0 || test $REPLACE_UTIMENSAT = 1; then
+    AC_LIBOBJ([utimensat])
+  fi
+  gl_SYS_STAT_MODULE_INDICATOR([utimensat])
   AC_C_VARARRAYS
   gl_gnulib_enabled_260941c0e5dc67ec9e87d1fb321c300b=false
   gl_gnulib_enabled_cloexec=false
@@ -485,6 +496,7 @@ AC_DEFUN
   gl_gnulib_enabled_03e0aaad4cb89ca757653bd367a6ccb7=false
   gl_gnulib_enabled_6099e9737f757db36c47fa9d9f02e88c=false
   gl_gnulib_enabled_strtoll=false
+  gl_gnulib_enabled_utimens=false
   gl_gnulib_enabled_682e609604ccaac6be382e4ee3a4eaec=false
   func_gl_gnulib_m4code_260941c0e5dc67ec9e87d1fb321c300b ()
   {
@@ -663,6 +675,13 @@ AC_DEFUN
       gl_gnulib_enabled_strtoll=true
     fi
   }
+  func_gl_gnulib_m4code_utimens ()
+  {
+    if ! $gl_gnulib_enabled_utimens; then
+      gl_UTIMENS
+      gl_gnulib_enabled_utimens=true
+    fi
+  }
   func_gl_gnulib_m4code_682e609604ccaac6be382e4ee3a4eaec ()
   {
     if ! $gl_gnulib_enabled_682e609604ccaac6be382e4ee3a4eaec; then
@@ -705,6 +724,9 @@ AC_DEFUN
   if test $HAVE_FSTATAT = 0 || test $REPLACE_FSTATAT = 1; then
     func_gl_gnulib_m4code_03e0aaad4cb89ca757653bd367a6ccb7
   fi
+  if test $HAVE_FUTIMENS = 0 || test $REPLACE_FUTIMENS = 1; then
+    func_gl_gnulib_m4code_utimens
+  fi
   if test $REPLACE_GETOPT = 1; then
     func_gl_gnulib_m4code_be453cec5eecf5731a274f2de7f2db36
   fi
@@ -729,6 +751,15 @@ AC_DEFUN
   if test $HAVE_TIMEGM = 0 || test $REPLACE_TIMEGM = 1; then
     func_gl_gnulib_m4code_5264294aa0a5557541b53c8c741f7f31
   fi
+  if test $HAVE_UTIMENSAT = 0 || test $REPLACE_UTIMENSAT = 1; then
+    func_gl_gnulib_m4code_260941c0e5dc67ec9e87d1fb321c300b
+  fi
+  if test $HAVE_UTIMENSAT = 0 || test $REPLACE_UTIMENSAT = 1; then
+    func_gl_gnulib_m4code_03e0aaad4cb89ca757653bd367a6ccb7
+  fi
+  if test $HAVE_UTIMENSAT = 0 || test $REPLACE_UTIMENSAT = 1; then
+    func_gl_gnulib_m4code_utimens
+  fi
   m4_pattern_allow([^gl_GNULIB_ENABLED_])
   AM_CONDITIONAL([gl_GNULIB_ENABLED_260941c0e5dc67ec9e87d1fb321c300b], [$gl_gnulib_enabled_260941c0e5dc67ec9e87d1fb321c300b])
   AM_CONDITIONAL([gl_GNULIB_ENABLED_cloexec], [$gl_gnulib_enabled_cloexec])
@@ -747,6 +778,7 @@ AC_DEFUN
   AM_CONDITIONAL([gl_GNULIB_ENABLED_03e0aaad4cb89ca757653bd367a6ccb7], [$gl_gnulib_enabled_03e0aaad4cb89ca757653bd367a6ccb7])
   AM_CONDITIONAL([gl_GNULIB_ENABLED_6099e9737f757db36c47fa9d9f02e88c], [$gl_gnulib_enabled_6099e9737f757db36c47fa9d9f02e88c])
   AM_CONDITIONAL([gl_GNULIB_ENABLED_strtoll], [$gl_gnulib_enabled_strtoll])
+  AM_CONDITIONAL([gl_GNULIB_ENABLED_utimens], [$gl_gnulib_enabled_utimens])
   AM_CONDITIONAL([gl_GNULIB_ENABLED_682e609604ccaac6be382e4ee3a4eaec], [$gl_gnulib_enabled_682e609604ccaac6be382e4ee3a4eaec])
   # End of code from modules
   m4_ifval(gl_LIBSOURCES_LIST, [
@@ -956,6 +988,7 @@ AC_DEFUN
   lib/fsync.c
   lib/ftoastr.c
   lib/ftoastr.h
+  lib/futimens.c
   lib/get-permissions.c
   lib/getdtablesize.c
   lib/getgroups.c
@@ -1063,6 +1096,7 @@ AC_DEFUN
   lib/unlocked-io.h
   lib/utimens.c
   lib/utimens.h
+  lib/utimensat.c
   lib/verify.h
   lib/vla.h
   lib/warn-on-use.h
@@ -1103,6 +1137,7 @@ AC_DEFUN
   m4/fstatat.m4
   m4/fsusage.m4
   m4/fsync.m4
+  m4/futimens.m4
   m4/getdtablesize.m4
   m4/getgroups.m4
   m4/getloadavg.m4
@@ -1184,6 +1219,7 @@ AC_DEFUN
   m4/unistd_h.m4
   m4/unlocked-io.m4
   m4/utimens.m4
+  m4/utimensat.m4
   m4/utimes.m4
   m4/vararrays.m4
   m4/warn-on-use.m4
diff --git a/m4/utimensat.m4 b/m4/utimensat.m4
new file mode 100644
index 0000000000..2bc1bfebb5
--- /dev/null
+++ b/m4/utimensat.m4
@@ -0,0 +1,69 @@
+# serial 6
+# See if we need to provide utimensat replacement.
+
+dnl Copyright (C) 2009-2020 Free Software Foundation, Inc.
+dnl This file is free software; the Free Software Foundation
+dnl gives unlimited permission to copy and/or distribute it,
+dnl with or without modifications, as long as this notice is preserved.
+
+# Written by Eric Blake.
+
+AC_DEFUN([gl_FUNC_UTIMENSAT],
+[
+  AC_REQUIRE([gl_SYS_STAT_H_DEFAULTS])
+  AC_REQUIRE([gl_USE_SYSTEM_EXTENSIONS])
+  AC_CHECK_FUNCS_ONCE([utimensat])
+  if test $ac_cv_func_utimensat = no; then
+    HAVE_UTIMENSAT=0
+  else
+    AC_CACHE_CHECK([whether utimensat works],
+      [gl_cv_func_utimensat_works],
+      [AC_RUN_IFELSE(
+         [AC_LANG_PROGRAM([[
+#include <fcntl.h>
+#include <sys/stat.h>
+#include <unistd.h>
+]],         [[int result = 0;
+              const char *f = "conftest.file";
+              if (close (creat (f, 0600)))
+                return 1;
+              /* Test whether the AT_SYMLINK_NOFOLLOW flag is supported.  */
+              {
+                if (utimensat (AT_FDCWD, f, NULL, AT_SYMLINK_NOFOLLOW))
+                  result |= 2;
+              }
+              /* Test whether UTIME_NOW and UTIME_OMIT work.  */
+              {
+                struct timespec ts[2];
+                ts[0].tv_sec = 1;
+                ts[0].tv_nsec = UTIME_OMIT;
+                ts[1].tv_sec = 1;
+                ts[1].tv_nsec = UTIME_NOW;
+                if (utimensat (AT_FDCWD, f, ts, 0))
+                  result |= 4;
+              }
+              sleep (1);
+              {
+                struct stat st;
+                struct timespec ts[2];
+                ts[0].tv_sec = 1;
+                ts[0].tv_nsec = UTIME_NOW;
+                ts[1].tv_sec = 1;
+                ts[1].tv_nsec = UTIME_OMIT;
+                if (utimensat (AT_FDCWD, f, ts, 0))
+                  result |= 8;
+                if (stat (f, &st))
+                  result |= 16;
+                else if (st.st_ctime < st.st_atime)
+                  result |= 32;
+              }
+              return result;
+            ]])],
+         [gl_cv_func_utimensat_works=yes],
+         [gl_cv_func_utimensat_works=no],
+         [gl_cv_func_utimensat_works="guessing yes"])])
+    if test "$gl_cv_func_utimensat_works" = no; then
+      REPLACE_UTIMENSAT=1
+    fi
+  fi
+])
diff --git a/src/fileio.c b/src/fileio.c
index 2532f5233c..82fd798920 100644
--- a/src/fileio.c
+++ b/src/fileio.c
@@ -2253,9 +2253,8 @@ DEFUN ("copy-file", Fcopy_file, Scopy_file, 2, 6,
 
   if (!NILP (keep_time))
     {
-      struct timespec atime = get_stat_atime (&st);
-      struct timespec mtime = get_stat_mtime (&st);
-      if (set_file_times (ofd, SSDATA (encoded_newname), atime, mtime) != 0)
+      struct timespec ts[] = { get_stat_atime (&st), get_stat_mtime (&st) };
+      if (futimens (ofd, ts) != 0)
 	xsignal2 (Qfile_date_error,
 		  build_string ("Cannot set file date"), newname);
     }
@@ -3430,39 +3429,41 @@ DEFUN ("default-file-modes", Fdefault_file_modes, Sdefault_file_modes, 0, 0, 0,
 }
 
 
-DEFUN ("set-file-times", Fset_file_times, Sset_file_times, 1, 2, 0,
+DEFUN ("set-file-times", Fset_file_times, Sset_file_times, 1, 3, 0,
        doc: /* Set times of file FILENAME to TIMESTAMP.
-Set both access and modification times.
-Return t on success, else nil.
-Use the current time if TIMESTAMP is nil.  TIMESTAMP is in the format of
-`current-time'. */)
-  (Lisp_Object filename, Lisp_Object timestamp)
+If optional FLAG is `nofollow', do not follow FILENAME if it is a
+symbolic link.  Set both access and modification times.  Return t on
+success, else nil.  Use the current time if TIMESTAMP is nil.
+TIMESTAMP is in the format of `current-time'. */)
+  (Lisp_Object filename, Lisp_Object timestamp, Lisp_Object flag)
 {
-  Lisp_Object absname, encoded_absname;
-  Lisp_Object handler;
-  struct timespec t = lisp_time_argument (timestamp);
+  int nofollow = symlink_nofollow_flag (flag);
 
-  absname = Fexpand_file_name (filename, BVAR (current_buffer, directory));
+  struct timespec ts[2];
+  if (!NILP (timestamp))
+    ts[0] = ts[1] = lisp_time_argument (timestamp);
+  else
+    ts[0].tv_nsec = ts[1].tv_nsec = UTIME_NOW;
 
   /* If the file name has special constructs in it,
      call the corresponding file name handler.  */
-  handler = Ffind_file_name_handler (absname, Qset_file_times);
+  Lisp_Object
+    absname = Fexpand_file_name (filename, BVAR (current_buffer, directory)),
+    handler = Ffind_file_name_handler (absname, Qset_file_times);
   if (!NILP (handler))
-    return call3 (handler, Qset_file_times, absname, timestamp);
+    return call4 (handler, Qset_file_times, absname, timestamp, flag);
 
-  encoded_absname = ENCODE_FILE (absname);
+  Lisp_Object encoded_absname = ENCODE_FILE (absname);
 
-  {
-    if (set_file_times (-1, SSDATA (encoded_absname), t, t) != 0)
-      {
+  if (utimensat (AT_FDCWD, SSDATA (encoded_absname), ts, nofollow) != 0)
+    {
 #ifdef MSDOS
-        /* Setting times on a directory always fails.  */
-        if (file_directory_p (encoded_absname))
-          return Qnil;
+      /* Setting times on a directory always fails.  */
+      if (file_directory_p (encoded_absname))
+	return Qnil;
 #endif
-        report_file_error ("Setting file times", absname);
-      }
-  }
+      report_file_error ("Setting file times", absname);
+    }
 
   return Qt;
 }
diff --git a/src/sysdep.c b/src/sysdep.c
index e8e8bbfb50..149d80f19e 100644
--- a/src/sysdep.c
+++ b/src/sysdep.c
@@ -2752,21 +2752,6 @@ emacs_perror (char const *message)
   errno = err;
 }
 
-/* Set the access and modification time stamps of FD (a.k.a. FILE) to be
-   ATIME and MTIME, respectively.
-   FD must be either negative -- in which case it is ignored --
-   or a file descriptor that is open on FILE.
-   If FD is nonnegative, then FILE can be NULL.  */
-int
-set_file_times (int fd, const char *filename,
-		struct timespec atime, struct timespec mtime)
-{
-  struct timespec timespec[2];
-  timespec[0] = atime;
-  timespec[1] = mtime;
-  return fdutimens (fd, filename, timespec);
-}
-
 /* Rename directory SRCFD's entry SRC to directory DSTFD's entry DST.
    This is like renameat except that it fails if DST already exists,
    or if this operation is not supported atomically.  Return 0 if
diff --git a/src/systime.h b/src/systime.h
index 00ca4a1c58..b59a3d1c69 100644
--- a/src/systime.h
+++ b/src/systime.h
@@ -67,9 +67,6 @@ timespec_valid_p (struct timespec t)
   return t.tv_nsec >= 0;
 }
 
-/* defined in sysdep.c */
-extern int set_file_times (int, const char *, struct timespec, struct timespec);
-
 /* defined in keyboard.c */
 extern void set_waiting_for_input (struct timespec *);
 
diff --git a/src/w32.c b/src/w32.c
index cf1a3b3767..40f286ad6c 100644
--- a/src/w32.c
+++ b/src/w32.c
@@ -3189,6 +3189,21 @@ fdutimens (int fd, char const *file, struct timespec const timespec[2])
     }
 }
 
+/* Set the access and modification time stamps of FD (a.k.a. FILE) to be
+   ATIME and MTIME, respectively.
+   FD must be either negative -- in which case it is ignored --
+   or a file descriptor that is open on FILE.
+   If FD is nonnegative, then FILE can be NULL.  */
+static int
+set_file_times (int fd, const char *filename,
+		struct timespec atime, struct timespec mtime)
+{
+  struct timespec timespec[2];
+  timespec[0] = atime;
+  timespec[1] = mtime;
+  return fdutimens (fd, filename, timespec);
+}
+
 
 /* ------------------------------------------------------------------------- */
 /* IO support and wrapper functions for the Windows API. */
diff --git a/test/lisp/filenotify-tests.el b/test/lisp/filenotify-tests.el
index 39156fbb5d..a184fabb9f 100644
--- a/test/lisp/filenotify-tests.el
+++ b/test/lisp/filenotify-tests.el
@@ -771,9 +771,9 @@ file-notify-test03-events
 	  (copy-file file-notify--test-tmpfile file-notify--test-tmpfile1)
 	  ;; The next two events shall not be visible.
 	  (file-notify--test-read-event)
-	  (set-file-modes file-notify--test-tmpfile 000)
+	  (set-file-modes file-notify--test-tmpfile 000 'nofollow)
 	  (file-notify--test-read-event)
-	  (set-file-times file-notify--test-tmpfile '(0 0))
+	  (set-file-times file-notify--test-tmpfile '(0 0) 'nofollow)
 	  (file-notify--test-read-event)
           (delete-directory file-notify--test-tmpdir 'recursive))
         (file-notify-rm-watch file-notify--test-desc)
@@ -864,9 +864,9 @@ file-notify-test03-events
 	  (write-region
 	   "any text" nil file-notify--test-tmpfile nil 'no-message)
 	  (file-notify--test-read-event)
-	  (set-file-modes file-notify--test-tmpfile 000)
+	  (set-file-modes file-notify--test-tmpfile 000 'nofollow)
 	  (file-notify--test-read-event)
-	  (set-file-times file-notify--test-tmpfile '(0 0))
+	  (set-file-times file-notify--test-tmpfile '(0 0) 'nofollow)
 	  (file-notify--test-read-event)
 	  (delete-file file-notify--test-tmpfile))
         (file-notify-rm-watch file-notify--test-desc)
diff --git a/test/lisp/files-tests.el b/test/lisp/files-tests.el
index ac56a7732f..05d9ceebf1 100644
--- a/test/lisp/files-tests.el
+++ b/test/lisp/files-tests.el
@@ -1003,9 +1003,9 @@ files-tests-file-name-non-special-set-file-selinux-context
 
 (ert-deftest files-tests-file-name-non-special-set-file-times ()
   (files-tests--with-temp-non-special (tmpfile nospecial)
-    (set-file-times nospecial))
+    (set-file-times nospecial nil 'nofollow))
   (files-tests--with-temp-non-special-and-file-name-handler (tmpfile nospecial)
-    (should-error (set-file-times nospecial))))
+    (should-error (set-file-times nospecial nil 'nofollow))))
 
 (ert-deftest files-tests-file-name-non-special-set-visited-file-modtime ()
   (files-tests--with-temp-non-special (tmpfile nospecial)
diff --git a/test/lisp/net/tramp-tests.el b/test/lisp/net/tramp-tests.el
index 9409cc2a39..378a0fab89 100644
--- a/test/lisp/net/tramp-tests.el
+++ b/test/lisp/net/tramp-tests.el
@@ -3687,7 +3687,8 @@ tramp-test22-file-times
 			    (file-attributes tmp-name1))))
 	    ;; Skip the test, if the remote handler is not able to set
 	    ;; the correct time.
-	    (skip-unless (set-file-times tmp-name1 (seconds-to-time 1)))
+	    (skip-unless (set-file-times tmp-name1 (seconds-to-time 1)
+					 'nofollow))
 	    ;; Dumb remote shells without perl(1) or stat(1) are not
 	    ;; able to return the date correctly.  They say "don't know".
 	    (unless (tramp-compat-time-equal-p
-- 
2.24.1