s3: preserve versions for POST policy uploads (#11316)

* s3: preserve versions for POST policy uploads

Route POST policy uploads through the existing version-aware write helpers
and validate promoted Object Lock headers before writing.

Return the generated version ID when versioning is enabled, return
x-amz-version-id: null when versioning is suspended, and omit the header
when versioning has never been enabled.

* s3: reuse versioning helpers in POST policy handler

Route the POST policy handler through the existing getVersioningState
and isObjectLockEnabled helpers instead of open-coding the object-lock
forces-versioning-enabled rule, matching the PUT path.

Drop the x-amz-version-id: null response header for suspended
versioning; the PUT handler omits it and the S3 PutObject sample
response for suspended buckets does not include it. Trim the moved
fileSize comment.

---------

Co-authored-by: Chris Lu <chris.lu@gmail.com>
This commit is contained in:
David Christopher
2026-09-14 16:36:45 -07:00
committed by GitHub
co-authored by Chris Lu
parent 01433e801d
commit e4ca0d09e7
2 changed files with 325 additions and 7 deletions
@@ -0,0 +1,285 @@
package s3api
import (
"bytes"
"context"
"fmt"
"io"
"mime/multipart"
"net/http"
"sync"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/stretchr/testify/require"
)
type postPolicyUploadResult struct {
versionID string
etag string
}
func postPolicyUpload(ctx context.Context, client *s3.Client, bucket, key string, body []byte) (postPolicyUploadResult, error) {
return postPolicyUploadWithFields(ctx, client, bucket, key, body, nil)
}
func postPolicyUploadWithFields(ctx context.Context, client *s3.Client, bucket, key string, body []byte, extraFields map[string]string) (postPolicyUploadResult, error) {
presigner := s3.NewPresignClient(client, s3.WithPresignClientFromClientOptions(func(options *s3.Options) {
options.BaseEndpoint = aws.String(defaultConfig.Endpoint)
options.EndpointResolver = nil
options.UsePathStyle = true
}))
presigned, err := presigner.PresignPostObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
}, func(options *s3.PresignPostOptions) {
options.Expires = time.Hour
for name, value := range extraFields {
options.Conditions = append(options.Conditions, map[string]string{name: value})
}
})
if err != nil {
return postPolicyUploadResult{}, err
}
var requestBody bytes.Buffer
writer := multipart.NewWriter(&requestBody)
for name, value := range presigned.Values {
if err := writer.WriteField(name, value); err != nil {
return postPolicyUploadResult{}, err
}
}
for name, value := range extraFields {
if err := writer.WriteField(name, value); err != nil {
return postPolicyUploadResult{}, err
}
}
file, err := writer.CreateFormFile("file", "payload.bin")
if err != nil {
return postPolicyUploadResult{}, err
}
if _, err := file.Write(body); err != nil {
return postPolicyUploadResult{}, err
}
if err := writer.Close(); err != nil {
return postPolicyUploadResult{}, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, presigned.URL, &requestBody)
if err != nil {
return postPolicyUploadResult{}, err
}
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
if err != nil {
return postPolicyUploadResult{}, err
}
defer resp.Body.Close()
responseBody, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return postPolicyUploadResult{}, readErr
}
if resp.StatusCode != http.StatusNoContent {
return postPolicyUploadResult{}, fmt.Errorf("POST Object returned %s: %s", resp.Status, responseBody)
}
return postPolicyUploadResult{
versionID: resp.Header.Get("x-amz-version-id"),
etag: resp.Header.Get("ETag"),
}, nil
}
func TestPostPolicyPreservesVersionHistoryAcrossPutAndDelete(t *testing.T) {
ctx := context.Background()
client := getS3Client(t)
bucket := getNewBucketName()
key := "post-policy-history.bin"
createBucket(t, client, bucket)
defer deleteBucket(t, client, bucket)
putObject(t, client, bucket, key, "legacy-null")
enableVersioning(t, client, bucket)
postOne, err := postPolicyUpload(ctx, client, bucket, key, []byte("post-one"))
require.NoError(t, err)
require.NotEmpty(t, postOne.versionID)
require.NotEmpty(t, postOne.etag)
putTwo := putObject(t, client, bucket, key, "put-two")
require.NotNil(t, putTwo.VersionId)
require.NotEmpty(t, *putTwo.VersionId)
postThree, err := postPolicyUpload(ctx, client, bucket, key, []byte("post-three"))
require.NoError(t, err)
require.NotEmpty(t, postThree.versionID)
deleted, err := client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
})
require.NoError(t, err)
require.True(t, aws.ToBool(deleted.DeleteMarker))
require.NotEmpty(t, aws.ToString(deleted.VersionId))
postFour, err := postPolicyUpload(ctx, client, bucket, key, []byte("post-four"))
require.NoError(t, err)
require.NotEmpty(t, postFour.versionID)
requireVersionBody(t, client, bucket, key, "null", []byte("legacy-null"), "legacy null version")
requireVersionBody(t, client, bucket, key, postOne.versionID, []byte("post-one"), "first POST version")
requireVersionBody(t, client, bucket, key, aws.ToString(putTwo.VersionId), []byte("put-two"), "interleaved PUT version")
requireVersionBody(t, client, bucket, key, postThree.versionID, []byte("post-three"), "second POST version")
requireVersionBody(t, client, bucket, key, postFour.versionID, []byte("post-four"), "POST after delete marker")
listed, err := client.ListObjectVersions(ctx, &s3.ListObjectVersionsInput{
Bucket: aws.String(bucket),
Prefix: aws.String(key),
})
require.NoError(t, err)
require.Len(t, listed.Versions, 5)
require.Len(t, listed.DeleteMarkers, 1)
latest := 0
latestVersionID := ""
for _, version := range listed.Versions {
if aws.ToBool(version.IsLatest) {
latest++
latestVersionID = aws.ToString(version.VersionId)
}
}
require.Equal(t, 1, latest)
require.Equal(t, postFour.versionID, latestVersionID)
require.False(t, aws.ToBool(listed.DeleteMarkers[0].IsLatest))
}
func TestPostPolicyRejectsIncompleteObjectLockHeaders(t *testing.T) {
ctx := context.Background()
client := getS3Client(t)
bucket := getNewBucketName()
key := "post-policy-invalid-object-lock.bin"
createBucketWithObjectLock(t, client, bucket)
defer deleteBucket(t, client, bucket)
_, err := postPolicyUploadWithFields(ctx, client, bucket, key, []byte("rejected"), map[string]string{
"x-amz-object-lock-mode": "GOVERNANCE",
})
require.ErrorContains(t, err, "400 Bad Request")
require.ErrorContains(t, err, "<Code>InvalidRequest</Code>")
listed, listErr := client.ListObjectVersions(ctx, &s3.ListObjectVersionsInput{
Bucket: aws.String(bucket),
Prefix: aws.String(key),
})
require.NoError(t, listErr)
require.Empty(t, listed.Versions)
}
func TestPostPolicyVersioningStateCompatibility(t *testing.T) {
ctx := context.Background()
client := getS3Client(t)
bucket := getNewBucketName()
key := "post-policy-state.bin"
createBucket(t, client, bucket)
defer deleteBucket(t, client, bucket)
unconfigured, err := postPolicyUpload(ctx, client, bucket, key, []byte("unconfigured"))
require.NoError(t, err)
require.Empty(t, unconfigured.versionID)
unconfigured, err = postPolicyUpload(ctx, client, bucket, key, []byte("unconfigured-two"))
require.NoError(t, err)
require.Empty(t, unconfigured.versionID)
requireVersionBody(t, client, bucket, key, "null", []byte("unconfigured-two"), "unconfigured POST replaces null version")
enableVersioning(t, client, bucket)
versioned := putObject(t, client, bucket, key, "numbered")
require.NotEmpty(t, aws.ToString(versioned.VersionId))
suspendVersioning(t, client, bucket)
suspended, err := postPolicyUpload(ctx, client, bucket, key, []byte("suspended-one"))
require.NoError(t, err)
require.Empty(t, suspended.versionID)
suspended, err = postPolicyUpload(ctx, client, bucket, key, []byte("suspended-two"))
require.NoError(t, err)
require.Empty(t, suspended.versionID)
requireVersionBody(t, client, bucket, key, "null", []byte("suspended-two"), "suspended POST replaces null version")
requireVersionBody(t, client, bucket, key, aws.ToString(versioned.VersionId), []byte("numbered"), "suspended POST preserves numbered version")
listed, err := client.ListObjectVersions(ctx, &s3.ListObjectVersionsInput{
Bucket: aws.String(bucket),
Prefix: aws.String(key),
})
require.NoError(t, err)
require.Len(t, listed.Versions, 2)
}
func TestPostPolicyConcurrentWritesKeepEveryVersion(t *testing.T) {
const writeCount = 8
ctx := context.Background()
client := getS3Client(t)
bucket := getNewBucketName()
key := "post-policy-concurrent.bin"
createBucket(t, client, bucket)
defer deleteBucket(t, client, bucket)
enableVersioning(t, client, bucket)
type writeResult struct {
versionID string
body []byte
err error
}
start := make(chan struct{})
results := make(chan writeResult, writeCount)
var wg sync.WaitGroup
for i := 0; i < writeCount; i++ {
i := i
wg.Add(1)
go func() {
defer wg.Done()
<-start
body := []byte(fmt.Sprintf("concurrent-%d", i))
if i%2 == 0 {
post, err := postPolicyUpload(ctx, client, bucket, key, body)
results <- writeResult{versionID: post.versionID, body: body, err: err}
return
}
put, err := client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
Body: bytes.NewReader(body),
})
versionID := ""
if put != nil {
versionID = aws.ToString(put.VersionId)
}
results <- writeResult{versionID: versionID, body: body, err: err}
}()
}
close(start)
wg.Wait()
close(results)
seen := make(map[string]struct{}, writeCount)
for result := range results {
require.NoError(t, result.err)
require.NotEmpty(t, result.versionID)
_, duplicate := seen[result.versionID]
require.False(t, duplicate, "each successful write must receive a unique version ID")
seen[result.versionID] = struct{}{}
requireVersionBody(t, client, bucket, key, result.versionID, result.body, "concurrent version body")
}
listed, err := client.ListObjectVersions(ctx, &s3.ListObjectVersionsInput{
Bucket: aws.String(bucket),
Prefix: aws.String(key),
})
require.NoError(t, err)
require.Len(t, listed.Versions, writeCount)
latest := 0
for _, version := range listed.Versions {
if aws.ToBool(version.IsLatest) {
latest++
}
}
require.Equal(t, 1, latest)
}
+40 -7
View File
@@ -14,6 +14,7 @@ import (
"github.com/dustin/go-humanize"
"github.com/gorilla/mux"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/policy"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
@@ -126,8 +127,6 @@ func (s3a *S3ApiServer) PostPolicyBucketHandler(w http.ResponseWriter, r *http.R
}
}
filePath := fmt.Sprintf("%s/%s", s3a.bucketDir(bucket), object)
// Get ContentType from post formData
// Otherwise from formFile ContentType
contentType := formValues.Get("Content-Type")
@@ -145,16 +144,50 @@ func (s3a *S3ApiServer) PostPolicyBucketHandler(w http.ResponseWriter, r *http.R
return
}
// Use fileSize, not r.ContentLength: the multipart body wrapping form
// fields and boundaries inflates ContentLength relative to the
// object body, which would mis-evaluate any size-filtered rule.
ttlSec := s3a.lifecycleTTLForObjectWrite(bucket, object, fileSize)
etag, errCode, sseMetadata := s3a.putToFiler(r, filePath, fileBody, bucket, object, 1, ttlSec, nil, false, "")
versioningState, err := s3a.getVersioningState(bucket)
if err != nil {
if errors.Is(err, filer_pb.ErrNotFound) {
s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchBucket)
return
}
glog.Errorf("PostPolicyBucketHandler: versioning state for bucket %s: %v", bucket, err)
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
return
}
objectLockEnabled, err := s3a.isObjectLockEnabled(bucket)
if err != nil && !errors.Is(err, filer_pb.ErrNotFound) {
glog.Errorf("PostPolicyBucketHandler: object lock state for bucket %s: %v", bucket, err)
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
return
}
if err := s3a.validateObjectLockHeaders(r, objectLockEnabled); err != nil {
glog.V(2).Infof("PostPolicyBucketHandler: object lock header validation failed for %s/%s: %v", bucket, object, err)
s3err.WriteErrorResponse(w, r, mapValidationErrorToS3Error(err))
return
}
var etag string
var versionId string
var sseMetadata SSEResponseMetadata
switch versioningState {
case s3_constants.VersioningEnabled:
versionId, etag, errCode, sseMetadata = s3a.putVersionedObject(r, bucket, object, fileBody, contentType)
case s3_constants.VersioningSuspended:
etag, errCode, sseMetadata = s3a.putSuspendedVersioningObject(r, bucket, object, fileBody, contentType)
default:
filePath := fmt.Sprintf("%s/%s", s3a.bucketDir(bucket), object)
// Use fileSize, not r.ContentLength: the multipart body inflates ContentLength.
ttlSec := s3a.lifecycleTTLForObjectWrite(bucket, object, fileSize)
etag, errCode, sseMetadata = s3a.putToFiler(r, filePath, fileBody, bucket, object, 1, ttlSec, nil, false, "")
}
if errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
if versionId != "" {
w.Header().Set("x-amz-version-id", versionId)
}
if successRedirect != "" {
// Replace raw query params..