From d7da59b4a33c9547da68431a22ee256e50a3df62 Mon Sep 17 00:00:00 2001
From: Nikita Zheleztsov <n.zheleztsov@proton.me>
Date: Wed, 20 Jul 2022 12:00:04 +0300
Subject: [PATCH] net.box: add checking of options

Currently net.box's methods doesn't check types of passed options.
This can lead to Lua's internal errors, which are not self-explaining.

Let's add this functionality and raise errors with meaningful messages
in case of incorrect options.

Closes #6063
Closes #6530

NO_DOC=Not a visible change
---
 .../unreleased/gh-6530-netbox-opts-check.md   |   5 +
 src/box/lua/net_box.lua                       |  95 +++++++--
 src/box/lua/schema.lua                        |  19 +-
 .../gh_6530_net_box_opts_check_test.lua       | 184 ++++++++++++++++++
 test/box-luatest/net_box_test.lua             |   2 +-
 5 files changed, 288 insertions(+), 17 deletions(-)
 create mode 100644 changelogs/unreleased/gh-6530-netbox-opts-check.md
 create mode 100644 test/box-luatest/gh_6530_net_box_opts_check_test.lua

diff --git a/changelogs/unreleased/gh-6530-netbox-opts-check.md b/changelogs/unreleased/gh-6530-netbox-opts-check.md
new file mode 100644
index 0000000000..72d46a09e7
--- /dev/null
+++ b/changelogs/unreleased/gh-6530-netbox-opts-check.md
@@ -0,0 +1,5 @@
+## bugfix/core
+
+* Added type checking for options in net.box's remote queries and
+  connect method. Now graceful errors are thrown in case of incorrect
+  options (gh-6063, gh-6530).
diff --git a/src/box/lua/net_box.lua b/src/box/lua/net_box.lua
index 5092956df6..570108fa53 100644
--- a/src/box/lua/net_box.lua
+++ b/src/box/lua/net_box.lua
@@ -16,6 +16,9 @@ local check_select_opts   = box.internal.check_select_opts
 local check_index_arg     = box.internal.check_index_arg
 local check_space_arg     = box.internal.check_space_arg
 local check_primary_index = box.internal.check_primary_index
+local check_param_table   = box.internal.check_param_table
+
+local ibuf_t = ffi.typeof('struct ibuf')
 
 local TIMEOUT_INFINITY = 500 * 365 * 86400
 
@@ -55,6 +58,38 @@ local IPROTO_FEATURE_NAMES = {
     [3]     = 'watchers',
 }
 
+local REQUEST_OPTION_TYPES = {
+    is_async    = "boolean",
+    iterator    = "string",
+    limit       = "number",
+    offset      = "number",
+    on_push     = "function",
+    on_push_ctx = "any",
+    return_raw  = "boolean",
+    skip_header = "boolean",
+    timeout     = "number",
+    buffer = function(buf)
+       if not ffi.istype(ibuf_t, buf) then
+           return false, "struct ibuf"
+       end
+       return true
+    end,
+}
+
+local CONNECT_OPTION_TYPES = {
+    user                        = "string",
+    password                    = "string",
+    wait_connected              = "number, boolean",
+    reconnect_after             = "number",
+    call_16                     = "boolean",
+    console                     = "boolean",
+    connect_timeout             = "number",
+    fetch_schema                = "boolean",
+    required_protocol_version   = "number",
+    required_protocol_features  = "table",
+    _disable_graceful_shutdown  = "boolean",
+}
+
 -- Given an array of IPROTO feature ids, returns a map {feature_name: bool}.
 local function iproto_features_resolve(feature_ids)
     local features = {}
@@ -436,7 +471,9 @@ end
 -- @retval Net.box object.
 --
 local function connect(...)
-    return new_sm(parse_connect_params(...))
+    local uri, opts = parse_connect_params(...)
+    check_param_table(opts, CONNECT_OPTION_TYPES)
+    return new_sm(uri, opts)
 end
 
 local function check_remote_arg(remote, method)
