#!/usr/bin/env vpython3
# Copyright 2018 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

import logging
import unittest

from pylib.symbols import apk_native_libs

# Mock ELF-like data
MOCK_ELF_DATA = '\x7fELFFFFFFFFFFFFFFFF'

class MockApkZipInfo(object):
  """A mock ApkZipInfo class, returned by MockApkReaderFactory instances."""
  def __init__(self, filename, file_size, compress_size, file_offset,
               file_data):
    self.filename = filename
    self.file_size = file_size
    self.compress_size = compress_size
    self.file_offset = file_offset
    self._data = file_data

  def __repr__(self):
    """Convert to string for debugging."""
    return 'MockApkZipInfo["%s",size=%d,compressed=%d,offset=%d]' % (
        self.filename, self.file_size, self.compress_size, self.file_offset)

  def IsCompressed(self):
    """Returns True iff the entry is compressed."""
    return self.file_size != self.compress_size

  def IsElfFile(self):
    """Returns True iff the entry is an ELF file."""
    if not self._data or len(self._data) < 4:
      return False

    return self._data[0:4] == '\x7fELF'


class MockApkReader(object):
  """A mock ApkReader instance used during unit-testing.

  Do not use directly, but use a MockApkReaderFactory context, as in:

     with MockApkReaderFactory() as mock:
       mock.AddTestEntry(file_path, file_size, compress_size, file_data)
       ...

       # Actually returns the mock instance.
       apk_reader = apk_native_libs.ApkReader('/some/path.apk')
  """
  def __init__(self, apk_path='test.apk'):
    """Initialize instance."""
    self._entries = []
    self._fake_offset = 0
    self._path = apk_path

  def __enter__(self):
    return self

  def __exit__(self, *kwarg):
    self.Close()
    return

  @property
  def path(self):
    return self._path

  def AddTestEntry(self, filepath, file_size, compress_size, file_data):
    """Add a new entry to the instance for unit-tests.

    Do not call this directly, use the AddTestEntry() method on the parent
    MockApkReaderFactory instance.

    Args:
      filepath: archive file path.
      file_size: uncompressed file size in bytes.
      compress_size: compressed size in bytes.
      file_data: file data to be checked by IsElfFile()

    Note that file_data can be None, or that its size can be actually
    smaller than |compress_size| when used during unit-testing.
    """
    self._entries.append(MockApkZipInfo(filepath, file_size, compress_size,
                         self._fake_offset, file_data))
    self._fake_offset += compress_size

  def Close(self): # pylint: disable=no-self-use
    """Close this reader instance."""
    return

  def ListEntries(self):
    """Return a list of MockApkZipInfo instances for this input APK."""
    return self._entries

  def FindEntry(self, file_path):
    """Find the MockApkZipInfo instance corresponds to a given file path."""
    for entry in self._entries:
      if entry.filename == file_path:
        return entry
    raise KeyError('Could not find mock zip archive member for: ' + file_path)


class MockApkReaderTest(unittest.TestCase):

  def testEmpty(self):
    with MockApkReader() as reader:
      entries = reader.ListEntries()
      self.assertTrue(len(entries) == 0)
      with self.assertRaises(KeyError):
        reader.FindEntry('non-existent-entry.txt')

  def testSingleEntry(self):
    with MockApkReader() as reader:
      reader.AddTestEntry('some-path/some-file', 20000, 12345, file_data=None)
      entries = reader.ListEntries()
      self.assertTrue(len(entries) == 1)
      entry = entries[0]
      self.assertEqual(entry.filename, 'some-path/some-file')
      self.assertEqual(entry.file_size, 20000)
      self.assertEqual(entry.compress_size, 12345)
      self.assertTrue(entry.IsCompressed())

      entry2 = reader.FindEntry('some-path/some-file')
      self.assertEqual(entry, entry2)

  def testMultipleEntries(self):
    with MockApkReader() as reader:
      _ENTRIES = {
        'foo.txt': (1024, 1024, 'FooFooFoo'),
        'lib/bar/libcode.so': (16000, 3240, 1024, '\x7fELFFFFFFFFFFFF'),
      }
      for path, props in _ENTRIES.items():
        reader.AddTestEntry(path, props[0], props[1], props[2])

      entries = reader.ListEntries()
      self.assertEqual(len(entries), len(_ENTRIES))
      for path, props in _ENTRIES.items():
        entry = reader.FindEntry(path)
        self.assertEqual(entry.filename, path)
        self.assertEqual(entry.file_size, props[0])
        self.assertEqual(entry.compress_size, props[1])


