#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#
# Copyright 2019 The ChromiumOS Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

"""Diff 2 chromiumos images by comparing each elf file.

   The script diffs every *ELF* files by dissembling every *executable*
   section, which means it is not a FULL elf differ.

   A simple usage example -
     chromiumos_image_diff.py --image1 image-path-1 --image2 image-path-2

   Note that image path should be inside the chroot, if not (ie, image is
   downloaded from web), please specify a chromiumos checkout via
   "--chromeos_root".

   And this script should be executed outside chroot.
"""


__author__ = "shenhan@google.com (Han Shen)"

import argparse
import os
import re
import sys
import tempfile

from cros_utils import command_executer
from cros_utils import logger
from cros_utils import misc
import image_chromeos


class CrosImage(object):
    """A cros image object."""

    def __init__(self, image, chromeos_root, no_unmount):
        self.image = image
        self.chromeos_root = chromeos_root
        self.mounted = False
        self._ce = command_executer.GetCommandExecuter()
        self.logger = logger.GetLogger()
        self.elf_files = []
        self.no_unmount = no_unmount
        self.unmount_script = ""
        self.stateful = ""
        self.rootfs = ""

    def MountImage(self, mount_basename):
        """Mount/unpack the image."""

        if mount_basename:
            self.rootfs = "/tmp/{0}.rootfs".format(mount_basename)
            self.stateful = "/tmp/{0}.stateful".format(mount_basename)
            self.unmount_script = "/tmp/{0}.unmount.sh".format(mount_basename)
        else:
            self.rootfs = tempfile.mkdtemp(
                suffix=".rootfs", prefix="chromiumos_image_diff"
            )
            ## rootfs is like /tmp/tmpxyz012.rootfs.
            match = re.match(r"^(.*)\.rootfs$", self.rootfs)
            basename = match.group(1)
            self.stateful = basename + ".stateful"
            os.mkdir(self.stateful)
            self.unmount_script = "{0}.unmount.sh".format(basename)

        self.logger.LogOutput(
            'Mounting "{0}" onto "{1}" and "{2}"'.format(
                self.image, self.rootfs, self.stateful
            )
        )
        ## First of all creating an unmount image
        self.CreateUnmountScript()
        command = image_chromeos.GetImageMountCommand(
            self.image, self.rootfs, self.stateful
        )
        rv = self._ce.RunCommand(command, print_to_console=True)
        self.mounted = rv == 0
        if not self.mounted:
            self.logger.LogError(
                'Failed to mount "{0}" onto "{1}" and "{2}".'.format(
                    self.image, self.rootfs, self.stateful
                )
            )
        return self.mounted

    def CreateUnmountScript(self):
        command = (
            "sudo umount {r}/usr/local {r}/usr/share/oem "
            "{r}/var {r}/mnt/stateful_partition {r}; sudo umount {s} ; "
            "rmdir {r} ; rmdir {s}\n"
        ).format(r=self.rootfs, s=self.stateful)
        f = open(self.unmount_script, "w", encoding="utf-8")
        f.write(command)
        f.close()
        self._ce.RunCommand(
            "chmod +x {}".format(self.unmount_script), print_to_console=False
        )
        self.logger.LogOutput(
            'Created an unmount script - "{0}"'.format(self.unmount_script)
        )

    def UnmountImage(self):
        """Unmount the image and delete mount point."""

        self.logger.LogOutput(
            'Unmounting image "{0}" from "{1}" and "{2}"'.format(
                self.image, self.rootfs, self.stateful
            )
        )
        if self.mounted:
            command = 'bash "{0}"'.format(self.unmount_script)
            if self.no_unmount:
                self.logger.LogOutput(
                    (
                        "Please unmount manually - \n"
                        '\t bash "{0}"'.format(self.unmount_script)
                    )
                )
            else:
                if self._ce.RunCommand(command, print_to_console=True) == 0:
                    self._ce.RunCommand("rm {0}".format(self.unmount_script))
                    self.mounted = False
                    self.rootfs = None
                    self.stateful = None
                    self.unmount_script = None

        return not self.mounted

    def FindElfFiles(self):
        """Find all elf files for the image.

        Returns:
          Always true
        """

        self.logger.LogOutput(
            'Finding all elf files in "{0}" ...'.format(self.rootfs)
        )
        # Note '\;' must be prefixed by 'r'.
        command = (
            'find "{0}" -type f -exec '
            'bash -c \'file -b "{{}}" | grep -q "ELF"\''
            r" \; "
            r'-exec echo "{{}}" \;'
        ).format(self.rootfs)
        self.logger.LogCmd(command)
        _, out, _ = self._ce.RunCommandWOutput(command, print_to_console=False)
        self.elf_files = out.splitlines()
        self.logger.LogOutput(
            "Total {0} elf files found.".format(len(self.elf_files))
        )
        return True


