From 187d5bbba52d6233cd132af68d35a57ca0459f55 Mon Sep 17 00:00:00 2001 From: Gustavo Lima Date: Sun, 12 Apr 2026 15:36:34 +0200 Subject: [PATCH] fix(cp): apply --destination-region in shouldOverride for --no-clobber Fixes #839 shouldOverride performs a HEAD request on the destination object to decide whether to skip the copy. doUpload and doCopy both called c.storageOpts.SetRegion(c.dstRegion) before creating the destination client, but shouldOverride did not. When --destination-region is set and the bucket lives in a non-default region, the HEAD was signed for the wrong region, producing HTTP 400 AuthorizationHeaderMalformed. Fix: derive a local dstOpts copy inside shouldOverride and set the region on it before constructing the destination client, matching the pattern already used in doUpload and doCopy. Reproducer: s5cmd cp --no-clobber --destination-region eu-central-1 \ local.txt s3://eu-bucket/object.txt # Fails: AWS Error: AuthorizationHeaderMalformed (400) After this change: HEAD request is signed for eu-central-1; --no-clobber works correctly. --- command/cp.go | 9 ++++++++- command/cp_test.go | 31 +++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/command/cp.go b/command/cp.go index 7ed06bfee..99b2f474a 100644 --- a/command/cp.go +++ b/command/cp.go @@ -864,7 +864,14 @@ func (c Copy) shouldOverride(ctx context.Context, srcurl *url.URL, dsturl *url.U return err } - dstClient, err := storage.NewClient(ctx, dsturl, c.storageOpts) + // Apply destination-region override so the HEAD request is signed for the + // correct region. Without this, --no-clobber with --destination-region + // sends a mis-signed HEAD that returns HTTP 400 AuthorizationHeaderMalformed. + dstOpts := c.storageOpts + if c.dstRegion != "" { + dstOpts.SetRegion(c.dstRegion) + } + dstClient, err := storage.NewClient(ctx, dsturl, dstOpts) if err != nil { return err } diff --git a/command/cp_test.go b/command/cp_test.go index ccac38ff3..8039a9041 100644 --- a/command/cp_test.go +++ b/command/cp_test.go @@ -1,13 +1,44 @@ package command import ( + "context" "io" "os" "testing" "gotest.tools/v3/assert" + + "github.com/peak/s5cmd/v2/storage" + "github.com/peak/s5cmd/v2/storage/url" ) +// TestShouldOverride_NoFlagsIsNoop verifies that shouldOverride returns nil +// immediately when none of the override-gating flags are set. This confirms +// the fast-path remains intact and also serves as a compile-time check that +// the Copy struct fields referenced by shouldOverride (including dstRegion) +// are spelled correctly. +func TestShouldOverride_NoFlagsIsNoop(t *testing.T) { + t.Parallel() + + srcURL, _ := url.New("s3://bucket/src.txt") + dstURL, _ := url.New("s3://bucket/dst.txt") + + c := Copy{ + noClobber: false, + ifSizeDiffer: false, + ifSourceNewer: false, + // dstRegion is set; the fix ensures it would be forwarded to dstOpts + // when the function proceeds past the early return. + dstRegion: "us-west-2", + storageOpts: storage.Options{}, + } + + // shouldOverride must return nil (no-op) when no override flag is set. + if err := c.shouldOverride(context.Background(), srcURL, dstURL); err != nil { + t.Errorf("expected nil when no override flags set, got %v", err) + } +} + func TestGuessContentType(t *testing.T) { t.Parallel()