fix: correct federated CopyObject checksum edge cases (#99 follow-ups)

Three residual checksum defects in the legacy etcd federation branch of
CopyObjectHandler, found by post-merge review of #157.

1. Empty-source 500 regression. A checksum-less object gains the S3 default
   CRC-64NVME (WantServerSideChecksumType is set), but minio-go streams no
   trailing checksum for a 0-byte body (contentLength == 0), so the remote
   computed none, federatedChecksumValue was empty, hash.NewChecksumWithType
   returned nil, and the handler returned 500 -- so every empty-object
   federated copy failed. For a 0-byte source, forward the empty-content digest
   as an ordinary checksum request header instead, so the remote validates,
   persists and returns it, matching the local path (e.g. CRC32 "AAAAAA==").

2. Inherited full-object checksum dropped. When the source already carries a
   full-object checksum, the local path sets dstOpts.WantChecksum, not
   WantServerSideChecksumType (only multipart-composite sources are promoted).
   The federated branch inspected only WantServerSideChecksumType, so a
   checksum-bearing source's checksum was silently discarded on a federated
   copy that requested no algorithm. Forward WantChecksum.Encoded (always a
   plain digest) as a checksum header so the remote validates and persists it,
   and bind the returned value, matching local persistence.

3. Multipart-suffixed remote value accepted. The bind accepted a value like
   "NSRBwg==-0": NewChecksumWithType parses the "-N" as ChecksumMultipart with
   WantParts 0 and the length-only validator passes, so the destination was
   returned as COMPOSITE. A single forwarded PutObject must yield a full-object
   digest, so reject a multipart-marked parsed value in addition to the
   existing nil (missing/malformed) rejection.

A forwarded checksum request header is stripped from objInfo.UserDefined so it
is not mistaken for object metadata.

Out of scope: the SSE federated-copy corruption (srcInfo.Reader/Size mismatch
for encrypted sources) predates this work and is filed separately.

New federated regressions cover empty source with requested and default
checksum (200 + correct value + persisted), an inherited full-object checksum
preserved without a requested algorithm, and a multipart-suffixed remote value
rejected. Red/green verified for each against the merged code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Feng Ruohang <rh@vonng.com>
This commit is contained in:
Feng Ruohang
2026-09-08 15:38:11 +08:00
parent 80e23dc9f2
commit 2c50d11f72
2 changed files with 138 additions and 12 deletions
+44 -11
View File
@@ -1938,16 +1938,42 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re
ServerSideEncryption: dstOpts.ServerSideEncryption,
UserTags: tag.ToMap(),
}
// When a server-side checksum was requested (explicitly, inherited from
// the source, or the S3 default for a checksum-less object), the local
// path computes, persists and returns it; the federated path must do the
// same. Ask the remote to compute and persist that checksum by streaming
// it as a trailing checksum, so the forwarded write's response carries
// the value back to us. Without this the federated copy silently returns
// an empty checksum (#99).
// The destination must carry the same checksum the local path would
// produce; the federated path has the remote compute, validate, persist
// and return it. Without this the federated copy silently returns an
// empty checksum (#99).
wantChecksumType := dstOpts.WantServerSideChecksumType
if wantChecksumType.IsSet() {
opts.Checksum = federatedChecksumType(wantChecksumType)
var checksumHeaderValue string
switch {
case wantChecksumType.IsSet():
if srcInfo.Size == 0 {
// minio-go streams no trailing checksum for an empty body, so the
// remote would compute none, the bind below would fail, and every
// empty-object federated copy would 500. Forward the empty-content
// digest as an ordinary checksum header instead, so the remote
// validates, persists and returns it (parity with the local path,
// e.g. CRC32 "AAAAAA==").
if empty := hash.NewChecksumFromData(wantChecksumType, nil); empty != nil {
checksumHeaderValue = empty.Encoded
}
} else {
// Stream a trailing checksum of the requested type so the remote
// computes and persists it over the copied bytes.
opts.Checksum = federatedChecksumType(wantChecksumType)
}
case dstOpts.WantChecksum != nil && dstOpts.WantChecksum.Type.IsSet():
// A full-object checksum inherited from a checksum-bearing source: the
// local path persists it without recomputation (only multipart
// composite sources are promoted to WantServerSideChecksumType above,
// so this is never composite). Forward the value as an ordinary
// checksum header so the remote validates and persists it, and bind
// the returned value below. WantChecksum.Encoded is always a plain
// digest, never a "-N" multipart form.
wantChecksumType = dstOpts.WantChecksum.Type.Base()
checksumHeaderValue = dstOpts.WantChecksum.Encoded
}
if checksumHeaderValue != "" {
opts.UserMetadata[wantChecksumType.Key()] = checksumHeaderValue
}
remoteObjInfo, rerr := core.PutObject(ctx, dstBucket, dstObject, srcInfo.Reader,
srcInfo.Size, "", "", opts)
@@ -1956,12 +1982,19 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re
return
}
objInfo.UserDefined = cloneMSS(opts.UserMetadata)
// A forwarded checksum header is a request detail, not object metadata.
if checksumHeaderValue != "" {
delete(objInfo.UserDefined, wantChecksumType.Key())
}
objInfo.ETag = remoteObjInfo.ETag
objInfo.ModTime = remoteObjInfo.LastModified
// Do not acknowledge a requested checksum the remote did not return.
// Bind the checksum the remote computed for this exact write. A single
// forwarded PutObject must yield a full-object digest, so reject a
// missing, malformed, or multipart-marked ("-N") value rather than
// mislabel the destination as composite.
if wantChecksumType.IsSet() {
cs := hash.NewChecksumWithType(wantChecksumType, federatedChecksumValue(wantChecksumType, remoteObjInfo))
if cs == nil {
if cs == nil || cs.Type.Is(hash.ChecksumMultipart) {
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrInternalError), r.URL)
return
}