Files
seaweedfs/weed/s3api/s3api_directory_marker.go
T
Chris Lu 2a97e08caa s3: cover the directory marker key with object lock (#10988)
* s3: enforce object lock when deleting a directory marker

The key "dir/" is deleted the unversioned way, ahead of the branches
that enforce Object Lock, so a principal with plain delete permission
could remove a key the gateway was reporting as COMPLIANCE-retained --
retention set through PutObjectRetention is stored on the directory
entry and served back by GetObjectRetention, only the delete ignored it.

The same path also takes any key ending in "/" regardless of size, while
a PUT only makes a marker of one up to 1KiB. A larger one is a genuine
versioned object, and deleting it here dropped its whole history after
the versioned delete of the same key had been refused.

Enforce in the marker delete itself, so the single, versioned and
multi-object delete paths are all covered.

* s3: apply object lock headers on a directory marker PUT

The trailing-slash branch runs before the versioning and Object Lock
handling, so it accepted x-amz-object-lock-* headers and stored none of
them: a bucket owner could believe a key was retained while nothing
recorded it, and an invalid mode or a past retention date that a regular
key rejects came back 200 here.

Validate the headers the way the regular path does, store what they ask
for beside the owner the same callback already sets, and refuse to
replace a key that is already retained.

* s3: check every version a marker delete would remove

The marker delete clears any history under the key in one recursive
removal, while the lock check ahead of it resolves the latest version
only. A version retained under an unretained one was taken with the
rest, so enforce against each version the removal covers.

* test: pin the marker lock refusals to AccessDenied

A bare require.Error passes on any failure, including one that has
nothing to do with the lock. Assert the code, the key the batch delete
reports, and that the marker survives each refusal.

* s3: check the history entries a version list leaves out

The version list skips an entry without a version id, while the removal
takes it with the rest, so an entry an older build left unnamed escaped
the check. Walk the history directly instead, and refuse when an unnamed
entry is still under a retention or a legal hold of its own.

* s3: let a governance bypass reach an unnamed history entry

The unnamed branch refused every active retention, so a caller allowed
to bypass governance could not clear one, which the named path lets
through. Refuse a legal hold and compliance mode as before, and take the
bypass into account for governance.

* s3: keep the object lock decision in one place

The unnamed history entry had to repeat the retention and legal hold
rules inline because the enforcement helper only takes a key to look up.
Split the part that judges an entry out of it and call that from both.

* s3: guard a marker PUT on the entry it replaces

The overwrite check resolved the key's latest version, but mkdir builds
a fresh entry for the marker itself, dropping the lock metadata the old
one carried. Once the key had a history, an unlocked version answered
for a retained marker and a plain PUT replaced it. Judge the entry the
write is about to replace instead; a versioned write of the same key
still adds a version, which is its own to allow.

* s3: guard a marker delete on the entry it removes

The check ran against the key rather than the entry, so once the key had
a history it answered with a version and the retention recorded on the
marker itself went unseen. Judge the entry that is about to be removed,
the same way the PUT side now does; the versions under it are still
covered by the walk that follows.

* s3: take the object write lock for a marker PUT

The overwrite check read the entry that the mkdir after it replaces, so
two marker PUTs could both pass while one was still unlocked. The marker
delete already runs under this lock; hold it across the check and the
mkdir so the entry cannot change in between, and so the two paths are
serialized against each other.
2026-08-27 16:35:45 -07:00

113 lines
5.3 KiB
Go

package s3api
import (
"errors"
"net/http"
"strings"
"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"
"github.com/seaweedfs/seaweedfs/weed/util"
)
// An explicit directory marker - the key "dir/" created by PutObject on a
// trailing-slash key - is stored as the filer directory itself rather than as an
// object beside it. That makes it a poor fit for versioning: a delete marker would
// have to replace an entry that other keys live under, and the history would have to
// sit inside the directory it describes, where a listing keeps meeting it.
//
// So the key is not versioned. Deleting it does what deleting it in an unversioned
// bucket already does: the directory is removed when nothing is left under it, and
// demoted to a plain directory when children remain. Listings need no version lookup
// to tell what a directory stands for, and a bucket made of directory markers costs
// the same to list versioned as unversioned.
// deleteDirectoryMarker removes the key "<dir>/". Callers hold the object write lock,
// so the entry this decides about cannot change between the read and the delete.
func (s3a *S3ApiServer) deleteDirectoryMarker(r *http.Request, bucket, object string) s3err.ErrorCode {
governanceBypassAllowed := s3a.evaluateGovernanceBypassRequest(r, bucket, object)
markerDir := s3a.bucketDir(bucket) + "/" + strings.TrimSuffix(strings.TrimPrefix(object, "/"), "/")
dir, name := util.FullPath(markerDir).DirAndName()
entry, err := s3a.getEntry(dir, name)
switch {
case errors.Is(err, filer_pb.ErrNotFound):
return s3err.ErrNone // deleting a key that is not there is a success
case err != nil:
// The entry may be a file a child write promoted to a directory, whose data
// belongs to the key without the trailing slash. Deleting without knowing
// would destroy it, so fail and leave the retry to the client.
glog.Errorf("deleteDirectoryMarker: cannot read %s/%s: %v", bucket, object, err)
return s3err.ErrInternalError
case len(entry.GetChunks()) > 0 || entry.IsInRemoteOnly():
// A promoted file, not a marker: "dir/" does not name its data.
glog.V(2).Infof("deleteDirectoryMarker: %s/%s holds uploaded data, leaving it alone", bucket, object)
return s3err.ErrNone
}
// The key is deleted the unversioned way, but Object Lock still covers it: the
// gateway lists it as an object and serves retention set on it. The lock that
// matters is the one on this entry, since that is what is removed -- looking the
// key up instead would answer with a version once the key has a history.
if err := s3a.enforceObjectLockOnEntry(entry, bucket, object, "", governanceBypassAllowed); err != nil {
glog.V(2).Infof("deleteDirectoryMarker: %s/%s is locked: %v", bucket, object, err)
return s3err.ErrAccessDenied
}
// Drop a history an older build recorded for this key. Nothing writes one now, and
// leaving it behind keeps reporting the key in ListObjectVersions, so a history we
// cannot read or remove fails the delete rather than half finishing it.
switch _, historyErr := s3a.getEntry(markerDir, s3_constants.VersionsFolder); {
case historyErr == nil:
// The removal below takes every entry under the key, so each has to be clear
// of a lock of its own.
versionsDir := markerDir + "/" + s3_constants.VersionsFolder
for startFrom := ""; ; {
entries, isLast, listErr := s3a.list(versionsDir, "", startFrom, false, 1000)
if listErr != nil {
glog.Errorf("deleteDirectoryMarker: cannot list history of %s/%s: %v", bucket, object, listErr)
return s3err.ErrInternalError
}
for _, entry := range entries {
startFrom = entry.Name
versionId, named := entry.Extended[s3_constants.ExtVersionIdKey]
if !named {
// An entry an older build left without a version id is what this
// removal is here to clear, but one still under a lock cannot be
// named to check it, so judge it on what it carries itself.
if err := s3a.enforceObjectLockOnEntry(entry, bucket, object, "", governanceBypassAllowed); err != nil {
glog.V(2).Infof("deleteDirectoryMarker: unnamed history entry %s of %s/%s is locked: %v", entry.Name, bucket, object, err)
return s3err.ErrAccessDenied
}
continue
}
if err := s3a.enforceObjectLockProtections(r, bucket, object, string(versionId), governanceBypassAllowed); err != nil {
glog.V(2).Infof("deleteDirectoryMarker: version %s of %s/%s is locked: %v", versionId, bucket, object, err)
return s3err.ErrAccessDenied
}
}
if isLast || len(entries) == 0 {
break
}
}
if rmErr := s3a.rm(markerDir, s3_constants.VersionsFolder, true, true); rmErr != nil {
glog.Errorf("deleteDirectoryMarker: failed to remove stale history of %s/%s: %v", bucket, object, rmErr)
return s3err.ErrInternalError
}
case !errors.Is(historyErr, filer_pb.ErrNotFound):
glog.Errorf("deleteDirectoryMarker: cannot read stale history of %s/%s: %v", bucket, object, historyErr)
return s3err.ErrInternalError
}
if err := s3a.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
return s3a.deleteUnversionedObjectWithClient(client, bucket, object, false)
}); err != nil {
glog.Errorf("deleteDirectoryMarker: failed to delete %s/%s: %v", bucket, object, err)
return s3err.ErrInternalError
}
return s3err.ErrNone
}