# Copyright (c) 2010 The Chromium OS Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
""" This module provides convenience routines to access Flash ROM (EEPROM)

saft_flashrom_util is based on utility 'flashrom'.

Original tool syntax:
    (read ) flashrom -r <file>
    (write) flashrom -l <layout_fn> [-i <image_name> ...] -w <file>

The layout_fn is in format of
    address_begin:address_end image_name
    which defines a region between (address_begin, address_end) and can
    be accessed by the name image_name.

Currently the tool supports multiple partial write but not partial read.

In the saft_flashrom_util, we provide read and partial write abilities.
For more information, see help(saft_flashrom_util.flashrom_util).
"""
import re
import logging


class TestError(Exception):
    """Represents an internal error, such as invalid arguments."""
    pass


class LayoutScraper(object):
    """Object of this class is used to retrieve layout from a BIOS file."""

    DEFAULT_CHROMEOS_FMAP_CONVERSION = {
            "BOOT_STUB": "FV_BSTUB",
            "RO_FRID": "RO_FRID",
            "GBB": "FV_GBB",
            "RECOVERY": "FVDEV",
            "VBLOCK_A": "VBOOTA",
            "VBLOCK_B": "VBOOTB",
            "FW_MAIN_A": "FVMAIN",
            "FW_MAIN_B": "FVMAINB",
            "RW_FWID_A": "RW_FWID_A",
            "RW_FWID_B": "RW_FWID_B",
            # Intel CSME FW Update sections
            "ME_RW_A": "ME_RW_A",
            "ME_RW_B": "ME_RW_B",
            # Memory Training data cache for recovery boots
            # Added on Nov 09, 2016
            "RECOVERY_MRC_CACHE": "RECOVERY_MRC_CACHE",
            # New sections in Depthcharge.
            "EC_MAIN_A": "ECMAINA",
            "EC_MAIN_B": "ECMAINB",
            # EC firmware layout
            "EC_RW": "EC_RW",
            "EC_RW_B": "EC_RW_B",
            "RW_FWID": "RW_FWID",
            "RW_LEGACY": "RW_LEGACY",
    }

    def __init__(self, os_if):
        self.image = None
        self.os_if = os_if

    def check_layout(self, layout, file_size):
        """Verify the layout to be consistent.

        The layout is consistent if there is no overlapping sections and the
        section boundaries do not exceed the file size.

        Inputs:
          layout: a dictionary keyed by a string (the section name) with
                  values being two integers tuples, the first and the last
                  bites' offset in the file.
          file_size: and integer, the size of the file the layout describes
                     the sections in.

        Raises:
          TestError in case the layout is not consistent.
        """

        # Generate a list of section range tuples.
        ost = sorted([layout[section] for section in layout])
        base = -1
        for section_base, section_end in ost:
            if section_base <= base or section_end + 1 < section_base:
                # Overlapped section is possible, like the fwid which is
                # inside the main fw section.
                logging.info('overlapped section at 0x%x..0x%x', section_base,
                             section_end)
            base = section_end
        if base > file_size:
            raise TestError('Section end 0x%x exceeds file size %x' %
                            (base, file_size))

    def get_layout(self, file_name):
        """Generate layout for a firmware file.

        Internally, this uses the "dump_fmap" command, and converts
        the output into a dictionary mapping region names to 2-tuples
        of the start and last addresses.

        Then verify the generated layout's consistency and return it to the
        caller.
        """
        command = 'dump_fmap -p %s' % file_name
        layout_data = {}  # keyed by the section name, elements - tuples of
        # (<section start addr>, <section end addr>)

        for line in self.os_if.run_shell_command_get_output(command):
            region_name, offset, size = line.split()

            try:
                name = self.DEFAULT_CHROMEOS_FMAP_CONVERSION[region_name]
            except KeyError:
                continue  # This line does not contain an area of interest.

            if name in layout_data:
                raise TestError('%s duplicated in the layout' % name)

            offset = int(offset)
            size = int(size)
            layout_data[name] = (offset, offset + size - 1)

        self.check_layout(layout_data, self.os_if.get_file_size(file_name))
        return layout_data


