package weed_server import ( "context" "strconv" "testing" "time" "github.com/seaweedfs/seaweedfs/weed/filer" "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" "github.com/seaweedfs/seaweedfs/weed/util" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) func entryWithETag(etag string, mtime time.Time) *filer.Entry { return &filer.Entry{ FullPath: "/test/obj", Attr: filer.Attr{Mtime: mtime}, Extended: map[string][]byte{s3_constants.ExtETagKey: []byte(etag)}, } } // one wraps a single clause into a condition. func one(c *filer_pb.WriteCondition_Clause) *filer_pb.WriteCondition { return &filer_pb.WriteCondition{Clauses: []*filer_pb.WriteCondition_Clause{c}} } func TestWriteConditionSatisfied(t *testing.T) { base := time.Unix(1700000000, 0) present := entryWithETag("abc", base) cases := []struct { name string cond *filer_pb.WriteCondition cur *filer.Entry want bool }{ {"empty-absent", &filer_pb.WriteCondition{}, nil, true}, {"ifnotexists-absent", one(&filer_pb.WriteCondition_Clause{Kind: filer_pb.WriteCondition_IF_NOT_EXISTS}), nil, true}, {"ifnotexists-present", one(&filer_pb.WriteCondition_Clause{Kind: filer_pb.WriteCondition_IF_NOT_EXISTS}), present, false}, {"ifexists-absent", one(&filer_pb.WriteCondition_Clause{Kind: filer_pb.WriteCondition_IF_EXISTS}), nil, false}, {"ifexists-present", one(&filer_pb.WriteCondition_Clause{Kind: filer_pb.WriteCondition_IF_EXISTS}), present, true}, {"etagmatch-hit", one(&filer_pb.WriteCondition_Clause{Kind: filer_pb.WriteCondition_IF_ETAG_MATCH, Etags: []string{`"abc"`}}), present, true}, {"etagmatch-miss", one(&filer_pb.WriteCondition_Clause{Kind: filer_pb.WriteCondition_IF_ETAG_MATCH, Etags: []string{`"zzz"`}}), present, false}, {"etagmatch-absent", one(&filer_pb.WriteCondition_Clause{Kind: filer_pb.WriteCondition_IF_ETAG_MATCH, Etags: []string{`"abc"`}}), nil, false}, {"etagnotmatch-hit", one(&filer_pb.WriteCondition_Clause{Kind: filer_pb.WriteCondition_IF_ETAG_NOT_MATCH, Etags: []string{`"abc"`}}), present, false}, {"etagnotmatch-miss", one(&filer_pb.WriteCondition_Clause{Kind: filer_pb.WriteCondition_IF_ETAG_NOT_MATCH, Etags: []string{`"zzz"`}}), present, true}, {"etagnotmatch-absent", one(&filer_pb.WriteCondition_Clause{Kind: filer_pb.WriteCondition_IF_ETAG_NOT_MATCH, Etags: []string{`"abc"`}}), nil, true}, {"unmodsince-ok", one(&filer_pb.WriteCondition_Clause{Kind: filer_pb.WriteCondition_IF_UNMODIFIED_SINCE, UnixTime: base.Unix()}), present, true}, {"unmodsince-fail", one(&filer_pb.WriteCondition_Clause{Kind: filer_pb.WriteCondition_IF_UNMODIFIED_SINCE, UnixTime: base.Unix() - 1}), present, false}, {"modsince-ok", one(&filer_pb.WriteCondition_Clause{Kind: filer_pb.WriteCondition_IF_MODIFIED_SINCE, UnixTime: base.Unix() - 1}), present, true}, {"modsince-fail", one(&filer_pb.WriteCondition_Clause{Kind: filer_pb.WriteCondition_IF_MODIFIED_SINCE, UnixTime: base.Unix()}), present, false}, } for _, tc := range cases { if got := writeConditionSatisfied(tc.cond, tc.cur); got != tc.want { t.Errorf("%s: got %v want %v", tc.name, got, tc.want) } } } func TestWriteConditionClauses(t *testing.T) { base := time.Unix(1700000000, 0) present := entryWithETag("abc", base) matchAny := func(etags ...string) *filer_pb.WriteCondition { return &filer_pb.WriteCondition{Clauses: []*filer_pb.WriteCondition_Clause{ {Kind: filer_pb.WriteCondition_IF_ETAG_MATCH, Etags: etags}, }} } noneOf := func(etags ...string) *filer_pb.WriteCondition { return &filer_pb.WriteCondition{Clauses: []*filer_pb.WriteCondition_Clause{ {Kind: filer_pb.WriteCondition_IF_ETAG_NOT_MATCH, Etags: etags}, }} } cases := []struct { name string cond *filer_pb.WriteCondition cur *filer.Entry want bool }{ {"set-match-hit", matchAny(`"x"`, `"abc"`, `"y"`), present, true}, {"set-match-miss", matchAny(`"x"`, `"y"`), present, false}, {"set-none-clean", noneOf(`"x"`, `"y"`), present, true}, {"set-none-hit", noneOf(`"abc"`, `"y"`), present, false}, // A weak request ETag never matches under strong comparison. {"weak-strong-fails", matchAny(`W/"abc"`), present, false}, // allow_weak compares ignoring the W/ marker. {"weak-allowed", &filer_pb.WriteCondition{Clauses: []*filer_pb.WriteCondition_Clause{ {Kind: filer_pb.WriteCondition_IF_ETAG_MATCH, Etags: []string{`W/"abc"`}, AllowWeak: true}, }}, present, true}, // Compound clauses are ANDed. {"compound-ok", &filer_pb.WriteCondition{Clauses: []*filer_pb.WriteCondition_Clause{ {Kind: filer_pb.WriteCondition_IF_ETAG_MATCH, Etags: []string{`"abc"`}}, {Kind: filer_pb.WriteCondition_IF_UNMODIFIED_SINCE, UnixTime: base.Unix()}, }}, present, true}, {"compound-second-fails", &filer_pb.WriteCondition{Clauses: []*filer_pb.WriteCondition_Clause{ {Kind: filer_pb.WriteCondition_IF_ETAG_MATCH, Etags: []string{`"abc"`}}, {Kind: filer_pb.WriteCondition_IF_UNMODIFIED_SINCE, UnixTime: base.Unix() - 1}, }}, present, false}, } for _, tc := range cases { if got := writeConditionSatisfied(tc.cond, tc.cur); got != tc.want { t.Errorf("%s: got %v want %v", tc.name, got, tc.want) } } } // The generic IF_EXTENDED_* guards express object-lock without S3 knowledge in // the filer: a legal hold (IF_EXTENDED_NOT_EQUAL) and a retention deadline // (IF_EXTENDED_TIME_ELAPSED). func TestWriteConditionObjectLockGuards(t *testing.T) { now := time.Now() withExt := func(ext map[string]string) *filer.Entry { e := &filer.Entry{FullPath: "/test/obj", Attr: filer.Attr{Mtime: now}, Extended: map[string][]byte{}} for k, v := range ext { e.Extended[k] = []byte(v) } return e } legalHold := &filer_pb.WriteCondition{Clauses: []*filer_pb.WriteCondition_Clause{ {Kind: filer_pb.WriteCondition_IF_EXTENDED_NOT_EQUAL, ExtKey: "lock-hold", ExtValue: "ON"}, }} retention := &filer_pb.WriteCondition{Clauses: []*filer_pb.WriteCondition_Clause{ {Kind: filer_pb.WriteCondition_IF_EXTENDED_TIME_ELAPSED, ExtKey: "retain-until"}, }} // Governance bypass: the retention guard is gated to COMPLIANCE mode, so a // governance-mode (or unmoded) entry is deletable while compliance stays // protected. gatedRetention := &filer_pb.WriteCondition{Clauses: []*filer_pb.WriteCondition_Clause{ {Kind: filer_pb.WriteCondition_IF_EXTENDED_TIME_ELAPSED, ExtKey: "retain-until", GateKey: "lock-mode", GateValue: "COMPLIANCE"}, }} future := strconv.FormatInt(now.Add(time.Hour).Unix(), 10) past := strconv.FormatInt(now.Add(-time.Hour).Unix(), 10) cases := []struct { name string cond *filer_pb.WriteCondition cur *filer.Entry want bool }{ {"hold-on-blocks", legalHold, withExt(map[string]string{"lock-hold": "ON"}), false}, {"hold-off-allows", legalHold, withExt(map[string]string{"lock-hold": "OFF"}), true}, {"hold-absent-allows", legalHold, withExt(nil), true}, {"hold-on-new-object", legalHold, nil, true}, // nothing to protect yet {"retain-future-blocks", retention, withExt(map[string]string{"retain-until": future}), false}, {"retain-past-allows", retention, withExt(map[string]string{"retain-until": past}), true}, {"retain-absent-allows", retention, withExt(nil), true}, {"retain-malformed-blocks", retention, withExt(map[string]string{"retain-until": "soon"}), false}, // Governance bypass (gated to COMPLIANCE): compliance still blocks, but // governance and unmoded entries become deletable despite future retention. {"bypass-compliance-blocks", gatedRetention, withExt(map[string]string{"retain-until": future, "lock-mode": "COMPLIANCE"}), false}, {"bypass-governance-allows", gatedRetention, withExt(map[string]string{"retain-until": future, "lock-mode": "GOVERNANCE"}), true}, {"bypass-no-mode-allows", gatedRetention, withExt(map[string]string{"retain-until": future}), true}, // Composed WORM guard: legal hold AND retention, both clear -> allowed. {"worm-both-clear", &filer_pb.WriteCondition{Clauses: []*filer_pb.WriteCondition_Clause{ {Kind: filer_pb.WriteCondition_IF_EXTENDED_NOT_EQUAL, ExtKey: "lock-hold", ExtValue: "ON"}, {Kind: filer_pb.WriteCondition_IF_EXTENDED_TIME_ELAPSED, ExtKey: "retain-until"}, }}, withExt(map[string]string{"lock-hold": "OFF", "retain-until": past}), true}, // Either guard tripping blocks the whole WORM condition. {"worm-hold-trips", &filer_pb.WriteCondition{Clauses: []*filer_pb.WriteCondition_Clause{ {Kind: filer_pb.WriteCondition_IF_EXTENDED_NOT_EQUAL, ExtKey: "lock-hold", ExtValue: "ON"}, {Kind: filer_pb.WriteCondition_IF_EXTENDED_TIME_ELAPSED, ExtKey: "retain-until"}, }}, withExt(map[string]string{"lock-hold": "ON", "retain-until": past}), false}, } for _, tc := range cases { if got := writeConditionSatisfied(tc.cond, tc.cur); got != tc.want { t.Errorf("%s: got %v want %v", tc.name, got, tc.want) } } } // An unrecognized clause kind (e.g. from a newer client) fails closed, so a // guard can't be silently bypassed by an older filer. NONE stays a no-op. func TestWriteConditionUnknownKindFailsClosed(t *testing.T) { present := entryWithETag("abc", time.Now()) unknown := &filer_pb.WriteCondition{Clauses: []*filer_pb.WriteCondition_Clause{ {Kind: filer_pb.WriteCondition_Kind(9999)}, }} if writeConditionSatisfied(unknown, present) { t.Error("unknown clause kind must not be satisfied (fail closed) for an existing entry") } if writeConditionSatisfied(unknown, nil) { t.Error("unknown clause kind must not be satisfied (fail closed) for an absent entry") } none := &filer_pb.WriteCondition{Clauses: []*filer_pb.WriteCondition_Clause{ {Kind: filer_pb.WriteCondition_NONE}, }} if !writeConditionSatisfied(none, present) { t.Error("a NONE clause must be satisfied (no-op)") } } // storedEntryETag prefers the stored Seaweed ETag attribute and falls back to // the Md5-derived ETag, matching the S3 gateway. func TestStoredEntryETag(t *testing.T) { withExt := entryWithETag("explicit", time.Unix(0, 0)) if got := storedEntryETag(withExt); got != "explicit" { t.Errorf("extended etag: got %q", got) } md5Only := &filer.Entry{Attr: filer.Attr{Md5: []byte{0xab, 0xcd}}} if got := storedEntryETag(md5Only); got != "abcd" { t.Errorf("md5 fallback: got %q", got) } } // The CreateEntry handler enforces the precondition atomically: a matching // If-Match overwrites, a non-matching one returns PRECONDITION_FAILED. func TestCreateEntryConditionEnforced(t *testing.T) { store := newRenameTestStore() store.entries["/test/obj"] = &filer.Entry{ FullPath: "/test/obj", Attr: filer.Attr{Inode: 1, Mtime: time.Unix(1700000000, 0)}, Extended: map[string][]byte{s3_constants.ExtETagKey: []byte("abc")}, } f := newRenameTestFiler(t, store) f.DirBucketsPath = "/buckets" fs := &FilerServer{filer: f, option: &FilerOption{}, entryLockTable: util.NewLockTable[util.FullPath]()} req := func(etag string) *filer_pb.CreateEntryRequest { return &filer_pb.CreateEntryRequest{ Directory: "/test", SkipCheckParentDirectory: true, Entry: &filer_pb.Entry{ Name: "obj", Attributes: &filer_pb.FuseAttributes{Mtime: 1700000001, FileMode: 0644, Inode: 2}, }, Condition: one(&filer_pb.WriteCondition_Clause{Kind: filer_pb.WriteCondition_IF_ETAG_MATCH, Etags: []string{etag}}), } } resp, err := fs.CreateEntry(context.Background(), req(`"zzz"`)) if err != nil { t.Fatalf("unexpected err: %v", err) } if resp.ErrorCode != filer_pb.FilerError_PRECONDITION_FAILED { t.Fatalf("mismatched etag: want PRECONDITION_FAILED, got %v (%q)", resp.ErrorCode, resp.Error) } resp, err = fs.CreateEntry(context.Background(), req(`"abc"`)) if err != nil { t.Fatalf("unexpected err: %v", err) } if resp.Error != "" { t.Fatalf("matching etag should overwrite, got error %q", resp.Error) } } // CreateEntry reuses a provided existing entry instead of reading the store // again; passing nil makes it look the path up itself. func TestCreateEntryReusesProvidedExisting(t *testing.T) { existing := &filer.Entry{ FullPath: "/test/obj", Attr: filer.Attr{Inode: 1, Mtime: time.Unix(1700000000, 0), Crtime: time.Unix(1700000000, 0)}, Extended: map[string][]byte{s3_constants.ExtETagKey: []byte("abc")}, } newEntry := func() *filer.Entry { return &filer.Entry{ FullPath: "/test/obj", Attr: filer.Attr{Inode: 1, Mtime: time.Unix(1700000001, 0)}, } } // Provided existing vs nil: the only difference is CreateEntry's own lookup, // so providing it must save exactly one path read (other store-layer reads // during the overwrite are the same in both runs). store := newRenameTestStore() store.entries["/test/obj"] = existing.ShallowClone() f := newRenameTestFiler(t, store) if err := f.CreateEntry(context.Background(), newEntry(), existing, false, false, nil, true, f.MaxFilenameLength); err != nil { t.Fatalf("create with existing: %v", err) } withExisting := store.findEntryCallCount("/test/obj") store2 := newRenameTestStore() store2.entries["/test/obj"] = existing.ShallowClone() f2 := newRenameTestFiler(t, store2) if err := f2.CreateEntry(context.Background(), newEntry(), nil, false, false, nil, true, f2.MaxFilenameLength); err != nil { t.Fatalf("create with nil: %v", err) } withNil := store2.findEntryCallCount("/test/obj") if withNil != withExisting+1 { t.Fatalf("providing existing should save one path lookup: existing=%d nil=%d", withExisting, withNil) } } // The UpdateEntry handler enforces the precondition under the per-path lock: a // matching If-Match applies the update, a non-matching one fails with // FailedPrecondition and leaves the stored entry untouched. func TestUpdateEntryConditionEnforced(t *testing.T) { newServer := func() (*FilerServer, *renameTestStore) { store := newRenameTestStore() store.entries["/test/obj"] = &filer.Entry{ FullPath: "/test/obj", Attr: filer.Attr{Inode: 1, Mtime: time.Unix(1700000000, 0), Mode: 0644}, Extended: map[string][]byte{s3_constants.ExtETagKey: []byte("abc")}, } f := newRenameTestFiler(t, store) return &FilerServer{filer: f, option: &FilerOption{}, entryLockTable: util.NewLockTable[util.FullPath]()}, store } req := func(etag string) *filer_pb.UpdateEntryRequest { return &filer_pb.UpdateEntryRequest{ Directory: "/test", Entry: &filer_pb.Entry{ Name: "obj", Attributes: &filer_pb.FuseAttributes{Mtime: 1700000005, FileMode: 0644, Inode: 1}, }, Condition: one(&filer_pb.WriteCondition_Clause{Kind: filer_pb.WriteCondition_IF_ETAG_MATCH, Etags: []string{etag}}), } } fs, store := newServer() if _, err := fs.UpdateEntry(context.Background(), req(`"zzz"`)); status.Code(err) != codes.FailedPrecondition { t.Fatalf("mismatched etag: want FailedPrecondition, got %v", err) } if got := store.entries["/test/obj"].Attr.Mtime.Unix(); got != 1700000000 { t.Fatalf("rejected update must not be applied, mtime %d", got) } fs, store = newServer() if _, err := fs.UpdateEntry(context.Background(), req(`"abc"`)); err != nil { t.Fatalf("matching etag should update: %v", err) } if got := store.entries["/test/obj"].Attr.Mtime.Unix(); got != 1700000005 { t.Fatalf("update not applied, mtime %d", got) } }