1############################################################################ 2# Copyright (C) Internet Systems Consortium, Inc. ("ISC") 3# 4# SPDX-License-Identifier: MPL-2.0 5# 6# This Source Code Form is subject to the terms of the Mozilla Public 7# License, v. 2.0. If a copy of the MPL was not distributed with this 8# file, you can obtain one at https://mozilla.org/MPL/2.0/. 9# 10# See the COPYRIGHT file distributed with this work for additional 11# information regarding copyright ownership. 12############################################################################ 13 14""" 15Utility to check ISC config grammar consistency. It detects statement names 16which use different grammar depending on position in the configuration file. 17E.g. "max-zone-ttl" in dnssec-policy uses '<duration>' 18vs. '( unlimited | <duration> ) used in options. 19""" 20 21from collections import namedtuple 22from itertools import groupby 23import fileinput 24 25import parsegrammar 26 27 28def statement2block(grammar, path): 29 """Return mapping statement name to "path" where it is allowed. 30 _top is placeholder name for the namesless topmost context. 31 32 E.g. { 33 'options: [('_top',)], 34 'server': [('_top', 'view'), ('_top',)], 35 'rate-limit': [('_top', 'options'), ('_top', 'view')], 36 'slip': [('_top', 'options', 'rate-limit'), ('_top', 'view', 'rate-limit')] 37 } 38 """ 39 key2place = {} 40 41 for key in grammar: 42 assert not key.startswith("_") 43 key2place.setdefault(key, []).append(tuple(path)) 44 if "_mapbody" in grammar[key]: 45 nested2block = statement2block(grammar[key]["_mapbody"], path + [key]) 46 # merge to uppermost output dictionary 47 for nested_key, nested_path in nested2block.items(): 48 key2place.setdefault(nested_key, []).extend(nested_path) 49 return key2place 50 51 52def get_statement_grammar(grammar, path, name): 53 """Descend into grammar dict using provided path 54 and return final dict found there. 55 56 Intermediate steps into "_mapbody" subkeys are done automatically. 57 """ 58 assert path[0] == "_top" 59 path = list(path) + [name] 60 for step in path[1:]: 61 if "_mapbody" in grammar: 62 grammar = grammar["_mapbody"] 63 grammar = grammar[step] 64 return grammar 65 66 67Statement = namedtuple("Statement", ["path", "name", "subgrammar"]) 68 69 70def groupby_grammar(statements): 71 """ 72 Return groups of Statement tuples with identical grammars and flags. 73 See itertools.groupby. 74 """ 75 76 def keyfunc(statement): 77 return sorted(statement.subgrammar.items()) 78 79 groups = [] 80 statements = sorted(statements, key=keyfunc) 81 for _key, group in groupby(statements, keyfunc): 82 groups.append(list(group)) # Store group iterator as a list 83 return groups 84 85 86def diff_statements(whole_grammar, places): 87 """ 88 Return map {statement name: [groups of [Statement]s with identical grammar]. 89 """ 90 out = {} 91 for statement_name, paths in places.items(): 92 grammars = [] 93 for path in paths: 94 statement_grammar = get_statement_grammar( 95 whole_grammar, path, statement_name 96 ) 97 grammars.append(Statement(path, statement_name, statement_grammar)) 98 groups = groupby_grammar(grammars) 99 out[statement_name] = groups 100 return out 101 102 103def pformat_grammar(node, level=1): 104 """Pretty print a given grammar node in the same way as cfg_test would""" 105 106 def sortkey(item): 107 """Treat 'type' specially and always put it first, for zone types""" 108 key, _ = item 109 if key == "type": 110 return "" 111 return key 112 113 if "_grammar" in node: # no nesting 114 assert "_id" not in node 115 assert "_mapbody" not in node 116 out = node["_grammar"] + ";" 117 if "_flags" in node: 118 out += " // " + ", ".join(node["_flags"]) 119 return out + "\n" 120 121 # a nested map 122 out = "" 123 indent = level * "\t" 124 if not node.get("_ignore_this_level"): 125 if "_id" in node: 126 out += node["_id"] + " " 127 out += "{\n" 128 129 for key, subnode in sorted(node["_mapbody"].items(), key=sortkey): 130 if not subnode.get("_ignore_this_level"): 131 out += f"{indent}{subnode.get('_pprint_name', key)}" 132 inner_grammar = pformat_grammar(node["_mapbody"][key], level=level + 1) 133 else: # act as if we were not in a map 134 inner_grammar = pformat_grammar(node["_mapbody"][key], level=level) 135 if inner_grammar[0] != ";": # we _did_ find some arguments 136 out += " " 137 out += inner_grammar 138 139 if not node.get("_ignore_this_level"): 140 out += indent[:-1] + "};" # unindent the closing bracket 141 if "_flags" in node: 142 out += " // " + ", ".join(node["_flags"]) 143 return out + "\n" 144 145 146def main(): 147 """ 148 Ingest output from cfg_test --grammar and print out statements which use 149 different grammar in different contexts. 150 """ 151 with fileinput.input() as filein: 152 grammar = parsegrammar.parse_mapbody(filein) 153 places = statement2block(grammar, ["_top"]) 154 155 for statementname, groups in diff_statements(grammar, places).items(): 156 if len(groups) > 1: 157 print(f'statement "{statementname}" is inconsistent across blocks') 158 for group in groups: 159 print( 160 "- path:", ", ".join(" -> ".join(variant.path) for variant in group) 161 ) 162 print(" ", pformat_grammar(group[0].subgrammar, level=1)) 163 print() 164 165 166if __name__ == "__main__": 167 main() 168