# flashrom utility wrapper
class flashrom_util(object):
    """ a wrapper for "flashrom" utility.

    You can read, write, or query flash ROM size with this utility.
    Although you can do "partial-write", the tools always takes a
    full ROM image as input parameter.

    NOTE before accessing flash ROM, you may need to first "select"
    your target - usually BIOS or EC. That part is not handled by
    this utility. Please find other external script to do it.

    To perform a read, you need to:
     1. Prepare a flashrom_util object
        ex: flashrom = flashrom_util.flashrom_util()
     2. Perform read operation
        ex: image = flashrom.read_whole()

        When the contents of the flashrom is read off the target, it's map
        gets created automatically (read from the flashrom image using
        'dump_fmap'). If the user wants this object to operate on some other
        file, they could either have the map for the file created explicitly by
        invoking flashrom.set_firmware_layout(filename), or supply their own map
        (which is a dictionary where keys are section names, and values are
        tuples of integers, base address of the section and the last address
        of the section).

    By default this object operates on the map retrieved from the image and
    stored locally, this map can be overwritten by an explicitly passed user
    map.

    To perform a (partial) write:

     1. Prepare a buffer storing an image to be written into the flashrom.
     2. Have the map generated automatically or prepare your own, for instance:
        ex: layout_map_all = { 'all': (0, rom_size - 1) }
        ex: layout_map = { 'ro': (0, 0xFFF), 'rw': (0x1000, rom_size-1) }
     4. Perform write operation

        ex using default map:
          flashrom.write_partial(new_image, (<section_name>, ...))
        ex using explicitly provided map:
          flashrom.write_partial(new_image, layout_map_all, ('all',))
    """

    def __init__(self, os_if, keep_temp_files=False, target_is_ec=False):
        """ constructor of flashrom_util. help(flashrom_util) for more info

        @param os_if: an object providing interface to OS services
        @param keep_temp_files: if true, preserve temp files after operations
        @param target_is_ec: if false, target is BIOS/AP

        @type os_if: client.cros.faft.utils.os_interface.OSInterface
        @type keep_temp_files: bool
        @type target_is_ec: bool
        """

        self.os_if = os_if
        self.keep_temp_files = keep_temp_files
        self.firmware_layout = {}
        self._target_command = ''
        if target_is_ec:
            self._enable_ec_access()
        else:
            self._enable_bios_access()

    def _enable_bios_access(self):
        if self.os_if.test_mode or self.os_if.target_hosted():
            self._target_command = '-p host'

    def _enable_ec_access(self):
        if self.os_if.test_mode or self.os_if.target_hosted():
            self._target_command = '-p ec'

    def _get_temp_filename(self, prefix):
        """Returns name of a temporary file in /tmp."""
        return self.os_if.create_temp_file(prefix)

    def _remove_temp_file(self, filename):
        """Removes a temp file if self.keep_temp_files is false."""
        if self.keep_temp_files:
            return
        if self.os_if.path_exists(filename):
            self.os_if.remove_file(filename)

    def _create_layout_file(self, layout_map):
        """Creates a layout file based on layout_map.

        Returns the file name containing layout information.
        """
        layout_text = [
                '0x%08lX:0x%08lX %s' % (v[0], v[1], k)
                for k, v in layout_map.items()
        ]
        layout_text.sort()  # XXX unstable if range exceeds 2^32
        tmpfn = self._get_temp_filename('lay_')
        with open(tmpfn, "w") as file:
            file.write('\n'.join(layout_text) + '\n')
        return tmpfn

    def check_target(self):
        """Check if flashrom programmer is working, by specifying no commands.

        The command executed is just 'flashrom -p <target>'.

        @return: True if flashrom completed successfully
        @raise autotest_lib.client.common_lib.error.CmdError: if flashrom failed
        """
        cmd = 'flashrom %s' % self._target_command
        self.os_if.run_shell_command(cmd)
        return True

    def get_section(self, base_image, section_name):
        """
        Retrieves a section of data based on section_name in layout_map.
        Raises error if unknown section or invalid layout_map.
        """
        if section_name not in self.firmware_layout:
            return ''
        pos = self.firmware_layout[section_name]
        if pos[0] >= pos[1] or pos[1] >= len(base_image):
            raise TestError(
                    'INTERNAL ERROR: invalid layout map: %s.' % section_name)
        blob = base_image[pos[0]:pos[1] + 1]
        # Trim down the main firmware body to its actual size since the
        # signing utility uses the size of the input file as the size of
        # the data to sign. Make it the same way as firmware creation.
        if section_name in ('FVMAIN', 'FVMAINB', 'ECMAINA', 'ECMAINB'):
            align = 4
            pad = blob[-1:]
            blob = blob.rstrip(pad)
            blob = blob + ((align - 1) - (len(blob) - 1) % align) * pad
        return blob

    def put_section(self, base_image, section_name, data):
        """
        Updates a section of data based on section_name in firmware_layout.
        Raises error if unknown section.
        Returns the full updated image data.
        """
        pos = self.firmware_layout[section_name]
        if pos[0] >= pos[1] or pos[1] >= len(base_image):
            raise TestError('INTERNAL ERROR: invalid layout map.')
        if len(data) != pos[1] - pos[0] + 1:
            # Pad the main firmware body since we trimed it down before.
            if (len(data) < pos[1] - pos[0] + 1
                        and section_name in ('FVMAIN', 'FVMAINB', 'ECMAINA',
                                             'ECMAINB', 'RW_FWID')):
                pad = base_image[pos[1]:pos[1] + 1]
                data = data + pad * (pos[1] - pos[0] + 1 - len(data))
            else:
                raise TestError('INTERNAL ERROR: unmatched data size.')
        return base_image[0:pos[0]] + data + base_image[pos[1] + 1:]

    def get_size(self):
        """ Gets size of current flash ROM """
        # TODO(hungte) Newer version of tool (flashrom) may support --get-size
        # command which is faster in future. Right now we use back-compatible
        # method: read whole and then get length.
        image = self.read_whole()
        return len(image)

    def set_firmware_layout(self, file_name):
        """get layout read from the BIOS """

        scraper = LayoutScraper(self.os_if)
        self.firmware_layout = scraper.get_layout(file_name)

    def enable_write_protect(self):
        """Enable the write protection of the flash chip."""

        # For MTD devices, this will fail: need both --wp-range and --wp-enable.
        # See: https://crrev.com/c/275381

        cmd = 'flashrom %s --verbose --wp-enable' % self._target_command
        self.os_if.run_shell_command(cmd, modifies_device=True)

    def disable_write_protect(self):
        """Disable the write protection of the flash chip."""
        cmd = 'flashrom %s --verbose --wp-disable' % self._target_command
        self.os_if.run_shell_command(cmd, modifies_device=True)

    def set_write_protect_region(self, image_file, region, enabled=None):
        """
        Set write protection region, using specified image's layout.

        The name should match those seen in `futility dump_fmap <image>`, and
        is not checked against self.firmware_layout, due to different naming.

        @param image_file: path of the image file to read regions from
        @param region: Region to set (usually WP_RO)
        @param enabled: if True, run --wp-enable; if False, run --wp-disable.
        """
        cmd = 'flashrom %s --verbose --image %s:%s --wp-region %s' % (
                self._target_command, region, image_file, region)
        if enabled is not None:
            cmd += ' '
            cmd += '--wp-enable' if enabled else '--wp-disable'

        self.os_if.run_shell_command(cmd, modifies_device=True)

    def set_write_protect_range(self, start, length, enabled=None):
        """
        Set write protection range by offset, using current image's layout.

        @param start: offset (bytes) from start of flash to start of range
        @param length: offset (bytes) from start of range to end of range
        @param enabled: If True, run --wp-enable; if False, run --wp-disable.
                        If None (default), don't specify either one.
        """
        cmd = 'flashrom %s --verbose --wp-range %s,%s' % (
                self._target_command, start, length)
        if enabled is not None:
            cmd += ' '
            cmd += '--wp-enable' if enabled else '--wp-disable'

        self.os_if.run_shell_command(cmd, modifies_device=True)

    def get_write_protect_status(self):
        """Get a dict describing the status of the write protection

        @return: {'enabled': True/False, 'start': '0x0', 'length': '0x0', ...}
        @rtype: dict
        """
        # https://crrev.com/8ebbd500b5d8da9f6c1b9b44b645f99352ef62b4/writeprotect.c

        status_pattern = re.compile(
                r'WP: status: (.*)')
        enabled_pattern = re.compile(
                r'WP: write protect is (\w+)\.?')
        range_pattern = re.compile(
                r'WP: write protect range: start=(\w+), len=(\w+)')
        range_err_pattern = re.compile(
                r'WP: write protect range: (.+)')

        output = self.os_if.run_shell_command_get_output(
                'flashrom %s --wp-status' % self._target_command)
        logging.debug('`flashrom %s --wp-status` returned %s',
                      self._target_command, output)

        wp_status = {}
        for line in output:
            if not line.startswith('WP: '):
                continue

            found_enabled = re.match(enabled_pattern, line)
            if found_enabled:
                status_word = found_enabled.group(1)
                wp_status['enabled'] = (status_word == 'enabled')
                continue

            found_range = re.match(range_pattern, line)
            if found_range:
                (start, length) = found_range.groups()
                wp_status['start'] = int(start, 16)
                wp_status['length'] = int(length, 16)
                continue

            found_range_err = re.match(range_err_pattern, line)
            if found_range_err:
                # WP: write protect range: (cannot resolve the range)
                wp_status['error'] = found_range_err.group(1)
                continue

            found_status = re.match(status_pattern, line)
            if found_status:
                wp_status['status'] = found_status.group(1)
                continue

        return wp_status

    def dump_flash(self, filename):
        """Read the flash device's data into a file, but don't parse it."""
        cmd = 'flashrom %s -r "%s"' % (self._target_command, filename)
        logging.info('flashrom_util.dump_flash(): %s', cmd)
        self.os_if.run_shell_command(cmd)

    def read_whole(self):
        """
        Reads whole flash ROM data.
        Returns the data read from flash ROM, or empty string for other error.
        """
        tmpfn = self._get_temp_filename('rd_')
        cmd = 'flashrom %s -r "%s"' % (self._target_command, tmpfn)
        logging.info('flashrom_util.read_whole(): %s', cmd)
        self.os_if.run_shell_command(cmd)
        result = self.os_if.read_file(tmpfn)
        self.set_firmware_layout(tmpfn)

        # clean temporary resources
        self._remove_temp_file(tmpfn)
        return result

    def write_partial(self, base_image, write_list, write_layout_map=None):
        """
        Writes data in sections of write_list to flash ROM.
        An exception is raised if write operation fails.
        """

        if write_layout_map:
            layout_map = write_layout_map
        else:
            layout_map = self.firmware_layout

        tmpfn = self._get_temp_filename('wr_')
        self.os_if.write_file(tmpfn, base_image)
        layout_fn = self._create_layout_file(layout_map)

        write_cmd = 'flashrom %s -l "%s" -i %s -w "%s"' % (
                self._target_command, layout_fn, ' -i '.join(write_list),
                tmpfn)
        logging.info('flashrom.write_partial(): %s', write_cmd)
        self.os_if.run_shell_command(write_cmd, modifies_device=True)

        # clean temporary resources
        self._remove_temp_file(tmpfn)
        self._remove_temp_file(layout_fn)

    def write_whole(self, base_image):
        """Write the whole base image. """
        layout_map = {'all': (0, len(base_image) - 1)}
        self.write_partial(base_image, ('all', ), layout_map)

    def get_write_cmd(self, image=None):
        """Get the command to write the whole image (no layout handling)

        @param image: the filename (empty to use current handler data)
        """
        return 'flashrom %s -w "%s"' % (self._target_command, image)
