Files
seaweedfs/weed/server/filer_grpc_server_condition.go
T
Chris LuandClaude Opus 5.5 45cd6033a0 s3: make conditional DeleteObject atomic on versioned buckets (#12624)
* s3: re-check a versioned DeleteObject If-Match under the object lock

A versioned or suspended DeleteObject without a versionId routed to the
owner filer after checking If-Match outside any lock, so concurrent
conditional deletes could all pass the check and each add a delete
marker. Keep conditional deletes on the lock path, which re-checks the
precondition under the object write lock, as conditional versioned PUTs
already do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3: guard a conditional version delete on the owner filer

DeleteObject with a versionId checked If-Match before routing the
delete to the owner filer, so concurrent conditional deletes of one
version could all report success. Carry the If-Match clause into the
owner's transaction as a guard on the version (the null entry for
versionId=null), evaluated under the object's path lock before anything
applies, and map that guard failing to 412 instead of the object-lock
403. An If-Match the filer cannot evaluate stays on the lock path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3: carry an If-Match ETag list into the routed delete

A DeleteObject whose If-Match listed several ETags fell back to the
gateway's object write lock, while a single-ETag delete of the same key
ran under the owner filer's path lock. The two locks do not exclude each
other, so a list delete and a single-ETag delete could both report
success. Send every candidate to the filer, whose strong comparison
matches checkDeleteIfMatch, so all conditional deletes with an owner are
decided under the owner's lock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3: fail a conditional null delete on an owner transport error

The owner may have committed the delete before the error, and the gateway
lock path it fell back to does not exclude routed deletes, so a second
If-Match delete could pass there too. Answer 500 as the specific-version
path does; unconditional deletes still fall back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3: fail a conditional null delete on any owner error but the nested refusal

Only the owner's non-empty-folder refusal is known to apply nothing. After
any other owner error the delete may have been partly applied, and a lock-path
re-check does not exclude a concurrent routed delete of the same version, so a
conditional null-version delete answers 500 instead of falling back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3: strip a nested conditional null delete on the owner

A conditional versionId=null delete that the owner refuses because other
keys are nested under the entry used to fall back to the gateway lock and
re-check If-Match there. That lock does not exclude routed deletes: once
the last nested key is removed, a concurrent routed delete can pass the
same check on the owner, and the fallback then treats the missing entry as
deleted, so both requests answer 204.

Demote the entry on the owner instead, in a transaction guarded by the
If-Match clauses and by the entry it was read as, with the object-lock
guards on the request. A missing or already demoted entry answers 412.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* filer: add an IF_OBJECT_EXISTS write condition

IF_EXISTS also holds for a plain directory, which is what is left of a key
once the object on it is stripped while other keys stay nested under it.
IF_OBJECT_EXISTS holds only for an entry that carries an object.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3: require an object for a conditional null delete with If-Match: *

Stripping the null version off an entry other keys are nested under leaves a
plain directory. Once the last nested key is gone, a routed delete with
If-Match: * still found an entry there and removed the empty directory, so a
second conditional delete could also answer 204. The owner now checks that
the entry carries an object.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3: settle a wildcard null delete on the entry when the filer predates IF_OBJECT_EXISTS

A filer older than IF_OBJECT_EXISTS fails it closed, so with the gateway
upgraded first every If-Match: * delete of a null version answered 412. When
the narrowed guard fails, the gateway now reads the entry on the owner: absent
or a plain directory is a 412, otherwise the file is deleted, or the directory
stripped, under IF_EXISTS and IF_ENTRY_EQUAL on the entry it read. The demote
keeps the caller's If-Match clauses, since the entry guard already requires an
object.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 12:47:22 -08:00

191 lines
6.6 KiB
Go

