########################################################################
# Copyright (C) 2010,2017-2019 VMWare, Inc.                            #
# All Rights Reserved                                                  #
########################################################################

"""Provides a class for parsing and writing boot.cfg

   See efiboot/mboot/config.c for a complete description of config options
   supported in boot.cfg.
"""

import os
import logging

from .Misc import isString

PRINTABLE = (""" !"#$%&'()*+,-./0123456789:;<=>?@"""
             """ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`"""
             """abcdefghijklmnopqrstuvwxyz{|}~""")
SPACE = "\t\n\v\f\r "

log = logging.getLogger(os.path.basename(__name__))

class BootCfgError(Exception):
   pass

class BootCfg(object):
   """A generic class encapsulates boot.cfg of a bootbank.

      Class Variables:
         * BOOTSTATE_SUCCESS   - The bootbank has booted successfully.
         * BOOTSTATE_UPDATED   - The bootbank is upgraded, but not booted yet.
         * BOOTSTATE_ATTEMPTED - The bootbank is being booted, but not finish
                                 yet.
         * BOOTSTATE_EMPTY     - The bootbank is empty.
         * BOOTSTATE_STAGED    - New IP has been staged to bootbank, but update
                                 is not finished yet.
      Attributes:
         * bootstate - An integer, one of 0, 1, 2, 3, or 4.
         * title     - A string specifying a title to show during boot.
         * prefix    - A string specifying path prefix of modules in bootbank.
         * kernel    - A string specifying the name of the kernel image. Note
                       that this is not always vmkernel. It could be a boot
                       module that gets loaded before vmkernel, such as tboot.
         * kernelopt - A dictionary of options to be passed to the kernel
                       command line. Keys must be strings. Values may be either
                       strings or None (to indicate a keyword-only argument).
                       Spaces are not valid.
         * modules   - A list of module names. Each item must be a string.
         * build     - A string specifying ESXi version.
         * updated   - A non-negative integer.
         * timeout   - A non-negative integer specifying the autoboot timeout
                       in seconds.
         * nobootif  - 0 or 1; when set to 1 mboot will not append
                       BOOTIF=<MAC_addr> in the boot cmdline.
         * noquirks  - 0 or 1; when set to 1 mboot will disable workarounds for
                       platform quirks.
         * norts     - 0 or 1; when set to 1 mboot will disable support for UEFI
                       Runtime Services.
         * quickboot - 0 or 1; when set to 1 safeboot will skip loading from
                       the bootbank.
         * nativehttp - 0: Never use native UEFI HTTP.  1: Use native UEFI HTTP
                        if mboot itself was loaded via native UEFI HTTP.  2:
                        Use native UEFI HTTP if it allows plain http URLs.  3:
                        Always use native UEFI HTTP.
         * runtimewd - 0 or 1; when set to 1 mboot will enable the hardware
                       runtime watchdog.
   """
   INT_OPTS = ('nobootif', 'noquirks', 'norts', 'quickboot', 'nativehttp',
               'runtimewd')

   BOOTSTATE_SUCCESS   = 0
   BOOTSTATE_UPDATED   = 1
   BOOTSTATE_ATTEMPTED = 2
   BOOTSTATE_EMPTY     = 3
   BOOTSTATE_STAGED    = 4

   BOOTSTATE_TYPES = (BOOTSTATE_SUCCESS, BOOTSTATE_UPDATED,
                      BOOTSTATE_ATTEMPTED, BOOTSTATE_EMPTY, BOOTSTATE_STAGED)

   def __init__(self, f=None):
      """Class constructor
            Parameters:
               * f - If specified, either a file name or a file-like object
                     from which to parse configuration.
            Raises:
               * IOError      - If file name or file object is not valid.
               * BootCfgError - If file format is not correct.
      """
      self.clear()
      if f is not None:
         self.parse(f)

   def clear(self):
      """Set default values.
      """
      self.bootstate = self.BOOTSTATE_SUCCESS
      self.title = ""
      self.prefix = ""
      self.kernel = ""
      self.kernelopt = dict()
      self.modules = list()
      self.build = ""
      self.updated = 0
      self.timeout = 5
      # Optional integer parameters specific to mboot.
      # If not set they will not be written out.
      self.nobootif = None
      self.noquirks = None
      self.norts = None
      self.quickboot = None
      self.nativehttp = None
      self.runtimewd = None
      # Unknown keywords and values.
      self._unknownMap = dict()

   def parse(self, f, expectedKeys=None):
      """Read configuration from file.
            Parameters:
               * f            - Either a file name or a file-like object from
                                which to parse configuration.
               * expectedKeys - A list of keys config files should contain, None
                                for no requirement.
            Raises:
               * IOError      - If file name or file object is not valid.
               * BootCfgError - If file format is not correct or an expected key
                                is absent.
      """
      if isString(f):
         fobj = open(f, "rb")
         fn = f
      else:
         fobj = f
         fn = hasattr(fobj, "name") and fobj.name or "<file>"

      try:
         lineno = 0
         for line in fobj:
            lineno += 1
            # Comments are not preserved.
            line = line.decode().split("#")[0].strip()
            if not line:
               continue
            try:
               name, value = (word.strip() for word in line.split("=", 1))
            except ValueError:
               msg = "Invalid format at line %d of %s" % (lineno, fn)
               raise BootCfgError(msg)
            if name == "bootstate":
               try:
                  self.bootstate = int(value)
               except ValueError:
                  msg = "Invalid 'bootstate' value at line %d of %s" \
                     % (lineno, fn)
                  raise BootCfgError(msg)
            elif name == "title":
               self.title = value
            elif name == "timeout":
               try:
                  self.timeout = int(value)
               except ValueError:
                  msg = "Invalid 'timeout' value at line %d of %s" \
                     % (lineno, fn)
                  raise BootCfgError(msg)
            elif name == "prefix":
               self.prefix = value
               # Avoid to break any codepath assuming that each prefix ends
               # with '/'.
               if self.prefix and self.prefix[-1] != '/':
                  self.prefix += '/'
            elif name == "kernel":
               self.kernel = value
            elif name == "kernelopt":
               try:
                  self._parseKernelOpt(value)
               except Exception:
                  msg = "Invalid 'kernelopt' value at line %s of %s" \
                        % (lineno, fn)
                  raise BootCfgError(msg)
            elif name == "modules":
               self.modules = [word.strip() for word in value.split("---")]
            elif name == "build":
               self.build = value
            elif name == "updated":
               try:
                  self.updated = int(value)
               except ValueError:
                  msg = "Invalid 'updated' value at line %d of %s" \
                     % (lineno, fn)
                  raise BootCfgError(msg)
            elif name in self.INT_OPTS:
               errMsg = "Invalid '%s' value at line %d of %s" \
                        % (name, lineno, fn)
               try:
                  intVal = int(value)
               except ValueError:
                  raise BootCfgError(errMsg)
               setattr(self, name, intVal)
            else:
               msg = "Unknown keyword '%s' at line %s of %s, keeping " \
                     "its value as it is"  % (name, lineno, fn)
               log.warn(msg)
               self._unknownMap[name] = value
            if expectedKeys and name in expectedKeys:
               expectedKeys.remove(name)
      finally:
         if isString(f):
            fobj.close()

      if expectedKeys:
         msg = "%s value is expected in boot.cfg %s, but not found" % \
               (expectedKeys, fn)
         raise BootCfgError(msg)

   def _parseKernelOpt(self, line):
      """A "Pythonification" of the C implementation.

      bora/lib/bootConfigLineParse/bootConfigLineParse.c
      """
      i = 0
      linelen = len(line)
      while i < linelen:
         while line[i] in SPACE:
            i += 1
            if i == linelen:
               return
         # Ignore comment and stop at line end
         if line[i] in "#\0\n":
            break
         # Parse key name
         keystart = i
         while i < linelen and line[i] not in " =:" and line[i] in PRINTABLE:
            i += 1
         key = line[keystart:i]
         self.kernelopt[key] = None
         # Empty key means wrong syntax
         if not key:
            raise Exception
         if i == linelen:
            return
         while line[i] in SPACE:
            i += 1
            if i == linelen:
               return
         # Seperator
         if line[i] not in "=:":
            continue
         i += 1
         if i == linelen:
            break
         while line[i] in SPACE:
            i += 1
            if i == linelen:
               return
         # Parse value
         valstart = i
         while line[i] not in " =," and line[i] in PRINTABLE:
            i += 1
            if i == linelen:
               break
         self.kernelopt[key] = line[valstart:i].strip('"')
         if i == linelen:
            return
         while line[i] in SPACE:
            i += 1
            if i == linelen:
               return
         # Stop at comma
         if line[i] == ",":
            i += 1
            if i == linelen:
               return

   def validate(self):
      """Validate basic sanity of the fields.
      """
      if not isinstance(self.bootstate, int) or not self.bootstate in \
         self.BOOTSTATE_TYPES:
         raise BootCfgError('Bootstate must be one of 0-%d, not %s'
                            % (len(self.BOOTSTATE_TYPES) - 1,
                               str(self.bootstate)))
      if not isinstance(self.timeout, int) or self.timeout < 0:
         raise BootCfgError('Timeout must be a non-negative integer, not %s'
                            % str(self.timeout))
      if not isinstance(self.updated, int) or self.updated < 0:
         raise BootCfgError('Updated must be a non-negative integer, not %s'
                            % str(self.updated))
      if self.prefix and self.prefix[-1] != '/':
         raise BootCfgError('Prefix must end in \'/\'')
      for name in self.INT_OPTS:
         value = getattr(self, name)
         if not (isinstance(value, int) or value is None):
            raise BootCfgError('%s must be an integer or unset, not %s'
                               % (name, str(value)))

   def write(self, f):
      """Write configuration to a file.
            Parameters:
               * f - Either a file name or a file-like object to which to
                     write configuration.
            Raises:
               * IOError - If file name or file object is not valid.
      """
      self.validate()

      bootCfgStr = """bootstate={bootstate}
title={title}
timeout={timeout}
prefix={prefix}
kernel={kernel}
kernelopt={kernelopt}
modules={modules}
build={build}
updated={updated}
"""
      makeopt = lambda k, v: v is None and k or "=".join((k, str(v)))
      bootCfgStr = bootCfgStr.format(
         bootstate = str(self.bootstate),
         title     = self.title,
         timeout   = str(self.timeout),
         prefix    = self.prefix,
         kernel    = self.kernel,
         kernelopt = " ".join(makeopt(k, v) for k, v in self.kernelopt.items()),
         modules   = " --- ".join(self.modules),
         build     = self.build,
         updated   = str(self.updated))

      # Optional int flags.
      for name in self.INT_OPTS:
         value = getattr(self, name)
         if value is not None:
            bootCfgStr += '%s=%u\n' % (name, value)

      # Unknown keywords.
      for name, value in self._unknownMap.items():
         bootCfgStr += '%s=%s\n' % (name, value)

      if isString(f):
         with open(f, "wb") as fobj:
            fobj.write(bootCfgStr.encode())
      else:
         f.write(bootCfgStr.encode())


   @staticmethod
   def kerneloptToDict(kernelOptStr):
      """Helper function to transform a kernelopt string into a dict.
      """
      if not isString(kernelOptStr):
         raise BootCfgError('Invalid kernelopt input, string type expected')
      b = BootCfg()
      b._parseKernelOpt(kernelOptStr)
      return b.kernelopt


   @staticmethod
   def kerneloptToStr(kernelOptDict):
      """Helper function to transform a kernelopt dict into a string.
      """
      if not isinstance(kernelOptDict, dict):
         raise BootCfgError('Invalid kernelopt input, dict type expected')
      keyValueList = []
      for k, v in kernelOptDict.items():
         if not isString(k):
            raise BootCfgError('Invalid key in kernelopt, string type expected')
         if not v:
            keyValueList.append(k)
         else:
            if not isString(v):
               raise BootCfgError('Invalid value for key \'%s\', string type '
                                  'expected' % k)
            keyValueList.append('='.join((k, v)))
      return ' '.join(keyValueList)