class ImageComparator(object):
    """A class that wraps comparsion actions."""

    def __init__(self, images, diff_file):
        self.images = images
        self.logger = logger.GetLogger()
        self.diff_file = diff_file
        self.tempf1 = None
        self.tempf2 = None

    def Cleanup(self):
        if self.tempf1 and self.tempf2:
            command_executer.GetCommandExecuter().RunCommand(
                "rm {0} {1}".format(self.tempf1, self.tempf2)
            )
            logger.GetLogger(
                'Removed "{0}" and "{1}".'.format(self.tempf1, self.tempf2)
            )

    def CheckElfFileSetEquality(self):
        """Checking whether images have exactly number of elf files."""

        self.logger.LogOutput("Checking elf file equality ...")
        i1 = self.images[0]
        i2 = self.images[1]
        t1 = i1.rootfs + "/"
        elfset1 = {e.replace(t1, "") for e in i1.elf_files}
        t2 = i2.rootfs + "/"
        elfset2 = {e.replace(t2, "") for e in i2.elf_files}
        dif1 = elfset1.difference(elfset2)
        msg = None
        if dif1:
            msg = 'The following files are not in "{image}" - "{rootfs}":\n'.format(
                image=i2.image, rootfs=i2.rootfs
            )
            for d in dif1:
                msg += "\t" + d + "\n"
        dif2 = elfset2.difference(elfset1)
        if dif2:
            msg = 'The following files are not in "{image}" - "{rootfs}":\n'.format(
                image=i1.image, rootfs=i1.rootfs
            )
            for d in dif2:
                msg += "\t" + d + "\n"
        if msg:
            self.logger.LogError(msg)
            return False
        return True

    def CompareImages(self):
        """Do the comparsion work."""

        if not self.CheckElfFileSetEquality():
            return False

        mismatch_list = []
        match_count = 0
        i1 = self.images[0]
        i2 = self.images[1]
        self.logger.LogOutput(
            "Start comparing {0} elf file by file ...".format(len(i1.elf_files))
        )
        ## Note - i1.elf_files and i2.elf_files have exactly the same entries here.

        ## Create 2 temp files to be used for all disassembed files.
        handle, self.tempf1 = tempfile.mkstemp()
        os.close(handle)  # We do not need the handle
        handle, self.tempf2 = tempfile.mkstemp()
        os.close(handle)

        cmde = command_executer.GetCommandExecuter()
        for elf1 in i1.elf_files:
            tmp_rootfs = i1.rootfs + "/"
            f1 = elf1.replace(tmp_rootfs, "")
            full_path1 = elf1
            full_path2 = elf1.replace(i1.rootfs, i2.rootfs)

            if full_path1 == full_path2:
                self.logger.LogError(
                    "Error:  We're comparing the SAME file - {0}".format(f1)
                )
                continue

            command = (
                'objdump -d "{f1}" > {tempf1} ; '
                'objdump -d "{f2}" > {tempf2} ; '
                # Remove path string inside the dissemble
                "sed -i 's!{rootfs1}!!g' {tempf1} ; "
                "sed -i 's!{rootfs2}!!g' {tempf2} ; "
                "diff {tempf1} {tempf2} 1>/dev/null 2>&1"
            ).format(
                f1=full_path1,
                f2=full_path2,
                rootfs1=i1.rootfs,
                rootfs2=i2.rootfs,
                tempf1=self.tempf1,
                tempf2=self.tempf2,
            )
            ret = cmde.RunCommand(command, print_to_console=False)
            if ret != 0:
                self.logger.LogOutput(
                    '*** Not match - "{0}" "{1}"'.format(full_path1, full_path2)
                )
                mismatch_list.append(f1)
                if self.diff_file:
                    command = (
                        'echo "Diffs of disassemble of "{f1}" and "{f2}"" '
                        ">> {diff_file} ; diff {tempf1} {tempf2} "
                        ">> {diff_file}"
                    ).format(
                        f1=full_path1,
                        f2=full_path2,
                        diff_file=self.diff_file,
                        tempf1=self.tempf1,
                        tempf2=self.tempf2,
                    )
                    cmde.RunCommand(command, print_to_console=False)
            else:
                match_count += 1
        ## End of comparing every elf files.

        if not mismatch_list:
            self.logger.LogOutput(
                "** COOL, ALL {0} BINARIES MATCHED!! **".format(match_count)
            )
            return True

        mismatch_str = "Found {0} mismatch:\n".format(len(mismatch_list))
        for b in mismatch_list:
            mismatch_str += "\t" + b + "\n"

        self.logger.LogOutput(mismatch_str)
        return False


