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 := ``
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}
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 TestS3PublicBucketReadAllowsWholeBucket(t *testing.T) {
store, err := NewFileStore(t.TempDir())
if err != nil {
t.Fatal(err)
}
if _, err := store.Put("app-uploads/static_sites/x/favicon.ico", strings.NewReader("ico"), "image/x-icon"); err != nil {
t.Fatal(err)
}
cfg := s3TestConfig()
cfg.AllowedBuckets = map[string]bool{"app-uploads": true}
cfg.PublicPrefixes = map[string]bool{"app-uploads": true}
srv := httptest.NewServer(NewServer(store, cfg))
defer srv.Close()
resp, err := http.Get(srv.URL + "/app-uploads/static_sites/x/favicon.ico")
if err != nil {
t.Fatal(err)
}
body := readResponseBody(t, resp, http.StatusOK)
if string(body) != "ico" {
t.Fatalf("public bucket GET body = %q", string(body))
}
}
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("")
}
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
}