From b2ee22cf840963fa6ada65df7f3e466f506a74a0 Mon Sep 17 00:00:00 2001 From: Ruihao Chen Date: Wed, 8 Jul 2026 13:07:37 +0800 Subject: [PATCH 1/8] objstore: support GCS S3-compatible signing --- pkg/objstore/s3store/gcs_compat.go | 59 ++++++++++++++++ pkg/objstore/s3store/gcs_test.go | 109 +++++++++++++++++++++++++++++ pkg/objstore/s3store/store.go | 29 +++++++- 3 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 pkg/objstore/s3store/gcs_compat.go create mode 100644 pkg/objstore/s3store/gcs_test.go diff --git a/pkg/objstore/s3store/gcs_compat.go b/pkg/objstore/s3store/gcs_compat.go new file mode 100644 index 0000000000000..0942b8ad1c744 --- /dev/null +++ b/pkg/objstore/s3store/gcs_compat.go @@ -0,0 +1,59 @@ +// Copyright 2026 PingCAP, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package s3store + +import ( + "context" + "net/http" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/aws/signer/v4" +) + +type gcsS3CompatibleSigner struct { + signer *v4.Signer +} + +func newGCSS3CompatibleSigner() *gcsS3CompatibleSigner { + return &gcsS3CompatibleSigner{signer: v4.NewSigner()} +} + +func (s *gcsS3CompatibleSigner) SignHTTP( + ctx context.Context, + credentials aws.Credentials, + r *http.Request, + payloadHash string, + service string, + region string, + signingTime time.Time, + optFns ...func(*v4.SignerOptions), +) error { + // GCS S3 interoperability rejects signatures that include these SDK-added + // headers. Keep sending the headers, but exclude them from the canonical + // request to match AWS SDK v1 behavior. + savedHeaders := http.Header{} + for _, key := range []string{"Accept-Encoding", "Amz-Sdk-Invocation-Id", "Amz-Sdk-Request"} { + if values, ok := r.Header[key]; ok { + savedHeaders[key] = append([]string(nil), values...) + r.Header.Del(key) + } + } + err := s.signer.SignHTTP(ctx, credentials, r, payloadHash, service, region, signingTime, optFns...) + for key, values := range savedHeaders { + r.Header[key] = values + } + return err +} diff --git a/pkg/objstore/s3store/gcs_test.go b/pkg/objstore/s3store/gcs_test.go new file mode 100644 index 0000000000000..0ac327ba92a5f --- /dev/null +++ b/pkg/objstore/s3store/gcs_test.go @@ -0,0 +1,109 @@ +// Copyright 2026 PingCAP, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package s3store + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + backuppb "github.com/pingcap/kvproto/pkg/brpb" + "github.com/pingcap/tidb/pkg/objstore/storeapi" + "github.com/stretchr/testify/require" +) + +func TestIsGCSS3Compatible(t *testing.T) { + require.True(t, isGCSS3Compatible(&backuppb.S3{ + Provider: "gcs", + Endpoint: "http://127.0.0.1:9000", + })) + require.True(t, isGCSS3Compatible(&backuppb.S3{ + Provider: "ceph", + Endpoint: "https://storage.googleapis.com", + })) + require.True(t, isGCSS3Compatible(&backuppb.S3{ + Endpoint: "https://storage.googleapis.com/", + })) + require.False(t, isGCSS3Compatible(&backuppb.S3{ + Provider: "ceph", + Endpoint: "https://s3.example.com", + })) + require.False(t, isGCSS3Compatible(&backuppb.S3{ + Endpoint: "://bad-endpoint", + })) +} + +func TestGCSS3CompatibleSignerSkipsSDKHeaders(t *testing.T) { + const listObjectsV2Response = ` + + bucket + + 0 + 1 + false +` + + var checkedRequests int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + signedHeaders := getSignedHeaders(r.Header.Get("Authorization")) + require.NotEmpty(t, signedHeaders) + require.NotContains(t, signedHeaders, "accept-encoding") + require.NotContains(t, signedHeaders, "amz-sdk-invocation-id") + require.NotContains(t, signedHeaders, "amz-sdk-request") + require.Contains(t, signedHeaders, "host") + require.Contains(t, signedHeaders, "x-amz-content-sha256") + require.Contains(t, signedHeaders, "x-amz-date") + checkedRequests++ + + switch r.Method { + case http.MethodHead: + w.WriteHeader(http.StatusOK) + case http.MethodGet: + require.Equal(t, "2", r.URL.Query().Get("list-type")) + w.Header().Set("Content-Type", "application/xml") + _, err := w.Write([]byte(listObjectsV2Response)) + require.NoError(t, err) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + storage, err := NewS3Storage(context.Background(), &backuppb.S3{ + Bucket: "bucket", + Endpoint: server.URL, + Provider: "gcs", + ForcePathStyle: true, + AccessKey: "access-key", + SecretAccessKey: "secret-access-key", + }, &storeapi.Options{ + CheckPermissions: []storeapi.Permission{storeapi.AccessBuckets, storeapi.ListObjects}, + }) + require.NoError(t, err) + require.NotNil(t, storage) + require.Equal(t, 2, checkedRequests) +} + +func getSignedHeaders(authorization string) string { + for _, part := range strings.Split(authorization, ",") { + part = strings.TrimSpace(part) + if strings.HasPrefix(part, "SignedHeaders=") { + return strings.TrimPrefix(part, "SignedHeaders=") + } + } + return "" +} diff --git a/pkg/objstore/s3store/store.go b/pkg/objstore/s3store/store.go index 9e66b12ea49e0..0e0648802d754 100644 --- a/pkg/objstore/s3store/store.go +++ b/pkg/objstore/s3store/store.go @@ -17,6 +17,7 @@ package s3store import ( "context" "fmt" + "net/url" "strings" alicred "github.com/aliyun/alibaba-cloud-sdk-go/sdk/auth/credentials" @@ -43,6 +44,8 @@ import ( const ( defaultRegion = "us-east-1" + gcsProvider = "gcs" + gcsEndpoint = "storage.googleapis.com" // to check the cloud type by endpoint tag. domainAliyun = "aliyuncs.com" ) @@ -50,6 +53,7 @@ const ( // NewS3Storage initialize a new s3 storage for metadata. func NewS3Storage(ctx context.Context, backend *backuppb.S3, opts *storeapi.Options) (obj *s3like.Storage, errRet error) { qs := *backend + gcsS3Compatible := isGCSS3Compatible(&qs) // Start with default configuration loading var configOpts []func(*config.LoadOptions) error @@ -125,6 +129,11 @@ func NewS3Storage(ctx context.Context, backend *backuppb.S3, opts *storeapi.Opti // These logs will be printed when log level is `DEBUG`. o.ClientLogMode |= aws.LogRetries | aws.LogRequest | aws.LogResponse | aws.LogDeprecatedUsage }) + if gcsS3Compatible { + s3Opts = append(s3Opts, func(o *s3.Options) { + o.HTTPSignerV4 = newGCSS3CompatibleSigner() + }) + } // ⚠️ Do NOT set a global endpoint in the AWS config. // Setting a global endpoint will break AssumeRoleWithWebIdentity, @@ -227,7 +236,7 @@ func NewS3Storage(ctx context.Context, backend *backuppb.S3, opts *storeapi.Opti // Perform region detection and validation var detectedRegion string - officialS3 := len(qs.Provider) == 0 || qs.Provider == "aws" + officialS3 := !gcsS3Compatible && (len(qs.Provider) == 0 || qs.Provider == "aws") if officialS3 { // For AWS provider, detect the actual bucket region // In AWS SDK v2, GetBucketRegion has a simpler signature @@ -300,6 +309,24 @@ func NewS3Storage(ctx context.Context, backend *backuppb.S3, opts *storeapi.Opti return s3Storage, nil } +func isGCSS3Compatible(qs *backuppb.S3) bool { + if strings.EqualFold(qs.Provider, gcsProvider) { + return true + } + if qs.Endpoint == "" { + return false + } + u, err := url.Parse(qs.Endpoint) + if err != nil { + return false + } + // GCS S3 interoperability documents storage.googleapis.com as the XML API + // endpoint for S3-compatible tools. + // See https://cloud.google.com/storage/docs/interoperability and + // https://cloud.google.com/storage/docs/request-endpoints. + return strings.EqualFold(u.Hostname(), gcsEndpoint) +} + // IsObjectLockEnabled checks whether the S3 bucket has Object Lock enabled. func IsObjectLockEnabled(svc S3API, options *backuppb.S3) bool { input := &s3.GetObjectLockConfigurationInput{ From 7beefe78a139eee0a26877bab2aead05cd83a844 Mon Sep 17 00:00:00 2001 From: Ruihao Chen Date: Wed, 8 Jul 2026 15:08:45 +0800 Subject: [PATCH 2/8] objstore: move GCS endpoint reference comment --- pkg/objstore/s3store/store.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pkg/objstore/s3store/store.go b/pkg/objstore/s3store/store.go index 0e0648802d754..ecbf540cf78b3 100644 --- a/pkg/objstore/s3store/store.go +++ b/pkg/objstore/s3store/store.go @@ -45,7 +45,11 @@ import ( const ( defaultRegion = "us-east-1" gcsProvider = "gcs" - gcsEndpoint = "storage.googleapis.com" + // GCS S3 interoperability documents storage.googleapis.com as the XML API + // endpoint for S3-compatible tools. + // See https://cloud.google.com/storage/docs/interoperability and + // https://cloud.google.com/storage/docs/request-endpoints. + gcsEndpoint = "storage.googleapis.com" // to check the cloud type by endpoint tag. domainAliyun = "aliyuncs.com" ) @@ -320,10 +324,6 @@ func isGCSS3Compatible(qs *backuppb.S3) bool { if err != nil { return false } - // GCS S3 interoperability documents storage.googleapis.com as the XML API - // endpoint for S3-compatible tools. - // See https://cloud.google.com/storage/docs/interoperability and - // https://cloud.google.com/storage/docs/request-endpoints. return strings.EqualFold(u.Hostname(), gcsEndpoint) } From be2d163c0a77f1c01db1db726c59074d5d2030b2 Mon Sep 17 00:00:00 2001 From: Ruihao Chen Date: Wed, 8 Jul 2026 15:30:17 +0800 Subject: [PATCH 3/8] objstore: clarify GCS signed header workaround --- pkg/objstore/s3store/gcs_compat.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pkg/objstore/s3store/gcs_compat.go b/pkg/objstore/s3store/gcs_compat.go index 0942b8ad1c744..c9710d81f6fec 100644 --- a/pkg/objstore/s3store/gcs_compat.go +++ b/pkg/objstore/s3store/gcs_compat.go @@ -41,9 +41,10 @@ func (s *gcsS3CompatibleSigner) SignHTTP( signingTime time.Time, optFns ...func(*v4.SignerOptions), ) error { - // GCS S3 interoperability rejects signatures that include these SDK-added - // headers. Keep sending the headers, but exclude them from the canonical - // request to match AWS SDK v1 behavior. + // GCS S3 interoperability rejects signatures that include Accept-Encoding. + // AWS SDK v2 also signs the SDK request metadata headers below, while AWS + // SDK v1 did not. Keep sending them, but exclude all three from the canonical + // request to stay close to the AWS SDK v1 signing behavior. savedHeaders := http.Header{} for _, key := range []string{"Accept-Encoding", "Amz-Sdk-Invocation-Id", "Amz-Sdk-Request"} { if values, ok := r.Header[key]; ok { From 4fb58ea84c22070c2be4cb5fdb1998023574ddfa Mon Sep 17 00:00:00 2001 From: Ruihao Chen Date: Wed, 8 Jul 2026 15:39:03 +0800 Subject: [PATCH 4/8] objstore: rename GCS S3 signer file --- pkg/objstore/s3store/{gcs_compat.go => gcs_s3_signer.go} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename pkg/objstore/s3store/{gcs_compat.go => gcs_s3_signer.go} (100%) diff --git a/pkg/objstore/s3store/gcs_compat.go b/pkg/objstore/s3store/gcs_s3_signer.go similarity index 100% rename from pkg/objstore/s3store/gcs_compat.go rename to pkg/objstore/s3store/gcs_s3_signer.go From 9ae4ce26222cb2693bffeb1b0a706e2fccdf66cc Mon Sep 17 00:00:00 2001 From: Ruihao Chen Date: Wed, 8 Jul 2026 15:46:39 +0800 Subject: [PATCH 5/8] objstore: add GCS signer files to Bazel --- pkg/objstore/s3store/BUILD.bazel | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/objstore/s3store/BUILD.bazel b/pkg/objstore/s3store/BUILD.bazel index 3921b110e5ba0..d6ab20d5ac8ec 100644 --- a/pkg/objstore/s3store/BUILD.bazel +++ b/pkg/objstore/s3store/BUILD.bazel @@ -4,6 +4,7 @@ go_library( name = "s3store", srcs = [ "client.go", + "gcs_s3_signer.go", "interface.go", "ks3.go", "logger.go", @@ -28,6 +29,7 @@ go_library( "@com_github_aws_aws_sdk_go_v2//aws", "@com_github_aws_aws_sdk_go_v2//aws/ratelimit", "@com_github_aws_aws_sdk_go_v2//aws/retry", + "@com_github_aws_aws_sdk_go_v2//aws/signer/v4:signer", "@com_github_aws_aws_sdk_go_v2_config//:config", "@com_github_aws_aws_sdk_go_v2_credentials//:credentials", "@com_github_aws_aws_sdk_go_v2_credentials//stscreds", @@ -57,6 +59,7 @@ go_test( timeout = "short", srcs = [ "client_test.go", + "gcs_test.go", "main_test.go", "retry_test.go", "s3_flags_test.go", From 0ccd9984f41add0e67e13dcd60252dba4c271fbf Mon Sep 17 00:00:00 2001 From: Ruihao Chen Date: Wed, 8 Jul 2026 17:35:24 +0800 Subject: [PATCH 6/8] objstore: avoid assertions in GCS test handler --- pkg/objstore/s3store/gcs_test.go | 66 +++++++++++++++++++++++++------- 1 file changed, 52 insertions(+), 14 deletions(-) diff --git a/pkg/objstore/s3store/gcs_test.go b/pkg/objstore/s3store/gcs_test.go index 0ac327ba92a5f..16ce3c85ab895 100644 --- a/pkg/objstore/s3store/gcs_test.go +++ b/pkg/objstore/s3store/gcs_test.go @@ -19,6 +19,7 @@ import ( "net/http" "net/http/httptest" "strings" + "sync" "testing" backuppb "github.com/pingcap/kvproto/pkg/brpb" @@ -57,26 +58,35 @@ func TestGCSS3CompatibleSignerSkipsSDKHeaders(t *testing.T) { false ` - var checkedRequests int + type requestInfo struct { + method string + signedHeaders string + listType string + writeErr error + } + + var ( + mu sync.Mutex + requests []requestInfo + ) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - signedHeaders := getSignedHeaders(r.Header.Get("Authorization")) - require.NotEmpty(t, signedHeaders) - require.NotContains(t, signedHeaders, "accept-encoding") - require.NotContains(t, signedHeaders, "amz-sdk-invocation-id") - require.NotContains(t, signedHeaders, "amz-sdk-request") - require.Contains(t, signedHeaders, "host") - require.Contains(t, signedHeaders, "x-amz-content-sha256") - require.Contains(t, signedHeaders, "x-amz-date") - checkedRequests++ + info := requestInfo{ + method: r.Method, + signedHeaders: getSignedHeaders(r.Header.Get("Authorization")), + listType: r.URL.Query().Get("list-type"), + } + defer func() { + mu.Lock() + requests = append(requests, info) + mu.Unlock() + }() switch r.Method { case http.MethodHead: w.WriteHeader(http.StatusOK) case http.MethodGet: - require.Equal(t, "2", r.URL.Query().Get("list-type")) w.Header().Set("Content-Type", "application/xml") - _, err := w.Write([]byte(listObjectsV2Response)) - require.NoError(t, err) + _, info.writeErr = w.Write([]byte(listObjectsV2Response)) default: w.WriteHeader(http.StatusNotFound) } @@ -95,7 +105,35 @@ func TestGCSS3CompatibleSignerSkipsSDKHeaders(t *testing.T) { }) require.NoError(t, err) require.NotNil(t, storage) - require.Equal(t, 2, checkedRequests) + + mu.Lock() + observedRequests := append([]requestInfo(nil), requests...) + mu.Unlock() + require.Len(t, observedRequests, 2) + + var headSeen, listSeen bool + for _, req := range observedRequests { + require.NoError(t, req.writeErr) + require.NotEmpty(t, req.signedHeaders) + require.NotContains(t, req.signedHeaders, "accept-encoding") + require.NotContains(t, req.signedHeaders, "amz-sdk-invocation-id") + require.NotContains(t, req.signedHeaders, "amz-sdk-request") + require.Contains(t, req.signedHeaders, "host") + require.Contains(t, req.signedHeaders, "x-amz-content-sha256") + require.Contains(t, req.signedHeaders, "x-amz-date") + + switch req.method { + case http.MethodHead: + headSeen = true + case http.MethodGet: + listSeen = true + require.Equal(t, "2", req.listType) + default: + require.Failf(t, "unexpected request method", "method: %s", req.method) + } + } + require.True(t, headSeen) + require.True(t, listSeen) } func getSignedHeaders(authorization string) string { From fe72e6f4afddddbb01bf2c45a9a31108126198ac Mon Sep 17 00:00:00 2001 From: Ruihao Chen Date: Mon, 13 Jul 2026 11:54:06 +0800 Subject: [PATCH 7/8] objstore: narrow GCS signer workaround --- pkg/objstore/s3store/gcs_s3_signer.go | 9 +- pkg/objstore/s3store/gcs_test.go | 201 +++++++++++++++++--------- pkg/objstore/s3store/store.go | 9 +- 3 files changed, 145 insertions(+), 74 deletions(-) diff --git a/pkg/objstore/s3store/gcs_s3_signer.go b/pkg/objstore/s3store/gcs_s3_signer.go index c9710d81f6fec..047f9fe5f1c6b 100644 --- a/pkg/objstore/s3store/gcs_s3_signer.go +++ b/pkg/objstore/s3store/gcs_s3_signer.go @@ -41,12 +41,11 @@ func (s *gcsS3CompatibleSigner) SignHTTP( signingTime time.Time, optFns ...func(*v4.SignerOptions), ) error { - // GCS S3 interoperability rejects signatures that include Accept-Encoding. - // AWS SDK v2 also signs the SDK request metadata headers below, while AWS - // SDK v1 did not. Keep sending them, but exclude all three from the canonical - // request to stay close to the AWS SDK v1 signing behavior. + // GCS S3 interoperability can reject signatures that include Accept-Encoding. + // See https://github.com/aws/aws-sdk-go-v2/issues/1816#issuecomment-1232780084. + // Keep sending the header, but exclude it from the canonical request. savedHeaders := http.Header{} - for _, key := range []string{"Accept-Encoding", "Amz-Sdk-Invocation-Id", "Amz-Sdk-Request"} { + for _, key := range []string{"Accept-Encoding"} { if values, ok := r.Header[key]; ok { savedHeaders[key] = append([]string(nil), values...) r.Header.Del(key) diff --git a/pkg/objstore/s3store/gcs_test.go b/pkg/objstore/s3store/gcs_test.go index 16ce3c85ab895..25ea7f37953a2 100644 --- a/pkg/objstore/s3store/gcs_test.go +++ b/pkg/objstore/s3store/gcs_test.go @@ -39,6 +39,9 @@ func TestIsGCSS3Compatible(t *testing.T) { require.True(t, isGCSS3Compatible(&backuppb.S3{ Endpoint: "https://storage.googleapis.com/", })) + require.True(t, isGCSS3Compatible(&backuppb.S3{ + Endpoint: "https://bucket.storage.googleapis.com", + })) require.False(t, isGCSS3Compatible(&backuppb.S3{ Provider: "ceph", Endpoint: "https://s3.example.com", @@ -48,7 +51,7 @@ func TestIsGCSS3Compatible(t *testing.T) { })) } -func TestGCSS3CompatibleSignerSkipsSDKHeaders(t *testing.T) { +func TestGCSS3CompatibleSignerSkipsAcceptEncoding(t *testing.T) { const listObjectsV2Response = ` bucket @@ -65,75 +68,139 @@ func TestGCSS3CompatibleSignerSkipsSDKHeaders(t *testing.T) { writeErr error } - var ( - mu sync.Mutex - requests []requestInfo - ) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - info := requestInfo{ - method: r.Method, - signedHeaders: getSignedHeaders(r.Header.Get("Authorization")), - listType: r.URL.Query().Get("list-type"), - } - defer func() { + testCases := []struct { + name string + provider string + endpoint string + httpClient func(string) *http.Client + }{ + { + name: "provider_gcs", + provider: "gcs", + }, + { + name: "endpoint_only", + endpoint: "https://storage.googleapis.com", + httpClient: newRewriteHostHTTPClient, + }, + { + name: "aws_provider_gcs_endpoint", + provider: "aws", + endpoint: "https://storage.googleapis.com", + httpClient: newRewriteHostHTTPClient, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var ( + mu sync.Mutex + requests []requestInfo + ) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + info := requestInfo{ + method: r.Method, + signedHeaders: getSignedHeaders(r.Header.Get("Authorization")), + listType: r.URL.Query().Get("list-type"), + } + defer func() { + mu.Lock() + requests = append(requests, info) + mu.Unlock() + }() + + switch r.Method { + case http.MethodHead: + w.WriteHeader(http.StatusOK) + case http.MethodGet: + w.Header().Set("Content-Type", "application/xml") + _, info.writeErr = w.Write([]byte(listObjectsV2Response)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + endpoint := tc.endpoint + if endpoint == "" { + endpoint = server.URL + } + opts := &storeapi.Options{ + CheckPermissions: []storeapi.Permission{storeapi.AccessBuckets, storeapi.ListObjects}, + } + if tc.httpClient != nil { + opts.HTTPClient = tc.httpClient(server.URL) + } + storage, err := NewS3Storage(context.Background(), &backuppb.S3{ + Bucket: "bucket", + Endpoint: endpoint, + Provider: tc.provider, + ForcePathStyle: true, + AccessKey: "access-key", + SecretAccessKey: "secret-access-key", + }, opts) + require.NoError(t, err) + require.NotNil(t, storage) + mu.Lock() - requests = append(requests, info) + observedRequests := append([]requestInfo(nil), requests...) mu.Unlock() - }() - - switch r.Method { - case http.MethodHead: - w.WriteHeader(http.StatusOK) - case http.MethodGet: - w.Header().Set("Content-Type", "application/xml") - _, info.writeErr = w.Write([]byte(listObjectsV2Response)) - default: - w.WriteHeader(http.StatusNotFound) - } - })) - defer server.Close() - - storage, err := NewS3Storage(context.Background(), &backuppb.S3{ - Bucket: "bucket", - Endpoint: server.URL, - Provider: "gcs", - ForcePathStyle: true, - AccessKey: "access-key", - SecretAccessKey: "secret-access-key", - }, &storeapi.Options{ - CheckPermissions: []storeapi.Permission{storeapi.AccessBuckets, storeapi.ListObjects}, - }) - require.NoError(t, err) - require.NotNil(t, storage) - - mu.Lock() - observedRequests := append([]requestInfo(nil), requests...) - mu.Unlock() - require.Len(t, observedRequests, 2) - - var headSeen, listSeen bool - for _, req := range observedRequests { - require.NoError(t, req.writeErr) - require.NotEmpty(t, req.signedHeaders) - require.NotContains(t, req.signedHeaders, "accept-encoding") - require.NotContains(t, req.signedHeaders, "amz-sdk-invocation-id") - require.NotContains(t, req.signedHeaders, "amz-sdk-request") - require.Contains(t, req.signedHeaders, "host") - require.Contains(t, req.signedHeaders, "x-amz-content-sha256") - require.Contains(t, req.signedHeaders, "x-amz-date") - - switch req.method { - case http.MethodHead: - headSeen = true - case http.MethodGet: - listSeen = true - require.Equal(t, "2", req.listType) - default: - require.Failf(t, "unexpected request method", "method: %s", req.method) - } + require.Len(t, observedRequests, 2) + + var headSeen, listSeen bool + for _, req := range observedRequests { + require.NoError(t, req.writeErr) + require.NotEmpty(t, req.signedHeaders) + require.NotContains(t, req.signedHeaders, "accept-encoding") + require.Contains(t, req.signedHeaders, "amz-sdk-invocation-id") + require.Contains(t, req.signedHeaders, "amz-sdk-request") + require.Contains(t, req.signedHeaders, "host") + require.Contains(t, req.signedHeaders, "x-amz-content-sha256") + require.Contains(t, req.signedHeaders, "x-amz-date") + + switch req.method { + case http.MethodHead: + headSeen = true + case http.MethodGet: + listSeen = true + require.Equal(t, "2", req.listType) + default: + require.Failf(t, "unexpected request method", "method: %s", req.method) + } + } + require.True(t, headSeen) + require.True(t, listSeen) + }) + } +} + +type rewriteHostTransport struct { + scheme string + host string + base http.RoundTripper +} + +func newRewriteHostHTTPClient(target string) *http.Client { + return &http.Client{ + Transport: &rewriteHostTransport{ + scheme: "http", + host: strings.TrimPrefix(target, "http://"), + base: http.DefaultTransport, + }, + } +} + +func (t *rewriteHostTransport) RoundTrip(r *http.Request) (*http.Response, error) { + req := r.Clone(r.Context()) + if req.Host == "" { + req.Host = r.URL.Host + } + req.URL.Scheme = t.scheme + req.URL.Host = t.host + if t.base == nil { + return http.DefaultTransport.RoundTrip(req) } - require.True(t, headSeen) - require.True(t, listSeen) + return t.base.RoundTrip(req) } func getSignedHeaders(authorization string) string { diff --git a/pkg/objstore/s3store/store.go b/pkg/objstore/s3store/store.go index ecbf540cf78b3..3d5becbd6e5c6 100644 --- a/pkg/objstore/s3store/store.go +++ b/pkg/objstore/s3store/store.go @@ -240,7 +240,11 @@ func NewS3Storage(ctx context.Context, backend *backuppb.S3, opts *storeapi.Opti // Perform region detection and validation var detectedRegion string - officialS3 := !gcsS3Compatible && (len(qs.Provider) == 0 || qs.Provider == "aws") + awsProvider := len(qs.Provider) == 0 || qs.Provider == "aws" + // GCS S3-compatible endpoints must skip AWS bucket-region discovery: + // GCS interoperability can reject the HeadBucket request before normal + // object access starts, and the configured region is only used for signing. + officialS3 := awsProvider && !gcsS3Compatible if officialS3 { // For AWS provider, detect the actual bucket region // In AWS SDK v2, GetBucketRegion has a simpler signature @@ -324,7 +328,8 @@ func isGCSS3Compatible(qs *backuppb.S3) bool { if err != nil { return false } - return strings.EqualFold(u.Hostname(), gcsEndpoint) + host := strings.ToLower(u.Hostname()) + return host == gcsEndpoint || strings.HasSuffix(host, "."+gcsEndpoint) } // IsObjectLockEnabled checks whether the S3 bucket has Object Lock enabled. From dcca32b01c4f87d9088e84890781b48ce9c23e6f Mon Sep 17 00:00:00 2001 From: Ruihao Chen Date: Wed, 15 Jul 2026 18:12:08 +0800 Subject: [PATCH 8/8] objstore: rename GCS S3 test file --- pkg/objstore/s3store/BUILD.bazel | 2 +- pkg/objstore/s3store/{gcs_test.go => gcs_s3_test.go} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename pkg/objstore/s3store/{gcs_test.go => gcs_s3_test.go} (100%) diff --git a/pkg/objstore/s3store/BUILD.bazel b/pkg/objstore/s3store/BUILD.bazel index d6ab20d5ac8ec..200559ec9b0db 100644 --- a/pkg/objstore/s3store/BUILD.bazel +++ b/pkg/objstore/s3store/BUILD.bazel @@ -59,7 +59,7 @@ go_test( timeout = "short", srcs = [ "client_test.go", - "gcs_test.go", + "gcs_s3_test.go", "main_test.go", "retry_test.go", "s3_flags_test.go", diff --git a/pkg/objstore/s3store/gcs_test.go b/pkg/objstore/s3store/gcs_s3_test.go similarity index 100% rename from pkg/objstore/s3store/gcs_test.go rename to pkg/objstore/s3store/gcs_s3_test.go