class ApkNativeLibrariesTest(unittest.TestCase):

  def setUp(self):
    logging.getLogger().setLevel(logging.ERROR)

  def testEmptyApk(self):
    with MockApkReader() as reader:
      libs_map = apk_native_libs.ApkNativeLibraries(reader)
      self.assertTrue(libs_map.IsEmpty())
      self.assertEqual(len(libs_map.GetLibraries()), 0)
      lib_path, lib_offset = libs_map.FindLibraryByOffset(0)
      self.assertIsNone(lib_path)
      self.assertEqual(lib_offset, 0)

  def testSimpleApk(self):
    with MockApkReader() as reader:
      _MOCK_ENTRIES = [
        # Top-level library should be ignored.
        ('libfoo.so', 1000, 1000, MOCK_ELF_DATA, False),
        # Library not under lib/ should be ignored.
        ('badlib/test-abi/libfoo2.so', 1001, 1001, MOCK_ELF_DATA, False),
        # Library under lib/<abi>/ but without .so extension should be ignored.
        ('lib/test-abi/libfoo4.so.1', 1003, 1003, MOCK_ELF_DATA, False),
        # Library under lib/<abi>/ with .so suffix, but compressed -> ignored.
        ('lib/test-abi/libfoo5.so', 1004, 1003, MOCK_ELF_DATA, False),
        # First correct library
        ('lib/test-abi/libgood1.so', 1005, 1005, MOCK_ELF_DATA, True),
        # Second correct library: support sub-directories
        ('lib/test-abi/subdir/libgood2.so', 1006, 1006, MOCK_ELF_DATA, True),
        # Third correct library, no lib prefix required
        ('lib/test-abi/crazy.libgood3.so', 1007, 1007, MOCK_ELF_DATA, True),
      ]
      file_offsets = []
      prev_offset = 0
      for ent in _MOCK_ENTRIES:
        reader.AddTestEntry(ent[0], ent[1], ent[2], ent[3])
        file_offsets.append(prev_offset)
        prev_offset += ent[2]

      libs_map = apk_native_libs.ApkNativeLibraries(reader)
      self.assertFalse(libs_map.IsEmpty())
      self.assertEqual(libs_map.GetLibraries(), [
          'lib/test-abi/crazy.libgood3.so',
          'lib/test-abi/libgood1.so',
          'lib/test-abi/subdir/libgood2.so',
          ])

      BIAS = 10
      for mock_ent, file_offset in zip(_MOCK_ENTRIES, file_offsets):
        if mock_ent[4]:
          lib_path, lib_offset = libs_map.FindLibraryByOffset(
              file_offset + BIAS)
          self.assertEqual(lib_path, mock_ent[0])
          self.assertEqual(lib_offset, BIAS)


  def testMultiAbiApk(self):
    with MockApkReader() as reader:
      _MOCK_ENTRIES = [
        ('lib/abi1/libfoo.so', 1000, 1000, MOCK_ELF_DATA),
        ('lib/abi2/libfoo.so', 1000, 1000, MOCK_ELF_DATA),
      ]
      for ent in _MOCK_ENTRIES:
        reader.AddTestEntry(ent[0], ent[1], ent[2], ent[3])

      libs_map = apk_native_libs.ApkNativeLibraries(reader)
      self.assertFalse(libs_map.IsEmpty())
      self.assertEqual(libs_map.GetLibraries(), [
          'lib/abi1/libfoo.so', 'lib/abi2/libfoo.so'])

      lib1_name, lib1_offset = libs_map.FindLibraryByOffset(10)
      self.assertEqual(lib1_name, 'lib/abi1/libfoo.so')
      self.assertEqual(lib1_offset, 10)

      lib2_name, lib2_offset = libs_map.FindLibraryByOffset(1000)
      self.assertEqual(lib2_name, 'lib/abi2/libfoo.so')
      self.assertEqual(lib2_offset, 0)