@@ -469,6 +506,7 @@ end
 
 local function stream_begin(stream, txn_opts, netbox_opts)
     check_remote_arg(stream, 'begin')
+    check_param_table(netbox_opts, REQUEST_OPTION_TYPES)
     local timeout
     local txn_isolation
     if txn_opts then
@@ -494,6 +532,7 @@ end
 
 local function stream_commit(stream, opts)
     check_remote_arg(stream, 'commit')
+    check_param_table(opts, REQUEST_OPTION_TYPES)
     local res = stream:_request(M_COMMIT, opts, nil, stream._stream_id)
     if opts and opts.is_async then
         return res
@@ -502,6 +541,7 @@ end
 
 local function stream_rollback(stream, opts)
     check_remote_arg(stream, 'rollback')
+    check_param_table(opts, REQUEST_OPTION_TYPES)
     local res = stream:_request(M_ROLLBACK, opts, nil, stream._stream_id)
     if opts and opts.is_async then
         return res
@@ -707,7 +747,12 @@ function remote_methods:wait_connected(timeout)
     return self:wait_state('active', timeout)
 end
 
-function remote_methods:_request(method, opts, format, stream_id, ...)
+--
+-- Make a request, which throws an exception in case of critical errors
+-- (e.g. wrong API usage) and returns nil,err if there's connection related
+-- issues.
+--
+function remote_methods:_request_impl(method, opts, format, stream_id, ...)
     local transport = self._transport
     local on_push, on_push_ctx, buffer, skip_header, return_raw, deadline
     -- Extract options, set defaults, check if the request is
@@ -720,14 +765,10 @@ function remote_methods:_request(method, opts, format, stream_id, ...)
             if opts.on_push or opts.on_push_ctx then
                 error('To handle pushes in an async request use future:pairs()')
             end
-            local res, err =
-                transport:perform_async_request(buffer, skip_header, return_raw,
-                                                table.insert, {}, format,
-                                                stream_id, method, ...)
-            if err then
-                box.error(err)
-            end
-            return res
+            return transport:perform_async_request(buffer, skip_header,
+                                                   return_raw, table.insert,
+                                                   {}, format, stream_id,
+                                                   method, ...)
         end
         if opts.timeout then
             deadline = fiber_clock() + opts.timeout
@@ -749,9 +790,6 @@ function remote_methods:_request(method, opts, format, stream_id, ...)
     local res, err = transport:perform_request(timeout, buffer, skip_header,
                                                return_raw, on_push, on_push_ctx,
                                                format, stream_id, method, ...)
-    if err then
-        box.error(err)
-    end
     -- Try to wait until a schema is reloaded if needed.
     -- Regardless of reloading result, the main response is
     -- returned, since it does not depend on any schema things.
@@ -759,10 +797,19 @@ function remote_methods:_request(method, opts, format, stream_id, ...)
         timeout = deadline and max(0, deadline - fiber_clock())
         self:wait_state('active', timeout)
     end
+    return res, err
+end
+
+function remote_methods:_request(method, opts, format, stream_id, ...)
+    local res, err = self:_request_impl(method, opts, format, stream_id, ...)
+    if err then
+        box.error(err)
+    end
     return res
 end
 
 function remote_methods:_inject(str, opts)
+    check_param_table(opts, REQUEST_OPTION_TYPES)
     return self:_request(M_INJECT, opts, nil, nil, str)
 end
 
@@ -772,7 +819,12 @@ end
 
 function remote_methods:ping(opts)
     check_remote_arg(self, 'ping')
-    return (pcall(self._request, self, M_PING, opts, nil, self._stream_id))
+    check_param_table(opts, REQUEST_OPTION_TYPES)
+    if opts and opts.is_async then
+        error("conn:ping() doesn't support `is_async` argument")
+    end
+    local _, err = self:_request_impl(M_PING, opts, nil, self._stream_id)
+    return err == nil
 end
 
 function remote_methods:reload_schema()