def Main(argv):
    """The main function."""

    command_executer.InitCommandExecuter()
    images = []

    parser = argparse.ArgumentParser()
    parser.add_argument(
        "--no_unmount",
        action="store_true",
        dest="no_unmount",
        default=False,
        help="Do not unmount after finish, this is useful for debugging.",
    )
    parser.add_argument(
        "--chromeos_root",
        dest="chromeos_root",
        default=None,
        action="store",
        help=(
            "[Optional] Specify a chromeos tree instead of "
            "deducing it from image path so that we can compare "
            "2 images that are downloaded."
        ),
    )
    parser.add_argument(
        "--mount_basename",
        dest="mount_basename",
        default=None,
        action="store",
        help=(
            "Specify a meaningful name for the mount point. With this being "
            'set, the mount points would be "/tmp/mount_basename.x.rootfs" '
            ' and "/tmp/mount_basename.x.stateful". (x is 1 or 2).'
        ),
    )
    parser.add_argument(
        "--diff_file",
        dest="diff_file",
        default=None,
        help="Dumping all the diffs (if any) to the diff file",
    )
    parser.add_argument(
        "--image1",
        dest="image1",
        default=None,
        required=True,
        help=("Image 1 file name."),
    )
    parser.add_argument(
        "--image2",
        dest="image2",
        default=None,
        required=True,
        help=("Image 2 file name."),
    )
    options = parser.parse_args(argv[1:])

    if options.mount_basename and options.mount_basename.find("/") >= 0:
        logger.GetLogger().LogError(
            '"--mount_basename" must be a name, not a path.'
        )
        parser.print_help()
        return 1

    result = False
    image_comparator = None
    try:
        for i, image_path in enumerate(
            [options.image1, options.image2], start=1
        ):
            image_path = os.path.realpath(image_path)
            if not os.path.isfile(image_path):
                logger.GetLogger().LogError(
                    '"{0}" is not a file.'.format(image_path)
                )
                return 1

            chromeos_root = None
            if options.chromeos_root:
                chromeos_root = options.chromeos_root
            else:
                ## Deduce chromeos root from image
                t = image_path
                while t != "/":
                    if misc.IsChromeOsTree(t):
                        break
                    t = os.path.dirname(t)
                if misc.IsChromeOsTree(t):
                    chromeos_root = t

            if not chromeos_root:
                logger.GetLogger().LogError(
                    "Please provide a valid chromeos root via --chromeos_root"
                )
                return 1

            image = CrosImage(image_path, chromeos_root, options.no_unmount)

            if options.mount_basename:
                mount_basename = "{basename}.{index}".format(
                    basename=options.mount_basename, index=i
                )
            else:
                mount_basename = None

            if image.MountImage(mount_basename):
                images.append(image)
                image.FindElfFiles()

        if len(images) == 2:
            image_comparator = ImageComparator(images, options.diff_file)
            result = image_comparator.CompareImages()
    finally:
        for image in images:
            image.UnmountImage()
        if image_comparator:
            image_comparator.Cleanup()

    return 0 if result else 1


if __name__ == "__main__":
    Main(sys.argv)
