# Copyright 2026 SigmaSRC, Inc. All Rights Reserved.

"""Pluggable config file parsing framework.

There is an interface for using the framework and an interface for
writing plugins.  Both interfaces are represented by the same abstract
base class; plugins must implement this interface.  There is a factory
function that takes a host, a filename and a subsystem name and
returns an instance of the correct plugin class (after loading the
module that defines it, if necessary).

This API doesn't explicitly deal with section names.  Rather than
extend the API with more methods to specify section names, I suggest
that the variable name can be prefixed with one or more section names
to form a "qualified variable name".  Because we don't want to reserve
a delimiter character that cannot be used in variable or section
names, the delimiter is up to the plugin.  I recommend to use a slash
as the delimiter unless the file format allows slashes in section or
variable names.  (Not having a fixed delimiter is not a serious
limitation of the API, since any use of this API must necessarily know
which subsystem defines the file format; the subsystem already
determines which variable names are interesting.  Thus, the qualified
variable name simply becomes "the variable name" from the POV of the
caller.)

"""

import os
import re
import hashlib

from sigma.util.fuelset import FuelSet

class PluginInterface(object):
    """Interface for plugins.

    Plugins must either inherit from this class or implement the same
    API independently.

    Plugins need not be thread-safe.

    """

    def __init__(self, host, filename, subsystem,
                 perm=None, user=None, group=None):
        """Constructor.

        Takes the same arguments as the factory function.

        """
        self._host = host
        self._filename = filename
        self._subsystem = subsystem
        self._perm = perm
        self._user = user
        self._group = group

    def report(self, *args):
        """Internal shorthand to call the host's report() method."""
        self._host.report(*args)

    def geterrors(self):
        """Get parsing errors, if any.

        This returns an empty list if there were no parsing errors.
        Otherwise it returns a non-empty list of non-empty strings
        detailing each of the errors encountered.  A human has to read
        the strings to interpret them.

        XXX This method is not usable from Fuel.

        """
        # The default implementation always returns an empty list
        return []

    # Old API:

    def getrawvalue(self, name):
        """Get a configuration variable without macro substitution.

        Equivalent to self.get(name, None).

        Deprecated; not accessible from Fuel.

        """
        return self.get(name, None)

    def getvalue(self, name):
        """Get a configuration variable after macro substitution (if any).

        Equivalent to self.expand(self.get(name, None)) except a
        None result is passed through unchanged.

        Deprecated; not accessible from Fuel.

        """
        value = self.get(name, None)
        if value is not None:
            value = self.expand(value)
        return value

    def getvalues(self, name):
        """Get a configuration variable after macro substitution and
        splitting.

        Equivalent to
            list(self.split(self.expand(self.get(name, None))))
        except None is returned if the variable doesn't exist.

        Deprecated; not accessible from Fuel.

        """
        value = self.get(name, None)
        if value is None:
            return None
        else:
            return self.splitlist(self.expand(value))

    def splitlist(self, value):
        """Internal helper -- override this rather than split()."""
        if value:
            return [value]
        else:
            return []

    # New API:

    def keys(self):
        """Return the set of valid keys (normalized if necessary)."""
        return FuelSet()

    def get(self, name, default=""):
        """Return the value for named config variable.

        No macro expansion is applied; use the expand() method for
        that.  If the variable does not exist, return the second
        argument, or an empty string if no second argument is given.

        """
        return default

    def has(self, name):
        """Return whether the named config variable exists."""
        return self.get(name, None) is not None

    def update(self, name, value, comment=None):
        """Set the value of a named config variable.

        The named variable may or may not already exist.  The value
        may contain macro references (which will not be expanded).

        Optionally, if the config file supports it, a comment may also
        be specified; if no comment is specified, an existing comment
        if any is retained.

        The default implementation does not support this operation.

        """
        raise NotImplementedError("update() not implemented")

    def delete(self, name):
        """Delete an existing named config variable.

        The named variable should already exist.

        The default implementation does not support this operation.

        """
        raise NotImplementedError("delete() not implemented")

    def save(self):
        """Save changes, if any changes were made.

        The filename used to save is the filename passed to the
        constructor.

        This may fail with an exception for various reasons (e.g. not
        implemented, no write permission, or not running on the client
        system).

        """
        raise NotImplementedError("save() not implemented")

    def expand(self, value):
        """Do macro expansion on a string.

        This is typically used on the return value of get().

        """
        return value

    def split(self, value):
        """Split a string into pieces and return the result as a set.

        This is typically used on the return value of get(), before or
        after running it through expand().

        The split delimiter used is up to the config file syntax.  The
        default implementation has *no* split delimiter and hence
        simply returns a set of zero or one elements (zero if the
        input is an empty string).

        Don't override this; override splitlist() instead.

        """
        return FuelSet(self.splitlist(value))

    def join(self, values):
        """Join the elements of a set together using the split delimiter.

        The split delimiter is up to the config file syntax.  The
        default implementation has *no* split delimiter and raises an
        exception when a set of more than one element is passed.

        """
        if len(values) > 1:
            raise NotImplementedError(
                "join() of multi-element set not implemented")
        return "".join(values)

