Skip to content

os: os.Chtimes is corrupting very old timestamps #75542

@ncw

Description

@ncw

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

go/src/os/file_posix.go

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

go/src/os/root_windows.go

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    BugReportIssues describing a possible bug in the Go implementation.FixPendingIssues that have a fix which has not yet been reviewed or submitted.NeedsFixThe path to resolution is known, but the work has not been done.help wanted

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions