diff --git a/resolver/dns.go b/resolver/dns.go index 72258ad1..f723eabb 100644 --- a/resolver/dns.go +++ b/resolver/dns.go @@ -1,41 +1,35 @@ package resolver import ( - "fmt" "net" cid "github.com/nspcc-dev/neofs-sdk-go/container/id" ) -const ( - testnetDomain = ".containers.testnet.fs.neo.org" - mainnetDomain = ".containers.fs.neo.org" -) +// DNS looks up NeoFS names using system DNS. +// +// See also net package. +type DNS struct{} -// ResolveContainerTestnet request txt record name + '.containers.testnet.fs.neo.org' to default dns server. -func ResolveContainerTestnet(name string) (*cid.ID, error) { - return ResolveContainerDomainName(name + testnetDomain) -} - -// ResolveContainerMainnet request txt record name + '.containers.fs.neo.org' to default dns server. -func ResolveContainerMainnet(name string) (*cid.ID, error) { - return ResolveContainerDomainName(name + mainnetDomain) -} - -// ResolveContainerDomainName trys to resolve container domain name to container ID using system dns server. -func ResolveContainerDomainName(domain string) (*cid.ID, error) { - results, err := net.LookupTXT(domain) +// ResolveContainerName looks up for DNS TXT records for the given domain name +// and returns the first one which represents valid container ID in a string format. +// Otherwise, returns an error. +// +// See also net.LookupTXT. +func (x *DNS) ResolveContainerName(name string) (*cid.ID, error) { + records, err := net.LookupTXT(name) if err != nil { return nil, err } - cnrID := cid.New() - for _, res := range results { - if err = cnrID.Parse(res); err != nil { - continue + var id cid.ID + + for i := range records { + err = id.Parse(records[i]) + if err == nil { + return &id, nil } - return cnrID, nil } - return nil, fmt.Errorf("not found") + return nil, errNotFound } diff --git a/resolver/doc.go b/resolver/doc.go new file mode 100644 index 00000000..0a469543 --- /dev/null +++ b/resolver/doc.go @@ -0,0 +1,22 @@ +/* +Package resolver provides functionality of NeoFS name system. + +DNS type is designed to resolve NeoFS-related names using Domain Name System: + const containerName = "some-container" + + var dns DNS + + containerID, err := dns.ResolveContainerName(containerName) + // ... + +NNS type is designed to resolve NeoFS-related names using Neo Name Service: + var nns NNS + + err := nns.Dial(nnsServerAddress) + // ... + + containerID, err := nns.ResolveContainerName(containerName) + // ... + +*/ +package resolver diff --git a/resolver/nns.go b/resolver/nns.go index 7c982064..fea728d9 100644 --- a/resolver/nns.go +++ b/resolver/nns.go @@ -1,10 +1,12 @@ package resolver import ( + "context" + "errors" "fmt" nns "github.com/nspcc-dev/neo-go/examples/nft-nd-nns" - "github.com/nspcc-dev/neo-go/pkg/rpc/client" + neoclient "github.com/nspcc-dev/neo-go/pkg/rpc/client" "github.com/nspcc-dev/neo-go/pkg/rpc/response/result" "github.com/nspcc-dev/neo-go/pkg/smartcontract" "github.com/nspcc-dev/neo-go/pkg/util" @@ -13,109 +15,103 @@ import ( cid "github.com/nspcc-dev/neofs-sdk-go/container/id" ) -type NNSResolver interface { - // ResolveContainerName get record for domain name+'.container' from NNS contract. - ResolveContainerName(name string) (*cid.ID, error) +// NNS looks up NeoFS names using Neo Name Service. +// +// Instances are created with a variable declaration. Before work, the connection +// to the NNS server MUST BE established using Dial method. +type NNS struct { + nnsContract util.Uint160 + + // neoclient.Client interface wrapper, needed for testing + neoClient interface { + invoke(contract util.Uint160, method string, prm []smartcontract.Parameter) (*result.Invoke, error) + } } -type nnsResolver struct { - rpc *client.Client - nnsHash util.Uint160 +// client is a core implementation of internal NNS.neoClient which is used by NNS.Dial. +type client neoclient.Client + +func (x *client) invoke(contract util.Uint160, method string, prm []smartcontract.Parameter) (*result.Invoke, error) { + return (*neoclient.Client)(x).InvokeFunction(contract, method, prm, nil) } -const ( - resolve = "resolve" - containerDomain = ".container" -) - -// NewNNSResolver creates resolver that can get records from NNS contract. -func NewNNSResolver(rpc *client.Client) (NNSResolver, error) { - nnsContract, err := rpc.GetContractStateByID(1) +// Dial connects to the address of the NNS server. If fails, the instance +// SHOULD NOT be used. +func (n *NNS) Dial(address string) error { + cli, err := neoclient.New(context.Background(), address, neoclient.Options{}) if err != nil { - return nil, err + return fmt.Errorf("create neo client: %w", err) } - return &nnsResolver{ - rpc: rpc, - nnsHash: nnsContract.Hash, - }, nil + if err = cli.Init(); err != nil { + return fmt.Errorf("initialize neo client: %w", err) + } + + nnsContract, err := cli.GetContractStateByID(1) + if err != nil { + return fmt.Errorf("get NNS contract state: %w", err) + } + + n.neoClient = (*client)(cli) + n.nnsContract = nnsContract.Hash + + return nil } -func (n *nnsResolver) ResolveContainerName(name string) (*cid.ID, error) { - res, err := n.rpc.InvokeFunction(n.nnsHash, resolve, []smartcontract.Parameter{ +// ResolveContainerName looks up for NNS TXT records for the given container name +// by calling `resolve` method of NNS contract. Returns the first record which represents +// valid container ID in a string format. Otherwise, returns an error. +// +// ResolveContainerName MUST NOT be called before successful Dial. +// +// See also https://docs.neo.org/docs/en-us/reference/nns.html. +func (n *NNS) ResolveContainerName(name string) (*cid.ID, error) { + res, err := n.neoClient.invoke(n.nnsContract, "resolve", []smartcontract.Parameter{ { Type: smartcontract.StringType, - Value: name + containerDomain, + Value: name + ".container", }, { Type: smartcontract.IntegerType, Value: int64(nns.TXT), }, - }, nil) + }) if err != nil { - return nil, err - } - if err = getInvocationError(res); err != nil { - return nil, err + return nil, fmt.Errorf("invoke NNS contract: %w", err) } - arr, err := getArrString(res.Stack) - if err != nil { - return nil, err + if res.State != vm.HaltState.String() { + return nil, fmt.Errorf("NNS contract fault exception: %s", res.FaultException) + } else if len(res.Stack) == 0 { + return nil, errors.New("empty stack in invocation result") } - cnrID := cid.New() - for _, rec := range arr { - if err = cnrID.Parse(rec); err != nil { - continue + itemArr, err := res.Stack[len(res.Stack)-1].Convert(stackitem.ArrayT) // top stack element is last in the array + if err != nil { + return nil, fmt.Errorf("convert stack item to %s", stackitem.ArrayT) + } + + if _, ok := itemArr.(stackitem.Null); !ok { + arr, ok := itemArr.Value().([]stackitem.Item) + if !ok { + // unexpected for types from stackitem package + return nil, errors.New("invalid cast to stack item slice") } - return cnrID, nil - } - return nil, fmt.Errorf("not found") -} + var id cid.ID -func getArrString(st []stackitem.Item) ([]string, error) { - array, err := getArray(st) - if err != nil { - return nil, err - } + for i := range arr { + bs, err := arr[i].TryBytes() + if err != nil { + return nil, fmt.Errorf("convert array item to byte slice: %w", err) + } - res := make([]string, len(array)) - for i, item := range array { - bs, err := item.TryBytes() - if err != nil { - return nil, err + err = id.Parse(string(bs)) + if err == nil { + return &id, nil + } } - res[i] = string(bs) } - return res, nil -} - -func getArray(st []stackitem.Item) ([]stackitem.Item, error) { - index := len(st) - 1 // top stack element is last in the array - arr, err := st[index].Convert(stackitem.ArrayT) - if err != nil { - return nil, err - } - if _, ok := arr.(stackitem.Null); ok { - return nil, nil - } - - iterator, ok := arr.Value().([]stackitem.Item) - if !ok { - return nil, fmt.Errorf("bad conversion") - } - return iterator, nil -} - -func getInvocationError(result *result.Invoke) error { - if result.State != vm.HaltState.String() { - return fmt.Errorf("invocation failed: %s", result.FaultException) - } - if len(result.Stack) == 0 { - return fmt.Errorf("result stack is empty") - } - return nil + return nil, errNotFound } diff --git a/resolver/nns_test.go b/resolver/nns_test.go new file mode 100644 index 00000000..d95f1609 --- /dev/null +++ b/resolver/nns_test.go @@ -0,0 +1,157 @@ +package resolver + +import ( + "errors" + "fmt" + "math/big" + "math/rand" + "strings" + "testing" + + nns "github.com/nspcc-dev/neo-go/examples/nft-nd-nns" + "github.com/nspcc-dev/neo-go/pkg/rpc/response/result" + "github.com/nspcc-dev/neo-go/pkg/smartcontract" + "github.com/nspcc-dev/neo-go/pkg/util" + "github.com/nspcc-dev/neo-go/pkg/vm" + "github.com/nspcc-dev/neo-go/pkg/vm/stackitem" + cidtest "github.com/nspcc-dev/neofs-sdk-go/container/id/test" + "github.com/stretchr/testify/require" +) + +// testNeoClient represents test Neo client which checks invocation arguments +// and returns predefined result. +type testNeoClient struct { + t *testing.T + + expectedContract util.Uint160 + + res result.Invoke + + err error +} + +func (x *testNeoClient) invoke(contract util.Uint160, method string, prm []smartcontract.Parameter) (*result.Invoke, error) { + require.Equal(x.t, x.expectedContract, contract) + require.Equal(x.t, "resolve", method) + require.Len(x.t, prm, 2) + require.Equal(x.t, smartcontract.StringType, prm[0].Type) + require.Equal(x.t, smartcontract.IntegerType, prm[1].Type) + require.EqualValues(x.t, nns.TXT, prm[1].Value) + + val, ok := prm[0].Value.(string) + require.True(x.t, ok) + require.True(x.t, strings.HasSuffix(val, ".container")) + require.NotEmpty(x.t, strings.TrimSuffix(val, ".container")) + + return &x.res, x.err +} + +// implements test stackitem.Item which is obviously incorrect: +// it returns itself on Convert(stackitem.ArrayT), but returns integer from Value. +type brokenArrayStackItem struct { + stackitem.Item +} + +func (x brokenArrayStackItem) Value() interface{} { + return 1 +} + +func (x brokenArrayStackItem) Convert(t stackitem.Type) (stackitem.Item, error) { + if t != stackitem.ArrayT { + panic(fmt.Sprintf("unexpected stack item type %s", t)) + } + + return x, nil +} + +func TestNNS_ResolveContainerName(t *testing.T) { + const testContainerName = "some_container" + + var nnsContract util.Uint160 + + rand.Read(nnsContract[:]) + + testC := &testNeoClient{ + t: t, + expectedContract: nnsContract, + } + + n := NNS{ + nnsContract: nnsContract, + neoClient: testC, + } + + t.Run("invocation failure", func(t *testing.T) { + testC.err = errors.New("invoke err") + + _, err := n.ResolveContainerName(testContainerName) + require.Error(t, err) + }) + + testC.err = nil + + t.Run("fault exception", func(t *testing.T) { + _, err := n.ResolveContainerName(testContainerName) + require.Error(t, err) + }) + + testC.res.State = vm.HaltState.String() + + t.Run("empty stack", func(t *testing.T) { + _, err := n.ResolveContainerName(testContainerName) + require.Error(t, err) + }) + + testC.res.Stack = make([]stackitem.Item, 1) + + t.Run("non-array last stack item", func(t *testing.T) { + testC.res.Stack[0] = stackitem.NewBigInteger(big.NewInt(11)) + + _, err := n.ResolveContainerName(testContainerName) + require.Error(t, err) + }) + + t.Run("null array", func(t *testing.T) { + testC.res.Stack[0] = stackitem.Null{} + + _, err := n.ResolveContainerName(testContainerName) + require.Error(t, err) + }) + + t.Run("array stack item with non-slice value", func(t *testing.T) { + testC.res.Stack[0] = brokenArrayStackItem{} + + _, err := n.ResolveContainerName(testContainerName) + require.Error(t, err) + }) + + arr := make([]stackitem.Item, 2) + testC.res.Stack[0] = stackitem.NewArray(arr) + + t.Run("non-bytes array element", func(t *testing.T) { + arr[0] = stackitem.NewArray(nil) + + _, err := n.ResolveContainerName(testContainerName) + require.Error(t, err) + }) + + arr[0] = stackitem.NewByteArray([]byte("some byte array 1")) + + t.Run("non-container array elements", func(t *testing.T) { + arr[1] = stackitem.NewByteArray([]byte("some byte array 2")) + + _, err := n.ResolveContainerName(testContainerName) + require.Error(t, err) + }) + + t.Run("with container array element", func(t *testing.T) { + id := cidtest.ID() + + arr[1] = stackitem.NewByteArray([]byte(id.String())) + + res, err := n.ResolveContainerName(testContainerName) + require.NoError(t, err) + + require.Equal(t, id, res) + }) +} diff --git a/resolver/util.go b/resolver/util.go new file mode 100644 index 00000000..15ba0898 --- /dev/null +++ b/resolver/util.go @@ -0,0 +1,5 @@ +package resolver + +import "errors" + +var errNotFound = errors.New("record not found")