# Copyright 2020 The ChromiumOS Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

"""A parser for the Minijail policy file."""

import collections
import itertools
import os.path
import re


try:
    import bpf
except ImportError:
    from minijail import bpf


# Representations of numbers with different radix (base) in C.
HEX_REGEX = r"-?0[xX][0-9a-fA-F]+"
OCTAL_REGEX = r"-?0[0-7]+"
DECIMAL_REGEX = r"-?[0-9]+"


Token = collections.namedtuple(
    "Token", ["type", "value", "filename", "line", "line_number", "column"]
)

# A regex that can tokenize a Minijail policy file line.
_TOKEN_SPECIFICATION = (
    ("COMMENT", r"#.*$"),
    ("WHITESPACE", r"\s+"),
    ("CONTINUATION", r"\\$"),
    ("DEFAULT", r"@default\b"),
    ("INCLUDE", r"@include\b"),
    ("FREQUENCY", r"@frequency\b"),
    ("DENYLIST", r"@denylist$"),
    ("PATH", r"(?:\.)?/\S+"),
    ("NUMERIC_CONSTANT", f"{HEX_REGEX}|{OCTAL_REGEX}|{DECIMAL_REGEX}"),
    ("COLON", r":"),
    ("SEMICOLON", r";"),
    ("COMMA", r","),
    ("BITWISE_COMPLEMENT", r"~"),
    ("LPAREN", r"\("),
    ("RPAREN", r"\)"),
    ("LBRACE", r"\{"),
    ("RBRACE", r"\}"),
    ("RBRACKET", r"\]"),
    ("LBRACKET", r"\["),
    ("OR", r"\|\|"),
    ("AND", r"&&"),
    ("BITWISE_OR", r"\|"),
    ("OP", r"&|\bin\b|==|!=|<=|<|>=|>"),
    ("EQUAL", r"="),
    ("ARGUMENT", r"\barg[0-9]+\b"),
    ("RETURN", r"\breturn\b"),
    (
        "ACTION",
        r"\ballow\b|\bkill-process\b|\bkill-thread\b|\bkill\b|\btrap\b|"
        r"\btrace\b|\blog\b|\buser-notify\b",
    ),
    ("IDENTIFIER", r"[a-zA-Z_][a-zA-Z_0-9-@]*"),
)
_TOKEN_RE = re.compile(
    "|".join(r"(?P<%s>%s)" % pair for pair in _TOKEN_SPECIFICATION)
)


class ParseException(Exception):
    """An exception that is raised when parsing fails."""

    # pylint: disable=too-many-arguments
    def __init__(
        self, message, filename, *, line="", line_number=1, token=None
    ):
        if token:
            line = token.line
            line_number = token.line_number
            column = token.column
            length = len(token.value)
        else:
            column = len(line)
            length = 1

        message = ("%s(%d:%d): %s") % (
            filename,
            line_number,
            column + 1,
            message,
        )
        message += "\n    %s" % line
        message += "\n    %s%s" % (" " * column, "^" * length)
        super().__init__(message)


class ParserState:
    """Stores the state of the Parser to provide better diagnostics."""

    def __init__(self, filename):
        self._filename = filename
        self._line = ""
        self._line_number = 0

    @property
    def filename(self):
        """Return the name of the file being processed."""
        return self._filename

    @property
    def line(self):
        """Return the current line being processed."""
        return self._line

    @property
    def line_number(self):
        """Return the current line number being processed."""
        return self._line_number

    def error(self, message, token=None):
        """Raise a ParserException with the provided message."""
        raise ParseException(
            message,
            self.filename,
            line=self._line,
            line_number=self._line_number,
            token=token,
        )

    def tokenize(self, lines):
        """Return a list of tokens for the current line."""
        tokens = []

        for line_number, line in enumerate(lines):
            self._line_number = line_number + 1
            self._line = line.rstrip("\r\n")

            last_end = 0
            for token in _TOKEN_RE.finditer(self._line):
                if token.start() != last_end:
                    self.error(
                        "invalid token",
                        token=Token(
                            "INVALID",
                            self._line[last_end : token.start()],
                            self.filename,
                            self._line,
                            self._line_number,
                            last_end,
                        ),
                    )
                last_end = token.end()

                # Omit whitespace and comments now to avoid sprinkling this
                # logic elsewhere.
                if token.lastgroup in ("WHITESPACE", "COMMENT", "CONTINUATION"):
                    continue
                tokens.append(
                    Token(
                        token.lastgroup,
                        token.group(),
                        self.filename,
                        self._line,
                        self._line_number,
                        token.start(),
                    )
                )
            if last_end != len(self._line):
                self.error(
                    "invalid token",
                    token=Token(
                        "INVALID",
                        self._line[last_end:],
                        self.filename,
                        self._line,
                        self._line_number,
                        last_end,
                    ),
                )

            if self._line.endswith("\\"):
                # This line is not finished yet.
                continue

            if tokens:
                # Return a copy of the token list so that the caller can be free
                # to modify it.
                yield tokens[::]
            tokens.clear()


