From 603159515f1fa2b9aa0c4f37c6726803b7c39ded Mon Sep 17 00:00:00 2001 From: "Wladimir J. van der Laan" Date: Thu, 6 Jul 2017 15:01:27 +0200 Subject: [PATCH] test: Check RPC argument mapping Parse the dispatch tables from the server implementation files, and the conversion table from the client. Perform the following consistency checks: - Arguments defined in conversion table, must be present in dispatch table. If not, it was probably forgotten to add them to the dispatch table, and they will not work. - Arguments defined in conversion table must have the same names as in the dispatch table. If not, they will not work. - All aliases for an argument must either be present in the conversion table, or not. Anything in between means an oversight and some aliases won't work. Any of these results in an error. It also performs a consistency check to see if the same named argument is sometimes converted, and sometimes not. E.g. one RPC call might have a 'verbose' argument that is converted, another RPC call might have one that is not converted. This is not necessarily wrong, but points at a possible error (as well as makes the API harder to memorize) - so it is emitted as a warning (could upgrade this to error). --- .travis.yml | 1 + contrib/devtools/check-rpc-mappings.py | 165 +++++++++++++++++++++++++++++++++ 2 files changed, 166 insertions(+) create mode 100755 contrib/devtools/check-rpc-mappings.py diff --git a/.travis.yml b/.travis.yml index fcf4b082f1b..5373d111fb0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -46,6 +46,7 @@ before_script: - if [ "$TRAVIS_EVENT_TYPE" = "pull_request" ]; then contrib/devtools/commit-script-check.sh $TRAVIS_COMMIT_RANGE; fi - unset CC; unset CXX - if [ "$CHECK_DOC" = 1 ]; then contrib/devtools/check-doc.py; fi + - if [ "$CHECK_DOC" = 1 ]; then contrib/devtools/check-rpc-mappings.py .; fi - mkdir -p depends/SDKs depends/sdk-sources - if [ -n "$OSX_SDK" -a ! -f depends/sdk-sources/MacOSX${OSX_SDK}.sdk.tar.gz ]; then curl --location --fail $SDK_URL/MacOSX${OSX_SDK}.sdk.tar.gz -o depends/sdk-sources/MacOSX${OSX_SDK}.sdk.tar.gz; fi - if [ -n "$OSX_SDK" -a -f depends/sdk-sources/MacOSX${OSX_SDK}.sdk.tar.gz ]; then tar -C depends/SDKs -xf depends/sdk-sources/MacOSX${OSX_SDK}.sdk.tar.gz; fi diff --git a/contrib/devtools/check-rpc-mappings.py b/contrib/devtools/check-rpc-mappings.py new file mode 100755 index 00000000000..3e01bee6fd0 --- /dev/null +++ b/contrib/devtools/check-rpc-mappings.py @@ -0,0 +1,165 @@ +#!/usr/bin/python3 +''' +Check RPC argument consistency. +''' +import sys, os, re +from collections import defaultdict + +# Source files (relative to root) to scan for dispatch tables +SOURCES = [ +"src/rpc/server.cpp", +"src/rpc/blockchain.cpp", +"src/rpc/mining.cpp", +"src/rpc/misc.cpp", +"src/rpc/net.cpp", +"src/rpc/rawtransaction.cpp", +"src/wallet/rpcwallet.cpp", +] +# Source file (relative to root) containing conversion mapping +SOURCE_CLIENT = 'src/rpc/client.cpp' +# Argument names that should be ignored in consistency checks +IGNORE_DUMMY_ARGS = {'dummy','arg0','arg1','arg2','arg3','arg4','arg5','arg6','arg7','arg8','arg9'} + +def parse_string(s): + assert(s[0] == '"') + assert(s[-1] == '"') + return s[1:-1] + +def make_string(s): + return '"%s"' % s + +class RPCCommand: + def __init__(self, name, args): + self.name = name + self.args = args + +class RPCArgument: + def __init__(self, names, idx): + self.names = names + self.idx = idx + +def process_commands(fname): + ''' + Find and parse dispatch table in implementation file `fname`. + ''' + cmds = [] + in_rpcs = False + with open(fname, "r") as f: + for line in f: + line = line.rstrip() + if not in_rpcs: + if re.match("static const CRPCCommand .*\[\] =", line): + in_rpcs = True + else: + if line.startswith('};'): + in_rpcs = False + elif '{' in line and '"' in line: + m = re.search('{ *("[^"]*"), *("[^"]*"), *&([^,]*), *(true|false), *{([^}]*)} *},', line) + if not m: + print('No match: %s' % line) + exit(1) + name = parse_string(m.group(2)) + args_str = m.group(5).strip() + if args_str: + args = [RPCArgument(parse_string(x.strip()).split('|'), idx) for idx,x in enumerate(args_str.split(','))] + else: + args = [] + cmds.append(RPCCommand(name, args)) + assert(not in_rpcs) # If this assertion is hit, something went wrong with parsing the C++ file: update the regexps + return cmds + +def process_mapping(fname): + ''' + Find and parse conversion table in implementation file `fname`. + ''' + cmds = [] + in_rpcs = False + with open(fname, "r") as f: + for line in f: + line = line.rstrip() + if not in_rpcs: + if line == 'static const CRPCConvertParam vRPCConvertParams[] =': + in_rpcs = True + else: + if line.startswith('};'): + in_rpcs = False + elif '{' in line and '"' in line: + m = re.search('{ *("[^"]*"), *([0-9]+) *, *("[^"]*") *},', line) + if not m: + print('No match: %s' % line) + exit(1) + name = parse_string(m.group(1)) + idx = int(m.group(2)) + argname = parse_string(m.group(3)) + cmds.append((name, idx, argname)) + assert(not in_rpcs) + return cmds + + +def main(): + root = sys.argv[1] + + # Get all commands from dispatch tables + cmds = [] + for fname in SOURCES: + cmds += process_commands(os.path.join(root, fname)) + + cmds_by_name = {} + for cmd in cmds: + cmds_by_name[cmd.name] = cmd + + # Get current convert mapping for client + client = SOURCE_CLIENT + mapping_order = process_mapping(os.path.join(root, client)) + mapping = set(mapping_order) + + print('* Checking consistency between dispatch tables and vRPCConvertParams') + + # Check mapping consistency + errors = 0 + for (cmdname, argidx, argname) in mapping: + try: + rargnames = cmds_by_name[cmdname].args[argidx].names + except IndexError: + print('ERROR: %s argument %i (named %s in vRPCConvertParams) is not defined in dispatch table' % (cmdname, argidx, argname)) + errors += 1 + continue + if argname not in rargnames: + print('ERROR: %s argument %i is named %s in vRPCConvertParams but %s in dispatch table' % (cmdname, argidx, argname, rargnames), file=sys.stderr) + errors += 1 + + # Check for conflicts in vRPCConvertParams conversion + # All aliases for an argument must either be present in the + # conversion table, or not. Anything in between means an oversight + # and some aliases won't work. + for cmd in cmds: + for arg in cmd.args: + convert = [((cmd.name,arg.idx,argname) in mapping) for argname in arg.names] + if any(convert) != all(convert): + print('ERROR: %s argument %s has conflicts in vRPCConvertParams conversion specifier %s' % (cmd.name, arg.names, convert)) + errors += 1 + arg.convert = all(convert) + + # Check for conversion difference by argument name. + # It is preferable for API consistency that arguments with the same name + # have the same conversion, so bin by argument name. + all_methods_by_argname = defaultdict(list) + converts_by_argname = defaultdict(list) + for cmd in cmds: + for arg in cmd.args: + for argname in arg.names: + all_methods_by_argname[argname].append(cmd.name) + converts_by_argname[argname].append(arg.convert) + + for argname,convert in converts_by_argname.items(): + if all(convert) != any(convert): + if argname in IGNORE_DUMMY_ARGS: # these are testing or dummy, don't warn for them + continue + print('WARNING: conversion mismatch for argument named %s (%s)' % + (argname, list(zip(all_methods_by_argname[argname], converts_by_argname[argname])))) + + exit(errors > 0) + +if __name__ == '__main__': + main() +