class MockApkNativeLibraries(apk_native_libs.ApkNativeLibraries):
  """A mock ApkNativeLibraries instance that can be used as input to
     ApkLibraryPathTranslator without creating an ApkReader instance.

     Create a new instance, then call AddTestEntry or AddTestEntries
     as many times as necessary, before using it as a regular
     ApkNativeLibraries instance.
  """
  # pylint: disable=super-init-not-called
  def __init__(self):
    self._native_libs = []

  # pylint: enable=super-init-not-called

  def AddTestEntry(self, lib_path, file_offset, file_size):
    """Add a new test entry.

    Args:
      entry: A tuple of (library-path, file-offset, file-size) values,
          (e.g. ('lib/armeabi-v8a/libfoo.so', 0x10000, 0x2000)).
    """
    self._native_libs.append((lib_path, file_offset, file_offset + file_size))

  def AddTestEntries(self, entries):
    """Add a list of new test entries.

    Args:
      entries: A list of (library-path, file-offset, file-size) values.
    """
    for entry in entries:
      self.AddTestEntry(entry[0], entry[1], entry[2])


class MockApkNativeLibrariesTest(unittest.TestCase):

  def testEmptyInstance(self):
    mock = MockApkNativeLibraries()
    self.assertTrue(mock.IsEmpty())
    self.assertEqual(mock.GetLibraries(), [])
    self.assertEqual(mock.GetDumpList(), [])

  def testAddTestEntry(self):
    mock = MockApkNativeLibraries()
    mock.AddTestEntry('lib/armeabi-v7a/libfoo.so', 0x20000, 0x4000)
    mock.AddTestEntry('lib/x86/libzoo.so', 0x10000, 0x10000)
    mock.AddTestEntry('lib/armeabi-v7a/libbar.so', 0x24000, 0x8000)
    self.assertFalse(mock.IsEmpty())
    self.assertEqual(mock.GetLibraries(), ['lib/armeabi-v7a/libbar.so',
                                           'lib/armeabi-v7a/libfoo.so',
                                           'lib/x86/libzoo.so'])
    self.assertEqual(mock.GetDumpList(), [
        ('lib/x86/libzoo.so', 0x10000, 0x10000),
        ('lib/armeabi-v7a/libfoo.so', 0x20000, 0x4000),
        ('lib/armeabi-v7a/libbar.so', 0x24000, 0x8000),
    ])

  def testAddTestEntries(self):
    mock = MockApkNativeLibraries()
    mock.AddTestEntries([
      ('lib/armeabi-v7a/libfoo.so', 0x20000, 0x4000),
      ('lib/x86/libzoo.so', 0x10000, 0x10000),
      ('lib/armeabi-v7a/libbar.so', 0x24000, 0x8000),
    ])
    self.assertFalse(mock.IsEmpty())
    self.assertEqual(mock.GetLibraries(), ['lib/armeabi-v7a/libbar.so',
                                           'lib/armeabi-v7a/libfoo.so',
                                           'lib/x86/libzoo.so'])
    self.assertEqual(mock.GetDumpList(), [
        ('lib/x86/libzoo.so', 0x10000, 0x10000),
        ('lib/armeabi-v7a/libfoo.so', 0x20000, 0x4000),
        ('lib/armeabi-v7a/libbar.so', 0x24000, 0x8000),
    ])


