Files
seaweedfs/weed/s3api/s3api_object_handlers_copy_part_sse.go
T
Chris Lu 2458f6c81c feat(s3api): apply lifecycle TTL at write time (#9377)
* feat(s3api): apply lifecycle TTL at write time

The S3 server already has the bucket's lifecycle XML at PUT time (via
the cached BucketConfig), so volume-TTL routing is just a per-write
decision instead of something that needs a separate filer.conf
projection kept in sync via operator commands.

- BucketConfig caches the canonical Rules parsed from the lifecycle
  XML once on load (BucketConfigCache invalidates on Put/Delete
  Lifecycle, so the rules stay current automatically).
- resolveLifecycleTTLForWrite walks the cached rules: longest-prefix
  match, applies tag and size filters against the request, returns
  Days * 86400. Versioned buckets, non-Expiration.Days rules, and
  unevaluable size filters (no Content-Length) yield 0 — the
  lifecycle worker handles those at scan time.
- putToFiler resolves TTL once and passes it through both the
  AssignVolumeRequest (so chunks land on a TTL volume) and the new
  entry's Attributes.TtlSec (so the filer's RocksDB compaction also
  expires the metadata).

Lifecycle XML PUT/DELETE now influences write routing immediately —
no operator command, no filer.conf bookkeeping. The lifecycle worker
remains authoritative for the cases the fast path can't cover (existing
objects via bootstrap, versioned buckets, noncurrent retention,
abort-MPU, tag/size filters that didn't hold at PUT time).

CompleteMultipartUpload and CopyObject still need wiring; left for
follow-ups so this PR stays scoped.

* perf(s3api): pre-filter and sort lifecycle rules for the per-PUT TTL walk

resolveLifecycleTTLForWrite walked every lifecycle rule on every
PutObject, including disabled / non-Expiration.Days rules that could
never fire on the fast path, and computed "longest prefix wins" via a
running max instead of an early exit.

Cache a pre-filtered + pre-sorted slice in BucketConfig:
- buildTTLFastPathRules drops everything except Status=Enabled +
  ExpirationDays>0;
- sorts by descending prefix length (stable, so equal-length rules
  keep their XML order).

The resolver returns on first prefix+filter match. A bucket whose
lifecycle XML has no Expiration.Days rules is now O(1); a typical
bucket with one Expiration.Days rule walks one HasPrefix per PUT.

The cache is built once per bucket-config load. PutBucketLifecycle /
DeleteBucketLifecycle already invalidate the cache, so the fast-path
slice stays current automatically.

* refactor(s3api): LifecycleTTLResolver object + four review fixes

Pulls the per-PUT TTL resolution into a dedicated type so the bucket
config holds one object instead of a slice + magic-walk function:

- LifecycleTTLResolver wraps the pre-filtered, pre-sorted rules.
  nil-safe Resolve so the call site doesn't have to special-case
  buckets with no eligible rules.

Four review findings:

1. (high) drop tag-filtered rules from the fast path. Tags are mutable
   post-PUT via PutObjectTagging but volume TTL is irreversible — an
   object that matched at write time would still expire after the tag
   was removed. Worker re-evaluates current tags at scan time. Fast
   path now keeps only stable predicates: prefix and size.

2. (high) move TTL resolution out of putToFiler. MPU parts, copy-part
   destinations, and other transient writes called putToFiler with
   object="" — bucket-wide rules (empty Prefix) matched and bound a
   TTL clock starting at part-upload time, before
   CompleteMultipartUpload existed. putToFiler now takes an explicit
   ttlSec parameter; only the user-visible PutObject paths
   (PutObjectHandler, postpolicy) feed it from the resolver. MPU and
   copy-part pass 0.

3. (medium) AWS overlapping-rule precedence is "shorter expiration
   wins", not "longest prefix wins". Sort by ExpirationDays ascending
   so the first prefix match is also the shortest applicable rule.

4. (medium) overflow no longer caps at math.MaxInt32 seconds (~68y).
   A longer policy would have expired early. Return 0 instead so the
   worker enforces the actual policy on its own schedule.

Versioning gate moves into the resolver constructor — versioned
buckets get a nil resolver. The five putToFiler callers all updated:
PutObjectHandler + postpolicy resolve via lifecycleTTLForObjectWrite,
suspended/versioned wrappers pass 0 by construction, MPU part and
copy-part SSE pass 0 with a one-line comment about why.