package weed_server
import (
"fmt"
"strconv"
"strings"
"time"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"google.golang.org/protobuf/proto"
)
// conditionIsSet reports whether a condition asks for any check at all.
func conditionIsSet(cond *filer_pb.WriteCondition) bool {
return cond != nil && len(cond.Clauses) > 0
}
// writeConditionSatisfied reports whether the precondition holds against the
// current entry (nil if absent), evaluated under the path lock. Every clause
// must hold (logical AND).
func writeConditionSatisfied(cond *filer_pb.WriteCondition, current *filer.Entry) bool {
for _, c := range cond.Clauses {
if !clauseSatisfied(c, current) {
return false
}
}
return true
}
// clauseSatisfied evaluates one primitive against the current entry. For the
// ETag kinds, etags is a set: IF_ETAG_MATCH holds when the current ETag equals
// any member, IF_ETAG_NOT_MATCH when it equals none. The IF_EXTENDED_* kinds are
// generic guards on an extended attribute used to enforce object-lock (legal
// hold and retention) without S3 knowledge in the filer.
func clauseSatisfied(c *filer_pb.WriteCondition_Clause, current *filer.Entry) bool {
exists := current != nil
switch c.Kind {
case filer_pb.WriteCondition_NONE:
return true
case filer_pb.WriteCondition_IF_NOT_EXISTS:
return !exists
case filer_pb.WriteCondition_IF_EXISTS:
return exists
case filer_pb.WriteCondition_IF_OBJECT_EXISTS:
return exists && (!current.IsDirectory() || current.ToProtoEntry().IsDirectoryKeyObject())
case filer_pb.WriteCondition_IF_ETAG_MATCH:
return exists && etagInSet(storedEntryETag(current), c.Etags, c.AllowWeak)
case filer_pb.WriteCondition_IF_ETAG_NOT_MATCH:
return !exists || !etagInSet(storedEntryETag(current), c.Etags, c.AllowWeak)
case filer_pb.WriteCondition_IF_UNMODIFIED_SINCE:
return !exists || current.Attr.Mtime.Unix() <= c.UnixTime
case filer_pb.WriteCondition_IF_MODIFIED_SINCE:
return !exists || current.Attr.Mtime.Unix() > c.UnixTime
case filer_pb.WriteCondition_IF_EXTENDED_NOT_EQUAL:
if !exists {
return true
}
v, ok := current.Extended[c.ExtKey]
return !ok || string(v) != c.ExtValue
case filer_pb.WriteCondition_IF_EXTENDED_TIME_ELAPSED:
if !exists {
return true
}
// An optional gate scopes the guard: when gate_key is set, the time check
// only applies if extended[gate_key] == gate_value. This lets the gateway
// express governance bypass (enforce retention only for COMPLIANCE mode)
// without reading the entry — the filer decides under the lock.
if c.GateKey != "" {
gv, gok := current.Extended[c.GateKey]
if !gok || string(gv) != c.GateValue {
return true
}
}
v, ok := current.Extended[c.ExtKey]
if !ok {
return true
}
deadline, err := strconv.ParseInt(strings.TrimSpace(string(v)), 10, 64)
if err != nil {
// An unparseable retention deadline is treated as still in force, so
// a malformed attribute fails safe (write blocked) rather than open.
return false
}
return deadline <= time.Now().Unix()
case filer_pb.WriteCondition_IF_CHUNKS_EQUAL:
return chunkFidsEqual(current, c.Fids)
case filer_pb.WriteCondition_IF_ENTRY_EQUAL:
if !exists || c.ExpectedEntry == nil {
return !exists && c.ExpectedEntry == nil
}
// Compare both sides in serialized form on clones: chunks are matched
// by their fid only — the stored entry may carry the restored file_id
// while an expected one built from a metadata event does not.
expected := proto.Clone(c.ExpectedEntry).(*filer_pb.Entry)
filer_pb.BeforeEntrySerialization(expected.Chunks)
actual := proto.Clone(current.ToProtoEntry()).(*filer_pb.Entry)
filer_pb.BeforeEntrySerialization(actual.Chunks)
return proto.Equal(actual, filer.FromPbEntry("", expected).ToProtoEntry())
default:
// An unrecognized clause kind (e.g. from a newer client) must not be
// treated as satisfied, which would silently bypass the guard. Fail
// closed so the write is blocked rather than slipping through.
return false
}
}
// chunkFidsEqual compares the stored chunk fids (absent entry = none) against
// expected as multisets; chunk order carries no meaning for needle liveness.
func chunkFidsEqual(current *filer.Entry, expected []string) bool {
var chunks []*filer_pb.FileChunk
if current != nil {
chunks = current.GetChunks()
}
if len(chunks) != len(expected) {
return false
}
counts := make(map[string]int, len(expected))
for _, fid := range expected {
counts[fid]++
}
for _, chunk := range chunks {
fid := chunk.GetFileIdString()
if counts[fid] == 0 {
return false
}
counts[fid]--
}
return true
}
// etagInSet reports whether stored matches any candidate. A strong comparison
// (allowWeak false) treats a weak ETag as never equal; a weak comparison
// ignores the W/ marker on both sides.
func etagInSet(stored string, candidates []string, allowWeak bool) bool {
for _, c := range candidates {
if etagEqual(stored, c, allowWeak) {
return true
}
}
return false
}
func etagEqual(stored, expected string, allowWeak bool) bool {
sv, sWeak := canonicalETag(stored)
ev, eWeak := canonicalETag(expected)
// RFC 7232 strong comparison: a weak ETag on either side never matches.
if !allowWeak && (sWeak || eWeak) {
return false
}
return sv == ev
}
// canonicalETag splits off the weak (W/) marker before stripping quotes, so a
// weak ETag like W/"abc" yields ("abc", true).
func canonicalETag(etag string) (value string, weak bool) {
etag = strings.TrimSpace(etag)
if strings.HasPrefix(etag, "W/") {
return strings.Trim(etag[len("W/"):], `"`), true
}
return strings.Trim(etag, `"`), false
}
// storedEntryETag mirrors the S3 gateway's ETag precedence — the stored
// Seaweed ETag extended attribute, then the remote ETag while the entry still
// holds the remote bytes, then Md5, then the chunk hash (an empty object has
// no ETag) — so conditional comparisons match what the gateway computes,
// without coupling the filer to S3 request handling.
func storedEntryETag(entry *filer.Entry) string {
if v, ok := entry.Extended[s3_constants.ExtETagKey]; ok && len(v) > 0 {
return normalizeETag(string(v))
}
if remote := entry.Remote; remote != nil && remote.RemoteETag != "" &&
entry.FileSize == uint64(remote.RemoteSize) &&
(remote.LastLocalSyncTsNs > 0 || (len(entry.GetChunks()) == 0 && len(entry.Content) == 0)) {
return normalizeETag(remote.RemoteETag)
}
if len(entry.Md5) > 0 {
return fmt.Sprintf("%x", entry.Md5)
}
if len(entry.GetChunks()) == 0 {
return ""
}
return normalizeETag(filer.ETagChunks(entry.GetChunks()))
}
func normalizeETag(etag string) string {
return strings.Trim(etag, `"`)
}