package s3api import ( "bytes" "testing" "github.com/prometheus/client_golang/prometheus/testutil" "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" "github.com/seaweedfs/seaweedfs/weed/pb/s3_lifecycle_pb" "github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle" stats_collect "github.com/seaweedfs/seaweedfs/weed/stats" ) func TestComputeEntryIdentity_BasicFields(t *testing.T) { entry := &filer_pb.Entry{ Attributes: &filer_pb.FuseAttributes{Mtime: 1700000000, MtimeNs: 123, FileSize: 4096}, Chunks: []*filer_pb.FileChunk{ {FileId: "1,abc"}, {FileId: "1,def"}, }, } id := computeEntryIdentity(entry) want := int64(1700000000)*int64(1e9) + int64(123) if id.MtimeNs != want { t.Fatalf("MtimeNs want %d, got %d", want, id.MtimeNs) } if id.Size != 4096 { t.Fatalf("Size want 4096, got %d", id.Size) } if id.HeadFid != "1,abc" { t.Fatalf("HeadFid want 1,abc, got %s", id.HeadFid) } } func TestComputeEntryIdentity_NilSafeMissingChunks(t *testing.T) { if got := computeEntryIdentity(nil); got != nil { t.Fatalf("nil entry should return nil, got %v", got) } id := computeEntryIdentity(&filer_pb.Entry{}) if id == nil { t.Fatalf("entry with nil Attributes should still produce identity") } if id.HeadFid != "" { t.Fatalf("missing chunks should yield empty HeadFid, got %s", id.HeadFid) } } func TestHashExtended_OrderStable(t *testing.T) { a := map[string][]byte{"k1": []byte("v1"), "k2": []byte("v2")} b := map[string][]byte{"k2": []byte("v2"), "k1": []byte("v1")} if !bytes.Equal(s3lifecycle.HashExtended(a), s3lifecycle.HashExtended(b)) { t.Fatalf("hash should be insensitive to map iteration order") } } func TestHashExtended_DelimiterCollisionResistant(t *testing.T) { // Naively concatenated: "k1=v1k2v2" could collide with "k1=v1k" / "2v2". // Length-prefix encoding must keep them apart. a := map[string][]byte{"k1": []byte("v1"), "k2": []byte("v2")} b := map[string][]byte{"k1": []byte("v1k2v2")} if bytes.Equal(s3lifecycle.HashExtended(a), s3lifecycle.HashExtended(b)) { t.Fatalf("delimiter-forged Extended payloads must not collide") } } func TestHashExtended_NilEqualsEmpty(t *testing.T) { if got := s3lifecycle.HashExtended(nil); len(got) != 0 { t.Fatalf("nil should produce zero-length hash, got %d bytes", len(got)) } if got := s3lifecycle.HashExtended(map[string][]byte{}); len(got) != 0 { t.Fatalf("empty map should produce zero-length hash, got %d bytes", len(got)) } } func TestIdentityMatches_NilWantTreatedAsMatch(t *testing.T) { // Bootstrap callers that don't yet have an identity to CAS against // pass nil expected_identity; the server treats this as "no CAS". live := &s3_lifecycle_pb.EntryIdentity{MtimeNs: 1, Size: 2} if !identityMatches(live, nil) { t.Fatalf("nil want should match") } } func TestIdentityMatches_NilLiveDoesNotMatch(t *testing.T) { if identityMatches(nil, &s3_lifecycle_pb.EntryIdentity{MtimeNs: 1}) { t.Fatalf("nil live should not match a populated want") } } func TestIdentityMatches_AllFieldsCompared(t *testing.T) { base := &s3_lifecycle_pb.EntryIdentity{MtimeNs: 100, Size: 2048, HeadFid: "1,abc", ExtendedHash: []byte{0x01, 0x02}} cases := []struct { name string live *s3_lifecycle_pb.EntryIdentity want bool }{ {"identical", &s3_lifecycle_pb.EntryIdentity{MtimeNs: 100, Size: 2048, HeadFid: "1,abc", ExtendedHash: []byte{0x01, 0x02}}, true}, {"mtime-drift", &s3_lifecycle_pb.EntryIdentity{MtimeNs: 101, Size: 2048, HeadFid: "1,abc", ExtendedHash: []byte{0x01, 0x02}}, false}, {"size-drift", &s3_lifecycle_pb.EntryIdentity{MtimeNs: 100, Size: 2049, HeadFid: "1,abc", ExtendedHash: []byte{0x01, 0x02}}, false}, {"fid-drift", &s3_lifecycle_pb.EntryIdentity{MtimeNs: 100, Size: 2048, HeadFid: "1,xyz", ExtendedHash: []byte{0x01, 0x02}}, false}, {"extended-drift", &s3_lifecycle_pb.EntryIdentity{MtimeNs: 100, Size: 2048, HeadFid: "1,abc", ExtendedHash: []byte{0x03, 0x04}}, false}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { if got := identityMatches(c.live, base); got != c.want { t.Fatalf("want %v, got %v", c.want, got) } }) } } func TestLifecycleDelete_RejectsEmptyRequest(t *testing.T) { s := &S3ApiServer{} resp, err := s.LifecycleDelete(nil, &s3_lifecycle_pb.LifecycleDeleteRequest{}) if err != nil { t.Fatalf("unexpected gRPC error: %v", err) } if resp.Outcome != s3_lifecycle_pb.LifecycleDeleteOutcome_BLOCKED { t.Fatalf("empty request should be BLOCKED, got %v", resp.Outcome) } } func TestLifecycleAbortMPU_RejectsTraversalUploadIDs(t *testing.T) { // "." and ".." pass the no-slash check but resolve to the bucket // root via util.JoinPath; they must be rejected before any rm call. s := &S3ApiServer{} cases := []string{ "", ".uploads", ".uploads/", ".uploads/.", ".uploads/..", ".uploads/u1/extra", } for _, path := range cases { t.Run(path, func(t *testing.T) { resp, err := s.LifecycleDelete(nil, &s3_lifecycle_pb.LifecycleDeleteRequest{ Bucket: "bk", ObjectPath: path, ActionKind: s3_lifecycle_pb.ActionKind_ABORT_MPU, }) if err != nil { t.Fatalf("unexpected gRPC error: %v", err) } if resp.Outcome != s3_lifecycle_pb.LifecycleDeleteOutcome_BLOCKED { t.Fatalf("path %q: outcome=%v reason=%q, want BLOCKED", path, resp.Outcome, resp.Reason) } }) } } func TestLifecycleDispatch_AbortMPUAfterFetchIsBlocked(t *testing.T) { // LifecycleDelete routes ABORT_MPU to lifecycleAbortMPU before // getObjectEntry; reaching lifecycleDispatch with ABORT_MPU means // some caller bypassed that route. Defensive BLOCKED so a // regression there can't accidentally rm a real object via the // expiration paths. s := &S3ApiServer{} resp, err := s.lifecycleDispatch(nil, &s3_lifecycle_pb.LifecycleDeleteRequest{ Bucket: "bk", ObjectPath: "k", ActionKind: s3_lifecycle_pb.ActionKind_ABORT_MPU, }, &filer_pb.Entry{Attributes: &filer_pb.FuseAttributes{}}) if err != nil { t.Fatalf("unexpected gRPC error: %v", err) } if resp.Outcome != s3_lifecycle_pb.LifecycleDeleteOutcome_BLOCKED { t.Fatalf("ABORT_MPU at dispatch should be BLOCKED, got %v reason=%q", resp.Outcome, resp.Reason) } if !contains(resp.Reason, "ABORT_MPU dispatched after fetch") { t.Fatalf("reason should name the route bypass, got %q", resp.Reason) } } func TestLifecycleDispatch_UnknownActionKindIsBlocked(t *testing.T) { // An ActionKind value the proto doesn't define yet must produce a // FATAL outcome rather than fall through to a default delete path. s := &S3ApiServer{} const bogus = s3_lifecycle_pb.ActionKind(999) resp, err := s.lifecycleDispatch(nil, &s3_lifecycle_pb.LifecycleDeleteRequest{ Bucket: "bk", ObjectPath: "k", ActionKind: bogus, }, &filer_pb.Entry{Attributes: &filer_pb.FuseAttributes{}}) if err != nil { t.Fatalf("unexpected gRPC error: %v", err) } if resp.Outcome != s3_lifecycle_pb.LifecycleDeleteOutcome_BLOCKED { t.Fatalf("unknown action kind should be BLOCKED, got %v reason=%q", resp.Outcome, resp.Reason) } if !contains(resp.Reason, "unknown action_kind") { t.Fatalf("reason should name the unknown kind, got %q", resp.Reason) } } func TestLifecycleDispatch_NoncurrentRequiresVersionID(t *testing.T) { // Noncurrent / EXPIRED_DELETE_MARKER target a specific version; an // empty version_id is a writer-side bug and must be rejected before // any filer call. This pinning keeps the early-return in place so // a refactor doesn't accidentally let the empty-version_id path // reach deleteSpecificObjectVersion. s := &S3ApiServer{} for _, kind := range []s3_lifecycle_pb.ActionKind{ s3_lifecycle_pb.ActionKind_NONCURRENT_DAYS, s3_lifecycle_pb.ActionKind_NEWER_NONCURRENT, s3_lifecycle_pb.ActionKind_EXPIRED_DELETE_MARKER, } { t.Run(kind.String(), func(t *testing.T) { resp, err := s.lifecycleDispatch(nil, &s3_lifecycle_pb.LifecycleDeleteRequest{ Bucket: "bk", ObjectPath: "k", ActionKind: kind, // VersionId intentionally empty }, &filer_pb.Entry{Attributes: &filer_pb.FuseAttributes{}}) if err != nil { t.Fatalf("unexpected gRPC error: %v", err) } if resp.Outcome != s3_lifecycle_pb.LifecycleDeleteOutcome_BLOCKED { t.Fatalf("kind %v with empty version_id should be BLOCKED, got %v reason=%q", kind, resp.Outcome, resp.Reason) } if !contains(resp.Reason, "version_id required") { t.Fatalf("reason should name the missing version_id, got %q", resp.Reason) } }) } } // contains is a tiny helper so the tests above don't pull in strings // just for a substring check. func contains(haystack, needle string) bool { if len(needle) == 0 { return true } for i := 0; i+len(needle) <= len(haystack); i++ { if haystack[i:i+len(needle)] == needle { return true } } return false } func TestEntryUsesMetadataOnlyDelete(t *testing.T) { cases := []struct { name string entry *filer_pb.Entry want bool }{ { name: "nil entry", entry: nil, want: false, }, { name: "nil attributes", entry: &filer_pb.Entry{}, want: false, }, { name: "TtlSec=0 (no per-write stamp)", entry: &filer_pb.Entry{Attributes: &filer_pb.FuseAttributes{TtlSec: 0}}, want: false, }, { name: "TtlSec>0 (PR 9377 stamped a fast-path TTL)", entry: &filer_pb.Entry{Attributes: &filer_pb.FuseAttributes{TtlSec: 86400}}, want: true, }, { name: "TtlSec<0 should not happen but must not flip the path on", entry: &filer_pb.Entry{Attributes: &filer_pb.FuseAttributes{TtlSec: -1}}, want: false, }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { if got := entryUsesMetadataOnlyDelete(c.entry); got != c.want { t.Fatalf("want %v, got %v", c.want, got) } }) } } func TestRecordMetadataOnlyIf_OnlyFiresWhenOn(t *testing.T) { // Counter must increment exactly once per (bucket, hex(rule_hash)) // when on=true, and not at all when on=false. Other lifecycle paths // in the same suite share the global counter — use distinct bucket // names per test so series don't bleed. c := stats_collect.S3LifecycleMetadataOnlyCounter bucket := "bk-counter-on" hash := []byte{0xde, 0xad, 0xbe, 0xef, 0x01, 0x02, 0x03, 0x04} hexHash := "deadbeef01020304" before := testutil.ToFloat64(c.WithLabelValues(bucket, hexHash)) recordMetadataOnlyIf(true, &s3_lifecycle_pb.LifecycleDeleteRequest{ Bucket: bucket, RuleHash: hash, }) if got := testutil.ToFloat64(c.WithLabelValues(bucket, hexHash)); got != before+1 { t.Fatalf("on=true should bump by 1; before=%v after=%v", before, got) } beforeOff := testutil.ToFloat64(c.WithLabelValues("bk-counter-off", hexHash)) recordMetadataOnlyIf(false, &s3_lifecycle_pb.LifecycleDeleteRequest{ Bucket: "bk-counter-off", RuleHash: hash, }) if got := testutil.ToFloat64(c.WithLabelValues("bk-counter-off", hexHash)); got != beforeOff { t.Fatalf("on=false should not bump; before=%v after=%v", beforeOff, got) } } func TestRecordMetadataOnlyIf_NilRequestSafe(t *testing.T) { // A nil req is a defensive no-op; never panic on the prometheus // label call which would otherwise dereference req.Bucket. recordMetadataOnlyIf(true, nil) } func TestRecordMetadataOnlyIf_EmptyRuleHashCollapsesToEmptyLabel(t *testing.T) { // Bootstrap or test paths may not stamp a rule hash; the label // must end up as an empty string rather than panicking on // hex.EncodeToString(nil). c := stats_collect.S3LifecycleMetadataOnlyCounter bucket := "bk-counter-emptyhash" before := testutil.ToFloat64(c.WithLabelValues(bucket, "")) recordMetadataOnlyIf(true, &s3_lifecycle_pb.LifecycleDeleteRequest{Bucket: bucket}) if got := testutil.ToFloat64(c.WithLabelValues(bucket, "")); got != before+1 { t.Fatalf("nil rule_hash should produce empty-label series; before=%v after=%v", before, got) } }