* refactor(s3api): drop unused BucketConfig.LifecycleRules field

The full canonical rule set was set on every bucket-config load but
never read — resolveLifecycleTTLForWrite worked off the resolver's
filtered slice, and the lifecycle worker reads bucket entries straight
off the meta-log instead of this cache. Remove the field and its
s3lifecycle import.

* perf(s3api): pre-compute LifecycleTTLResolver hot-path fields

Resolve was doing per-call work that's actually constant per bucket-
config load: int64 multiplication, max-int32 overflow check, field
indirections through *s3lifecycle.Rule. Move it to the constructor
and pack the rule into a compact ttlRule (prefix + ttlSec int32 +
sizeGT/sizeLT) so the inner loop is HasPrefix → optional size check
→ return.

Drop overflowing rules at construction rather than handling per-
resolve: capping would expire long policies early, and returning 0
in the inner loop would prevent any shorter overlapping rule from
firing. Drop-at-construction composes correctly with the ascending
sort.

Benchmarks (Apple M4):
  NilReceiver           0.99 ns/op   0 B/op
  OneRuleMatching       2.75 ns/op   0 B/op
  FiveRulesNoMatch     13.5  ns/op   0 B/op

* fix(s3api): refresh LifecycleTTL resolver on bucket-config update

storeBucketLifecycleConfiguration writes to Entry.Extended via
updateBucketConfig, which clones the cached BucketConfig and calls
the user fn, then caches the result. The clone inherits the prior
LifecycleTTL pointer and nothing rebuilt it from the new XML, so
add/replace/delete of a lifecycle policy left the wrong resolver in
cache until eviction. Same gap on the meta-log side: peer-driven
updates flowed through updateBucketConfigCacheFromEntry without
re-deriving the resolver.

Centralize the Entry -> derived-field mapping in one helper that
resets every Extended-backed field then repopulates from the entry,
and call it from getBucketConfig (initial load), updateBucketConfig
(after updateEntry succeeds, before caching), and
updateBucketConfigCacheFromEntry (meta-log path). Reset is the
load-bearing part: deleting the lifecycle XML must yield a nil
resolver, since stamping a stale TTL onto subsequent writes is
irreversible.

* fix(s3api): PostPolicy passes object size, not multipart wire size

lifecycleTTLForObjectWrite was reading r.ContentLength, which on the
PostPolicy path is the multipart envelope (form fields + boundaries),
not the uploaded object body. A size-filtered rule would evaluate
against that inflated total and stamp (or skip) a TTL the policy
didn't intend.

Take the object size as an explicit parameter. PutObject still passes
r.ContentLength (correct there); PostPolicy passes the fileSize
already extracted from the form part. Negative size means unknown
and continues to skip any size-filtered rule.

* fix(s3api): treat Object Lock as versioned for lifecycle TTL fast path

Object Lock requires versioning at the API level, but it can be
enabled at create time without S3 ever writing the explicit
Versioning header. The lifecycle resolver construction site only
checked Versioning, so an Object-Lock bucket with no Versioning byte
would still get a fast-path resolver and stamp volume TTL onto
writes — destroying noncurrent versions when the volume expires.

Mirror the OR already used in BucketIsVersioned: ObjectLockConfig
non-nil counts as versioned for resolver construction. Existing
explicit-Versioning paths are unchanged.
2026-05-08 21:35:27 -07:00

428 lines
17 KiB
Go