class BasicPlugin(PluginInterface):
    """Parse a very basic config file.

    This uses standard # comments, and a name : value syntax.
    Multiple values are separated by commas.

    Subclasses can tune the parsing by overriding the class variables
    'regex', 'comment' and 'multiple', which give the regular
    expression to parse a variable name and value, the
    start-of-comment character, and the delimiter between multiple
    values, respectively.

    There is also a class variable 'writedelimiter' which is the
    delimiter used when adding new variable-value pairs.

    In the future we'll support more parsing control, e.g. how to
    recognize continuation lines.

    """

    # Parsing control variables.  The regex is used to parse a line;
    # it should define named groups named "name" and "value".
    regex = re.compile(r"\s*(?P<name>[^\s:]+)\s*:\s*(?P<value>.*)$")
    writedelimiter = ": "               # Delimiter to use when writing.
    comment = "#"                       # Start of comments
    multiple = ","                      # Delimiter between multiple values

    def __init__(self, host, filename, subsystem,
                 perm=None, user=None, group=None):
        PluginInterface.__init__(self, host, filename, subsystem,
                                 perm, user, group)
        self._errors = []
        self._variables = {}
        # Using a File object here is good because that allows pconf to
        # have the same code working against the database or the live
        # filesystem.
        self._file = host.file(filename)
        if not self._file.exists:
            host.report("Configfile %r not found", filename)
        else:
            try:
                stream = self._file.open()
            except IOError as msg:
                host.report("Can't open configfile %r: %s", filename, msg)
            else:
                try:
                    self._parse(stream)
                finally:
                    stream.close()

    def geterrors(self):
        return list(self._errors)       # Make a copy just in case

    def _parse(self, stream):
        digest = hashlib.md5()
        lineno = 0
        for line in stream:
            digest.update(line.encode('utf-8') if isinstance(line, str) else line)
            lineno += 1
            line = line.strip()
            if not line or line.startswith(self.comment):
                continue
            m = self.regex.match(line)
            if not m:
                self._errors.append("%s(%d): unparseable line: %r" %
                                    (self._filename, lineno, line))
            else:
                varname, value = m.group("name", "value")
                if varname in self._variables:
                    self._errors.append("%s(%d): duplicate variable: %r" %
                                        (self._filename, lineno, varname))
                else:
                    self._variables[varname] = value.strip()
        self._md5sum = digest.hexdigest()

    # New API

    def keys(self):
        return FuelSet(self._variables.keys())

    def get(self, name, default=""):
        return self._variables.get(name, default)

    def has(self, name):
        return name in self._variables

    def splitlist(self, value):
        if not value:
            return []
        return [s.strip() for s in value.split(self.multiple)]

    def join(self, values):
        return (self.multiple or " ").join(values)

    # Write API

    _originals = _updates = _deletions = None

    def _writing(self):
        if self._originals is None:
            self._originals = self._variables.copy()
            self._updates = {}
            self._deletions = {}

    def update(self, name, value, comment=None):
        self._writing()
        self._variables[name] = value
        self._updates[name] = (value, comment)
        if name in self._deletions:
            del self._deletions[name]

    def delete(self, name):
        if name not in self._variables:
            raise RuntimeError("delete(): %r doesn't exists", name)
        self._writing()
        del self._variables[name]
        self._deletions[name] = None
        if name in self._updates:
            del self._updates[name]

    def save(self):
        """ Save modifications to the config file, if enforcement is on.
            If it didn't exist before, create it.
            If there are no modifications to save, don't update the
            modification date of the file.
            When we're done, report whether we changed the file.
        """
        if not self._host.enforce:
            self.report("enforcement disabled, not writing %r", self._filename)
            return
        if self._originals is None:
            # _writing was never called, so nothing to write.
            # XXX Should we be able to create an empty config file?
            # If so, remove this special case.
            self.report("no changes to %r", self._filename)
            return
        if self._file.exists:
            f = self._file.open()
            try:
                (oldvarchanged, oldvartext) = self._parse_update(f)
            finally:
                f.close()
        else:
            oldvarchanged = True
            oldvartext = ""
        newvartext = self._parse_append()
        try:
            if (not oldvarchanged) and newvartext == "":
                self.report("no effective changes to %r", self._filename)
            else:
                try:
                    self._file.save(oldvartext + newvartext,
                                    "changed configuration settings",
                                    perm=self._perm,
                                    user=self._user,
                                    group=self._group)
                except EnvironmentError as err:
                    self._host.error(0, "can't save %r: %s",
                                     self._filename, err)
        finally:
            self._originals = self._updates = self._deletions = None

    def _parse_append(self):
        """ Return text for updated variables that are new in the file. """
        newlines = []
        keys = sorted(self._updates.keys())
        # Put them in alphabetical order so it's predictable.  The
        # order that the update calls were done might conceivably be
        # better, but it's not obviously better and at this point
        # we have no record of it anyway.
        for key in keys:
            value,comment = self._updates[key]
            newline = key + self.writedelimiter + value + "\n"
            if comment != None:
                newline = self.comment+comment+"\n"+newline
            newlines.append(newline)
        return "".join(newlines)

    def _parse_update(self, f):
        """ Returns a pair, whether the contents of the file changed,
            and the new text for the configfile.
            Things we write are removed from self._updates.  If a
            config variable is completely new, this routine ignores it
            and leaves it in self._updates.
            """
        digest = hashlib.md5()
        changes = 0
        newlines = []
        seenvars = {}
        # Update the variables that are in the file already.
        for rawline in f:
            digest.update(rawline.encode('utf-8') if isinstance(rawline, str) else rawline)
            sline = rawline.strip()
            if not sline or sline.startswith(self.comment):
                newlines.append(rawline)
                continue
            newline = rawline
            m = self.regex.match(rawline)
            if m:
                varname, value = m.group("name", "value")
                seenvars[varname] = True
                if varname in self._deletions:
                    newline = ("%s *** DELETED *** %s" %
                               (self.comment, rawline))
                elif varname in self._updates:
                    value, comment = self._updates[varname]
                    # Ignore the comment.  The other alternative would
                    # be to insert the comment at the beginning of the
                    # line, but that would give us 20 comments if
                    # there were 20 updates.
                    i, j = m.span("value")
                    newline = rawline[:i] + value + rawline[j:]
            newlines.append(newline)
            changes += int(newline != rawline)
        # A key can appear more than once in the file, so we can't
        # take them out of _updates as we find them.  Instead we save
        # them in seenvars and take them out at the end.
        for key in seenvars.keys():
            if key in self._updates:
                del self._updates[key]
        md5sum = digest.hexdigest()
        if md5sum != self._md5sum:
            # We used to return None in this case.  The caller
            # couldn't distinguish a lack of changes from the error
            # when the file changes underneath us.  The caller may
            # have new variables to append, so the caller cares, so
            # confusing these two unusual cases is bad.
            raise RuntimeError("%r was changed during edit" % self._filename)
        return (changes != 0, "".join(newlines))