@@ -790,6 +842,7 @@ end
 function remote_methods:call(func_name, args, opts)
     check_remote_arg(self, 'call')
     check_call_args(args)
+    check_param_table(opts, REQUEST_OPTION_TYPES)
     args = args or {}
     local res = self:_request(M_CALL_17, opts, nil, self._stream_id,
                               tostring(func_name), args)
@@ -809,6 +862,7 @@ end
 function remote_methods:eval(code, args, opts)
     check_remote_arg(self, 'eval')
     check_eval_args(args)
+    check_param_table(opts, REQUEST_OPTION_TYPES)
     args = args or {}
     local res = self:_request(M_EVAL, opts, nil, self._stream_id, code, args)
     if type(res) ~= 'table' or opts and opts.is_async then
@@ -822,6 +876,7 @@ function remote_methods:execute(query, parameters, sql_opts, netbox_opts)
     if sql_opts ~= nil then
         box.error(box.error.UNSUPPORTED, "execute", "options")
     end
+    check_param_table(netbox_opts, REQUEST_OPTION_TYPES)
     return self:_request(M_EXECUTE, netbox_opts, nil, self._stream_id,
                          query, parameters or {}, sql_opts or {})
 end
@@ -834,6 +889,7 @@ function remote_methods:prepare(query, parameters, sql_opts, netbox_opts) -- lua
     if sql_opts ~= nil then
         box.error(box.error.UNSUPPORTED, "prepare", "options")
     end
+    check_param_table(netbox_opts, REQUEST_OPTION_TYPES)
     return self:_request(M_PREPARE, netbox_opts, nil, self._stream_id, query)
 end
 
@@ -846,6 +902,7 @@ function remote_methods:unprepare(query, parameters, sql_opts, netbox_opts)
     if sql_opts ~= nil then
         box.error(box.error.UNSUPPORTED, "unprepare", "options")
     end
+    check_param_table(netbox_opts, REQUEST_OPTION_TYPES)
     return self:_request(M_UNPREPARE, netbox_opts, nil, self._stream_id,
                          query, parameters or {}, sql_opts or {})
 end
@@ -984,12 +1041,14 @@ space_metatable = function(remote)
 
     function methods:insert(tuple, opts)
         check_space_arg(self, 'insert')
+        check_param_table(opts, REQUEST_OPTION_TYPES)
         return remote:_request(M_INSERT, opts, self._format_cdata,
                                self._stream_id, self.id, tuple)
     end
 
     function methods:replace(tuple, opts)
         check_space_arg(self, 'replace')
+        check_param_table(opts, REQUEST_OPTION_TYPES)
         return remote:_request(M_REPLACE, opts, self._format_cdata,
                                self._stream_id, self.id, tuple)
     end
@@ -1011,6 +1070,7 @@ space_metatable = function(remote)
 
     function methods:upsert(key, oplist, opts)
         check_space_arg(self, 'upsert')
+        check_param_table(opts, REQUEST_OPTION_TYPES)
         return nothing_or_data(remote:_request(M_UPSERT, opts, nil,
                                                self._stream_id, self.id,
                                                key, oplist))
@@ -1037,6 +1097,7 @@ index_metatable = function(remote)
 
     function methods:select(key, opts)
         check_index_arg(self, 'select')
