aboutsummaryrefslogtreecommitdiff
path: root/test/lint/check-rpc-mappings.py
blob: 33e49bac13be6fbe5d13a2eb7d221354246861c8 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
#!/usr/bin/env python3
# Copyright (c) 2017-2018 The Bitcoin Core developers
# Distributed under the MIT software license, see the accompanying
# file COPYING or http://www.opensource.org/licenses/mit-license.php.
"""Check RPC argument consistency."""

from collections import defaultdict
import os
import re
import sys

# 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'}

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
        self.convert = False

def parse_string(s):
    assert s[0] == '"'
    assert s[-1] == '"'
    return s[1:-1]

def process_commands(fname):
    """Find and parse dispatch table in implementation file `fname`."""
    cmds = []
    in_rpcs = False
    with open(fname, "r", encoding="utf8") 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('{ *("[^"]*"), *("[^"]*"), *&([^,]*), *{([^}]*)} *},', line)
                    assert m, 'No match to table expression: %s' % line
                    name = parse_string(m.group(2))
                    args_str = m.group(4).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 and cmds, "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", encoding="utf8") 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)
                    assert m, 'No match to table expression: %s' % line
                    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 and cmds
    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 = set(process_mapping(os.path.join(root, client)))

    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]))))

    sys.exit(errors > 0)


if __name__ == '__main__':
    main()