class PostfixPlugin(BasicPlugin):

    """Parse a postfix configuration file.

    This uses standard # comments, and a name = value syntax.
    Multiple values are separated by commas.

    This recognizes substitutions of the form $NAME.  It doesn't (yet)
    recognize the ${NAME} syntax.  I also wonder if we should have
    default values for certain substitutions.  Time will tell.

    The substitution character, default '$', can be overridden by
    setting the class or instance variable 'substitution'.

    XXX The postfix config file syntax has some features that are not
    (yet) supported here: multi-line values can be given by indenting
    subsequent (non-blank, non-comment, I presume) lines; as mentioned
    above it also support ${NAME}; and lists can be separated by
    either commas or whitespace (or a combination, I presume).

    """

    regex = re.compile(r"\s*(?P<name>[^\s=]+)\s*=\s*(?P<value>.*)$")
    writedelimiter = " = "              # Override base class delimiter
    substitution = "$"                  # Variable substitution character

    # New API

    def expand(self, value):
        # This is so darn complicated because of what I want to happen
        # for recursive substitutions: foo = /$foo/ should set foo to
        # the string /$foo/.

        regex = re.compile(re.escape(self.substitution) + r"(\w+)")

        stack = [] # shared with _substitute() and _expand()

        def _substitute(m):
            # Callback for re.sub(); mutually recursive with _expand()
            varname = m.group(1)
            if varname in stack:
                raise RuntimeError
            stack.append(varname)
            try:
                value = self.get(varname, None)
                if value is None:
                    return m.group(0)
                return _expand(value)
            finally:
                v = stack.pop()
                assert v == varname

        def _expand(value):
            try:
                value = regex.sub(_substitute, value)
            except RuntimeError:
                if stack:
                    raise
            return value

        return _expand(value)

