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 {