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

https://github.com/golang/go/blob/3cf1aaf8b9c846c44ec8db679495dd5816d1ec30/src/os/file_posix.go#L179-L199

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

https://github.com/golang/go/blob/3cf1aaf8b9c846c44ec8db679495dd5816d1ec30/src/os/root_windows.go#L367-L372

This problem was discovered by an rclone user in https://github.com/rclone/rclone/issues/8834

Comment From: gabyhelp

Related Issues

Related Code Changes

Related Discussions

(Emoji vote if this was helpful or unhelpful; more detailed feedback welcome in this discussion.)

Comment From: Jorropo

I don't see any rounding in t.Unix (only truncation). It looks safe to me, please send a patch.

We ideally want a test but it might or might not be pain to do on the runners. Your new implementation looks obviously correct and it will still be tested in the existing time ranges so I would still merge it even if you don't add a test.

Comment From: ncw

Thanks for the feedback @Jorropo

I'll work on a patch.

Comment From: ncw

@Jorropo looking into this further I see that the idea that a datetime can fit into UnixNano int64 is throughout the syscall code and also in golang.org/x/sys/unix and golang.org/x/sys/windows whereas this isn't true for any 64 bit Linux and has never been true for Windows.

For example uses like this

src/os/root_windows.go:         a = syscall.NsecToFiletime(atime.UnixNano())
src/os/root_windows.go:         w = syscall.NsecToFiletime(mtime.UnixNano())

Or this

src/syscall/syscall_linux_arm64.go:             NsecToTimespec(TimevalToNsec(tv[0])),
src/syscall/syscall_linux_arm64.go:             NsecToTimespec(TimevalToNsec(tv[0])),
src/syscall/syscall_linux_loong64.go:           NsecToTimespec(TimevalToNsec(tv[0])),

Or this

src/syscall/syscall_bsd.go:             NsecToTimeval(TimespecToNsec(ts[0])),

All of which are needlessly truncating date/times into an int64.

This could do with some support from syscall, eg something like


 package syscall

+import "time"
+
 // TimespecToNsec returns the time stored in ts as nanoseconds.
 func TimespecToNsec(ts Timespec) int64 { return ts.Nano() }

@@ -20,9 +22,21 @@ func NsecToTimespec(nsec int64) Timespec {
    return setTimespec(sec, nsec)
 }

+// TimeToTimespec converts a time.Time into a [Timespec].
+func TimeToTimespec(t time.Time) Timespec {
+   return setTimespec(t.Unix(), int64(t.Nanosecond()))
+}
+
 // TimevalToNsec returns the time stored in tv as nanoseconds.
 func TimevalToNsec(tv Timeval) int64 { return tv.Nano() }

+// TimevalToTimespec converts a [Timeval] into a [Timespec].
+func TimevalToTimespec(tv Timeval) Timespec {
+   sec, usec := tv.Unix()
+   nsec := usec * 1e3
+   setTimespec(tv, nsec)
+}
+
 // NsecToTimeval converts a number of nanoseconds into a [Timeval].
 func NsecToTimeval(nsec int64) Timeval {
    nsec += 999 // round up to microsecond
@@ -34,3 +48,16 @@ func NsecToTimeval(nsec int64) Timeval {
    }
    return setTimeval(sec, usec)
 }
+
+// TimespecToTimeval converts a [Timespec] into a [Timeval].
+func TimespecToTimeval(ts Timespec) Timeval {
+   sec, nsec := ts.Unix()
+   nsec += 999 // round up to microsecond
+   usec := nsec % 1e9 / 1e3
+   sec := nsec / 1e9
+   if usec < 0 {
+       usec += 1e6
+       sec--
+   }
+   return setTimeval(sec, usec)
+}

The same changes would need to be made in golang.org/x/sys/unix too.

This is starting to sound like a big job!

Should I

  1. Just do the small fix outlined above in os
  2. Do the full fix in syscall and os (am I allowed to make new public functions in syscall - I thought it was frozen?)
  3. Do the full fix in syscall and os and golang.org/x/sys/unix?
  4. Something else?

Thanks