Repository: otiai10/copy
Pull request #83 (2022-07-16 13:22:00)

Support PreserveTimes for symlinks on Unix
This is an addition to https://github.com/otiai10/copy/pull/31 - While symlink timestamps cannot be set universally (neither at time of link creation nor by a simple call to `os.Chtimes()`) they can be set on Unix making use of `unix.Lutimes()`. This PR extends the existing `PreserveTime` option by that, transferring the original link timestamp(s) to the newly created one if the option is set.

For unsupported architectures, a stub is provided similar to already existing ones (e.g. for ownership preservation).


Patch:
diff --git a/copy.go b/copy.go
index 81e1ecc..77eaf3c 100644
--- a/copy.go
+++ b/copy.go
@@ -171,7 +171,13 @@ func dcopy(srcdir, destdir string, info os.FileInfo, opt Options) (err error) {
 func onsymlink(src, dest string, opt Options) error {
 	switch opt.OnSymlink(src) {
 	case Shallow:
-		return lcopy(src, dest)
+		if err := lcopy(src, dest); err != nil {
+			return err
+		}
+		if opt.PreserveTimes {
+			return preserveLtimes(src, dest)
+		}
+		return nil
 	case Deep:
 		orig, err := os.Readlink(src)
 		if err != nil {
diff --git a/go.mod b/go.mod
index 703f47f..2abf99c 100644
--- a/go.mod
+++ b/go.mod
@@ -2,4 +2,7 @@ module github.com/otiai10/copy
 
 go 1.14
 
-require github.com/otiai10/mint v1.3.3
+require (
+	github.com/otiai10/mint v1.3.3
+	golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect
+)
diff --git a/go.sum b/go.sum
index 152c77b..a632948 100644
--- a/go.sum
+++ b/go.sum
@@ -4,3 +4,5 @@ github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6
 github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo=
 github.com/otiai10/mint v1.3.3 h1:7JgpsBaN0uMkyju4tbYHu0mnM55hNKVYLsXmwr15NQI=
 github.com/otiai10/mint v1.3.3/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc=
+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ=
+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
diff --git a/preserve_ltimes.go b/preserve_ltimes.go
new file mode 100644
index 0000000..cc006d3
--- /dev/null
+++ b/preserve_ltimes.go
@@ -0,0 +1,20 @@
+//go:build !windows && !plan9 && !js
+// +build !windows,!plan9,!js
+
+package copy
+
+import (
+	"golang.org/x/sys/unix"
+)
+
+func preserveLtimes(src, dest string) error {
+	info := new(unix.Stat_t)
+	if err := unix.Lstat(src, info); err != nil {
+		return err
+	}
+
+	return unix.Lutimes(dest, []unix.Timeval{
+		unix.NsecToTimeval(info.Atim.Nano()),
+		unix.NsecToTimeval(info.Mtim.Nano()),
+	})
+}
diff --git a/preserve_ltimes_x.go b/preserve_ltimes_x.go
new file mode 100644
index 0000000..02aec40
--- /dev/null
+++ b/preserve_ltimes_x.go
@@ -0,0 +1,8 @@
+//go:build windows || js || plan9
+// +build windows js plan9
+
+package copy
+
+func preserveLtimes(src, dest string) error {
+	return nil // Unsupported
+}


Test patch:
diff --git a/preserve_ltimes_test.go b/preserve_ltimes_test.go
new file mode 100644
index 0000000..4289776
--- /dev/null
+++ b/preserve_ltimes_test.go
@@ -0,0 +1,35 @@
+//go:build !windows && !plan9 && !js
+// +build !windows,!plan9,!js
+
+package copy
+
+import (
+	"os"
+	"testing"
+
+	. "github.com/otiai10/mint"
+	"golang.org/x/sys/unix"
+)
+
+func TestOptions_PreserveLTimes(t *testing.T) {
+	err := Copy("test/data/case15", "test/data.copy/case15")
+	Expect(t, err).ToBe(nil)
+	opt := Options{PreserveTimes: true}
+	err = Copy("test/data/case15", "test/data.copy/case15-preserveltimes", opt)
+	Expect(t, err).ToBe(nil)
+
+	orig, err := os.Lstat("test/data/case15/symlink")
+	Expect(t, err).ToBe(nil)
+	plain, err := os.Lstat("test/data.copy/case15/symlink")
+	Expect(t, err).ToBe(nil)
+	preserved, err := os.Lstat("test/data.copy/case15-preserveltimes/symlink")
+	Expect(t, err).ToBe(nil)
+	Expect(t, plain.ModTime().Unix()).Not().ToBe(orig.ModTime().Unix())
+	Expect(t, preserved.ModTime().Unix()).ToBe(orig.ModTime().Unix())
+}
+
+func TestOptions_PreserveLTimesErrorReturn(t *testing.T) {
+	err := preserveLtimes("doesnotexist_original.txt", "doesnotexist_copy.txt")
+	Expect(t, err).ToBe(unix.ENOENT)
+	Expect(t, os.IsNotExist(err)).ToBe(true)
+}
diff --git a/preserve_ltimes_x_test.go b/preserve_ltimes_x_test.go
new file mode 100644
index 0000000..1c73b5a
--- /dev/null
+++ b/preserve_ltimes_x_test.go
@@ -0,0 +1,28 @@
+//go:build windows || js || plan9
+// +build windows js plan9
+
+package copy
+
+import (
+	"os"
+	"testing"
+
+	. "github.com/otiai10/mint"
+)
+
+func TestOptions_PreserveLTimes(t *testing.T) {
+	err := Copy("test/data/case15", "test/data.copy/case15")
+	Expect(t, err).ToBe(nil)
+	opt := Options{PreserveTimes: true}
+	err = Copy("test/data/case15", "test/data.copy/case15-preserveltimes", opt)
+	Expect(t, err).ToBe(nil)
+
+	orig, err := os.Lstat("test/data/case15/symlink")
+	Expect(t, err).ToBe(nil)
+	plain, err := os.Lstat("test/data.copy/case15/symlink")
+	Expect(t, err).ToBe(nil)
+	preserved, err := os.Lstat("test/data.copy/case15-preserveltimes/symlink")
+	Expect(t, err).ToBe(nil)
+	Expect(t, plain.ModTime().Unix()).Not().ToBe(orig.ModTime().Unix())
+	Expect(t, preserved.ModTime().Unix()).Not().ToBe(orig.ModTime().Unix())
+}
diff --git a/test/data/case15/README.md b/test/data/case15/README.md
new file mode 100644
index 0000000..ba38bad
--- /dev/null
+++ b/test/data/case15/README.md
@@ -0,0 +1,5 @@
+File with specific atime and mtime.
+
+These two properties should be preserved if we provide the PreserveTimes options to copy.Copy.
+
+The properties should be preserverd also for the directories and the links.
diff --git a/test/data/case15/symlink b/test/data/case15/symlink
new file mode 120000
index 0000000..42061c0
--- /dev/null
+++ b/test/data/case15/symlink
@@ -0,0 +1,1 @@
+README.md
\ No newline at end of file