package main import ( "archive/tar" "compress/gzip" "crypto/sha256" "encoding/hex" "encoding/xml" "errors" "fmt" "io" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "strconv" "strings" "testing" "time" ) const testAccessKey = "test-access" const testSecretKey = "test-secret" func TestObjectLifecycle(t *testing.T) { store, err := NewFileStore(t.TempDir()) if err != nil { t.Fatal(err) } srv := httptest.NewServer(NewServer(store, Config{AuthToken: "secret"})) defer srv.Close() putReq, err := http.NewRequest(http.MethodPut, srv.URL+"/objects/attachments/test.txt", strings.NewReader("hello")) if err != nil { t.Fatal(err) } putReq.Header.Set("Authorization", "Bearer secret") putReq.Header.Set("Content-Type", "text/plain") putResp, err := http.DefaultClient.Do(putReq) if err != nil { t.Fatal(err) } if putResp.StatusCode != http.StatusCreated { t.Fatalf("PUT status = %d", putResp.StatusCode) } _ = putResp.Body.Close() getReq, err := http.NewRequest(http.MethodGet, srv.URL+"/objects/attachments/test.txt", nil) if err != nil { t.Fatal(err) } getReq.Header.Set("Authorization", "Bearer secret") getResp, err := http.DefaultClient.Do(getReq) if err != nil { t.Fatal(err) } defer getResp.Body.Close() if getResp.StatusCode != http.StatusOK { t.Fatalf("GET status = %d", getResp.StatusCode) } body, err := io.ReadAll(getResp.Body) if err != nil { t.Fatal(err) } if string(body) != "hello" { t.Fatalf("GET body = %q", string(body)) } if got := getResp.Header.Get("Content-Type"); !strings.HasPrefix(got, "text/plain") { t.Fatalf("Content-Type = %q", got) } delReq, err := http.NewRequest(http.MethodDelete, srv.URL+"/objects/attachments/test.txt", nil) if err != nil { t.Fatal(err) } delReq.Header.Set("Authorization", "Bearer secret") delResp, err := http.DefaultClient.Do(delReq) if err != nil { t.Fatal(err) } if delResp.StatusCode != http.StatusNoContent { t.Fatalf("DELETE status = %d", delResp.StatusCode) } _ = delResp.Body.Close() } func TestUnauthorized(t *testing.T) { store, err := NewFileStore(t.TempDir()) if err != nil { t.Fatal(err) } srv := httptest.NewServer(NewServer(store, Config{AuthToken: "secret"})) defer srv.Close() resp, err := http.Get(srv.URL + "/objects/test.txt") if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("status = %d", resp.StatusCode) } } func TestRejectsBadKeys(t *testing.T) { bad := []string{"", "/absolute", "../escape", "a/../b", "a//b", "a\\b", "bucket/a.meta.json", ".multipart/a", ".magpie.db"} for _, key := range bad { if err := ValidateKey(key); err == nil { t.Fatalf("ValidateKey(%q) returned nil", key) } } } func TestRangeRequest(t *testing.T) { store, err := NewFileStore(t.TempDir()) if err != nil { t.Fatal(err) } if _, err := store.Put("video/sample.txt", strings.NewReader("abcdef"), "text/plain"); err != nil { t.Fatal(err) } srv := httptest.NewServer(NewServer(store, Config{AuthToken: "secret"})) defer srv.Close() req, err := http.NewRequest(http.MethodGet, srv.URL+"/objects/video/sample.txt", nil) if err != nil { t.Fatal(err) } req.Header.Set("Authorization", "Bearer secret") req.Header.Set("Range", "bytes=1-3") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusPartialContent { t.Fatalf("status = %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { t.Fatal(err) } if string(body) != "bcd" { t.Fatalf("range body = %q", string(body)) } } func TestHealthIncludesDiskStatus(t *testing.T) { store, err := NewFileStore(t.TempDir()) if err != nil { t.Fatal(err) } srv := httptest.NewServer(NewServer(store, Config{AuthToken: "secret"})) defer srv.Close() req, err := http.NewRequest(http.MethodGet, srv.URL+"/health", nil) if err != nil { t.Fatal(err) } req.Header.Set("Authorization", "Bearer secret") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("health status = %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { t.Fatal(err) } if !strings.Contains(string(body), "free_bytes") { t.Fatalf("health body missing disk status: %s", string(body)) } } func TestMetricsEndpoint(t *testing.T) { store, err := NewFileStore(t.TempDir()) if err != nil { t.Fatal(err) } srv := httptest.NewServer(NewServer(store, Config{AuthToken: "secret"})) defer srv.Close() req, err := http.NewRequest(http.MethodGet, srv.URL+"/metrics", nil) if err != nil { t.Fatal(err) } req.Header.Set("Authorization", "Bearer secret") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } body := readResponseBody(t, resp, http.StatusOK) if !strings.Contains(string(body), "magpie_requests_total") { t.Fatalf("metrics body = %s", string(body)) } } func TestRateLimit(t *testing.T) { store, err := NewFileStore(t.TempDir()) if err != nil { t.Fatal(err) } srv := httptest.NewServer(NewServer(store, Config{AuthToken: "secret", RateLimitPerMinute: 1})) defer srv.Close() firstReq, err := http.NewRequest(http.MethodGet, srv.URL+"/health", nil) if err != nil { t.Fatal(err) } firstReq.Header.Set("Authorization", "Bearer secret") first, err := http.DefaultClient.Do(firstReq) if err != nil { t.Fatal(err) } _ = readResponseBody(t, first, http.StatusOK) secondReq, err := http.NewRequest(http.MethodGet, srv.URL+"/health", nil) if err != nil { t.Fatal(err) } secondReq.Header.Set("Authorization", "Bearer secret") second, err := http.DefaultClient.Do(secondReq) if err != nil { t.Fatal(err) } _ = readResponseBody(t, second, http.StatusTooManyRequests) } func TestRejectsOversizedBearerUpload(t *testing.T) { store, err := NewFileStore(t.TempDir()) if err != nil { t.Fatal(err) } srv := httptest.NewServer(NewServer(store, Config{AuthToken: "secret", MaxObjectSize: 4})) defer srv.Close() req, err := http.NewRequest(http.MethodPut, srv.URL+"/objects/attachments/big.txt", strings.NewReader("hello")) if err != nil { t.Fatal(err) } req.Header.Set("Authorization", "Bearer secret") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusRequestEntityTooLarge { t.Fatalf("oversized status = %d", resp.StatusCode) } } func TestS3LifecycleWithHeaderAuth(t *testing.T) { store, err := NewFileStore(t.TempDir()) if err != nil { t.Fatal(err) } srv := httptest.NewServer(NewServer(store, s3TestConfig())) defer srv.Close() putReq, err := http.NewRequest(http.MethodPut, srv.URL+"/bucket/attachments/test.txt", strings.NewReader("hello")) if err != nil { t.Fatal(err) } putReq.Header.Set("Content-Type", "text/plain") signS3Header(t, putReq) putResp, err := http.DefaultClient.Do(putReq) if err != nil { t.Fatal(err) } if putResp.StatusCode != http.StatusOK { t.Fatalf("S3 PUT status = %d", putResp.StatusCode) } _ = putResp.Body.Close() headReq, err := http.NewRequest(http.MethodHead, srv.URL+"/bucket/attachments/test.txt", nil) if err != nil { t.Fatal(err) } signS3Header(t, headReq) headResp, err := http.DefaultClient.Do(headReq) if err != nil { t.Fatal(err) } if headResp.StatusCode != http.StatusOK { t.Fatalf("S3 HEAD status = %d", headResp.StatusCode) } if got := headResp.Header.Get("ETag"); got == "" || got == "\"\"" { t.Fatalf("S3 HEAD ETag = %q", got) } _ = headResp.Body.Close() getReq, err := http.NewRequest(http.MethodGet, srv.URL+"/bucket/attachments/test.txt", nil) if err != nil { t.Fatal(err) } signS3Header(t, getReq) getResp, err := http.DefaultClient.Do(getReq) if err != nil { t.Fatal(err) } defer getResp.Body.Close() if getResp.StatusCode != http.StatusOK { t.Fatalf("S3 GET status = %d", getResp.StatusCode) } body, err := io.ReadAll(getResp.Body) if err != nil { t.Fatal(err) } if string(body) != "hello" { t.Fatalf("S3 GET body = %q", string(body)) } } func TestBucketAllowlist(t *testing.T) { store, err := NewFileStore(t.TempDir()) if err != nil { t.Fatal(err) } cfg := s3TestConfig() cfg.AllowedBuckets = map[string]bool{"allowed": true} srv := httptest.NewServer(NewServer(store, cfg)) defer srv.Close() req, err := http.NewRequest(http.MethodPut, srv.URL+"/blocked/test.txt", strings.NewReader("x")) if err != nil { t.Fatal(err) } signS3Header(t, req) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } _ = readResponseBody(t, resp, http.StatusForbidden) } func TestS3ReadOnlyKeyCannotWrite(t *testing.T) { store, err := NewFileStore(t.TempDir()) if err != nil { t.Fatal(err) } cfg := Config{S3Keys: []AccessKey{{ID: testAccessKey, Secret: testSecretKey, Permissions: permissions("read")}}} srv := httptest.NewServer(NewServer(store, cfg)) defer srv.Close() req, err := http.NewRequest(http.MethodPut, srv.URL+"/bucket/attachments/test.txt", strings.NewReader("hello")) if err != nil { t.Fatal(err) } signS3Header(t, req) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusForbidden { t.Fatalf("read-only PUT status = %d", resp.StatusCode) } } func TestS3MultipartUpload(t *testing.T) { store, err := NewFileStore(t.TempDir()) if err != nil { t.Fatal(err) } srv := httptest.NewServer(NewServer(store, s3TestConfig())) defer srv.Close() initReq, err := http.NewRequest(http.MethodPost, srv.URL+"/bucket/large/object.txt?uploads", nil) if err != nil { t.Fatal(err) } signS3Header(t, initReq) initResp, err := http.DefaultClient.Do(initReq) if err != nil { t.Fatal(err) } initBody := readResponseBody(t, initResp, http.StatusOK) var initResult createMultipartUploadResult if err := xml.Unmarshal(initBody, &initResult); err != nil { t.Fatal(err) } if initResult.UploadID == "" { t.Fatalf("empty upload id: %s", string(initBody)) } for partNumber, body := range map[int]string{1: "hello ", 2: "world"} { partReq, err := http.NewRequest(http.MethodPut, srv.URL+"/bucket/large/object.txt?uploadId="+url.QueryEscape(initResult.UploadID)+"&partNumber="+strconv.Itoa(partNumber), strings.NewReader(body)) if err != nil { t.Fatal(err) } signS3Header(t, partReq) partResp, err := http.DefaultClient.Do(partReq) if err != nil { t.Fatal(err) } _ = readResponseBody(t, partResp, http.StatusOK) } completeXML := `12` completeReq, err := http.NewRequest(http.MethodPost, srv.URL+"/bucket/large/object.txt?uploadId="+url.QueryEscape(initResult.UploadID), strings.NewReader(completeXML)) if err != nil { t.Fatal(err) } signS3Header(t, completeReq) completeResp, err := http.DefaultClient.Do(completeReq) if err != nil { t.Fatal(err) } _ = readResponseBody(t, completeResp, http.StatusOK) getReq, err := http.NewRequest(http.MethodGet, srv.URL+"/bucket/large/object.txt", nil) if err != nil { t.Fatal(err) } signS3Header(t, getReq) getResp, err := http.DefaultClient.Do(getReq) if err != nil { t.Fatal(err) } body := readResponseBody(t, getResp, http.StatusOK) if string(body) != "hello world" { t.Fatalf("multipart body = %q", string(body)) } } func TestS3MultipartUploadIDCannotEscapeRoot(t *testing.T) { store, err := NewFileStore(t.TempDir()) if err != nil { t.Fatal(err) } if _, err := store.Put("bucket/kept.txt", strings.NewReader("keep"), "text/plain"); err != nil { t.Fatal(err) } srv := httptest.NewServer(NewServer(store, s3TestConfig())) defer srv.Close() req, err := http.NewRequest(http.MethodDelete, srv.URL+"/bucket/kept.txt?uploadId=..", nil) if err != nil { t.Fatal(err) } signS3Header(t, req) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } _ = readResponseBody(t, resp, http.StatusNotFound) obj, err := store.Open("bucket/kept.txt") if err != nil { t.Fatalf("stored object was removed by invalid upload id: %v", err) } _ = obj.Close() } func TestS3CopyObject(t *testing.T) { store, err := NewFileStore(t.TempDir()) if err != nil { t.Fatal(err) } if _, err := store.Put("bucket/source.txt", strings.NewReader("copy body"), "text/plain"); err != nil { t.Fatal(err) } srv := httptest.NewServer(NewServer(store, s3TestConfig())) defer srv.Close() copyReq, err := http.NewRequest(http.MethodPut, srv.URL+"/bucket/dest.txt", nil) if err != nil { t.Fatal(err) } copyReq.Header.Set("X-Amz-Copy-Source", "/bucket/source.txt") signS3Header(t, copyReq) copyResp, err := http.DefaultClient.Do(copyReq) if err != nil { t.Fatal(err) } _ = readResponseBody(t, copyResp, http.StatusOK) obj, err := store.Open("bucket/dest.txt") if err != nil { t.Fatal(err) } defer obj.Close() body, err := io.ReadAll(obj) if err != nil { t.Fatal(err) } if string(body) != "copy body" { t.Fatalf("copied body = %q", string(body)) } } func TestS3DeleteObjects(t *testing.T) { store, err := NewFileStore(t.TempDir()) if err != nil { t.Fatal(err) } for _, key := range []string{"bucket/a.txt", "bucket/b.txt", "bucket/c.txt"} { if _, err := store.Put(key, strings.NewReader("x"), "text/plain"); err != nil { t.Fatal(err) } } srv := httptest.NewServer(NewServer(store, s3TestConfig())) defer srv.Close() deleteXML := `a.txtb.txt` deleteReq, err := http.NewRequest(http.MethodPost, srv.URL+"/bucket?delete", strings.NewReader(deleteXML)) if err != nil { t.Fatal(err) } signS3Header(t, deleteReq) deleteResp, err := http.DefaultClient.Do(deleteReq) if err != nil { t.Fatal(err) } body := readResponseBody(t, deleteResp, http.StatusOK) if !strings.Contains(string(body), "a.txt") || !strings.Contains(string(body), "b.txt") { t.Fatalf("delete response = %s", string(body)) } for _, key := range []string{"bucket/a.txt", "bucket/b.txt"} { if _, err := store.Stat(key); !errors.Is(err, ErrNotFound) { t.Fatalf("expected %s deleted, err=%v", key, err) } } if _, err := store.Stat("bucket/c.txt"); err != nil { t.Fatalf("expected c.txt preserved: %v", err) } } func TestStaticPeerReplicationPutAndDelete(t *testing.T) { peerStore, err := NewFileStore(t.TempDir()) if err != nil { t.Fatal(err) } peerCfg := s3TestConfig() peerCfg.ReplicationSecret = "replication-secret" peer := httptest.NewServer(NewServer(peerStore, peerCfg)) defer peer.Close() primaryStore, err := NewFileStore(t.TempDir()) if err != nil { t.Fatal(err) } cfg := s3TestConfig() cfg.ReplicationPeers = []string{peer.URL} cfg.ReplicationSecret = "replication-secret" cfg.AllowInsecureReplication = true primary := httptest.NewServer(NewServer(primaryStore, cfg)) defer primary.Close() putReq, err := http.NewRequest(http.MethodPut, primary.URL+"/bucket/replicated.txt", strings.NewReader("copy me")) if err != nil { t.Fatal(err) } signS3Header(t, putReq) putResp, err := http.DefaultClient.Do(putReq) if err != nil { t.Fatal(err) } _ = readResponseBody(t, putResp, http.StatusOK) assertEventually(t, func() bool { info, err := peerStore.Stat("bucket/replicated.txt") return err == nil && info.Size == int64(len("copy me")) }) deleteReq, err := http.NewRequest(http.MethodDelete, primary.URL+"/bucket/replicated.txt", nil) if err != nil { t.Fatal(err) } signS3Header(t, deleteReq) deleteResp, err := http.DefaultClient.Do(deleteReq) if err != nil { t.Fatal(err) } _ = readResponseBody(t, deleteResp, http.StatusNoContent) assertEventually(t, func() bool { _, err := peerStore.Stat("bucket/replicated.txt") return errors.Is(err, ErrNotFound) }) specialName := "a?b#c%.txt" specialReq, err := http.NewRequest(http.MethodPut, primary.URL+"/bucket/"+url.PathEscape(specialName), strings.NewReader("special")) if err != nil { t.Fatal(err) } signS3Header(t, specialReq) specialResp, err := http.DefaultClient.Do(specialReq) if err != nil { t.Fatal(err) } _ = readResponseBody(t, specialResp, http.StatusOK) assertEventually(t, func() bool { info, err := peerStore.Stat("bucket/" + specialName) return err == nil && info.Size == int64(len("special")) }) } func TestReplicationQueueRetriesFailedPeer(t *testing.T) { primaryStore, err := NewFileStore(t.TempDir()) if err != nil { t.Fatal(err) } cfg := s3TestConfig() cfg.ReplicationPeers = []string{"http://127.0.0.1:1"} cfg.ReplicationSecret = "replication-secret" cfg.AllowInsecureReplication = true primary := httptest.NewServer(NewServer(primaryStore, cfg)) defer primary.Close() putReq, err := http.NewRequest(http.MethodPut, primary.URL+"/bucket/retry.txt", strings.NewReader("retry")) if err != nil { t.Fatal(err) } signS3Header(t, putReq) putResp, err := http.DefaultClient.Do(putReq) if err != nil { t.Fatal(err) } _ = readResponseBody(t, putResp, http.StatusOK) assertEventually(t, func() bool { stats, err := primaryStore.ReplicationQueueStats() return err == nil && stats.PendingJobs == 1 && stats.MaxAttempts > 0 }) } func TestRepairQueuesAllObjectsForPeers(t *testing.T) { store, err := NewFileStore(t.TempDir()) if err != nil { t.Fatal(err) } for _, key := range []string{"bucket/a.txt", "bucket/b.txt"} { if _, err := store.Put(key, strings.NewReader("x"), "text/plain"); err != nil { t.Fatal(err) } } for i := 0; i < 1001; i++ { key := fmt.Sprintf("many/%04d.txt", i) if _, err := store.Put(key, strings.NewReader("x"), "text/plain"); err != nil { t.Fatal(err) } } count, err := store.EnqueueRepair([]string{"http://peer-a", "http://peer-b"}) if err != nil { t.Fatal(err) } if count != 2006 { t.Fatalf("repair jobs = %d", count) } stats, err := store.ReplicationQueueStats() if err != nil { t.Fatal(err) } if stats.PendingJobs != 2006 { t.Fatalf("pending jobs = %d", stats.PendingJobs) } } func TestBackupAndRestore(t *testing.T) { source := t.TempDir() store, err := NewFileStore(source) if err != nil { t.Fatal(err) } if _, err := store.Put("bucket/object.txt", strings.NewReader("backup"), "text/plain"); err != nil { t.Fatal(err) } archive := filepath.Join(t.TempDir(), "backup.tar.gz") if err := BackupDir(source, archive); err != nil { t.Fatal(err) } info, err := os.Stat(archive) if err != nil { t.Fatal(err) } if info.Mode().Perm() != 0o600 { t.Fatalf("backup mode = %o", info.Mode().Perm()) } destination := t.TempDir() if err := RestoreDir(archive, destination); err != nil { t.Fatal(err) } restored, err := NewFileStore(destination) if err != nil { t.Fatal(err) } obj, err := restored.Open("bucket/object.txt") if err != nil { t.Fatal(err) } defer obj.Close() body, err := io.ReadAll(obj) if err != nil { t.Fatal(err) } if string(body) != "backup" { t.Fatalf("restored body = %q", string(body)) } } func TestBackupRefusesSymlink(t *testing.T) { source := t.TempDir() if err := os.MkdirAll(filepath.Join(source, "bucket"), 0o700); err != nil { t.Fatal(err) } outside := filepath.Join(t.TempDir(), "outside.txt") if err := os.WriteFile(outside, []byte("secret"), 0o600); err != nil { t.Fatal(err) } if err := os.Symlink(outside, filepath.Join(source, "bucket", "link.txt")); err != nil { t.Fatal(err) } archive := filepath.Join(t.TempDir(), "backup.tar.gz") if err := BackupDir(source, archive); err == nil { t.Fatal("expected backup with symlink to fail") } } func TestRestoreEnforcesMaxBytes(t *testing.T) { archive := filepath.Join(t.TempDir(), "archive.tar.gz") if err := writeTestTarGz(archive, "bucket/object.txt", "body"); err != nil { t.Fatal(err) } if err := RestoreDirWithLimit(archive, t.TempDir(), 3); err == nil { t.Fatal("expected restore above max bytes to fail") } } func TestPutVerifiedRejectsBeforeObjectVisible(t *testing.T) { store, err := NewFileStore(t.TempDir()) if err != nil { t.Fatal(err) } _, err = store.PutVerified("bucket/object.txt", strings.NewReader("body"), "text/plain", func() error { return errPayloadHashMismatch }) if !errors.Is(err, errPayloadHashMismatch) { t.Fatalf("PutVerified error = %v", err) } if _, err := store.Stat("bucket/object.txt"); !errors.Is(err, ErrNotFound) { t.Fatalf("object became visible, err=%v", err) } } func TestS3PresignedGet(t *testing.T) { store, err := NewFileStore(t.TempDir()) if err != nil { t.Fatal(err) } if _, err := store.Put("bucket/attachments/test.txt", strings.NewReader("hello"), "text/plain"); err != nil { t.Fatal(err) } srv := httptest.NewServer(NewServer(store, s3TestConfig())) defer srv.Close() presignedURL := presignS3URL(t, http.MethodGet, srv.URL+"/bucket/attachments/test.txt", map[string]string{ "response-content-disposition": "attachment", }) resp, err := http.Get(presignedURL) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("presigned GET status = %d", resp.StatusCode) } if got := resp.Header.Get("Content-Disposition"); got != "attachment" { t.Fatalf("Content-Disposition = %q", got) } body, err := io.ReadAll(resp.Body) if err != nil { t.Fatal(err) } if string(body) != "hello" { t.Fatalf("presigned GET body = %q", string(body)) } } func TestS3AllowedBucketsArePrivateByDefault(t *testing.T) { store, err := NewFileStore(t.TempDir()) if err != nil { t.Fatal(err) } if _, err := store.Put("app-uploads/avatars/test.txt", strings.NewReader("hello"), "text/plain"); err != nil { t.Fatal(err) } cfg := s3TestConfig() cfg.AllowedBuckets = map[string]bool{"app-uploads": true} srv := httptest.NewServer(NewServer(store, cfg)) defer srv.Close() resp, err := http.Get(srv.URL + "/app-uploads/avatars/test.txt") if err != nil { t.Fatal(err) } _ = readResponseBody(t, resp, http.StatusForbidden) } func TestS3PublicPrefixReadCanBeEnabled(t *testing.T) { store, err := NewFileStore(t.TempDir()) if err != nil { t.Fatal(err) } if _, err := store.Put("app-uploads/avatars/test.txt", strings.NewReader("hello"), "text/plain"); err != nil { t.Fatal(err) } if _, err := store.Put("app-uploads/private/test.txt", strings.NewReader("secret"), "text/plain"); err != nil { t.Fatal(err) } cfg := s3TestConfig() cfg.AllowedBuckets = map[string]bool{"app-uploads": true} cfg.PublicPrefixes = map[string]bool{"app-uploads/avatars": true, "app-uploads": true} srv := httptest.NewServer(NewServer(store, cfg)) defer srv.Close() resp, err := http.Get(srv.URL + "/app-uploads/avatars/test.txt") if err != nil { t.Fatal(err) } body := readResponseBody(t, resp, http.StatusOK) if string(body) != "hello" { t.Fatalf("public GET body = %q", string(body)) } privateResp, err := http.Get(srv.URL + "/app-uploads/private/test.txt") if err != nil { t.Fatal(err) } _ = readResponseBody(t, privateResp, http.StatusForbidden) } func TestS3PublicReadHardensActiveContent(t *testing.T) { store, err := NewFileStore(t.TempDir()) if err != nil { t.Fatal(err) } if _, err := store.Put("app-uploads/avatars/evil.html", strings.NewReader(""), "text/html"); err != nil { t.Fatal(err) } if _, err := store.Put("app-uploads/avatars/evil.svg", strings.NewReader(""), "image/svg+xml"); err != nil { t.Fatal(err) } if _, err := store.Put("app-uploads/avatars/pic.png", strings.NewReader("\x89PNG"), "image/png"); err != nil { t.Fatal(err) } cfg := s3TestConfig() cfg.AllowedBuckets = map[string]bool{"app-uploads": true} cfg.PublicPrefixes = map[string]bool{"app-uploads/avatars": true} srv := httptest.NewServer(NewServer(store, cfg)) defer srv.Close() // HTML must be served with a restrictive CSP and forced to download. htmlResp, err := http.Get(srv.URL + "/app-uploads/avatars/evil.html") if err != nil { t.Fatal(err) } _ = readResponseBody(t, htmlResp, http.StatusOK) if got := htmlResp.Header.Get("Content-Security-Policy"); got != "default-src 'none'; sandbox" { t.Fatalf("html Content-Security-Policy = %q", got) } if got := htmlResp.Header.Get("Content-Disposition"); got != "attachment" { t.Fatalf("html Content-Disposition = %q", got) } // SVG is an image type but scriptable, so it must also be forced to download. svgResp, err := http.Get(srv.URL + "/app-uploads/avatars/evil.svg") if err != nil { t.Fatal(err) } _ = readResponseBody(t, svgResp, http.StatusOK) if got := svgResp.Header.Get("Content-Disposition"); got != "attachment" { t.Fatalf("svg Content-Disposition = %q", got) } // Inert raster media stays inline so avatars/images still render. pngResp, err := http.Get(srv.URL + "/app-uploads/avatars/pic.png") if err != nil { t.Fatal(err) } _ = readResponseBody(t, pngResp, http.StatusOK) if got := pngResp.Header.Get("Content-Disposition"); got != "" { t.Fatalf("png Content-Disposition = %q, want inline (empty)", got) } if got := pngResp.Header.Get("Content-Security-Policy"); got != "default-src 'none'; sandbox" { t.Fatalf("png Content-Security-Policy = %q", got) } } func TestS3AuthenticatedReadKeepsDispositionOverride(t *testing.T) { store, err := NewFileStore(t.TempDir()) if err != nil { t.Fatal(err) } if _, err := store.Put("bucket/attachments/page.html", strings.NewReader("

ok

"), "text/html"); err != nil { t.Fatal(err) } srv := httptest.NewServer(NewServer(store, s3TestConfig())) defer srv.Close() presignedURL := presignS3URL(t, http.MethodGet, srv.URL+"/bucket/attachments/page.html", map[string]string{ "response-content-disposition": "inline", }) resp, err := http.Get(presignedURL) if err != nil { t.Fatal(err) } _ = readResponseBody(t, resp, http.StatusOK) // Authenticated reads are not public, so the caller's override is honored and // no public-read hardening is applied. if got := resp.Header.Get("Content-Disposition"); got != "inline" { t.Fatalf("authenticated Content-Disposition = %q", got) } if got := resp.Header.Get("Content-Security-Policy"); got != "" { t.Fatalf("authenticated Content-Security-Policy = %q, want none", got) } } func TestS3RejectsTamperedPresignedPath(t *testing.T) { store, err := NewFileStore(t.TempDir()) if err != nil { t.Fatal(err) } srv := httptest.NewServer(NewServer(store, s3TestConfig())) defer srv.Close() presignedURL := presignS3URL(t, http.MethodGet, srv.URL+"/bucket/attachments/test.txt", nil) tampered := strings.Replace(presignedURL, "test.txt", "other.txt", 1) resp, err := http.Get(tampered) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusForbidden { t.Fatalf("tampered status = %d", resp.StatusCode) } } func TestS3RejectsOversizedUpload(t *testing.T) { store, err := NewFileStore(t.TempDir()) if err != nil { t.Fatal(err) } cfg := s3TestConfig() cfg.MaxObjectSize = 4 srv := httptest.NewServer(NewServer(store, cfg)) defer srv.Close() req, err := http.NewRequest(http.MethodPut, srv.URL+"/bucket/attachments/big.txt", strings.NewReader("hello")) if err != nil { t.Fatal(err) } signS3Header(t, req) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusRequestEntityTooLarge { t.Fatalf("S3 oversized status = %d", resp.StatusCode) } } func TestS3RejectsTooLongPresignedExpiry(t *testing.T) { store, err := NewFileStore(t.TempDir()) if err != nil { t.Fatal(err) } cfg := s3TestConfig() cfg.PresignedMaxExpiry = time.Hour srv := httptest.NewServer(NewServer(store, cfg)) defer srv.Close() presignedURL := presignS3URLWithExpiry(t, http.MethodGet, srv.URL+"/bucket/attachments/test.txt", nil, "7200") resp, err := http.Get(presignedURL) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusForbidden { t.Fatalf("long-expiry status = %d", resp.StatusCode) } } func TestS3RejectsWrongCredentialScope(t *testing.T) { store, err := NewFileStore(t.TempDir()) if err != nil { t.Fatal(err) } srv := httptest.NewServer(NewServer(store, s3TestConfig())) defer srv.Close() req, err := http.NewRequest(http.MethodPut, srv.URL+"/bucket/scope.txt", strings.NewReader("body")) if err != nil { t.Fatal(err) } signS3HeaderWithScope(t, req, "wrong", "s3") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } _ = readResponseBody(t, resp, http.StatusForbidden) } func TestS3ReadOnlyPresignedURLCannotWrite(t *testing.T) { store, err := NewFileStore(t.TempDir()) if err != nil { t.Fatal(err) } cfg := Config{S3Keys: []AccessKey{{ID: testAccessKey, Secret: testSecretKey, Permissions: permissions("read")}}} srv := httptest.NewServer(NewServer(store, cfg)) defer srv.Close() presignedURL := presignS3URL(t, http.MethodPut, srv.URL+"/bucket/attachments/test.txt", nil) req, err := http.NewRequest(http.MethodPut, presignedURL, strings.NewReader("nope")) if err != nil { t.Fatal(err) } resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } _ = readResponseBody(t, resp, http.StatusForbidden) } func TestS3PresignedURLCannotWrite(t *testing.T) { store, err := NewFileStore(t.TempDir()) if err != nil { t.Fatal(err) } srv := httptest.NewServer(NewServer(store, s3TestConfig())) defer srv.Close() presignedURL := presignS3URL(t, http.MethodPut, srv.URL+"/bucket/attachments/test.txt", nil) req, err := http.NewRequest(http.MethodPut, presignedURL, strings.NewReader("nope")) if err != nil { t.Fatal(err) } resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } _ = readResponseBody(t, resp, http.StatusForbidden) } func TestS3RejectsStaleHeaderSignature(t *testing.T) { store, err := NewFileStore(t.TempDir()) if err != nil { t.Fatal(err) } srv := httptest.NewServer(NewServer(store, s3TestConfig())) defer srv.Close() req, err := http.NewRequest(http.MethodPut, srv.URL+"/bucket/replay.txt", strings.NewReader("replay")) if err != nil { t.Fatal(err) } signS3HeaderAt(t, req, time.Now().UTC().Add(-time.Hour)) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } _ = readResponseBody(t, resp, http.StatusForbidden) } func TestS3CopyObjectRequiresAllowedSourceBucket(t *testing.T) { store, err := NewFileStore(t.TempDir()) if err != nil { t.Fatal(err) } if _, err := store.Put("blocked/secret.txt", strings.NewReader("secret"), "text/plain"); err != nil { t.Fatal(err) } cfg := s3TestConfig() cfg.AllowedBuckets = map[string]bool{"allowed": true} srv := httptest.NewServer(NewServer(store, cfg)) defer srv.Close() req, err := http.NewRequest(http.MethodPut, srv.URL+"/allowed/leak.txt", nil) if err != nil { t.Fatal(err) } req.Header.Set("X-Amz-Copy-Source", "/blocked/secret.txt") signS3Header(t, req) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } _ = readResponseBody(t, resp, http.StatusForbidden) } func TestS3RejectsPayloadHashMismatch(t *testing.T) { store, err := NewFileStore(t.TempDir()) if err != nil { t.Fatal(err) } srv := httptest.NewServer(NewServer(store, s3TestConfig())) defer srv.Close() req, err := http.NewRequest(http.MethodPut, srv.URL+"/bucket/tampered.txt", strings.NewReader("tampered")) if err != nil { t.Fatal(err) } signS3HeaderWithPayloadHash(t, req, sha256Hex("original")) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } _ = readResponseBody(t, resp, http.StatusForbidden) if _, err := store.Stat("bucket/tampered.txt"); !errors.Is(err, ErrNotFound) { t.Fatalf("tampered object persisted, err=%v", err) } } func TestS3CopyObjectRequiresReadAndWritePermission(t *testing.T) { store, err := NewFileStore(t.TempDir()) if err != nil { t.Fatal(err) } if _, err := store.Put("bucket/source.txt", strings.NewReader("secret"), "text/plain"); err != nil { t.Fatal(err) } cfg := Config{S3Keys: []AccessKey{{ID: testAccessKey, Secret: testSecretKey, Permissions: permissions("write")}}} srv := httptest.NewServer(NewServer(store, cfg)) defer srv.Close() req, err := http.NewRequest(http.MethodPut, srv.URL+"/bucket/copy.txt", nil) if err != nil { t.Fatal(err) } req.Header.Set("X-Amz-Copy-Source", "/bucket/source.txt") signS3Header(t, req) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } _ = readResponseBody(t, resp, http.StatusForbidden) } func TestBearerAPIHonorsBucketAllowlist(t *testing.T) { store, err := NewFileStore(t.TempDir()) if err != nil { t.Fatal(err) } cfg := Config{AuthToken: "secret", AllowedBuckets: map[string]bool{"allowed": true}} srv := httptest.NewServer(NewServer(store, cfg)) defer srv.Close() req, err := http.NewRequest(http.MethodPut, srv.URL+"/objects/blocked/file.txt", strings.NewReader("x")) if err != nil { t.Fatal(err) } req.Header.Set("Authorization", "Bearer secret") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } _ = readResponseBody(t, resp, http.StatusForbidden) } func TestCleanupMultipartRemovesStaleUploads(t *testing.T) { store, err := NewFileStore(t.TempDir()) if err != nil { t.Fatal(err) } dir := filepath.Join(store.root, ".multipart", "old-upload") if err := os.MkdirAll(dir, 0o700); err != nil { t.Fatal(err) } old := time.Now().Add(-2 * time.Hour) if err := os.Chtimes(dir, old, old); err != nil { t.Fatal(err) } if err := store.CleanupMultipart(time.Hour); err != nil { t.Fatal(err) } if _, err := os.Stat(dir); !errors.Is(err, os.ErrNotExist) { t.Fatalf("multipart dir still exists, err=%v", err) } } func TestRestoreRefusesExistingSymlinkTarget(t *testing.T) { archive := filepath.Join(t.TempDir(), "archive.tar.gz") if err := writeTestTarGz(archive, "bucket/object.txt", "body"); err != nil { t.Fatal(err) } destination := t.TempDir() if err := os.MkdirAll(filepath.Join(destination, "bucket"), 0o700); err != nil { t.Fatal(err) } outside := filepath.Join(t.TempDir(), "outside.txt") if err := os.Symlink(outside, filepath.Join(destination, "bucket", "object.txt")); err != nil { t.Fatal(err) } if err := RestoreDir(archive, destination); err == nil { t.Fatal("expected restore through symlink to fail") } } func TestRestoreRefusesSymlinkParent(t *testing.T) { archive := filepath.Join(t.TempDir(), "archive.tar.gz") if err := writeTestTarGz(archive, "bucket/object.txt", "body"); err != nil { t.Fatal(err) } destination := t.TempDir() outside := t.TempDir() if err := os.Symlink(outside, filepath.Join(destination, "bucket")); err != nil { t.Fatal(err) } if err := RestoreDir(archive, destination); err == nil { t.Fatal("expected restore through symlink parent to fail") } } func TestS3MultipartCompleteEnforcesAggregateMaxSize(t *testing.T) { store, err := NewFileStore(t.TempDir()) if err != nil { t.Fatal(err) } cfg := s3TestConfig() cfg.MaxObjectSize = 6 srv := httptest.NewServer(NewServer(store, cfg)) defer srv.Close() initReq, err := http.NewRequest(http.MethodPost, srv.URL+"/bucket/large.txt?uploads", nil) if err != nil { t.Fatal(err) } signS3Header(t, initReq) initResp, err := http.DefaultClient.Do(initReq) if err != nil { t.Fatal(err) } initBody := readResponseBody(t, initResp, http.StatusOK) var initResult createMultipartUploadResult if err := xml.Unmarshal(initBody, &initResult); err != nil { t.Fatal(err) } parts := []struct { number int body string }{ {number: 1, body: "abcd"}, {number: 2, body: "efgh"}, } for _, part := range parts { partNumber := part.number body := part.body partReq, err := http.NewRequest(http.MethodPut, srv.URL+"/bucket/large.txt?uploadId="+url.QueryEscape(initResult.UploadID)+"&partNumber="+strconv.Itoa(partNumber), strings.NewReader(body)) if err != nil { t.Fatal(err) } signS3Header(t, partReq) partResp, err := http.DefaultClient.Do(partReq) if err != nil { t.Fatal(err) } wantStatus := http.StatusOK if partNumber == 2 { wantStatus = http.StatusRequestEntityTooLarge } _ = readResponseBody(t, partResp, wantStatus) } } func TestS3RejectsReplicationHeaderWithoutSecret(t *testing.T) { store, err := NewFileStore(t.TempDir()) if err != nil { t.Fatal(err) } srv := httptest.NewServer(NewServer(store, s3TestConfig())) defer srv.Close() req, err := http.NewRequest(http.MethodPut, srv.URL+"/bucket/suppressed.txt", strings.NewReader("x")) if err != nil { t.Fatal(err) } req.Header.Set("X-Magpie-Replication", "1") signS3Header(t, req) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } _ = readResponseBody(t, resp, http.StatusForbidden) } func TestS3RejectsOversizedBatchDelete(t *testing.T) { store, err := NewFileStore(t.TempDir()) if err != nil { t.Fatal(err) } srv := httptest.NewServer(NewServer(store, s3TestConfig())) defer srv.Close() var body strings.Builder body.WriteString("") for i := 0; i <= maxBatchDeleteItems; i++ { body.WriteString("key-") body.WriteString(strconv.Itoa(i)) body.WriteString("") } body.WriteString("") req, err := http.NewRequest(http.MethodPost, srv.URL+"/bucket?delete", strings.NewReader(body.String())) if err != nil { t.Fatal(err) } signS3Header(t, req) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } _ = readResponseBody(t, resp, http.StatusBadRequest) } func TestS3ListBucketWithPrefix(t *testing.T) { store, err := NewFileStore(t.TempDir()) if err != nil { t.Fatal(err) } fixtures := []string{ "bucket/attachments/a.txt", "bucket/attachments/b.txt", "bucket/avatars/c.txt", "otherbucket/attachments/d.txt", } for _, key := range fixtures { if _, err := store.Put(key, strings.NewReader("x"), "text/plain"); err != nil { t.Fatal(err) } } srv := httptest.NewServer(NewServer(store, s3TestConfig())) defer srv.Close() req, err := http.NewRequest(http.MethodGet, srv.URL+"/bucket?list-type=2&prefix=attachments/", nil) if err != nil { t.Fatal(err) } signS3Header(t, req) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("list status = %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { t.Fatal(err) } text := string(body) if !strings.Contains(text, "attachments/a.txt") { t.Fatalf("list missing a.txt: %s", text) } if !strings.Contains(text, "attachments/b.txt") { t.Fatalf("list missing b.txt: %s", text) } if strings.Contains(text, "avatars/c.txt") || strings.Contains(text, "otherbucket") { t.Fatalf("list included wrong keys: %s", text) } if !strings.Contains(text, "2") { t.Fatalf("list key count wrong: %s", text) } } func TestS3ListBucketMaxKeys(t *testing.T) { store, err := NewFileStore(t.TempDir()) if err != nil { t.Fatal(err) } for _, key := range []string{"bucket/a.txt", "bucket/b.txt", "bucket/c.txt"} { if _, err := store.Put(key, strings.NewReader("x"), "text/plain"); err != nil { t.Fatal(err) } } srv := httptest.NewServer(NewServer(store, s3TestConfig())) defer srv.Close() req, err := http.NewRequest(http.MethodGet, srv.URL+"/bucket?list-type=2&max-keys=2", nil) if err != nil { t.Fatal(err) } signS3Header(t, req) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { t.Fatal(err) } if resp.StatusCode != http.StatusOK { t.Fatalf("max-keys list status = %d body=%s", resp.StatusCode, string(body)) } if !strings.Contains(string(body), "2") { t.Fatalf("max-keys not honored: %s", string(body)) } } func TestS3ListBucketContinuationToken(t *testing.T) { store, err := NewFileStore(t.TempDir()) if err != nil { t.Fatal(err) } for _, key := range []string{"bucket/a.txt", "bucket/b.txt", "bucket/c.txt"} { if _, err := store.Put(key, strings.NewReader("x"), "text/plain"); err != nil { t.Fatal(err) } } srv := httptest.NewServer(NewServer(store, s3TestConfig())) defer srv.Close() first := signedGET(t, srv.URL+"/bucket?list-type=2&max-keys=2") firstBody := readResponseBody(t, first, http.StatusOK) var firstPage s3ListBucketResult if err := xml.Unmarshal(firstBody, &firstPage); err != nil { t.Fatal(err) } if !firstPage.IsTruncated || firstPage.NextContinuationToken == "" || firstPage.KeyCount != 2 { t.Fatalf("first page = %#v body=%s", firstPage, string(firstBody)) } second := signedGET(t, srv.URL+"/bucket?list-type=2&max-keys=2&continuation-token="+url.QueryEscape(firstPage.NextContinuationToken)) secondBody := readResponseBody(t, second, http.StatusOK) var secondPage s3ListBucketResult if err := xml.Unmarshal(secondBody, &secondPage); err != nil { t.Fatal(err) } if secondPage.IsTruncated || secondPage.NextContinuationToken != "" || secondPage.KeyCount != 1 { t.Fatalf("second page = %#v body=%s", secondPage, string(secondBody)) } if len(secondPage.Contents) != 1 || secondPage.Contents[0].Key != "c.txt" { t.Fatalf("second page contents = %#v", secondPage.Contents) } } func TestS3ListBucketRejectsInvalidContinuationToken(t *testing.T) { store, err := NewFileStore(t.TempDir()) if err != nil { t.Fatal(err) } srv := httptest.NewServer(NewServer(store, s3TestConfig())) defer srv.Close() resp := signedGET(t, srv.URL+"/bucket?list-type=2&continuation-token=not-base64!!!") _ = readResponseBody(t, resp, http.StatusBadRequest) } func TestMetadataPersistsAcrossStoreRestart(t *testing.T) { dir := t.TempDir() store, err := NewFileStore(dir) if err != nil { t.Fatal(err) } if _, err := store.Put("bucket/attachments/a.txt", strings.NewReader("x"), "text/plain"); err != nil { t.Fatal(err) } reopened, err := NewFileStore(dir) if err != nil { t.Fatal(err) } objects, err := reopened.List("bucket/attachments/", 1000) if err != nil { t.Fatal(err) } if len(objects) != 1 || objects[0].Key != "bucket/attachments/a.txt" { t.Fatalf("persisted objects = %#v", objects) } } func TestReindexBackfillsMetadataFromFilesystem(t *testing.T) { dir := t.TempDir() objectPath := filepath.Join(dir, "bucket", "attachments", "manual.txt") if err := os.MkdirAll(filepath.Dir(objectPath), 0o700); err != nil { t.Fatal(err) } if err := os.WriteFile(objectPath, []byte("manual"), 0o600); err != nil { t.Fatal(err) } store, err := NewFileStore(dir) if err != nil { t.Fatal(err) } objects, err := store.List("bucket/attachments/", 1000) if err != nil { t.Fatal(err) } if len(objects) != 0 { t.Fatalf("objects before reindex = %#v", objects) } if err := store.Reindex(); err != nil { t.Fatal(err) } objects, err = store.List("bucket/attachments/", 1000) if err != nil { t.Fatal(err) } if len(objects) != 1 || objects[0].Key != "bucket/attachments/manual.txt" || objects[0].ETag == "" { t.Fatalf("objects after reindex = %#v", objects) } } func TestScrubReportsMissingCorruptAndOrphanedObjects(t *testing.T) { dir := t.TempDir() store, err := NewFileStore(dir) if err != nil { t.Fatal(err) } if _, err := store.Put("bucket/good.txt", strings.NewReader("good"), "text/plain"); err != nil { t.Fatal(err) } if _, err := store.Put("bucket/missing.txt", strings.NewReader("missing"), "text/plain"); err != nil { t.Fatal(err) } if _, err := store.Put("bucket/corrupt.txt", strings.NewReader("before"), "text/plain"); err != nil { t.Fatal(err) } missingPath, _ := store.pathFor("bucket/missing.txt") _ = os.Remove(missingPath) corruptPath, _ := store.pathFor("bucket/corrupt.txt") if err := os.WriteFile(corruptPath, []byte("after"), 0o600); err != nil { t.Fatal(err) } orphanPath := filepath.Join(dir, "bucket", "orphan.txt") if err := os.WriteFile(orphanPath, []byte("orphan"), 0o600); err != nil { t.Fatal(err) } report, err := store.Scrub() if err != nil { t.Fatal(err) } if !contains(report.MissingKeys, "bucket/missing.txt") { t.Fatalf("missing keys = %#v", report.MissingKeys) } if !contains(report.CorruptKeys, "bucket/corrupt.txt") { t.Fatalf("corrupt keys = %#v", report.CorruptKeys) } if !contains(report.OrphanKeys, "bucket/orphan.txt") { t.Fatalf("orphan keys = %#v", report.OrphanKeys) } } func contains(values []string, value string) bool { for _, candidate := range values { if candidate == value { return true } } return false } func assertEventually(t *testing.T, condition func() bool) { t.Helper() deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { if condition() { return } time.Sleep(20 * time.Millisecond) } t.Fatal("condition not met before timeout") } func s3TestConfig() Config { return Config{S3AccessKeyID: testAccessKey, S3SecretAccessKey: testSecretKey, S3Region: "auto"} } func signedGET(t *testing.T, rawURL string) *http.Response { t.Helper() req, err := http.NewRequest(http.MethodGet, rawURL, nil) if err != nil { t.Fatal(err) } signS3Header(t, req) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } return resp } func readResponseBody(t *testing.T, resp *http.Response, wantStatus int) []byte { t.Helper() defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { t.Fatal(err) } if resp.StatusCode != wantStatus { t.Fatalf("status = %d want=%d body=%s", resp.StatusCode, wantStatus, string(body)) } return body } func signS3Header(t *testing.T, req *http.Request) { t.Helper() signS3HeaderAt(t, req, time.Now().UTC()) } func signS3HeaderAt(t *testing.T, req *http.Request, at time.Time) { t.Helper() amzDate := at.UTC().Format("20060102T150405Z") date := amzDate[:8] scope := date + "/auto/s3/aws4_request" signedHeaders := "host;x-amz-content-sha256;x-amz-date" if req.Header.Get("X-Amz-Copy-Source") != "" { signedHeaders += ";x-amz-copy-source" } payloadHash := requestPayloadHash(t, req) req.Header.Set("X-Amz-Date", amzDate) req.Header.Set("X-Amz-Content-Sha256", payloadHash) canonical := canonicalRequest(req, signedHeaders, payloadHash, false) sig := signature(testSecretKey, amzDate, scope, canonical) req.Header.Set( "Authorization", awsAlgorithm+" Credential="+testAccessKey+"/"+scope+", SignedHeaders="+signedHeaders+", Signature="+sig, ) } func requestPayloadHash(t *testing.T, req *http.Request) string { t.Helper() if req.Body == nil { return sha256Hex("") } if req.GetBody == nil { return "UNSIGNED-PAYLOAD" } body, err := req.GetBody() if err != nil { t.Fatal(err) } defer body.Close() hash := sha256.New() if _, err := io.Copy(hash, body); err != nil { t.Fatal(err) } return hex.EncodeToString(hash.Sum(nil)) } func signS3HeaderWithPayloadHash(t *testing.T, req *http.Request, payloadHash string) { t.Helper() amzDate := time.Now().UTC().Format("20060102T150405Z") date := amzDate[:8] scope := date + "/auto/s3/aws4_request" signedHeaders := "host;x-amz-content-sha256;x-amz-date" req.Header.Set("X-Amz-Date", amzDate) req.Header.Set("X-Amz-Content-Sha256", payloadHash) canonical := canonicalRequest(req, signedHeaders, payloadHash, false) sig := signature(testSecretKey, amzDate, scope, canonical) req.Header.Set( "Authorization", awsAlgorithm+" Credential="+testAccessKey+"/"+scope+", SignedHeaders="+signedHeaders+", Signature="+sig, ) } func signS3HeaderWithScope(t *testing.T, req *http.Request, region string, service string) { t.Helper() amzDate := time.Now().UTC().Format("20060102T150405Z") date := amzDate[:8] scope := date + "/" + region + "/" + service + "/aws4_request" signedHeaders := "host;x-amz-content-sha256;x-amz-date" payloadHash := requestPayloadHash(t, req) req.Header.Set("X-Amz-Date", amzDate) req.Header.Set("X-Amz-Content-Sha256", payloadHash) canonical := canonicalRequest(req, signedHeaders, payloadHash, false) sig := signature(testSecretKey, amzDate, scope, canonical) req.Header.Set( "Authorization", awsAlgorithm+" Credential="+testAccessKey+"/"+scope+", SignedHeaders="+signedHeaders+", Signature="+sig, ) } func presignS3URL(t *testing.T, method string, rawURL string, extra map[string]string) string { return presignS3URLWithExpiry(t, method, rawURL, extra, "3600") } func presignS3URLWithExpiry(t *testing.T, method string, rawURL string, extra map[string]string, expires string) string { t.Helper() parsed, err := url.Parse(rawURL) if err != nil { t.Fatal(err) } amzDate := time.Now().UTC().Format("20060102T150405Z") date := amzDate[:8] scope := date + "/auto/s3/aws4_request" query := parsed.Query() for key, value := range extra { query.Set(key, value) } query.Set("X-Amz-Algorithm", awsAlgorithm) query.Set("X-Amz-Credential", testAccessKey+"/"+scope) query.Set("X-Amz-Date", amzDate) query.Set("X-Amz-Expires", expires) query.Set("X-Amz-SignedHeaders", "host") parsed.RawQuery = query.Encode() req, err := http.NewRequest(method, parsed.String(), nil) if err != nil { t.Fatal(err) } canonical := canonicalRequest(req, "host", "UNSIGNED-PAYLOAD", true) query.Set("X-Amz-Signature", signature(testSecretKey, amzDate, scope, canonical)) parsed.RawQuery = query.Encode() return parsed.String() } func sha256Hex(value string) string { sum := sha256.Sum256([]byte(value)) return fmt.Sprintf("%x", sum[:]) } func writeTestTarGz(path string, name string, body string) error { out, err := os.Create(path) if err != nil { return err } defer out.Close() gz := gzip.NewWriter(out) defer gz.Close() tw := tar.NewWriter(gz) defer tw.Close() if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o600, Size: int64(len(body))}); err != nil { return err } _, err = tw.Write([]byte(body)) return err }