class JavaPlugin(BasicPlugin):
    """Parse a Java properties file.

    We don't implement substitution.  For example, if you get
    server.loader from a properties file with the line

       server.loader=${catalina.home}/server/classes

    you'll just get '${catalina.home}/server/classes' which starts
    with a literal dollarsign character, regardless of the value of
    catalina.home.  We don't implement any sort of continuation line.
    We don't implement defaults.  Like Java, the key is
    case-sensitive.  Note that internal to Sigma, there is an
    AppProperties class that defines *case-insensitive* properties
    lookup; that is different from this.
    """
    regex = re.compile(r"\s*(?P<name>[^\s=]+)\s*=\s*(?P<value>.*)$")
    writedelimiter = "="

class ShellPlugin(BasicPlugin):
    # Shell variable syntax: NAME=value, possibly followed by a comment
    regex = re.compile(r"\s*(?P<name>\w+)=(?P<value>('[^']*'|\"[^\"]*\"|\S*))\s*(#.*)?$")
    writedelimiter = "="
    multiple = ":"                      # os.pathsep

    def get(self, name, default=""):
        value = BasicPlugin.get(self, name, default)
        if value is not None and len(value) >= 2:
            if value[0] + value[-1] in ('""', "''"):
                # XXX What about escapes?
                value = value[1:-1]
        return value

class SpacePlugin(BasicPlugin):
    # Space-delimited syntax (name can be anything not containing a space)
    regex = re.compile(r"\s*(?P<name>\S+)\s+(?P<value>.*)$")
    writedelimiter = " "
    multiple = None                     # split on whitespace!

class SysctlPlugin(BasicPlugin):

    """
    This plugin is modeled after postfix plugin.
    The only difference is that if there are duplicate values for 
    the same name, it uses the last found value instead of the first.
    """

    regex = re.compile(r"\s*(?P<name>[^\s=]+)\s*=\s*(?P<value>.*)$")
    writedelimiter = " = "              # Override base class delimiter
    substitution = "$"                  # Variable substitution character

    # New API

    def expand(self, value):
        # This is so darn complicated because of what I want to happen
        # for recursive substitutions: foo = /$foo/ should set foo to
        # the string /$foo/.

        regex = re.compile(re.escape(self.substitution) + r"(\w+)")

        stack = [] # shared with _substitute() and _expand()

        def _substitute(m):
            # Callback for re.sub(); mutually recursive with _expand()
            varname = m.group(1)
            if varname in stack:
                raise RuntimeError
            stack.append(varname)
            try:
                value = self.get(varname, None)
                if value is None:
                    return m.group(0)
                return _expand(value)
            finally:
                v = stack.pop()
                assert v == varname

        def _expand(value):
            try:
                value = regex.sub(_substitute, value)
            except RuntimeError:
                if stack:
                    raise
            return value

        return _expand(value)

    # overriding _parse because we want to return the last found value
    def _parse(self, stream):
        digest = hashlib.md5()
        lineno = 0
        for line in stream:
            digest.update(line.encode('utf-8') if isinstance(line, str) else line)
            lineno += 1
            line = line.strip()
            if not line or line.startswith(self.comment):
                continue
            m = self.regex.match(line)
            if not m:
                self._errors.append("%s(%d): unparseable line: %r" %
                                    (self._filename, lineno, line))
            else:
                varname, value = m.group("name", "value")
                if varname in self._variables:
                    self._errors.append("%s(%d): duplicate variable: %r" %
                                        (self._filename, lineno, varname))
                self._variables[varname] = value.strip()
        self._md5sum = digest.hexdigest()

dispatch = {
    "aliases": BasicPlugin,
    "postfix": PostfixPlugin,
    "shell": ShellPlugin,
    "space": SpacePlugin,
    "java": JavaPlugin,
    "sysctl": SysctlPlugin
    }

def ParseConfigFile(host, filename, subsystem,
                    perm=None, user=None, group=None):
    """Parse the configuration file for a given subsystem.

    The host argument is an rtlib.Host instance, whose file() method
    will be used to access the file by filename (using the open()
    method on the resulting rtlib.File instance).

    The filename argument designates the file from which the config
    file should be read.

    The subsystem argument is a string indicating the name of the
    program or subsystem that determines the format; it is used to
    determine the plugin to be used to parse the file.

    The file is parsed, and the return value is a config file object,
    i.e. an instance of some class that implements (but not
    necessarily inherits) the PluginInterface interface.  If there are
    parsing errors, the parser tries to recover as well as it can and
    stores the errors on the config file object for retrieval by the
    geterrors() method.

    It's not specified how the subsystem argument is mapped to a
    parse; eventually there will probably be some kind of
    configuration file or database table mapping the subsystem name to
    a plugin name plus option values for that plugin.

    """
    cls = dispatch.get(subsystem) or BasicPlugin
    return cls(host, filename, subsystem, perm, user, group)