Atom = collections.namedtuple("Atom", ["argument_index", "op", "value"])
"""A single boolean comparison within a filter expression."""

Filter = collections.namedtuple("Filter", ["expression", "action"])
"""The result of parsing a DNF filter expression, with its action.

Since the expression is in Disjunctive Normal Form, it is composed of two levels
of lists, one for disjunctions and the inner one for conjunctions. The elements
of the inner list are Atoms.
"""

Syscall = collections.namedtuple("Syscall", ["name", "number"])
"""A system call."""

ParsedFilterStatement = collections.namedtuple(
    "ParsedFilterStatement", ["syscalls", "filters", "token"]
)
"""The result of parsing a filter statement.

Statements have a list of syscalls, and an associated list of filters that will
be evaluated sequentially when any of the syscalls is invoked.
"""

FilterStatement = collections.namedtuple(
    "FilterStatement", ["syscall", "frequency", "filters"]
)
"""The filter list for a particular syscall.

This is a mapping from one syscall to a list of filters that are evaluated
sequentially. The last filter is always an unconditional action.
"""

ParsedPolicy = collections.namedtuple(
    "ParsedPolicy", ["default_action", "filter_statements"]
)
"""The result of parsing a minijail .policy file."""


# pylint: disable=too-few-public-methods
class PolicyParser:
    """A parser for the Minijail seccomp policy file format."""

    def __init__(
        self,
        arch,
        *,
        kill_action,
        include_depth_limit=10,
        override_default_action=None,
        denylist=False,
        ret_log=False,
    ):
        self._parser_states = [ParserState("<memory>")]
        self._kill_action = kill_action
        self._include_depth_limit = include_depth_limit
        if denylist:
            self._default_action = bpf.Allow()
        else:
            self._default_action = self._kill_action
        self._override_default_action = override_default_action
        self._frequency_mapping = collections.defaultdict(int)
        self._arch = arch
        self._denylist = denylist
        self._ret_log = ret_log

    @property
    def _parser_state(self):
        return self._parser_states[-1]

    # single-constant = identifier
    #                 | numeric-constant
    #                 ;
    def _parse_single_constant(self, token):
        if token.type == "IDENTIFIER":
            if token.value not in self._arch.constants:
                self._parser_state.error("invalid constant", token=token)
            single_constant = self._arch.constants[token.value]
        elif token.type == "NUMERIC_CONSTANT":
            # As `int(_, 0)` in Python != `strtol(_, _, 0)` in C, to make sure
            # the number parsing behaves exactly in C, instead of using `int()`
            # directly, we list out all the possible formats for octal, decimal
            # and hex numbers, and determine the corresponding base by regex.
            try:
                if re.match(HEX_REGEX, token.value):
                    base = 16
                elif re.match(OCTAL_REGEX, token.value):
                    base = 8
                elif re.match(DECIMAL_REGEX, token.value):
                    base = 10
                else:
                    # This should never happen.
                    raise ValueError
                single_constant = int(token.value, base=base)
            except ValueError:
                self._parser_state.error("invalid constant", token=token)
        else:
            self._parser_state.error("invalid constant", token=token)
        if single_constant > self._arch.max_unsigned:
            self._parser_state.error("unsigned overflow", token=token)
        elif single_constant < self._arch.min_signed:
            self._parser_state.error("signed underflow", token=token)
        elif single_constant < 0:
            # This converts the constant to an unsigned representation of the
            # same value, since BPF only uses unsigned values.
            single_constant = self._arch.truncate_word(single_constant)
        return single_constant

    # constant = [ '~' ] , '(' , value , ')'
    #          | [ '~' ] , single-constant
    #          ;
    def _parse_constant(self, tokens):
        negate = False
        if tokens[0].type == "BITWISE_COMPLEMENT":
            negate = True
            tokens.pop(0)
            if not tokens:
                self._parser_state.error("empty complement")
            if tokens[0].type == "BITWISE_COMPLEMENT":
                self._parser_state.error(
                    "invalid double complement", token=tokens[0]
                )
        if tokens[0].type == "LPAREN":
            last_open_paren = tokens.pop(0)
            single_value = self.parse_value(tokens)
            if not tokens or tokens[0].type != "RPAREN":
                self._parser_state.error(
                    "unclosed parenthesis", token=last_open_paren
                )
        else:
            single_value = self._parse_single_constant(tokens[0])
        tokens.pop(0)
        if negate:
            single_value = self._arch.truncate_word(~single_value)
        return single_value

    # value = constant , [ { '|' , constant } ]
    #       ;
    def parse_value(self, tokens):
        """Parse constants separated bitwise OR operator |.

        Constants can be:

        - A number that can be parsed with strtol() in C.
        - A named constant expression.
        - A parenthesized, valid constant expression.
        - A valid constant expression prefixed with the unary bitwise
          complement operator ~.
        - A series of valid constant expressions separated by bitwise
          OR operator |.

        If there is an error parsing any of the constants, the whole process
        fails.
        """

        value = 0
        while tokens:
            value |= self._parse_constant(tokens)
            if not tokens or tokens[0].type != "BITWISE_OR":
                break
            tokens.pop(0)
        else:
            self._parser_state.error("empty constant")
        return value

    # atom = argument , op , value
    #      ;
    def _parse_atom(self, tokens):
        if not tokens:
            self._parser_state.error("missing argument")
        argument = tokens.pop(0)
        if argument.type != "ARGUMENT":
            self._parser_state.error("invalid argument", token=argument)

        if not tokens:
            self._parser_state.error("missing operator")
        operator = tokens.pop(0)
        if operator.type != "OP":
            self._parser_state.error("invalid operator", token=operator)

        value = self.parse_value(tokens)
        argument_index = int(argument.value[3:])
        if not 0 <= argument_index < bpf.MAX_SYSCALL_ARGUMENTS:
            self._parser_state.error("invalid argument", token=argument)
        return Atom(argument_index, operator.value, value)

    # clause = atom , [ { '&&' , atom } ]
    #        ;
    def _parse_clause(self, tokens):
        atoms = []
        while tokens:
            atoms.append(self._parse_atom(tokens))
            if not tokens or tokens[0].type != "AND":
                break
            tokens.pop(0)
        else:
            self._parser_state.error("empty clause")
        return atoms

    # argument-expression = clause , [ { '||' , clause } ]
    #                   ;
    def parse_argument_expression(self, tokens):
        """Parse a argument expression in Disjunctive Normal Form.

        Since BPF disallows back jumps, we build the basic blocks in reverse
        order so that all the jump targets are known by the time we need to
        reference them.
        """

        clauses = []
        while tokens:
            clauses.append(self._parse_clause(tokens))
            if not tokens or tokens[0].type != "OR":
                break
            tokens.pop(0)
        else:
            self._parser_state.error("empty argument expression")
        return clauses

    # default-action = 'kill-process'
    #                | 'kill-thread'
    #                | 'kill'
    #                | 'trap'
    #                | 'user-notify'
    #                ;
    def _parse_default_action(self, tokens):
        if not tokens:
            self._parser_state.error("missing default action")
        action_token = tokens.pop(0)
        if action_token.type != "ACTION":
            return self._parser_state.error(
                "invalid default action", token=action_token
            )
        if action_token.value == "kill-process":
            return bpf.KillProcess()
        if action_token.value == "kill-thread":
            return bpf.KillThread()
        if action_token.value == "kill":
            return self._kill_action
        if action_token.value == "trap":
            return bpf.Trap()
        if action_token.value == "user-notify":
            return bpf.UserNotify()
        return self._parser_state.error(
            "invalid permissive default action", token=action_token
        )

    # action = 'allow' | '1'
    #        | 'kill-process'
    #        | 'kill-thread'
    #        | 'kill'
    #        | 'trap'
    #        | 'trace'
    #        | 'log'
    #        | 'user-notify'
    #        | 'return' , single-constant
    #        ;
    def parse_action(self, tokens):
        if not tokens:
            self._parser_state.error("missing action")
        action_token = tokens.pop(0)
        # denylist policies must specify a return for every line.
        if self._denylist:
            if action_token.type != "RETURN":
                self._parser_state.error("invalid denylist policy")

        if action_token.type == "ACTION":
            if action_token.value == "allow":
                return bpf.Allow()
            if action_token.value == "kill":
                return self._kill_action
            if action_token.value == "kill-process":
                return bpf.KillProcess()
            if action_token.value == "kill-thread":
                return bpf.KillThread()
            if action_token.value == "trap":
                return bpf.Trap()
            if action_token.value == "trace":
                return bpf.Trace()
            if action_token.value == "user-notify":
                return bpf.UserNotify()
            if action_token.value == "log":
                return bpf.Log()
        elif action_token.type == "NUMERIC_CONSTANT":
            constant = self._parse_single_constant(action_token)
            if constant == 1:
                return bpf.Allow()
        elif action_token.type == "RETURN":
            if not tokens:
                self._parser_state.error("missing return value")
            if self._ret_log:
                tokens.pop(0)
                return bpf.Log()
            else:
                return bpf.ReturnErrno(
                    self._parse_single_constant(tokens.pop(0))
                )
        return self._parser_state.error("invalid action", token=action_token)

    # single-filter = action
    #               | argument-expression , [ ';' , action ]
    #               | '!','(', argument-expression, [ ';', action ], ')'
    #               ;
    def _parse_single_filter(self, tokens):
        if not tokens:
            self._parser_state.error("missing filter")
        if tokens[0].type == "ARGUMENT":
            # Only argument expressions can start with an ARGUMENT token.
            argument_expression = self.parse_argument_expression(tokens)
            if tokens and tokens[0].type == "SEMICOLON":
                tokens.pop(0)
                action = self.parse_action(tokens)
            else:
                action = bpf.Allow()
            return Filter(argument_expression, action)
        else:
            return Filter(None, self.parse_action(tokens))

    # filter = '{' , single-filter , [ { ',' , single-filter } ] , '}'
    #        | single-filter
    #        ;
    def parse_filter(self, tokens):
        """Parse a filter and return a list of Filter objects."""
        if not tokens:
            self._parser_state.error("missing filter")
        filters = []
        if tokens[0].type == "LBRACE":
            opening_brace = tokens.pop(0)
            while tokens:
                filters.append(self._parse_single_filter(tokens))
                if not tokens or tokens[0].type != "COMMA":
                    break
                tokens.pop(0)
            if not tokens or tokens[0].type != "RBRACE":
                self._parser_state.error("unclosed brace", token=opening_brace)
            tokens.pop(0)
        else:
            filters.append(self._parse_single_filter(tokens))
        return filters

    # key-value-pair = identifier , '=', identifier , [ { ',' , identifier } ]
    #                ;
    def _parse_key_value_pair(self, tokens):
        if not tokens:
            self._parser_state.error("missing key")
        key = tokens.pop(0)
        if key.type != "IDENTIFIER":
            self._parser_state.error("invalid key", token=key)
        if not tokens:
            self._parser_state.error("missing equal")
        if tokens[0].type != "EQUAL":
            self._parser_state.error("invalid equal", token=tokens[0])
        tokens.pop(0)
        value_list = []
        while tokens:
            value = tokens.pop(0)
            if value.type != "IDENTIFIER":
                self._parser_state.error("invalid value", token=value)
            value_list.append(value.value)
            if not tokens or tokens[0].type != "COMMA":
                break
            tokens.pop(0)
        else:
            self._parser_state.error("empty value")
        return (key.value, value_list)

    # metadata = '[' , key-value-pair , [ { ';' , key-value-pair } ] , ']'
    #          ;
    def _parse_metadata(self, tokens):
        if not tokens:
            self._parser_state.error("missing opening bracket")
        opening_bracket = tokens.pop(0)
        if opening_bracket.type != "LBRACKET":
            self._parser_state.error(
                "invalid opening bracket", token=opening_bracket
            )
        metadata = {}
        while tokens:
            first_token = tokens[0]
            key, value = self._parse_key_value_pair(tokens)
            if key in metadata:
                self._parser_state.error(
                    'duplicate metadata key: "%s"' % key, token=first_token
                )
            metadata[key] = value
            if not tokens or tokens[0].type != "SEMICOLON":
                break
            tokens.pop(0)
        if not tokens or tokens[0].type != "RBRACKET":
            self._parser_state.error("unclosed bracket", token=opening_bracket)
        tokens.pop(0)
        return metadata

    # syscall-descriptor = syscall-name , [ metadata ]
    #                    | syscall-group-name , [ metadata ]
    #                    ;
    def _parse_syscall_descriptor(self, tokens):
        if not tokens:
            self._parser_state.error("missing syscall descriptor")
        syscall_descriptor = tokens.pop(0)
        # `kill` as a syscall name is a special case since kill is also a valid
        # action and actions have precendence over identifiers.
        if (
            syscall_descriptor.type != "IDENTIFIER"
            and syscall_descriptor.value != "kill"
        ):
            self._parser_state.error(
                "invalid syscall descriptor", token=syscall_descriptor
            )
        if tokens and tokens[0].type == "LBRACKET":
            metadata = self._parse_metadata(tokens)
            if (
                "arch" in metadata
                and self._arch.arch_name not in metadata["arch"]
            ):
                return ()
        if "@" in syscall_descriptor.value:
            # This is a syscall group.
            subtokens = syscall_descriptor.value.split("@")
            if len(subtokens) != 2:
                self._parser_state.error(
                    "invalid syscall group name", token=syscall_descriptor
                )
            syscall_group_name, syscall_namespace_name = subtokens
            if syscall_namespace_name not in self._arch.syscall_groups:
                self._parser_state.error(
                    "nonexistent syscall group namespace",
                    token=syscall_descriptor,
                )
            syscall_namespace = self._arch.syscall_groups[
                syscall_namespace_name
            ]
            if syscall_group_name not in syscall_namespace:
                self._parser_state.error(
                    "nonexistent syscall group", token=syscall_descriptor
                )
            return (
                Syscall(name, self._arch.syscalls[name])
                for name in syscall_namespace[syscall_group_name]
            )
        if syscall_descriptor.value not in self._arch.syscalls:
            self._parser_state.error(
                "nonexistent syscall", token=syscall_descriptor
            )
        return (
            Syscall(
                syscall_descriptor.value,
                self._arch.syscalls[syscall_descriptor.value],
            ),
        )

    # filter-statement =
    #     '{' , syscall-descriptor , [ { ',', syscall-descriptor } ] , '}' ,
    #         ':' , filter
    #   | syscall-descriptor , ':' , filter
    #   ;
    def parse_filter_statement(self, tokens):
        """Parse a filter statement and return a ParsedFilterStatement."""
        if not tokens:
            self._parser_state.error("empty filter statement")
        syscall_descriptors = []
        if tokens[0].type == "LBRACE":
            opening_brace = tokens.pop(0)
            while tokens:
                syscall_descriptors.extend(
                    self._parse_syscall_descriptor(tokens)
                )
                if not tokens or tokens[0].type != "COMMA":
                    break
                tokens.pop(0)
            if not tokens or tokens[0].type != "RBRACE":
                self._parser_state.error("unclosed brace", token=opening_brace)
            tokens.pop(0)
        else:
            syscall_descriptors.extend(self._parse_syscall_descriptor(tokens))
        if not tokens:
            self._parser_state.error("missing colon")
        if tokens[0].type != "COLON":
            self._parser_state.error("invalid colon", token=tokens[0])
        # Given that there can be multiple syscalls and filters in a single
        # filter statement, use the colon token as the anchor for error location
        # purposes.
        colon_token = tokens.pop(0)
        parsed_filter = self.parse_filter(tokens)
        if not syscall_descriptors:
            return None
        return ParsedFilterStatement(
            tuple(syscall_descriptors), parsed_filter, colon_token
        )

    # include-statement = '@include' , posix-path
    #                   ;
    def _parse_include_statement(self, tokens):
        if not tokens:
            self._parser_state.error("empty filter statement")
        if tokens[0].type != "INCLUDE":
            self._parser_state.error("invalid include", token=tokens[0])
        tokens.pop(0)
        if not tokens:
            self._parser_state.error("empty include path")
        include_path = tokens.pop(0)
        if include_path.type != "PATH":
            self._parser_state.error("invalid include path", token=include_path)
        if len(self._parser_states) == self._include_depth_limit:
            self._parser_state.error("@include statement nested too deep")
        include_filename = os.path.normpath(
            os.path.join(
                os.path.dirname(self._parser_state.filename), include_path.value
            )
        )
        if not os.path.isfile(include_filename):
            self._parser_state.error(
                "Could not @include %s" % include_filename, token=include_path
            )
        return self._parse_policy_file(include_filename)

    def _parse_frequency_file(self, filename):
        self._parser_states.append(ParserState(filename))
        try:
            frequency_mapping = collections.defaultdict(int)
            with open(filename, encoding="utf-8") as frequency_file:
                for tokens in self._parser_state.tokenize(frequency_file):
                    syscall_numbers = self._parse_syscall_descriptor(tokens)
                    if not tokens:
                        self._parser_state.error("missing colon")
                    if tokens[0].type != "COLON":
                        self._parser_state.error(
                            "invalid colon", token=tokens[0]
                        )
                    tokens.pop(0)

                    if not tokens:
                        self._parser_state.error("missing number")
                    number = tokens.pop(0)
                    if number.type != "NUMERIC_CONSTANT":
                        self._parser_state.error("invalid number", token=number)
                    number_value = int(number.value, base=0)
                    if number_value < 0:
                        self._parser_state.error("invalid number", token=number)

                    for syscall_number in syscall_numbers:
                        frequency_mapping[syscall_number] += number_value
            return frequency_mapping
        finally:
            self._parser_states.pop()

    # frequency-statement = '@frequency' , posix-path
    #                      ;
    def _parse_frequency_statement(self, tokens):
        if not tokens:
            self._parser_state.error("empty frequency statement")
        if tokens[0].type != "FREQUENCY":
            self._parser_state.error("invalid frequency", token=tokens[0])
        tokens.pop(0)
        if not tokens:
            self._parser_state.error("empty frequency path")
        frequency_path = tokens.pop(0)
        if frequency_path.type != "PATH":
            self._parser_state.error(
                "invalid frequency path", token=frequency_path
            )
        frequency_filename = os.path.normpath(
            os.path.join(
                os.path.dirname(self._parser_state.filename),
                frequency_path.value,
            )
        )
        if not os.path.isfile(frequency_filename):
            self._parser_state.error(
                "Could not open frequency file %s" % frequency_filename,
                token=frequency_path,
            )
        return self._parse_frequency_file(frequency_filename)

    # default-statement = '@default' , default-action
    #                   ;
    def _parse_default_statement(self, tokens):
        if not tokens:
            self._parser_state.error("empty default statement")
        if tokens[0].type != "DEFAULT":
            self._parser_state.error("invalid default", token=tokens[0])
        tokens.pop(0)
        if not tokens:
            self._parser_state.error("empty action")
        return self._parse_default_action(tokens)

    def _parse_policy_file(self, filename):
        self._parser_states.append(ParserState(filename))
        try:
            statements = []
            denylist_header = False
            with open(filename, encoding="utf-8") as policy_file:
                for tokens in self._parser_state.tokenize(policy_file):
                    if tokens[0].type == "INCLUDE":
                        statements.extend(self._parse_include_statement(tokens))
                    elif tokens[0].type == "FREQUENCY":
                        for (
                            syscall_number,
                            frequency,
                        ) in self._parse_frequency_statement(tokens).items():
                            self._frequency_mapping[syscall_number] += frequency
                    elif tokens[0].type == "DEFAULT":
                        self._default_action = self._parse_default_statement(
                            tokens
                        )
                    elif tokens[0].type == "DENYLIST":
                        tokens.pop()
                        if not self._denylist:
                            self._parser_state.error(
                                "policy is denylist, but "
                                "flag --denylist not "
                                "passed in."
                            )
                        else:
                            denylist_header = True
                    else:
                        statement = self.parse_filter_statement(tokens)
                        if statement is None:
                            # If all the syscalls in the statement are for
                            # another arch, skip the whole statement.
                            continue
                        statements.append(statement)

                    if tokens:
                        self._parser_state.error(
                            "extra tokens", token=tokens[0]
                        )
            if self._denylist and not denylist_header:
                self._parser_state.error(
                    "policy must contain @denylist flag to"
                    " be compiled with --denylist flag."
                )
            return statements
        finally:
            self._parser_states.pop()

    def parse_file(self, filename):
        """Parse a file and return the list of FilterStatements."""
        self._frequency_mapping = collections.defaultdict(int)
        try:
            statements = self._parse_policy_file(filename)
        except RecursionError:
            raise ParseException(
                "recursion limit exceeded",
                filename,
                line=self._parser_states[-1].line,
            )

        # Collapse statements into a single syscall-to-filter-list, remembering
        # the token for each filter for better diagnostics.
        syscall_filter_mapping = {}
        syscall_filter_definitions = {}
        filter_statements = []
        for syscalls, filters, token in statements:
            for syscall in syscalls:
                if syscall not in syscall_filter_mapping:
                    filter_statements.append(
                        FilterStatement(
                            syscall, self._frequency_mapping.get(syscall, 1), []
                        )
                    )
                    syscall_filter_mapping[syscall] = filter_statements[-1]
                    syscall_filter_definitions[syscall] = []
                for filt in filters:
                    syscall_filter_mapping[syscall].filters.append(filt)
                    syscall_filter_definitions[syscall].append(token)
        default_action = self._override_default_action or self._default_action
        for filter_statement in filter_statements:
            unconditional_actions_suffix = list(
                itertools.dropwhile(
                    lambda filt: filt.expression is not None,
                    filter_statement.filters,
                )
            )
            if len(unconditional_actions_suffix) == 1:
                # The last filter already has an unconditional action, no need
                # to add another one.
                continue
            if len(unconditional_actions_suffix) > 1:
                previous_definition_token = syscall_filter_definitions[
                    filter_statement.syscall
                ][-len(unconditional_actions_suffix)]
                current_definition_token = syscall_filter_definitions[
                    filter_statement.syscall
                ][-len(unconditional_actions_suffix) + 1]
                raise ParseException(
                    (
                        "Syscall %s (number %d) already had "
                        "an unconditional action applied"
                    )
                    % (
                        filter_statement.syscall.name,
                        filter_statement.syscall.number,
                    ),
                    filename=current_definition_token.filename,
                    token=current_definition_token,
                ) from ParseException(
                    "Previous definition",
                    filename=previous_definition_token.filename,
                    token=previous_definition_token,
                )
            assert not unconditional_actions_suffix
            filter_statement.filters.append(
                Filter(expression=None, action=default_action)
            )
        return ParsedPolicy(default_action, filter_statements)
