From 69ead22fccd7930e8b852270bd1ed0aabfa8923a Mon Sep 17 00:00:00 2001 From: Andrey Markelov Date: Thu, 2 Jul 2026 21:50:05 -0700 Subject: [PATCH] Preserve source file modification time on upload Use the source file's mtime as ClientModified instead of time.Now(), so Dropbox records the original timestamp. For stdin uploads the spool file mtime (approximately now) is used. --- cmd/pipe_test.go | 45 +++++++++++++++++++++++++++++++++++++ cmd/put.go | 10 ++++++--- cmd/put_test.go | 58 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 3 deletions(-) diff --git a/cmd/pipe_test.go b/cmd/pipe_test.go index bb982e28..4f8696ae 100644 --- a/cmd/pipe_test.go +++ b/cmd/pipe_test.go @@ -7,6 +7,7 @@ import ( "os" "strings" "testing" + "time" "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox" "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files" @@ -112,6 +113,50 @@ func TestPutStdin_UploadsContent(t *testing.T) { } } +func TestPutStdinSetsClientModified(t *testing.T) { + content := "hello from stdin" + cmd := testPutCmdWithStdin(strings.NewReader(content)) + + start := time.Now().UTC() + var uploadedClientModified *dropbox.DBXTime + mock := &mockFilesClient{ + getMetadataFn: func(arg *files.GetMetadataArg) (files.IsMetadata, error) { + return nil, &files.GetMetadataAPIError{} + }, + uploadFn: func(arg *files.UploadArg, r io.Reader) (*files.FileMetadata, error) { + uploadedClientModified = arg.ClientModified + data, err := io.ReadAll(r) + if err != nil { + t.Fatal(err) + } + if string(data) != content { + t.Fatalf("uploaded content = %q, want %q", string(data), content) + } + return &files.FileMetadata{}, nil + }, + } + stubFilesClient(t, mock) + + err := put(cmd, []string{"-", "/dest.txt"}) + end := time.Now().UTC() + if err != nil { + t.Fatalf("put stdin error: %v", err) + } + if uploadedClientModified == nil { + t.Fatal("ClientModified = nil, want stdin spool modified time") + } + + got := time.Time(*uploadedClientModified) + lower := start.Add(-time.Second) + upper := end.Add(time.Second) + if got.Before(lower) || got.After(upper) { + t.Fatalf("ClientModified = %s, want between %s and %s", got.Format(time.RFC3339Nano), lower.Format(time.RFC3339Nano), upper.Format(time.RFC3339Nano)) + } + if got.Location() != time.UTC { + t.Fatalf("ClientModified location = %v, want UTC", got.Location()) + } +} + func TestPutStdinIfExistsSkipDoesNotReadStdin(t *testing.T) { cmd := testPutCmdWithStdin(failReadReader{t: t}) _ = cmd.Flags().Set("if-exists", "skip") diff --git a/cmd/put.go b/cmd/put.go index e3ba5672..49dfc783 100644 --- a/cmd/put.go +++ b/cmd/put.go @@ -506,6 +506,12 @@ func putFile(src, dst string, opts putOptions) error { return err } +// Dropbox upload commit timestamps must be UTC with second precision. +func dropboxClientModified(value time.Time) *dropbox.DBXTime { + ts := dropbox.DBXTime(value.UTC().Round(time.Second)) + return &ts +} + func putFileWithResult(src, dst string, opts putOptions) (putResult, error) { ifExists, err := normalizePutIfExists(opts.ifExists) if err != nil { @@ -537,9 +543,7 @@ func putFileWithResult(src, dst string, opts putOptions) (putResult, error) { commitInfo.Mode.Tag = writeModeForIfExists(ifExists) commitInfo.StrictConflict = ifExists != putIfExistsOverwrite - // The Dropbox API only accepts timestamps in UTC with second precision. - ts := dropbox.DBXTime(time.Now().UTC().Round(time.Second)) - commitInfo.ClientModified = &ts + commitInfo.ClientModified = dropboxClientModified(contentsInfo.ModTime()) if contentsInfo.Size() > singleShotUploadSizeCutoff { metadata, err := uploadChunked(dbx, uploadProgressReader(contents, contentsInfo.Size(), putErrorOutput(opts)), commitInfo, contentsInfo.Size(), opts.workers, opts.chunkSize, opts.debug) diff --git a/cmd/put_test.go b/cmd/put_test.go index db53b587..c19c95f8 100644 --- a/cmd/put_test.go +++ b/cmd/put_test.go @@ -1118,6 +1118,64 @@ func TestPutTextModeWritesNoStdoutOnSuccess(t *testing.T) { } } +func TestDropboxClientModifiedUsesUTCSecondPrecision(t *testing.T) { + input := time.Date(2026, 7, 1, 12, 34, 56, 600*1e6, time.FixedZone("source", -7*60*60)) + + got := dropboxClientModified(input) + if got == nil { + t.Fatal("dropboxClientModified returned nil") + } + + gotTime := time.Time(*got) + want := input.UTC().Round(time.Second) + if !gotTime.Equal(want) { + t.Fatalf("client modified = %s, want %s", gotTime.Format(time.RFC3339Nano), want.Format(time.RFC3339Nano)) + } + if gotTime.Location() != time.UTC { + t.Fatalf("client modified location = %v, want UTC", gotTime.Location()) + } +} + +func TestPutFileUsesSourceModifiedTime(t *testing.T) { + tmpFile := filepath.Join(t.TempDir(), "mtime.txt") + if err := os.WriteFile(tmpFile, []byte("test"), 0644); err != nil { + t.Fatal(err) + } + sourceModTime := time.Date(2020, 4, 23, 14, 37, 4, 0, time.FixedZone("source", -7*60*60)) + if err := os.Chtimes(tmpFile, sourceModTime, sourceModTime); err != nil { + t.Fatal(err) + } + + var uploadedClientModified *dropbox.DBXTime + mock := &mockFilesClient{ + uploadFn: func(arg *files.UploadArg, content io.Reader) (*files.FileMetadata, error) { + uploadedClientModified = arg.ClientModified + if _, err := io.ReadAll(content); err != nil { + t.Fatal(err) + } + return &files.FileMetadata{}, nil + }, + } + stubFilesClient(t, mock) + + if err := putFile(tmpFile, "/mtime.txt", putOptions{ + chunkSize: 1 << 24, + workers: 4, + ifExists: putIfExistsOverwrite, + }); err != nil { + t.Fatalf("putFile error: %v", err) + } + + if uploadedClientModified == nil { + t.Fatal("ClientModified = nil, want source file modified time") + } + got := time.Time(*uploadedClientModified) + want := sourceModTime.UTC().Round(time.Second) + if !got.Equal(want) { + t.Fatalf("ClientModified = %s, want %s", got.Format(time.RFC3339Nano), want.Format(time.RFC3339Nano)) + } +} + func TestPutFileIfExistsSkipSkipsExistingFile(t *testing.T) { tmpFile := filepath.Join(t.TempDir(), "test.txt") if err := os.WriteFile(tmpFile, []byte("test"), 0644); err != nil {