package s3api
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"sort"
"strconv"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
)
// errCopySourceSSEUnsupported is returned by openSourcePlaintextReader when
// the source object's SSE type is not yet implemented in the UploadPartCopy
// slow path. Callers map it to a 501 NotImplemented S3 response so clients
// can distinguish "we will not handle this shape" from "the server failed".
var errCopySourceSSEUnsupported = errors.New("UploadPartCopy source SSE type not yet supported")
// isTransientFilerError reports whether an error talking to the filer is
// retryable from the client's perspective (filer briefly unreachable, leader
// election in flight, deadline exceeded, etc.). Such errors should map to a
// 503 ServiceUnavailable response so SDK retry logic engages, rather than a
// 500 InternalError which most clients treat as fatal.
func isTransientFilerError(err error) bool {
if err == nil {
return false
}
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
return true
}
if s, ok := status.FromError(err); ok {
switch s.Code() {
case codes.Unavailable, codes.DeadlineExceeded, codes.ResourceExhausted, codes.Aborted:
return true
}
}
return false
}
// uploadEntryHasSSE reports whether the multipart upload entry was created
// with any server-side encryption configured (SSE-S3 or SSE-KMS — explicit at
// CreateMultipartUpload time or applied as bucket default). It is used to
// decide whether UploadPartCopy must re-encrypt source bytes for the
// destination, rather than copying them as raw bytes (the fast path).
func uploadEntryHasSSE(uploadEntry *filer_pb.Entry) bool {
if uploadEntry == nil || uploadEntry.Extended == nil {
return false
}
if _, ok := uploadEntry.Extended[s3_constants.SeaweedFSSSEKMSKeyID]; ok {
return true
}
if v, ok := uploadEntry.Extended[s3_constants.SeaweedFSSSES3Encryption]; ok && string(v) == s3_constants.SSEAlgorithmAES256 {
return true
}
return false
}
// sourceEntryHasSSE reports whether the source object's chunks are SSE
// ciphertext on disk and therefore cannot be raw-copied — they must be
// decrypted on read.
func sourceEntryHasSSE(srcEntry *filer_pb.Entry) bool {
if srcEntry == nil {
return false
}
for _, c := range srcEntry.GetChunks() {
if c.GetSseType() != filer_pb.SSEType_NONE {
return true
}
}
if srcEntry.Extended != nil {
if _, ok := srcEntry.Extended[s3_constants.SeaweedFSSSES3Key]; ok {
return true
}
if _, ok := srcEntry.Extended[s3_constants.SeaweedFSSSEKMSKey]; ok {
return true
}
if _, ok := srcEntry.Extended[s3_constants.AmzServerSideEncryptionCustomerAlgorithm]; ok {
return true
}
}
return false
}
// readCloserAdapter pairs an arbitrary io.Reader with an io.Closer so callers
// can release the original underlying source even when the inner Reader (e.g.
// cipher.StreamReader, io.LimitReader) does not implement io.Closer.
type readCloserAdapter struct {
io.Reader
closer io.Closer
}
func (r *readCloserAdapter) Close() error {
if r.closer == nil {
return nil
}
return r.closer.Close()
}
// openSourcePlaintextReader returns a reader yielding the source object's
// plaintext bytes for [startOffset, endOffset], applying any necessary SSE
// decryption based on the source entry's metadata.
//
// Used by CopyObjectPartHandler when source or destination is SSE-encrypted:
// the fast raw-chunk-copy path leaves destination chunks SseType=NONE and
// completedMultipartChunk's NONE→SSE_S3 backfill (PR #9224) then writes
// destination-baseIV-derived metadata onto bytes that were actually encrypted
// with the source's key — producing deterministic byte corruption on GET (#8908).
//
// Returns errCopySourceSSEUnsupported when the source's SSE type is not yet
// implemented in this slow path (SSE-KMS, SSE-C). Callers should map that
// sentinel to a 501 NotImplemented S3 response rather than collapsing it to
// 500 InternalError, so clients can distinguish "we will not handle this
// shape" from "the server failed".
func (s3a *S3ApiServer) openSourcePlaintextReader(
ctx context.Context,
srcEntry *filer_pb.Entry,
startOffset, endOffset int64,
) (io.ReadCloser, error) {
if srcEntry == nil {
return nil, fmt.Errorf("nil source entry")
}
if endOffset < startOffset {
return io.NopCloser(io.LimitReader(emptyReader{}, 0)), nil
}
sliceLen := endOffset - startOffset + 1
switch s3a.detectPrimarySSEType(srcEntry) {
case s3_constants.SSETypeS3:
return s3a.openSSES3SourcePlaintextReader(ctx, srcEntry, startOffset, sliceLen)
case s3_constants.SSETypeKMS:
return nil, fmt.Errorf("%w: UploadPartCopy from SSE-KMS source", errCopySourceSSEUnsupported)
case s3_constants.SSETypeC:
return nil, fmt.Errorf("%w: UploadPartCopy from SSE-C source", errCopySourceSSEUnsupported)
default:
// Unencrypted source: stream raw bytes and apply range.
raw, err := s3a.getEncryptedStreamFromVolumes(ctx, srcEntry)
if err != nil {
return nil, fmt.Errorf("open unencrypted source: %w", err)
}
return applyRange(raw, startOffset, sliceLen)
}
}
// openSSES3SourcePlaintextReader builds a decrypted reader for an SSE-S3
// source. It reuses buildMultipartSSES3Reader, which decrypts each chunk
// independently using its per-chunk metadata — correct for both multipart-SSE
// objects (multiple SSE-S3 chunks) and single-part SSE-S3 objects whose single
// chunk also carries per-chunk metadata after PR #9211.
//
// For older single-part SSE-S3 objects whose chunks lack per-chunk metadata,
// this falls back to the entry-level SSE-S3 key + the entry's stored IV,
// matching the read path's single-part fallback.
func (s3a *S3ApiServer) openSSES3SourcePlaintextReader(
ctx context.Context,
srcEntry *filer_pb.Entry,
startOffset, sliceLen int64,
) (io.ReadCloser, error) {
chunks := srcEntry.GetChunks()
hasPerChunkSSE := false
for _, c := range chunks {
if c.GetSseType() == filer_pb.SSEType_SSE_S3 && len(c.GetSseMetadata()) > 0 {
hasPerChunkSSE = true
break
}
}
if hasPerChunkSSE {
sortedChunks := make([]*filer_pb.FileChunk, len(chunks))
copy(sortedChunks, chunks)
sort.Slice(sortedChunks, func(i, j int) bool {
return sortedChunks[i].GetOffset() < sortedChunks[j].GetOffset()
})
decReader, err := buildMultipartSSES3Reader(
sortedChunks,
GetSSES3KeyManager(),
func(c *filer_pb.FileChunk) (io.ReadCloser, error) {
return s3a.createEncryptedChunkReader(ctx, c)
},
)
if err != nil {
return nil, fmt.Errorf("build SSE-S3 source reader: %w", err)
}
// buildMultipartSSES3Reader returns a *lazyMultipartChunkReader whose
// Close() releases the live chunk body. Use it as the closer.
var closer io.Closer
if rc, ok := decReader.(io.Closer); ok {
closer = rc
}
return applyRange(&readCloserAdapter{Reader: decReader, closer: closer}, startOffset, sliceLen)
}
// Legacy single-part fallback: entry-level SeaweedFSSSES3Key + entry IV.
keyData, ok := srcEntry.Extended[s3_constants.SeaweedFSSSES3Key]
if !ok || len(keyData) == 0 {
return nil, fmt.Errorf("SSE-S3 source has no per-chunk metadata and no entry-level SSE-S3 key")
}
keyManager := GetSSES3KeyManager()
sseS3Key, err := DeserializeSSES3Metadata(keyData, keyManager)
if err != nil {
return nil, fmt.Errorf("deserialize entry-level SSE-S3 key: %w", err)
}
iv, err := GetSSES3IV(srcEntry, sseS3Key, keyManager)
if err != nil {
return nil, fmt.Errorf("get SSE-S3 IV: %w", err)
}
encStream, err := s3a.getEncryptedStreamFromVolumes(ctx, srcEntry)
if err != nil {
return nil, fmt.Errorf("open ciphertext source: %w", err)
}
dec, err := CreateSSES3DecryptedReader(encStream, sseS3Key, iv)
if err != nil {
encStream.Close()
return nil, fmt.Errorf("create SSE-S3 decrypted reader: %w", err)
}
rc, ok := dec.(io.ReadCloser)
if !ok {
rc = &readCloserAdapter{Reader: dec, closer: encStream}
}
return applyRange(rc, startOffset, sliceLen)
}
// applyRange skips startOffset bytes from src and limits the result to
// sliceLen bytes. The returned ReadCloser closes the underlying source.
func applyRange(src io.ReadCloser, startOffset, sliceLen int64) (io.ReadCloser, error) {
if startOffset > 0 {
if _, err := io.CopyN(io.Discard, src, startOffset); err != nil {
src.Close()
return nil, fmt.Errorf("skip to range start %d: %w", startOffset, err)
}
}
if sliceLen <= 0 {
return &readCloserAdapter{Reader: io.LimitReader(src, 0), closer: src}, nil
}
return &readCloserAdapter{Reader: io.LimitReader(src, sliceLen), closer: src}, nil
}
// emptyReader yields no bytes. Used for empty-range UploadPartCopy.
type emptyReader struct{}
func (emptyReader) Read([]byte) (int, error) { return 0, io.EOF }
// applyDestSSEHeadersToCopyRequest stages the destination's SSE setup on the
// (cloned) request so that putToFiler's existing handleAllSSEEncryption picks
// it up. The upload-entry markers (laid down at CreateMultipartUpload) bind
// every part of the upload to the same key+baseIV, matching PutObjectPart.
func (s3a *S3ApiServer) applyDestSSEHeadersToCopyRequest(
r *http.Request, uploadEntry *filer_pb.Entry, uploadID string,
) error {
if uploadEntry == nil || uploadEntry.Extended == nil {
return nil
}
if keyIDBytes, hasKMS := uploadEntry.Extended[s3_constants.SeaweedFSSSEKMSKeyID]; hasKMS {
// Mirror the SSE-KMS branch of PutObjectPartHandler: stage
// X-Amz-Server-Side-Encryption=aws:kms plus the key ID, encryption
// context, bucket-key flag and base IV onto the request.
keyID := string(keyIDBytes)
bucketKeyEnabled := false
if v, ok := uploadEntry.Extended[s3_constants.SeaweedFSSSEKMSBucketKeyEnabled]; ok && string(v) == "true" {
bucketKeyEnabled = true
}
var encryptionContext map[string]string
if cb, ok := uploadEntry.Extended[s3_constants.SeaweedFSSSEKMSEncryptionContext]; ok {
if err := json.Unmarshal(cb, &encryptionContext); err != nil {
glog.Errorf("UploadPartCopy: failed to parse SSE-KMS context for upload %s: %v", uploadID, err)
encryptionContext = nil
}
}
if len(encryptionContext) == 0 {
// Bucket and object are populated on the cloned request; reuse
// the same builder PutObjectPartHandler does.
bucket, object := s3_constants.GetBucketAndObject(r)
encryptionContext = BuildEncryptionContext(bucket, object, bucketKeyEnabled)
}
var baseIV []byte
if ivBytes, ok := uploadEntry.Extended[s3_constants.SeaweedFSSSEKMSBaseIV]; ok {
decoded, decErr := base64.StdEncoding.DecodeString(string(ivBytes))
if decErr != nil || len(decoded) != s3_constants.AESBlockSize {
return fmt.Errorf("invalid SSE-KMS base IV on upload %s", uploadID)
}
baseIV = decoded
} else {
return fmt.Errorf("no SSE-KMS base IV on upload %s", uploadID)
}
r.Header.Set(s3_constants.AmzServerSideEncryption, "aws:kms")
r.Header.Set(s3_constants.AmzServerSideEncryptionAwsKmsKeyId, keyID)
if bucketKeyEnabled {
r.Header.Set(s3_constants.AmzServerSideEncryptionBucketKeyEnabled, "true")
}
if len(encryptionContext) > 0 {
if cj, err := json.Marshal(encryptionContext); err == nil {
r.Header.Set(s3_constants.AmzServerSideEncryptionContext, base64.StdEncoding.EncodeToString(cj))
}
}
r.Header.Set(s3_constants.SeaweedFSSSEKMSBaseIVHeader, base64.StdEncoding.EncodeToString(baseIV))
return nil
}
// SSE-S3 path: reuse the existing PutObjectPart helper unchanged. It is
// pure header manipulation on r and does not touch S3ApiServer state.
return s3a.handleSSES3MultipartHeaders(r, uploadEntry, uploadID)
}
// fakeContentRequest builds a minimal request representing "PUT this body" for
// the multipart-part write path used by UploadPartCopy. It clones the original
// request's headers (so things like AmzAccountId carry over) and clears the
// copy-only headers; SSE setup is added later by applyDestSSEHeadersToCopyRequest.
func fakeContentRequest(orig *http.Request, body io.ReadCloser, contentLength int64) *http.Request {
cloned := orig.Clone(orig.Context())
cloned.Body = body
cloned.ContentLength = contentLength
if cloned.Header == nil {
cloned.Header = http.Header{}
} else {
cloned.Header = cloned.Header.Clone()
}
cloned.Header.Set("Content-Length", strconv.FormatInt(contentLength, 10))
cloned.Header.Del("X-Amz-Copy-Source")
cloned.Header.Del("X-Amz-Copy-Source-Range")
cloned.Header.Del("X-Amz-Metadata-Directive")
cloned.Header.Del("X-Amz-Tagging-Directive")
// Content-Md5 cannot be reproduced from the source plaintext without
// streaming it once first; clear it so putToFiler doesn't validate.
cloned.Header.Del("Content-Md5")
return cloned
}
// copyObjectPartViaReencryption implements the slow path of UploadPartCopy when
// either the source object is SSE-encrypted or the destination multipart upload
// is configured for SSE encryption. It:
//
// 1. Opens a plaintext reader of the source range (decrypting if needed).
// 2. Stages the destination's SSE-S3 / SSE-KMS multipart headers on a cloned
// request so handleAllSSEEncryption (called from putToFiler) routes the
// body through the matching multipart-encryption helper.
// 3. Calls putToFiler with the plaintext reader, which encrypts using the
// destination upload session's key+baseIV (consistent with PutObjectPart),
// auto-chunks, and writes the part entry with proper per-chunk SSE metadata.
//
// Without this path, copyChunksForRange's raw byte copy leaves destination
// chunks SseType=NONE; completedMultipartChunk then "backfills" SSE-S3 metadata
// with destination-baseIV-derived IVs, but the bytes on disk were encrypted
// with the source's key — yielding deterministic byte corruption on GET (#8908).
func (s3a *S3ApiServer) copyObjectPartViaReencryption(
r *http.Request,
srcEntry *filer_pb.Entry,
startOffset, endOffset int64,
dstBucket, uploadID string,
partID int,
uploadEntry *filer_pb.Entry,
) (etag string, sseMetadata SSEResponseMetadata, errCode s3err.ErrorCode) {
if endOffset < startOffset {
tag, code := s3a.writeEmptyCopyPart(dstBucket, uploadID, partID)
return tag, SSEResponseMetadata{}, code
}
sliceLen := endOffset - startOffset + 1
srcReader, err := s3a.openSourcePlaintextReader(r.Context(), srcEntry, startOffset, endOffset)
if err != nil {
glog.Errorf("UploadPartCopy: open source plaintext reader: %v", err)
// Distinguish "we will not handle this shape" (501) from "the server
// failed" (500). SSE-KMS / SSE-C source support in this slow path is
// staged work; the explicit error lets clients see it as a feature
// gap rather than a server fault.
if errors.Is(err, errCopySourceSSEUnsupported) {
return "", SSEResponseMetadata{}, s3err.ErrNotImplemented
}
return "", SSEResponseMetadata{}, s3err.ErrInternalError
}
defer srcReader.Close()
cloned := fakeContentRequest(r, srcReader, sliceLen)
if err := s3a.applyDestSSEHeadersToCopyRequest(cloned, uploadEntry, uploadID); err != nil {
glog.Errorf("UploadPartCopy: apply destination SSE headers: %v", err)
return "", SSEResponseMetadata{}, s3err.ErrInternalError
}
// Surface putToFiler's SSE response metadata to the caller so the handler
// can mirror PutObjectPart's behavior of writing
// x-amz-server-side-encryption / x-amz-server-side-encryption-aws-kms-key-id
// on the UploadPartCopy response. Without this, clients have no way to
// see that the destination was encrypted.
filePath := s3a.genPartUploadPath(dstBucket, uploadID, partID)
// Copy-part is an MPU part write under .uploads/<id>/<n>; lifecycle
// TTL only applies to the eventual completed object. Pass 0.
tag, code, putSSE := s3a.putToFiler(cloned, filePath, srcReader, dstBucket, "", partID, 0, nil)
if code != s3err.ErrNone {
return "", SSEResponseMetadata{}, code
}
return tag, putSSE, s3err.ErrNone
}
// writeEmptyCopyPart writes a 0-byte part entry for an empty UploadPartCopy
// range, mirroring the legacy fast path's handling of endOffset < startOffset.
func (s3a *S3ApiServer) writeEmptyCopyPart(dstBucket, uploadID string, partID int) (string, s3err.ErrorCode) {
uploadDir := s3a.genUploadsFolder(dstBucket) + "/" + uploadID
partName := fmt.Sprintf("%04d_%s.part", partID, "copy")
if exists, _ := s3a.exists(uploadDir, partName, false); exists {
if err := s3a.rm(uploadDir, partName, false, false); err != nil {
return "", s3err.ErrInternalError
}
}
if err := s3a.mkFile(uploadDir, partName, nil, func(e *filer_pb.Entry) {
if e.Attributes == nil {
e.Attributes = &filer_pb.FuseAttributes{}
}
e.Attributes.FileSize = 0
}); err != nil {
return "", s3err.ErrInternalError
}
const emptyMD5Hex = "d41d8cd98f00b204e9800998ecf8427e"
return emptyMD5Hex, s3err.ErrNone
}