class ApkLibraryPathTranslatorTest(unittest.TestCase):

  def _CheckUntranslated(self, translator, path, offset):
    """Check that a given (path, offset) is not modified by translation."""
    self.assertEqual(translator.TranslatePath(path, offset), (path, offset))


  def _CheckTranslated(self, translator, path, offset, new_path, new_offset):
    """Check that (path, offset) is translated into (new_path, new_offset)."""
    self.assertEqual(translator.TranslatePath(path, offset),
                     (new_path, new_offset))

  def testEmptyInstance(self):
    translator = apk_native_libs.ApkLibraryPathTranslator()
    self._CheckUntranslated(
        translator, '/data/data/com.example.app-1/base.apk', 0x123456)

  def testSimpleApk(self):
    mock_libs = MockApkNativeLibraries()
    mock_libs.AddTestEntries([
      ('lib/test-abi/libfoo.so', 200, 2000),
      ('lib/test-abi/libbar.so', 3200, 3000),
      ('lib/test-abi/crazy.libzoo.so', 6200, 2000),
    ])
    translator = apk_native_libs.ApkLibraryPathTranslator()
    translator.AddHostApk('com.example.app', mock_libs)

    # Offset is within the first uncompressed library
    self._CheckTranslated(
        translator,
        '/data/data/com.example.app-9.apk', 757,
        '/data/data/com.example.app-9.apk!lib/libfoo.so', 557)

    # Offset is within the second compressed library.
    self._CheckUntranslated(
        translator,
        '/data/data/com.example.app-9/base.apk', 2800)

    # Offset is within the third uncompressed library.
    self._CheckTranslated(
        translator,
        '/data/data/com.example.app-1/base.apk', 3628,
        '/data/data/com.example.app-1/base.apk!lib/libbar.so', 428)

    # Offset is within the fourth uncompressed library with crazy. prefix
    self._CheckTranslated(
        translator,
        '/data/data/com.example.app-XX/base.apk', 6500,
        '/data/data/com.example.app-XX/base.apk!lib/libzoo.so', 300)

    # Out-of-bounds apk offset.
    self._CheckUntranslated(
        translator,
        '/data/data/com.example.app-1/base.apk', 10000)

    # Invalid package name.
    self._CheckUntranslated(
        translator, '/data/data/com.example2.app-1/base.apk', 757)

    # Invalid apk name.
    self._CheckUntranslated(
          translator, '/data/data/com.example.app-2/not-base.apk', 100)

    # Invalid file extensions.
    self._CheckUntranslated(
          translator, '/data/data/com.example.app-2/base', 100)

    self._CheckUntranslated(
          translator, '/data/data/com.example.app-2/base.apk.dex', 100)

  def testBundleApks(self):
    mock_libs1 = MockApkNativeLibraries()
    mock_libs1.AddTestEntries([
      ('lib/test-abi/libfoo.so', 200, 2000),
      ('lib/test-abi/libbbar.so', 3200, 3000),
    ])
    mock_libs2 = MockApkNativeLibraries()
    mock_libs2.AddTestEntries([
      ('lib/test-abi/libzoo.so', 200, 2000),
      ('lib/test-abi/libtool.so', 3000, 4000),
    ])
    translator = apk_native_libs.ApkLibraryPathTranslator()
    translator.AddHostApk('com.example.app', mock_libs1, 'base-master.apk')
    translator.AddHostApk('com.example.app', mock_libs2, 'feature-master.apk')

    self._CheckTranslated(
      translator,
      '/data/app/com.example.app-XUIYIUW/base-master.apk', 757,
      '/data/app/com.example.app-XUIYIUW/base-master.apk!lib/libfoo.so', 557)

    self._CheckTranslated(
      translator,
      '/data/app/com.example.app-XUIYIUW/feature-master.apk', 3200,
      '/data/app/com.example.app-XUIYIUW/feature-master.apk!lib/libtool.so',
      200)


if __name__ == '__main__':
  unittest.main()
