diff --git a/CHANGELOG.md b/CHANGELOG.md index 232d2cccf7..77ec20fc18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ Changelog for NeoFS Node ## [Unreleased] ### Added +- Support of numeric object search queries (#2733) +- Support of `GT`, `GE`, `LT` and `LE` numeric comparison operators in CLI (#2733) ### Fixed - Access to `PUT` objects no longer grants `DELETE` rights (#2261) diff --git a/cmd/neofs-cli/modules/object/search.go b/cmd/neofs-cli/modules/object/search.go index 68e16bbb56..c3ca5fd05a 100644 --- a/cmd/neofs-cli/modules/object/search.go +++ b/cmd/neofs-cli/modules/object/search.go @@ -85,6 +85,10 @@ var searchBinaryOpVocabulary = map[string]object.SearchMatchType{ "EQ": object.MatchStringEqual, "NE": object.MatchStringNotEqual, "COMMON_PREFIX": object.MatchCommonPrefix, + "GT": object.MatchNumGT, + "GE": object.MatchNumGE, + "LE": object.MatchNumLE, + "LT": object.MatchNumLT, } func parseSearchFilters(cmd *cobra.Command) (object.SearchFilters, error) { diff --git a/go.mod b/go.mod index b878d8c0f4..3834df3fe0 100644 --- a/go.mod +++ b/go.mod @@ -17,9 +17,9 @@ require ( github.com/nspcc-dev/hrw/v2 v2.0.0 github.com/nspcc-dev/locode-db v0.5.0 github.com/nspcc-dev/neo-go v0.105.1 - github.com/nspcc-dev/neofs-api-go/v2 v2.14.0 + github.com/nspcc-dev/neofs-api-go/v2 v2.14.1-0.20240213170208-cfca09b5acbe github.com/nspcc-dev/neofs-contract v0.19.1 - github.com/nspcc-dev/neofs-sdk-go v1.0.0-rc.11.0.20240130073207-03ed6db7e1cd + github.com/nspcc-dev/neofs-sdk-go v1.0.0-rc.11.0.20240220193911-24254bf9aebe github.com/nspcc-dev/tzhash v1.7.1 github.com/olekukonko/tablewriter v0.0.5 github.com/panjf2000/ants/v2 v2.8.2 diff --git a/go.sum b/go.sum index 6f213fdd73..b9e2f8a3f6 100644 --- a/go.sum +++ b/go.sum @@ -251,14 +251,14 @@ github.com/nspcc-dev/neo-go v0.105.1 h1:r0b2yIwLBi+ARBKU94gHL9oTFEB/XMJ0YlS2HN9Q github.com/nspcc-dev/neo-go v0.105.1/go.mod h1:GNh0cRALV/cuj+/xg2ZHDsrFbqcInqG7jjhqsLEnlNc= github.com/nspcc-dev/neo-go/pkg/interop v0.0.0-20231127165613-b35f351f0ba0 h1:N+dMIBmteXjJpkH6UZ7HmNftuFxkqszfGLbhsEctnv0= github.com/nspcc-dev/neo-go/pkg/interop v0.0.0-20231127165613-b35f351f0ba0/go.mod h1:J/Mk6+nKeKSW4wygkZQFLQ6SkLOSGX5Ga0RuuuktEag= -github.com/nspcc-dev/neofs-api-go/v2 v2.14.0 h1:jhuN8Ldqz7WApvUJRFY0bjRXE1R3iCkboMX5QVZhHVk= -github.com/nspcc-dev/neofs-api-go/v2 v2.14.0/go.mod h1:DRIr0Ic1s+6QgdqmNFNLIqMqd7lNMJfYwkczlm1hDtM= +github.com/nspcc-dev/neofs-api-go/v2 v2.14.1-0.20240213170208-cfca09b5acbe h1:Hoq88+PWS6tNnX4Y0jxE0C8wvxPI8UlVnCs2ZJDEy4Y= +github.com/nspcc-dev/neofs-api-go/v2 v2.14.1-0.20240213170208-cfca09b5acbe/go.mod h1:eaffSBIGhXUIMYvRBGXmlgQRLyyCWlzOft9jGYlqwrw= github.com/nspcc-dev/neofs-contract v0.19.1 h1:U1Uh+MlzfkalO0kRJ2pADZyHrmAOroC6KLFjdWnTNR0= github.com/nspcc-dev/neofs-contract v0.19.1/go.mod h1:ZOGouuwuHpgvYkx/LCGufGncIzEUhYEO18LL4cWEbyw= github.com/nspcc-dev/neofs-crypto v0.4.0 h1:5LlrUAM5O0k1+sH/sktBtrgfWtq1pgpDs09fZo+KYi4= github.com/nspcc-dev/neofs-crypto v0.4.0/go.mod h1:6XJ8kbXgOfevbI2WMruOtI+qUJXNwSGM/E9eClXxPHs= -github.com/nspcc-dev/neofs-sdk-go v1.0.0-rc.11.0.20240130073207-03ed6db7e1cd h1:kRIn6i7BTa55ae4cH+UcqRfH//XC20mSC4E9WcWxkmM= -github.com/nspcc-dev/neofs-sdk-go v1.0.0-rc.11.0.20240130073207-03ed6db7e1cd/go.mod h1:2PKUuH7kQaAmQ/USBgmiD/k08ssnSvayor6JAFhrC1c= +github.com/nspcc-dev/neofs-sdk-go v1.0.0-rc.11.0.20240220193911-24254bf9aebe h1:VCJyY86/CSMTRjiXAPGGwRlss3FVGSGHZirCjQZzN2E= +github.com/nspcc-dev/neofs-sdk-go v1.0.0-rc.11.0.20240220193911-24254bf9aebe/go.mod h1:icGhc6HFg+yKivBUoP7cut62SASuijDiWD5Txd6vWqY= github.com/nspcc-dev/rfc6979 v0.2.0 h1:3e1WNxrN60/6N0DW7+UYisLeZJyfqZTNOjeV/toYvOE= github.com/nspcc-dev/rfc6979 v0.2.0/go.mod h1:exhIh1PdpDC5vQmyEsGvc4YDM/lyQp/452QxGq/UEso= github.com/nspcc-dev/tzhash v1.7.1 h1:6zmexLqdTF/ssbUAh7XJS7RxgKWaw28kdNpE/4UFdEU= diff --git a/pkg/core/object/object.go b/pkg/core/object/object.go index e62f4ac65f..ea6ec07120 100644 --- a/pkg/core/object/object.go +++ b/pkg/core/object/object.go @@ -1,10 +1,15 @@ package object import ( + "errors" + "github.com/nspcc-dev/neofs-sdk-go/object" oid "github.com/nspcc-dev/neofs-sdk-go/object/id" ) +// ErrInvalidSearchQuery is returned when some object search query is invalid. +var ErrInvalidSearchQuery = errors.New("invalid search query") + // AddressOf returns the address of the object. func AddressOf(obj *object.Object) oid.Address { var addr oid.Address diff --git a/pkg/local_object_storage/engine/select.go b/pkg/local_object_storage/engine/select.go index 0e5a8ca227..dc8b2b9ef4 100644 --- a/pkg/local_object_storage/engine/select.go +++ b/pkg/local_object_storage/engine/select.go @@ -1,6 +1,9 @@ package engine import ( + "errors" + + objectcore "github.com/nspcc-dev/neofs-node/pkg/core/object" "github.com/nspcc-dev/neofs-node/pkg/local_object_storage/shard" cid "github.com/nspcc-dev/neofs-sdk-go/container/id" "github.com/nspcc-dev/neofs-sdk-go/object" @@ -64,6 +67,10 @@ func (e *StorageEngine) _select(prm SelectPrm) (SelectRes, error) { e.iterateOverUnsortedShards(func(sh hashedShard) (stop bool) { res, err := sh.Select(shPrm) if err != nil { + if errors.Is(err, objectcore.ErrInvalidSearchQuery) { + outError = err + return true + } e.reportShardError(sh, "could not select objects from shard", err) return false } diff --git a/pkg/local_object_storage/metabase/select.go b/pkg/local_object_storage/metabase/select.go index 46ba4d8f6e..8e1626687f 100644 --- a/pkg/local_object_storage/metabase/select.go +++ b/pkg/local_object_storage/metabase/select.go @@ -4,9 +4,11 @@ import ( "encoding/binary" "errors" "fmt" + "math/big" "strconv" "strings" + objectcore "github.com/nspcc-dev/neofs-node/pkg/core/object" "github.com/nspcc-dev/neofs-node/pkg/util" cid "github.com/nspcc-dev/neofs-sdk-go/container/id" "github.com/nspcc-dev/neofs-sdk-go/object" @@ -56,6 +58,12 @@ func (r SelectRes) AddressList() []oid.Address { } // Select returns list of addresses of objects that match search filters. +// +// Only creation epoch, payload size, user attributes and unknown system ones +// are allowed with numeric operators. Values of numeric filters must be base-10 +// integers. +// +// Returns [object.ErrInvalidSearchQuery] if specified query is invalid. func (db *DB) Select(prm SelectPrm) (res SelectRes, err error) { db.modeMtx.RLock() defer db.modeMtx.RUnlock() @@ -265,12 +273,18 @@ func (db *DB) selectFromFKBT( f object.SearchFilter, // filter for operation and value to map[string]int, // resulting cache fNum int, // index of filter -) { // - matchFunc, ok := db.matchers[f.Operation()] - if !ok { - db.log.Debug("missing matcher", zap.Uint32("operation", uint32(f.Operation()))) +) { + var nonNumMatcher matcher + op := f.Operation() - return + isNumOp := isNumericOp(op) + if !isNumOp { + var ok bool + nonNumMatcher, ok = db.matchers[op] + if !ok { + db.log.Debug("missing matcher", zap.Uint32("operation", uint32(op))) + return + } } fkbtRoot := tx.Bucket(name) @@ -278,7 +292,56 @@ func (db *DB) selectFromFKBT( return } - err := matchFunc.matchBucket(fkbtRoot, f.Header(), f.Value(), func(k, _ []byte) error { + if isNumOp { + // TODO: big math takes less code but inefficient + filterNum, ok := new(big.Int).SetString(f.Value(), 10) + if !ok { + db.log.Debug("unexpected non-decimal numeric filter", zap.String("value", f.Value())) + return + } + + var objNum big.Int + + err := fkbtRoot.ForEach(func(objVal, _ []byte) error { + if len(objVal) == 0 { + return nil + } + + _, ok := objNum.SetString(string(objVal), 10) + if !ok { + return nil + } + + switch objNum.Cmp(filterNum) { + case -1: + ok = op == object.MatchNumLT || op == object.MatchNumLE + case 0: + ok = op == object.MatchNumLE || op == object.MatchNumGE + case 1: + ok = op == object.MatchNumGT || op == object.MatchNumGE + } + if !ok { + return nil + } + + fkbtLeaf := fkbtRoot.Bucket(objVal) + if fkbtLeaf == nil { + return nil + } + + return fkbtLeaf.ForEach(func(objAddr, _ []byte) error { + markAddressInCache(to, fNum, string(objAddr)) + return nil + }) + }) + if err != nil { + db.log.Debug("error in FKBT selection", zap.String("error", err.Error())) + } + + return + } + + err := nonNumMatcher.matchBucket(fkbtRoot, f.Header(), f.Value(), func(k, _ []byte) error { fkbtLeaf := fkbtRoot.Bucket(k) if fkbtLeaf == nil { return nil @@ -473,6 +536,71 @@ func (db *DB) matchSlowFilters(tx *bbolt.Tx, addr oid.Address, f object.SearchFi } for i := range f { + op := f[i].Operation() + if isNumericOp(op) { + attr := f[i].Header() + if attr != object.FilterCreationEpoch && attr != object.FilterPayloadSize { + break + } + + // filter by creation epoch or payload size, both uint64 + filterVal := f[i].Value() + if len(filterVal) == 0 { + return false + } + + if filterVal[0] == '-' { + if op == object.MatchNumLT || op == object.MatchNumLE { + return false + } + continue + } + + if len(filterVal) > 20 { // max uint64 strlen + if op == object.MatchNumGT || op == object.MatchNumGE { + return false + } + continue + } + + num, err := strconv.ParseUint(filterVal, 10, 64) + if err != nil { + if errors.Is(err, strconv.ErrRange) { + if op == object.MatchNumGT || op == object.MatchNumGE { + return false + } + continue + } + // has already been checked + db.log.Debug("unexpected failure to parse numeric filter uint value", zap.Error(err)) + return false + } + + var objVal uint64 + if attr == object.FilterPayloadSize { + objVal = obj.PayloadSize() + } else { + objVal = obj.CreationEpoch() + } + + switch { + case objVal > num: + if op == object.MatchNumLT || op == object.MatchNumLE { + return false + } + case objVal == num: + if op == object.MatchNumLT || op == object.MatchNumGT { + return false + } + case objVal < num: + if op == object.MatchNumGT || op == object.MatchNumGE { + return false + } + } + + continue + } + matchFunc, ok := db.matchers[f[i].Operation()] if !ok { return false @@ -514,7 +642,37 @@ func groupFilters(filters object.SearchFilters) (filterGroup, error) { } for i := range filters { - switch filters[i].Header() { + hdr := filters[i].Header() + if isNumericOp(filters[i].Operation()) { + switch hdr { + case + object.FilterVersion, + object.FilterID, + object.FilterContainerID, + object.FilterOwnerID, + object.FilterPayloadChecksum, + object.FilterType, + object.FilterPayloadHomomorphicHash, + object.FilterParentID, + object.FilterSplitID, + object.FilterRoot, + object.FilterPhysical: + // only object.FilterCreationEpoch and object.PayloadSize are numeric system + // object attributes now. Unsupported system attributes will lead to an empty + // results rather than a denial of service. + return res, fmt.Errorf("%w: invalid filter #%d: numeric filter with non-numeric system object attribute", + objectcore.ErrInvalidSearchQuery, i) + } + + // TODO: big math takes less code but inefficient + _, ok := new(big.Int).SetString(filters[i].Value(), 10) + if !ok { + return res, fmt.Errorf("%w: invalid filter #%d: numeric filter with non-decimal value", + objectcore.ErrInvalidSearchQuery, i) + } + } + + switch hdr { case object.FilterContainerID: // support deprecated field err := res.cnr.DecodeString(filters[i].Value()) if err != nil { @@ -643,3 +801,7 @@ func filterExpired(tx *bbolt.Tx, epoch uint64, cID cid.ID, oIDs []oid.ID) ([]oid return res, nil } + +func isNumericOp(op object.SearchMatchType) bool { + return op == object.MatchNumGT || op == object.MatchNumGE || op == object.MatchNumLT || op == object.MatchNumLE +} diff --git a/pkg/local_object_storage/metabase/select_test.go b/pkg/local_object_storage/metabase/select_test.go index df2b058ca9..5327238be8 100644 --- a/pkg/local_object_storage/metabase/select_test.go +++ b/pkg/local_object_storage/metabase/select_test.go @@ -3,6 +3,8 @@ package meta_test import ( "crypto/sha256" "encoding/hex" + "math" + "math/big" "strconv" "testing" @@ -864,3 +866,212 @@ func metaSelect(db *meta.DB, cnr cidSDK.ID, fs objectSDK.SearchFilters) ([]oid.A res, err := db.Select(prm) return res.AddressList(), err } + +func numQuery(key string, op objectSDK.SearchMatchType, val string) (res objectSDK.SearchFilters) { + res.AddFilter(key, val, op) + return +} + +var allNumOps = []objectSDK.SearchMatchType{ + objectSDK.MatchNumGT, + objectSDK.MatchNumGE, + objectSDK.MatchNumLT, + objectSDK.MatchNumLE, +} + +func TestNumericSelect(t *testing.T) { + db := newDB(t) + cnr := cidtest.ID() + + for i, testAttr := range []struct { + key string + set func(obj *objectSDK.Object, val uint64) + }{ + {key: "$Object:creationEpoch", set: func(obj *objectSDK.Object, val uint64) { obj.SetCreationEpoch(val) }}, + {key: "$Object:payloadLength", set: func(obj *objectSDK.Object, val uint64) { obj.SetPayloadSize(val) }}, + {key: "any_user_attr", set: func(obj *objectSDK.Object, val uint64) { + addAttribute(obj, "any_user_attr", strconv.FormatUint(val, 10)) + }}, + } { + cnr := cidtest.ID() + obj1 := generateObjectWithCID(t, cnr) + addr1 := object.AddressOf(obj1) + obj2 := generateObjectWithCID(t, cnr) + addr2 := object.AddressOf(obj2) + + const smallNum = 10 + const midNum = 11 + const bigNum = 12 + + testAttr.set(obj1, smallNum) + testAttr.set(obj2, bigNum) + + require.NoError(t, putBig(db, obj1), i) + require.NoError(t, putBig(db, obj2), i) + + for j, testCase := range []struct { + op objectSDK.SearchMatchType + num uint64 + exp []oid.Address + }{ + {op: objectSDK.MatchNumLT, num: smallNum - 1, exp: nil}, + {op: objectSDK.MatchNumLT, num: smallNum, exp: nil}, + {op: objectSDK.MatchNumLT, num: midNum, exp: []oid.Address{addr1}}, + {op: objectSDK.MatchNumLT, num: bigNum, exp: []oid.Address{addr1}}, + {op: objectSDK.MatchNumLT, num: bigNum + 1, exp: []oid.Address{addr1, addr2}}, + + {op: objectSDK.MatchNumLE, num: smallNum - 1, exp: nil}, + {op: objectSDK.MatchNumLE, num: smallNum, exp: []oid.Address{addr1}}, + {op: objectSDK.MatchNumLE, num: midNum, exp: []oid.Address{addr1}}, + {op: objectSDK.MatchNumLE, num: bigNum, exp: []oid.Address{addr1, addr2}}, + {op: objectSDK.MatchNumLE, num: bigNum + 1, exp: []oid.Address{addr1, addr2}}, + + {op: objectSDK.MatchNumGE, num: smallNum - 1, exp: []oid.Address{addr1, addr2}}, + {op: objectSDK.MatchNumGE, num: smallNum, exp: []oid.Address{addr1, addr2}}, + {op: objectSDK.MatchNumGE, num: midNum, exp: []oid.Address{addr2}}, + {op: objectSDK.MatchNumGE, num: bigNum, exp: []oid.Address{addr2}}, + {op: objectSDK.MatchNumGE, num: bigNum + 1, exp: nil}, + + {op: objectSDK.MatchNumGT, num: smallNum - 1, exp: []oid.Address{addr1, addr2}}, + {op: objectSDK.MatchNumGT, num: smallNum, exp: []oid.Address{addr2}}, + {op: objectSDK.MatchNumGT, num: midNum, exp: []oid.Address{addr2}}, + {op: objectSDK.MatchNumGT, num: bigNum, exp: nil}, + {op: objectSDK.MatchNumGT, num: bigNum + 1, exp: nil}, + } { + query := numQuery(testAttr.key, testCase.op, strconv.FormatUint(testCase.num, 10)) + + res, err := metaSelect(db, cnr, query) + require.NoError(t, err, [2]any{i, j}) + require.Len(t, res, len(testCase.exp), [2]any{i, j}) + + for i := range testCase.exp { + require.Contains(t, res, testCase.exp[i], [2]any{i, j}) + } + } + } + + // negative values + cnrNeg := cidtest.ID() + obj = generateObjectWithCID(t, cnrNeg) + const objVal = int64(-10) + const negAttr = "negative_attr" + addAttribute(obj, negAttr, strconv.FormatInt(objVal, 10)) + addr := object.AddressOf(obj) + + require.NoError(t, putBig(db, obj)) + + val := objVal - 1 + testSelect(t, db, cnrNeg, numQuery(negAttr, objectSDK.MatchNumGT, strconv.FormatInt(val, 10)), addr) + testSelect(t, db, cnrNeg, numQuery(negAttr, objectSDK.MatchNumGE, strconv.FormatInt(val, 10)), addr) + testSelect(t, db, cnrNeg, numQuery(negAttr, objectSDK.MatchNumLT, strconv.FormatInt(val, 10))) + testSelect(t, db, cnrNeg, numQuery(negAttr, objectSDK.MatchNumLE, strconv.FormatInt(val, 10))) + val = objVal + testSelect(t, db, cnrNeg, numQuery(negAttr, objectSDK.MatchNumGT, strconv.FormatInt(val, 10))) + testSelect(t, db, cnrNeg, numQuery(negAttr, objectSDK.MatchNumGE, strconv.FormatInt(val, 10)), addr) + testSelect(t, db, cnrNeg, numQuery(negAttr, objectSDK.MatchNumLT, strconv.FormatInt(val, 10))) + testSelect(t, db, cnrNeg, numQuery(negAttr, objectSDK.MatchNumLE, strconv.FormatInt(val, 10)), addr) + val = objVal + 1 + testSelect(t, db, cnrNeg, numQuery(negAttr, objectSDK.MatchNumGT, strconv.FormatInt(val, 10))) + testSelect(t, db, cnrNeg, numQuery(negAttr, objectSDK.MatchNumGE, strconv.FormatInt(val, 10))) + testSelect(t, db, cnrNeg, numQuery(negAttr, objectSDK.MatchNumLT, strconv.FormatInt(val, 10)), addr) + testSelect(t, db, cnrNeg, numQuery(negAttr, objectSDK.MatchNumLE, strconv.FormatInt(val, 10)), addr) + + // uint64 overflow + for _, attr := range []string{ + "$Object:creationEpoch", + "$Object:payloadLength", + } { + b := new(big.Int).SetUint64(math.MaxUint64) + b.Add(b, big.NewInt(1)) + filterVal := b.String() + + testSelect(t, db, cnrNeg, numQuery(attr, objectSDK.MatchNumGT, filterVal)) + testSelect(t, db, cnrNeg, numQuery(attr, objectSDK.MatchNumGE, filterVal)) + testSelect(t, db, cnrNeg, numQuery(attr, objectSDK.MatchNumLT, filterVal), addr) + testSelect(t, db, cnrNeg, numQuery(attr, objectSDK.MatchNumLE, filterVal), addr) + } + + t.Run("invalid filtering value format", func(t *testing.T) { + for _, op := range allNumOps { + query := numQuery("any_key", op, "1.0") + + _, err := metaSelect(db, cnr, query) + require.ErrorIs(t, err, object.ErrInvalidSearchQuery, op) + require.ErrorContains(t, err, "numeric filter with non-decimal value", op) + } + }) + + t.Run("non-numeric system attributes", func(t *testing.T) { + for _, attr := range []string{ + "$Object:version", + "$Object:objectID", + "$Object:containerID", + "$Object:ownerID", + "$Object:payloadHash", + "$Object:objectType", + "$Object:homomorphicHash", + "$Object:split.parent", + "$Object:split.splitID", + "$Object:ROOT", + "$Object:PHY", + } { + for _, op := range allNumOps { + query := numQuery(attr, op, "123") + + _, err := metaSelect(db, cnr, query) + require.ErrorIs(t, err, object.ErrInvalidSearchQuery, [2]any{attr, op}) + require.ErrorContains(t, err, "numeric filter with non-numeric system object attribute", [2]any{attr, op}) + } + } + }) + + t.Run("unsupported system attribute", func(t *testing.T) { + for _, op := range allNumOps { + query := numQuery("$Object:definitelyUnknown", op, "123") + + res, err := metaSelect(db, cnr, query) + require.NoError(t, err, op) + require.Empty(t, res, op) + } + }) + + t.Run("non-numeric user attributes", func(t *testing.T) { + // unlike system attributes, the user cannot always guarantee that the filtered + // attribute will be correct in all objects. This should not cause a denial of + // service, so such objects are simply not included in the result + obj1 := generateObjectWithCID(t, cnr) + addr1 := object.AddressOf(obj1) + obj2 := generateObjectWithCID(t, cnr) + addr2 := object.AddressOf(obj2) + + const attr = "any_user_attribute" + addAttribute(obj1, attr, "123") + addAttribute(obj2, attr, "any_non_num") + + require.NoError(t, putBig(db, obj1)) + require.NoError(t, putBig(db, obj2)) + + var query objectSDK.SearchFilters + + testSelect(t, db, cnr, query, + addr1, addr2) + + for _, testCase := range []struct { + op objectSDK.SearchMatchType + val string + }{ + {op: objectSDK.MatchNumGT, val: "122"}, + {op: objectSDK.MatchNumGE, val: "122"}, + {op: objectSDK.MatchNumGE, val: "123"}, + {op: objectSDK.MatchNumLE, val: "123"}, + {op: objectSDK.MatchNumLE, val: "124"}, + {op: objectSDK.MatchNumLT, val: "124"}, + } { + query = numQuery(attr, testCase.op, testCase.val) + + res, err := metaSelect(db, cnr, query) + require.NoError(t, err, testCase) + require.Equal(t, []oid.Address{addr1}, res, testCase) + } + }) +} diff --git a/pkg/local_object_storage/shard/select.go b/pkg/local_object_storage/shard/select.go index 9408ede278..95a29646f9 100644 --- a/pkg/local_object_storage/shard/select.go +++ b/pkg/local_object_storage/shard/select.go @@ -39,6 +39,8 @@ func (r SelectRes) AddressList() []oid.Address { // // Returns any error encountered that // did not allow to completely select the objects. +// +// Returns [object.ErrInvalidSearchQuery] if specified query is invalid. func (s *Shard) Select(prm SelectPrm) (SelectRes, error) { s.m.RLock() defer s.m.RUnlock() diff --git a/pkg/services/object/search/search.go b/pkg/services/object/search/search.go index 5e1249a1b9..8990104401 100644 --- a/pkg/services/object/search/search.go +++ b/pkg/services/object/search/search.go @@ -2,12 +2,27 @@ package searchsvc import ( "context" + "fmt" + "math/big" + objectcore "github.com/nspcc-dev/neofs-node/pkg/core/object" + "github.com/nspcc-dev/neofs-sdk-go/object" "go.uber.org/zap" ) // Search serves a request to select the objects. +// +// Only creation epoch, payload size, user attributes and unknown system ones +// are allowed with numeric operators. Values of numeric filters must be base-10 +// integers. +// +// Returns [object.ErrInvalidSearchQuery] if specified query is invalid. func (s *Service) Search(ctx context.Context, prm Prm) error { + err := verifyQuery(prm) + if err != nil { + return err + } + exec := &execCtx{ svc: s, ctx: ctx, @@ -48,3 +63,20 @@ func (exec *execCtx) analyzeStatus(execCnr bool) { exec.analyzeStatus(false) } } + +func verifyQuery(prm Prm) error { + for i := range prm.filters { + //nolint:exhaustive + switch prm.filters[i].Operation() { + case object.MatchNumGT, object.MatchNumGE, object.MatchNumLT, object.MatchNumLE: + // TODO: big math takes less code but inefficient + _, ok := new(big.Int).SetString(prm.filters[i].Value(), 10) + if !ok { + return fmt.Errorf("%w: invalid filter #%d: numeric filter with non-decimal value", + objectcore.ErrInvalidSearchQuery, i) + } + } + } + + return nil +} diff --git a/pkg/services/object/search/search_test.go b/pkg/services/object/search/search_test.go index 4d6333d46a..d9337d2f67 100644 --- a/pkg/services/object/search/search_test.go +++ b/pkg/services/object/search/search_test.go @@ -11,6 +11,7 @@ import ( clientcore "github.com/nspcc-dev/neofs-node/pkg/core/client" netmapcore "github.com/nspcc-dev/neofs-node/pkg/core/netmap" + "github.com/nspcc-dev/neofs-node/pkg/core/object" "github.com/nspcc-dev/neofs-node/pkg/network" "github.com/nspcc-dev/neofs-node/pkg/services/object/util" "github.com/nspcc-dev/neofs-node/pkg/services/object_manager/placement" @@ -19,6 +20,7 @@ import ( cid "github.com/nspcc-dev/neofs-sdk-go/container/id" cidtest "github.com/nspcc-dev/neofs-sdk-go/container/id/test" "github.com/nspcc-dev/neofs-sdk-go/netmap" + objectsdk "github.com/nspcc-dev/neofs-sdk-go/object" oid "github.com/nspcc-dev/neofs-sdk-go/object/id" "github.com/stretchr/testify/require" ) @@ -425,3 +427,20 @@ func TestGetFromPastEpoch(t *testing.T) { require.NoError(t, err) assertContains(ids11, ids12, ids21, ids22) } + +func TestNumericFilters(t *testing.T) { + var s Service + ctx := context.Background() + var prm Prm + + for _, op := range []objectsdk.SearchMatchType{} { + var query objectsdk.SearchFilters + query.AddFilter("any_key", "1.0", op) + + prm.WithSearchFilters(query) + + err := s.Search(ctx, prm) + require.ErrorIs(t, err, object.ErrInvalidSearchQuery, op) + require.ErrorContains(t, err, "numeric filter with non-decimal value", op) + } +}