-
Notifications
You must be signed in to change notification settings - Fork 18.4k
Description
Go version
go version go1.25.0 linux/amd64
Output of go env
in your module/workspace:
Details
AR='ar'
CC='gcc'
CGO_CFLAGS='-O2 -g'
CGO_CPPFLAGS=''
CGO_CXXFLAGS='-O2 -g'
CGO_ENABLED='1'
CGO_FFLAGS='-O2 -g'
CGO_LDFLAGS='-O2 -g'
CXX='g++'
GCCGO='gccgo'
GO111MODULE=''
GOAMD64='v1'
GOARCH='amd64'
GOAUTH='netrc'
GOBIN=''
GOCACHE='/home/ncw/.cache/go-build'
GOCACHEPROG=''
GODEBUG=''
GOENV='/home/ncw/.config/go/env'
GOEXE=''
GOEXPERIMENT=''
GOFIPS140='off'
GOFLAGS=''
GOGCCFLAGS='-fPIC -m64 -pthread -Wl,--no-gc-sections -fmessage-length=0 -ffile-prefix-map=/tmp/go-build512443455=/tmp/go-build -gno-record-gcc-switches'
GOHOSTARCH='amd64'
GOHOSTOS='linux'
GOINSECURE=''
GOMOD='/dev/null'
GOMODCACHE='/home/ncw/go/pkg/mod'
GONOPROXY=''
GONOSUMDB=''
GOOS='linux'
GOPATH='/home/ncw/go'
GOPRIVATE=''
GOPROXY='https://proxy.golang.org,direct'
GOROOT='/opt/go/go1.25'
GOSUMDB='sum.golang.org'
GOTELEMETRY='local'
GOTELEMETRYDIR='/home/ncw/.config/go/telemetry'
GOTMPDIR=''
GOTOOLCHAIN='auto'
GOTOOLDIR='/opt/go/go1.25/pkg/tool/linux_amd64'
GOVCS=''
GOVERSION='go1.25.0'
GOWORK=''
PKG_CONFIG='pkg-config'
What did you do?
Running this program demonstrates the problem. It needs to be run on a file system that supports a wide timerange. ext4 doesn't but ntfs-3g does.
package main
import (
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"time"
)
func statAndPrint(label, path string, expected time.Time) {
fi, err := os.Stat(path)
if err != nil {
log.Fatalf("stat (%s): %v", label, err)
}
actual := fi.ModTime().UTC()
fmt.Printf("%s\n", label)
fmt.Printf("Expected mtime: %sZ (unix nano=%d)\n", expected.Format("2006-01-02T15:04:05"), expected.UnixNano())
fmt.Printf("Actual mtime: %s (unix nano=%d)\n", actual.Format(time.RFC3339), actual.UnixNano())
if expected.Equal(actual) {
fmt.Printf("ALL OK - times equal\n")
} else {
fmt.Printf("FAIL - times differ by %v\n", actual.Sub(expected))
}
}
func main() {
if len(os.Args) < 2 {
log.Fatalf("usage: %s <directory>", os.Args[0])
}
dir := os.Args[1]
fname := filepath.Join(dir, "chtimes_test.txt")
const expectedStr = "1665-12-01T13:57:13" // interpreted as UTC
// Create (or truncate) the file
if err := os.WriteFile(fname, []byte{}, 0o644); err != nil {
log.Fatalf("create: %v", err)
}
// Parse expected time in UTC and set both atime & mtime via os.Chtimes
expected, err := time.ParseInLocation("2006-01-02T15:04:05", expectedStr, time.UTC)
if err != nil {
log.Fatalf("parse: %v", err)
}
if err := os.Chtimes(fname, expected, expected); err != nil {
log.Fatalf("chtimes: %v", err)
}
statAndPrint("Set timestamp with os.Chtimes", fname, expected)
fmt.Printf("------------------------------------------------------------\n")
// Now set the timestamp via the system `touch` command.
// Use TZ=UTC so the -t timestamp is not affected by local timezone.
touchArg := expected.Format("200601021504.05") // CCYYMMDDhhmm.SS
cmd := exec.Command("touch", "-t", touchArg, fname)
cmd.Env = append(os.Environ(), "TZ=UTC")
if out, err := cmd.CombinedOutput(); err != nil {
log.Fatalf("touch: %v; output: %s", err, string(out))
}
statAndPrint("Set timestamp with touch (TZ=UTC)", fname, expected)
}
You can create a suitable file system to check this on with an NTFS-3g loopback on Linux like this
truncate -s 10M ntfs.img
sudo mkfs.ntfs -F -L NTFS_VOL ntfs.img
sudo mkdir -p /mnt/ntfsimg
sudo mount -t ntfs-3g -o loop,uid=$UID,gid=$(id -g) ntfs.img /mnt/ntfsimg
What did you see happen?
It attempts to set a 1665 date as a timestamp on a file. This is read back as a 2250 timestamp.
Set timestamp with os.Chtimes
Expected mtime: 1665-12-01T13:57:13Z (unix nano=8850864706709551616)
Actual mtime: 2250-06-22T13:31:46Z (unix nano=8850864706709551600)
FAIL - times differ by 2562047h47m16.854775807s
It then sets the timestamp with touch
which does work as expected (showing that this date is representable by the file system and by Go time.Time
).
Set timestamp with touch (TZ=UTC)
Expected mtime: 1665-12-01T13:57:13Z (unix nano=8850864706709551616)
Actual mtime: 1665-12-01T13:57:13Z (unix nano=8850864706709551616)
ALL OK - times equal
Note how all the unix nano values are the same (+/- 16nS)
What did you expect to see?
I expected the go standard library not to lose the extra precision in the Go timestamp when applying it to the file.
The problem appears to be here
Lines 179 to 199 in 3cf1aaf
func Chtimes(name string, atime time.Time, mtime time.Time) error { | |
utimes := chtimesUtimes(atime, mtime) | |
if e := syscall.UtimesNano(fixLongPath(name), utimes[0:]); e != nil { | |
return &PathError{Op: "chtimes", Path: name, Err: e} | |
} | |
return nil | |
} | |
func chtimesUtimes(atime, mtime time.Time) [2]syscall.Timespec { | |
var utimes [2]syscall.Timespec | |
set := func(i int, t time.Time) { | |
if t.IsZero() { | |
utimes[i] = syscall.Timespec{Sec: _UTIME_OMIT, Nsec: _UTIME_OMIT} | |
} else { | |
utimes[i] = syscall.NsecToTimespec(t.UnixNano()) | |
} | |
} | |
set(0, atime) | |
set(1, mtime) | |
return utimes | |
} |
In particular the use of UnixNano()
to convert the time as the 1665 date is not representable as an int64
nS since the epoch but it is representable as a syscall.Timespec
type Timespec struct {
Sec int64
Nsec int64
}
This could probably fixed by constructing the Timespec
something like this instead of utimes[i] = syscall.NsecToTimespec(t.UnixNano())
utimes[i] = syscall.Timespec{
Sec: t.Unix(),
Nsec: int64(t.Nanosecond()),
}
Though I am uncertain as to whether there is any rounding in t.Unix()
which might affect things.
Note that this likely affects Windows too as the time is roundtripped through an int64
there too
Lines 367 to 372 in 3cf1aaf
if !atime.IsZero() { | |
a = syscall.NsecToFiletime(atime.UnixNano()) | |
} | |
if !mtime.IsZero() { | |
w = syscall.NsecToFiletime(mtime.UnixNano()) | |
} |
This problem was discovered by an rclone user in rclone/rclone#8834