+        check_param_table(opts, REQUEST_OPTION_TYPES)
         local key_is_nil = (key == nil or
                             (type(key) == 'table' and #key == 0))
         local iterator, offset, limit = check_select_opts(opts, key_is_nil)
@@ -1047,6 +1108,7 @@ index_metatable = function(remote)
 
     function methods:get(key, opts)
         check_index_arg(self, 'get')
+        check_param_table(opts, REQUEST_OPTION_TYPES)
         if opts and opts.buffer then
             error("index:get() doesn't support `buffer` argument")
         end
@@ -1059,6 +1121,7 @@ index_metatable = function(remote)
 
     function methods:min(key, opts)
         check_index_arg(self, 'min')
+        check_param_table(opts, REQUEST_OPTION_TYPES)
         if opts and opts.buffer then
             error("index:min() doesn't support `buffer` argument")
         end
@@ -1071,6 +1134,7 @@ index_metatable = function(remote)
 
     function methods:max(key, opts)
         check_index_arg(self, 'max')
+        check_param_table(opts, REQUEST_OPTION_TYPES)
         if opts and opts.buffer then
             error("index:max() doesn't support `buffer` argument")
         end
@@ -1083,6 +1147,7 @@ index_metatable = function(remote)
 
     function methods:count(key, opts)
         check_index_arg(self, 'count')
+        check_param_table(opts, REQUEST_OPTION_TYPES)
         if opts and opts.buffer then
             error("index:count() doesn't support `buffer` argument")
         end
@@ -1094,6 +1159,7 @@ index_metatable = function(remote)
 
     function methods:delete(key, opts)
         check_index_arg(self, 'delete')
+        check_param_table(opts, REQUEST_OPTION_TYPES)
         return nothing_or_data(remote:_request(M_DELETE, opts,
                                                self.space._format_cdata,
                                                self._stream_id, self.space.id,
@@ -1102,6 +1168,7 @@ index_metatable = function(remote)
 
     function methods:update(key, oplist, opts)
         check_index_arg(self, 'update')
+        check_param_table(opts, REQUEST_OPTION_TYPES)
         return nothing_or_data(remote:_request(M_UPDATE, opts,
                                                self.space._format_cdata,
                                                self._stream_id, self.space.id,
diff --git a/src/box/lua/schema.lua b/src/box/lua/schema.lua
index 38e881c2ac..1c658d996e 100644
--- a/src/box/lua/schema.lua
+++ b/src/box/lua/schema.lua
@@ -248,7 +248,9 @@ end
  @param table - table with parameters
  @param template - table with expected types of expected parameters
   type could be comma separated string with lua types (number, string etc),
-  or 'any' if any type is allowed
+  or 'any' if any type is allowed. Instead of this string, function, which will
+  be used to check if the parameter is correct, can be used too. It should
+  accept option as an argument and return either true or false + expected_type.
  The function checks following:
  1)that parameters table is a table (or nil)
  2)all keys in parameters are present in template
@@ -259,7 +261,13 @@ end
  @example
  check_param_table(options, { user = 'string',
                               port = 'string, number',
-                              data = 'any'} )
+                              data = 'any',
+                              addr = function(opt)
+                                if not ffi.istype(addr_t, buf) then
+                                    return false, "struct addr"
+                                end
+                                return true
+                              end} )
 --]]
 local function check_param_table(table, template)
     if table == nil then
@@ -277,6 +285,13 @@ local function check_param_table(table, template)
         if template[k] == nil then
             box.error(box.error.ILLEGAL_PARAMS,
                       "unexpected option '" .. k .. "'")
+        elseif type(template[k]) == 'function' then
+            local res, expected_type = template[k](v)
+            if not res then
+                box.error(box.error.ILLEGAL_PARAMS,
+                          "options parameter '" .. k ..
+                          "' should be of type " .. expected_type)
+            end
         elseif template[k] == 'any' then -- luacheck: ignore
             -- any type is ok
         elseif (string.find(template[k], ',') == nil) then
