package s3compat import ( "context" "net/http" "net/http/httptest" "strings" "sync" "testing" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/credentials" awss3 "github.com/aws/aws-sdk-go-v2/service/s3" ) // recorder is a fake S3 that records the conditional headers it was sent. type recorder struct { mu sync.Mutex seen map[string]string // "METHOD If-Match" -> value auth map[string]string // "METHOD" -> Authorization } func newRecorder(t *testing.T) (*recorder, string) { t.Helper() r := &recorder{seen: map[string]string{}, auth: map[string]string{}} srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { r.mu.Lock() r.seen[req.Method+" If-Match"] = req.Header.Get("If-Match") r.auth[req.Method] = req.Header.Get("Authorization") r.mu.Unlock() w.Header().Set("ETag", `"deadbeef"`) w.WriteHeader(http.StatusOK) })) t.Cleanup(srv.Close) return r, srv.URL } func (r *recorder) get(k string) string { r.mu.Lock() defer r.mu.Unlock() return r.seen[k] } func (r *recorder) authFor(m string) string { r.mu.Lock() defer r.mu.Unlock() return r.auth[m] } func testClient(t *testing.T) *awss3.Client { t.Helper() cfg := aws.Config{ Region: "nbg1", Credentials: credentials.NewStaticCredentialsProvider("k", "s", ""), } return awss3.NewFromConfig(cfg, Options()) } // TestUnquotedIfMatch_RewritesPutOnly is the core of the fix: a PutObject's // If-Match must reach the wire unquoted (what Ceph RGW compares against), // while a DeleteObject's must be left exactly as the SDK wrote it — the quoted // form is what works for conditional DELETE on the same store, and a working // path is not worth touching. func TestUnquotedIfMatch_RewritesPutOnly(t *testing.T) { rec, url := newRecorder(t) t.Setenv("CARLOS_S3_ENDPOINT", url) // The httptest host is 127.0.0.1, not a Hetzner hostname, so force the // quirk on by name — which also exercises the CARLOS_S3_QUIRKS escape // hatch that lets an operator try a fix against an unrecognised store. t.Setenv("CARLOS_S3_QUIRKS", "unquoted-if-match") c := testClient(t) ctx := context.Background() quoted := `"deadbeef"` if _, err := c.PutObject(ctx, &awss3.PutObjectInput{ Bucket: aws.String("b"), Key: aws.String("k"), Body: strings.NewReader("x"), IfMatch: aws.String(quoted), }); err != nil { t.Fatalf("PutObject: %v", err) } if got := rec.get("PUT If-Match"); got != "deadbeef" { t.Errorf("PUT If-Match = %q, want %q (quotes stripped)", got, "deadbeef") } if _, err := c.DeleteObject(ctx, &awss3.DeleteObjectInput{ Bucket: aws.String("b"), Key: aws.String("k"), IfMatch: aws.String(quoted), }); err != nil { t.Fatalf("DeleteObject: %v", err) } if got := rec.get("DELETE If-Match"); got != quoted { t.Errorf("DELETE If-Match = %q, want %q (left untouched)", got, quoted) } } // TestUnquotedIfMatch_RewriteIsSigned guards the subtlety that makes this a // Build-step middleware rather than a Finalize one: If-Match is a SIGNED // header, so a rewrite after signing would produce SignatureDoesNotMatch // instead of a fix. If the signer did not see if-match, it is not in // SignedHeaders — which is observable from the Authorization header. func TestUnquotedIfMatch_RewriteIsSigned(t *testing.T) { rec, url := newRecorder(t) t.Setenv("CARLOS_S3_ENDPOINT", url) t.Setenv("CARLOS_S3_QUIRKS", "unquoted-if-match") c := testClient(t) if _, err := c.PutObject(context.Background(), &awss3.PutObjectInput{ Bucket: aws.String("b"), Key: aws.String("k"), Body: strings.NewReader("x"), IfMatch: aws.String(`"deadbeef"`), }); err != nil { t.Fatalf("PutObject: %v", err) } auth := rec.authFor("PUT") if !strings.Contains(auth, "SignedHeaders=") { t.Fatalf("no SignedHeaders in Authorization: %q", auth) } if !strings.Contains(auth, "if-match") { t.Errorf("if-match missing from SignedHeaders — the rewrite is outside the signature: %q", auth) } } // TestOptions_NoOpWithoutEndpoint is the flagship guard: on stock AWS this // package must change nothing at all, including registering no middleware. func TestOptions_NoOpWithoutEndpoint(t *testing.T) { t.Setenv("CARLOS_S3_ENDPOINT", "") t.Setenv("CARLOS_S3_QUIRKS", "") var o awss3.Options Options()(&o) if o.BaseEndpoint != nil { t.Errorf("BaseEndpoint = %q, want nil on stock AWS", *o.BaseEndpoint) } if o.UsePathStyle { t.Error("UsePathStyle = true, want false on stock AWS") } if len(o.APIOptions) != 0 { t.Errorf("APIOptions = %d, want 0 — no quirk middleware on stock AWS", len(o.APIOptions)) } if o.RequestChecksumCalculation != 0 { t.Errorf("RequestChecksumCalculation = %v, want the SDK default on stock AWS", o.RequestChecksumCalculation) } } // TestOptions_UniversalSettingsUnderEndpoint pins what every compatible store // gets regardless of provider. func TestOptions_UniversalSettingsUnderEndpoint(t *testing.T) { t.Setenv("CARLOS_S3_ENDPOINT", "https://nbg1.your-objectstorage.com") t.Setenv("CARLOS_S3_QUIRKS", "") var o awss3.Options Options()(&o) if o.BaseEndpoint == nil || *o.BaseEndpoint != "https://nbg1.your-objectstorage.com" { t.Errorf("BaseEndpoint = %v, want the override", o.BaseEndpoint) } if !o.UsePathStyle { t.Error("UsePathStyle = false, want true under an override") } if o.RequestChecksumCalculation != aws.RequestChecksumCalculationWhenRequired { t.Error("RequestChecksumCalculation should be when-required under an override") } if len(o.APIOptions) != 1 { t.Errorf("APIOptions = %d, want 1 (the Hetzner If-Match quirk matched by host)", len(o.APIOptions)) } } func TestEndpointHost(t *testing.T) { for _, tt := range []struct{ ep, want string }{ {"https://nbg1.your-objectstorage.com", "nbg1.your-objectstorage.com"}, {"nbg1.your-objectstorage.com", "nbg1.your-objectstorage.com"}, // no scheme {"http://localhost:9000", "localhost"}, {"", ""}, } { t.Setenv("CARLOS_S3_ENDPOINT", tt.ep) if got := EndpointHost(); got != tt.want { t.Errorf("EndpointHost() with %q = %q, want %q", tt.ep, got, tt.want) } } } // TestQuirkMatching keeps the table honest: a quirk measured on one provider // must not leak onto stores nobody has tested it against. func TestQuirkMatching(t *testing.T) { t.Setenv("CARLOS_S3_QUIRKS", "") for _, tt := range []struct { host string want bool }{ {"nbg1.your-objectstorage.com", true}, {"fsn1.your-objectstorage.com", true}, {"s3.eu-west-1.amazonaws.com", false}, {"minio.internal", false}, {"evil-your-objectstorage.com", false}, // suffix match needs the dot } { if got := unquotedIfMatch.enabled(tt.host); got != tt.want { t.Errorf("unquoted-if-match enabled for %q = %v, want %v", tt.host, got, tt.want) } } } // TestQuirkForceEnable covers the escape hatch: trying a known fix against an // unrecognised store should not need a rebuild. func TestQuirkForceEnable(t *testing.T) { t.Setenv("CARLOS_S3_QUIRKS", "unquoted-if-match") if !unquotedIfMatch.enabled("some.unknown.store") { t.Error("CARLOS_S3_QUIRKS did not force the quirk on for an unmatched host") } t.Setenv("CARLOS_S3_QUIRKS", " other-quirk , unquoted-if-match ") if !unquotedIfMatch.enabled("some.unknown.store") { t.Error("CARLOS_S3_QUIRKS should tolerate spaces and a list") } t.Setenv("CARLOS_S3_QUIRKS", "something-else") if unquotedIfMatch.enabled("some.unknown.store") { t.Error("an unrelated CARLOS_S3_QUIRKS entry must not enable this quirk") } } // TestQuirkNamesAreUnique — the table is looked up by name from an env var, so // duplicates would make CARLOS_S3_QUIRKS ambiguous. func TestQuirkNamesAreUnique(t *testing.T) { seen := map[string]bool{} for _, q := range Quirks { if q.Name == "" { t.Error("a quirk has no Name; CARLOS_S3_QUIRKS could not address it") } if q.Reason == "" { t.Errorf("quirk %q has no Reason — the table exists to record WHY", q.Name) } if seen[q.Name] { t.Errorf("duplicate quirk name %q", q.Name) } seen[q.Name] = true } }