From 5195b4c0e807fbee3fb1750e48e6ae7a309dd387 Mon Sep 17 00:00:00 2001 From: Yike Ye Date: Fri, 11 Sep 2026 16:14:53 +0800 Subject: [PATCH] fix: keep mtime when utimens only updates atime (UTIME_OMIT) libfuse passes a time that should be left unchanged as { tv_sec = 0, tv_nsec = UTIME_OMIT }. Since f045211 ("Make utimens(NULL) work correctly") sshfs_utimens() only looked at tv_sec and replaced 0 with the current time, so an atime-only update overwrote the file's mtime on the server. On macOS this happens whenever Finder previews a file with Quick Look. SSH_FILEXFER_ATTR_ACMODTIME always sets both times, so when one of them is UTIME_OMIT fetch the current attributes and send its current value, map UTIME_NOW to the current time, and skip the request if both are omitted. Add a regression test that updates only the atime with touch -a. Refs: #282 Co-Authored-By: Claude Opus 5 (1M context) --- sshfs.c | 40 ++++++++++++++++++++++++++++++++-------- test/test_sshfs.py | 18 ++++++++++++++++++ 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/sshfs.c b/sshfs.c index 3bd4ec5..ddb4a52 100644 --- a/sshfs.c +++ b/sshfs.c @@ -2873,21 +2873,36 @@ static void sshfs_inc_modifver(void) pthread_mutex_unlock(&sshfs.lock); } +static int sshfs_getattr(const char *path, struct stat *stbuf, + struct fuse_file_info *fi); + +/* + * SSH_FILEXFER_ATTR_ACMODTIME always sets both atime and mtime, so a time the + * caller wants left alone (UTIME_OMIT) has to be sent as its current value. + * Treating it as "now" makes an atime-only update (e.g. Finder recording that + * a file was previewed) overwrite the file's modification time. + */ +static time_t utimens_resolve(const struct timespec *ts, time_t cur, time_t now) +{ + if (ts->tv_nsec == UTIME_OMIT) + return cur; + if (ts->tv_nsec == UTIME_NOW) + return now; + return ts->tv_sec; +} + static int sshfs_utimens(const char *path, const struct timespec tv[2], struct fuse_file_info *fi) { - (void) fi; int err; struct buffer buf; struct sshfs_file *sf = NULL; - time_t asec = tv[0].tv_sec, msec = tv[1].tv_sec; + struct stat cur; + time_t now = time(NULL); + time_t asec, msec; - struct timeval now; - gettimeofday(&now, NULL); - if (asec == 0) - asec = now.tv_sec; - if (msec == 0) - msec = now.tv_sec; + if (tv[0].tv_nsec == UTIME_OMIT && tv[1].tv_nsec == UTIME_OMIT) + return 0; if (fi != NULL) { sf = get_sshfs_file(fi); @@ -2895,6 +2910,15 @@ static int sshfs_utimens(const char *path, const struct timespec tv[2], return -EIO; } + memset(&cur, 0, sizeof(cur)); + if (tv[0].tv_nsec == UTIME_OMIT || tv[1].tv_nsec == UTIME_OMIT) { + err = sshfs_getattr(path, &cur, fi); + if (err) + return err; + } + asec = utimens_resolve(&tv[0], cur.st_atime, now); + msec = utimens_resolve(&tv[1], cur.st_mtime, now); + buf_init(&buf, 0); if (sf == NULL) buf_add_path(&buf, path); diff --git a/test/test_sshfs.py b/test/test_sshfs.py index c4cc64f..c5b2d06 100755 --- a/test/test_sshfs.py +++ b/test/test_sshfs.py @@ -162,6 +162,7 @@ def test_sshfs( # file timestamps. tst_utimens(mnt_dir, tol=1) tst_utimens_now(mnt_dir) + tst_utimens_omit(mnt_dir) tst_link(mnt_dir, cache_timeout) tst_truncate_path(mnt_dir) @@ -698,6 +699,23 @@ def tst_utimens_now(mnt_dir): assert fstat.st_mtime != 0 +def tst_utimens_omit(mnt_dir): + # Updating only the access time (UTIME_OMIT for mtime) must leave the + # modification time alone instead of setting it to "now". + fullname = pjoin(mnt_dir, name_generator()) + + fd = os.open(fullname, os.O_CREAT | os.O_RDWR) + os.close(fd) + old = int(os.lstat(fullname).st_mtime) - 1000 + os.utime(fullname, (old, old)) + + subprocess.check_call(["touch", "-a", fullname]) + + fstat = os.lstat(fullname) + assert fstat.st_mtime == old + assert fstat.st_atime > old + + def tst_passthrough(src_dir, mnt_dir, cache_timeout): name = name_generator() src_name = pjoin(src_dir, name)