diff --git a/test/box-luatest/gh_6530_net_box_opts_check_test.lua b/test/box-luatest/gh_6530_net_box_opts_check_test.lua
new file mode 100644
index 0000000000..084f80237d
--- /dev/null
+++ b/test/box-luatest/gh_6530_net_box_opts_check_test.lua
@@ -0,0 +1,184 @@
+local server = require('test.luatest_helpers.server')
+local buffer = require('buffer')
+local net = require('net.box')
+local t = require('luatest')
+local g = t.group()
+
+g.before_all = function()
+    g.server = server:new{alias = 'default'}
+    g.server:start()
+    g.server:exec(function()
+        local s = box.schema.space.create('test')
+        s:format({
+                  {name = 'id',   type = 'unsigned'},
+                  {name = 'data', type = 'string'},
+                 })
+        s:create_index('primary', {parts = {'id'}})
+        box.schema.user.grant('guest', 'read,write', 'space', 'test')
+    end)
+
+    g.conn = net.connect(g.server.net_box_uri)
+end
+
+g.after_all = function()
+    g.server:exec(function()
+        if box.space.test then
+            box.space.test:drop()
+        end
+    end)
+
+    g.conn:close()
+    g.server:drop()
+end
+
+g.test_netbox_connect_opts = function()
+    t.assert_error_msg_contains("unexpected option 'some_opt'", function()
+        net.connect(g.server.net_box_uri, {some_opt = 1})
+    end)
+    t.assert_error_msg_contains("parameter 'user' should be of type string", function()
+        net.connect(g.server.net_box_uri, {user = true})
+    end)
+    t.assert_error_msg_contains("parameter 'password' should be of type string", function()
+        net.connect(g.server.net_box_uri, {password = true})
+    end)
+    t.assert_error_msg_contains("parameter 'wait_connected' should be one of types: number, boolean", function()
+        net.connect(g.server.net_box_uri, {wait_connected = 'string'})
+    end)
+    t.assert_error_msg_contains("parameter 'reconnect_after' should be of type number", function()
+        net.connect(g.server.net_box_uri, {reconnect_after = true})
+    end)
+    t.assert_error_msg_contains("parameter 'call_16' should be of type boolean", function()
+        net.connect(g.server.net_box_uri, {call_16 = 1})
+    end)
+    t.assert_error_msg_contains("parameter 'console' should be of type boolean", function()
+        net.connect(g.server.net_box_uri, {console = 1})
+    end)
+    t.assert_error_msg_contains("parameter 'connect_timeout' should be of type number", function()
+        net.connect(g.server.net_box_uri, {connect_timeout = 'string'})
+    end)
+    t.assert_error_msg_contains("parameter 'required_protocol_version' should be of type number", function()
+        net.connect(g.server.net_box_uri, {required_protocol_version = 'string'})
+    end)
+    t.assert_error_msg_contains("parameter 'required_protocol_features' should be of type table", function()
+        net.connect(g.server.net_box_uri, {required_protocol_features = 1})
+    end)
+end
+
+g.test_netbox_select_opts = function()
+    t.assert_error_msg_contains("options should be a table", function()
+        g.conn.space.test:select({1}, 123)
+    end)
+    t.assert_error_msg_contains("parameter 'timeout' should be of type number", function()
+        g.conn.space.test:select({1}, {timeout = true})
+    end)
+    t.assert_error_msg_contains("parameter 'buffer' should be of type struct ibuf", function()
+        g.conn.space.test:select({1}, {buffer = true})
+    end)
+    t.assert_error_msg_contains("parameter 'is_async' should be of type boolean", function()
+        g.conn.space.test:select({1}, {is_async = 1})
+    end)
+    t.assert_error_msg_contains("parameter 'on_push' should be of type function", function()
+        g.conn.space.test:select({1}, {on_push = 1})
+    end)
+    t.assert_error_msg_contains("parameter 'return_raw' should be of type boolean", function()
+        g.conn.space.test:select({1}, {return_raw = 1})
+    end)
+    t.assert_error_msg_contains("in an async request use future:pairs()", function()
+        g.conn.space.test:select({1}, {is_async = true, on_push_ctx = true })
+    end)
+end
+
+g.test_netbox_all_methods_opts = function()
+    t.assert_error_msg_contains("options should be a table", function()
+        g.conn:ping(123)
+    end)
+
+    t.assert_error_msg_contains("options should be a table", function()
+        g.conn.space.test:get({1}, 123)
+    end)
+    t.assert_error_msg_contains("options should be a table", function()
+        g.conn.space.test:insert({1, 'A'}, 123)
+    end)
+    t.assert_error_msg_contains("options should be a table", function()
+        g.conn.space.test:replace({1, 'A'}, 123)
+    end)
+    t.assert_error_msg_contains("options should be a table", function()
+        g.conn.space.test:update(1, {'!', 1, 'A'}, 123)
+    end)
+    t.assert_error_msg_contains("options should be a table", function()
+        g.conn.space.test:upsert(1, {'!', 1, 'A'}, 123)
+    end)
+    t.assert_error_msg_contains("options should be a table", function()
+        g.conn.space.test:delete({1}, 123)
+    end)
+
+    t.assert_error_msg_contains("options should be a table", function()
+        g.conn.space.test.index.primary:select({1}, 123)
+    end)
+    t.assert_error_msg_contains("options should be a table", function()
+        g.conn.space.test.index.primary:get({1}, 123)
+    end)
+    t.assert_error_msg_contains("options should be a table", function()
+        g.conn.space.test.index.primary:min({1}, 123)
+    end)
+    t.assert_error_msg_contains("options should be a table", function()
+        g.conn.space.test.index.primary:max({1}, 123)
+    end)
+    t.assert_error_msg_contains("options should be a table", function()
+        g.conn.space.test.index.primary:count({1}, 123)
+    end)
+    t.assert_error_msg_contains("options should be a table", function()
+        g.conn.space.test.index.primary:delete({1}, 123)
+    end)
+    t.assert_error_msg_contains("options should be a table", function()
+        g.conn.space.test.index.primary:update(1, {'!', 1, 'A'}, 123)
+    end)
+
+    t.assert_error_msg_contains("options should be a table", function()
+        g.conn:eval('return {nil,5}', {}, 123)
+    end)
+    t.assert_error_msg_contains("options should be a table", function()
+        g.conn:call('f2',{1,'B'}, 123)
+    end)
+
+    local stream = g.conn:new_stream()
+    t.assert_error_msg_contains("options should be a table", function()
+        stream:begin({}, 123)
+    end)
+    t.assert_error_msg_contains("options should be a table", function()
+        stream:commit(123)
+    end)
+    t.assert_error_msg_contains("options should be a table", function()
+        stream:rollback(123)
+    end)
+
+    t.assert_error_msg_contains("options should be a table", function()
+        g.conn:execute('', {}, nil, 123)
+    end)
+    t.assert_error_msg_contains("options should be a table", function()
+        g.conn:prepare('', {}, nil, 123)
+    end)
+    t.assert_error_msg_contains("options should be a table", function()
+        g.conn:unprepare(1, {}, nil, 123)
+    end)
+end
+
+g.test_netbox_not_supported_opts = function()
+    t.assert_error_msg_contains("doesn't support", function()
+        g.conn:ping({is_async = true})
+    end)
+
+    local ibuf = buffer.ibuf()
+    t.assert_error_msg_contains("doesn't support", function()
+        g.conn.space.test.index.primary:get({1}, {buffer = ibuf})
+    end)
+    t.assert_error_msg_contains("doesn't support", function()
+        g.conn.space.test.index.primary:min({1}, {buffer = ibuf})
+    end)
+    t.assert_error_msg_contains("doesn't support", function()
+        g.conn.space.test.index.primary:max({1}, {buffer = ibuf})
+    end)
+    t.assert_error_msg_contains("doesn't support", function()
+        g.conn.space.test.index.primary:count({1}, {buffer = ibuf})
+    end)
+end
diff --git a/test/box-luatest/net_box_test.lua b/test/box-luatest/net_box_test.lua
index 023b45ce09..d15f503f5d 100644
--- a/test/box-luatest/net_box_test.lua
+++ b/test/box-luatest/net_box_test.lua
@@ -307,7 +307,7 @@ g.test_box_error = function()
     t.assert_error_msg_equals(
         "Illegal parameters, Netbox text protocol support was dropped, "..
         "please use require('console').connect() instead",
-        net.connect, g.server.net_box_uri, {console = 123})
+        net.connect, g.server.net_box_uri, {console = true})
     local c = net.connect(g.server.net_box_uri)
 
     t.assert_error_msg_equals(
-- 
GitLab