1# -*- coding: utf-8 -*- 2import glob 3import os 4 5from .lexer import lex 6from .analyzer import analyze, enter_block_ctx 7from .errors import NgxParserDirectiveError 8 9# map of external / third-party directives to a parse function 10EXTERNAL_PARSERS = {} 11 12 13# TODO: raise special errors for invalid "if" args 14def _prepare_if_args(stmt): 15 """Removes parentheses from an "if" directive's arguments""" 16 args = stmt['args'] 17 if args and args[0].startswith('(') and args[-1].endswith(')'): 18 args[0] = args[0][1:].lstrip() 19 args[-1] = args[-1][:-1].rstrip() 20 start = int(not args[0]) 21 end = len(args) - int(not args[-1]) 22 args[:] = args[start:end] 23 24 25def parse(filename, onerror=None, catch_errors=True, ignore=(), single=False, 26 comments=False, strict=False, combine=False, check_ctx=True, 27 check_args=True): 28 """ 29 Parses an nginx config file and returns a nested dict payload 30 31 :param filename: string contianing the name of the config file to parse 32 :param onerror: function that determines what's saved in "callback" 33 :param catch_errors: bool; if False, parse stops after first error 34 :param ignore: list or tuple of directives to exclude from the payload 35 :param combine: bool; if True, use includes to create a single config obj 36 :param single: bool; if True, including from other files doesn't happen 37 :param comments: bool; if True, including comments to json payload 38 :param strict: bool; if True, unrecognized directives raise errors 39 :param check_ctx: bool; if True, runs context analysis on directives 40 :param check_args: bool; if True, runs arg count analysis on directives 41 :returns: a payload that describes the parsed nginx config 42 """ 43 config_dir = os.path.dirname(filename) 44 45 payload = { 46 'status': 'ok', 47 'errors': [], 48 'config': [], 49 } 50 51 # start with the main nginx config file/context 52 includes = [(filename, ())] # stores (filename, config context) tuples 53 included = {filename: 0} # stores {filename: array index} map 54 55 def _handle_error(parsing, e): 56 """Adds representaions of an error to the payload""" 57 file = parsing['file'] 58 error = str(e) 59 line = getattr(e, 'lineno', None) 60 61 parsing_error = {'error': error, 'line': line} 62 payload_error = {'file': file, 'error': error, 'line': line} 63 if onerror is not None: 64 payload_error['callback'] = onerror(e) 65 66 parsing['status'] = 'failed' 67 parsing['errors'].append(parsing_error) 68 69 payload['status'] = 'failed' 70 payload['errors'].append(payload_error) 71 72 def _parse(parsing, tokens, ctx=(), consume=False): 73 """Recursively parses nginx config contexts""" 74 fname = parsing['file'] 75 parsed = [] 76 77 # parse recursively by pulling from a flat stream of tokens 78 for token, lineno, quoted in tokens: 79 comments_in_args = [] 80 81 # we are parsing a block, so break if it's closing 82 if token == '}' and not quoted: 83 break 84 85 # if we are consuming, then just continue until end of context 86 if consume: 87 # if we find a block inside this context, consume it too 88 if token == '{' and not quoted: 89 _parse(parsing, tokens, consume=True) 90 continue 91 92 # the first token should always(?) be an nginx directive 93 directive = token 94 95 if combine: 96 stmt = { 97 'file': fname, 98 'directive': directive, 99 'line': lineno, 100 'args': [] 101 } 102 else: 103 stmt = { 104 'directive': directive, 105 'line': lineno, 106 'args': [] 107 } 108 109 # if token is comment 110 if directive.startswith('#') and not quoted: 111 if comments: 112 stmt['directive'] = '#' 113 stmt['comment'] = token[1:] 114 parsed.append(stmt) 115 continue 116 117 # TODO: add external parser checking and handling 118 119 # parse arguments by reading tokens 120 args = stmt['args'] 121 token, __, quoted = next(tokens) # disregard line numbers of args 122 while token not in ('{', ';', '}') or quoted: 123 if token.startswith('#') and not quoted: 124 comments_in_args.append(token[1:]) 125 else: 126 stmt['args'].append(token) 127 128 token, __, quoted = next(tokens) 129 130 # consume the directive if it is ignored and move on 131 if stmt['directive'] in ignore: 132 # if this directive was a block consume it too 133 if token == '{' and not quoted: 134 _parse(parsing, tokens, consume=True) 135 continue 136 137 # prepare arguments 138 if stmt['directive'] == 'if': 139 _prepare_if_args(stmt) 140 141 try: 142 # raise errors if this statement is invalid 143 analyze( 144 fname=fname, stmt=stmt, term=token, ctx=ctx, strict=strict, 145 check_ctx=check_ctx, check_args=check_args 146 ) 147 except NgxParserDirectiveError as e: 148 if catch_errors: 149 _handle_error(parsing, e) 150 151 # if it was a block but shouldn't have been then consume 152 if e.strerror.endswith(' is not terminated by ";"'): 153 if token != '}' and not quoted: 154 _parse(parsing, tokens, consume=True) 155 else: 156 break 157 158 # keep on parsin' 159 continue 160 else: 161 raise e 162 163 # add "includes" to the payload if this is an include statement 164 if not single and stmt['directive'] == 'include': 165 pattern = args[0] 166 if not os.path.isabs(args[0]): 167 pattern = os.path.join(config_dir, args[0]) 168 169 stmt['includes'] = [] 170 171 # get names of all included files 172 if glob.has_magic(pattern): 173 fnames = glob.glob(pattern) 174 fnames.sort() 175 else: 176 try: 177 # if the file pattern was explicit, nginx will check 178 # that the included file can be opened and read 179 open(str(pattern)).close() 180 fnames = [pattern] 181 except Exception as e: 182 fnames = [] 183 e.lineno = stmt['line'] 184 if catch_errors: 185 _handle_error(parsing, e) 186 else: 187 raise e 188 189 for fname in fnames: 190 # the included set keeps files from being parsed twice 191 # TODO: handle files included from multiple contexts 192 if fname not in included: 193 included[fname] = len(includes) 194 includes.append((fname, ctx)) 195 index = included[fname] 196 stmt['includes'].append(index) 197 198 # if this statement terminated with '{' then it is a block 199 if token == '{' and not quoted: 200 inner = enter_block_ctx(stmt, ctx) # get context for block 201 stmt['block'] = _parse(parsing, tokens, ctx=inner) 202 203 parsed.append(stmt) 204 205 # add all comments found inside args after stmt is added 206 for comment in comments_in_args: 207 comment_stmt = { 208 'directive': '#', 209 'line': stmt['line'], 210 'args': [], 211 'comment': comment 212 } 213 parsed.append(comment_stmt) 214 215 return parsed 216 217 # the includes list grows as "include" directives are found in _parse 218 for fname, ctx in includes: 219 tokens = lex(fname) 220 parsing = { 221 'file': fname, 222 'status': 'ok', 223 'errors': [], 224 'parsed': [] 225 } 226 try: 227 parsing['parsed'] = _parse(parsing, tokens, ctx=ctx) 228 except Exception as e: 229 _handle_error(parsing, e) 230 231 payload['config'].append(parsing) 232 233 if combine: 234 return _combine_parsed_configs(payload) 235 else: 236 return payload 237 238 239def _combine_parsed_configs(old_payload): 240 """ 241 Combines config files into one by using include directives. 242 243 :param old_payload: payload that's normally returned by parse() 244 :return: the new combined payload 245 """ 246 old_configs = old_payload['config'] 247 248 def _perform_includes(block): 249 for stmt in block: 250 if 'block' in stmt: 251 stmt['block'] = list(_perform_includes(stmt['block'])) 252 if 'includes' in stmt: 253 for index in stmt['includes']: 254 config = old_configs[index]['parsed'] 255 for stmt in _perform_includes(config): 256 yield stmt 257 else: 258 yield stmt # do not yield include stmt itself 259 260 combined_config = { 261 'file': old_configs[0]['file'], 262 'status': 'ok', 263 'errors': [], 264 'parsed': [] 265 } 266 267 for config in old_configs: 268 combined_config['errors'] += config.get('errors', []) 269 if config.get('status', 'ok') == 'failed': 270 combined_config['status'] = 'failed' 271 272 first_config = old_configs[0]['parsed'] 273 combined_config['parsed'] += _perform_includes(first_config) 274 275 combined_payload = { 276 'status': old_payload.get('status', 'ok'), 277 'errors': old_payload.get('errors', []), 278 'config': [combined_config] 279 } 280 return combined_payload 281 282 283def register_external_parser(parser, directives): 284 """ 285 :param parser: parser function 286 :param directives: list of directive strings 287 :return: 288 """ 289 for directive in directives: 290 EXTERNAL_PARSERS[directive] = parser 291