chromium/chrome/android/monochrome/scripts/monochrome_apk_checker_test.py

# Copyright 2018 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

import contextlib
import io
import os
import posixpath
import re
import subprocess

import typ

def BuildFileMatchRegex(*file_matchers):
  return re.compile('^' + '|'.join(file_matchers) + '$')


# Chrome specific files which are not in Monochrome.apk
CHROME_SPECIFIC = BuildFileMatchRegex(
    r'lib/.*/libchrome\.so',
    r'lib/.*/libchrome\.\d{4}\.\d{2,3}\.so', # libchrome placeholders
    r'lib/.*/libchromium_android_linker\.so',
    r'lib/.*/libchromeview\.so', # placeholder library
    r'lib/.*/libchrome_crashpad_handler\.so',
    r'lib/.*/crazy\.libchrome\.so',
    r'lib/.*/crazy\.libchrome\.align',
    r'lib/.*/gdbserver',
    # R.font.accent_font is an alias in internal Chrome.
    r'res/.*/accent_font.xml',
    # Monochrome doesn't have any res directories whose api number is less
    # than v24.
    r'res/.*-v1\d/.*\.xml',
    r'res/.*-v2[0-3]/.*\.xml',
    r'META-INF/.*',
    r'assets/dexopt/baseline.prof',
    r'assets/dexopt/baseline.profm',
    r'assets/metaresources.arsc',
    r'assets/AndroidManifest.xml')

# WebView specific files which are not in Monochrome.apk
WEBVIEW_SPECIFIC = BuildFileMatchRegex(
    r'lib/.*/libwebviewchromium\.so',
    r'lib/.*/libchromium_android_linker\.so',
    r'assets/webview_licenses.notice',
    r'res/.*/accent_font.xml',
    r'res/.*/icon_webview(.webp)?',
    r'META-INF/.*',
     # Monochrome doesn't have any res directories
     # whose api level is less than v24.
    r'res/.*-v1\d/.*\.xml',
    r'res/.*-v2[0-3]/.*\.xml',
    r'lib/.*/gdbserver',
    # libarcore is only added to the aab version of monochrome.
    r'lib/.*/libarcore_sdk_c\.so')

# The files in Chrome are not same as those in Monochrome
CHROME_CHANGES = BuildFileMatchRegex(
    r'AndroidManifest\.xml',
    r'resources\.arsc',
    r'classes\d*\.dex',
    r'res/.*\.xml', # Resource id isn't same
    r'assets/unwind_cfi_32', # Generated from apk's shared library
     # All pak files except chrome_100_percent.pak are different
    r'assets/resources\.pak',
    r'assets/locales/.*\.pak')

# The files in WebView are not same as those in Monochrome
WEBVIEW_CHANGES = BuildFileMatchRegex(
    r'AndroidManifest\.xml',
    r'resources\.arsc',
    r'classes\d?\.dex',
    r'res/.*\.xml', # Resource id isn't same
    r'assets/.*\.pak') # All pak files are not same as Monochrome

# Parse the output of unzip -lv, like
# 2384  Defl:N      807  66% 2001-01-01 00:00 2f2d9fce  res/xml/privacy.xml
ZIP_ENTRY = re.compile(
    "^ *[0-9]+ +\S+ +[0-9]+ +(?P<cmpr>[0-9]{1,2})% +\S+ +\S+ +"
    "(?P<crc>[0-9a-fA-F]+) +(?P<name>\S+)"
  )

class APKEntry:
  def __init__(self,  filename, crc, uncompressed):
    self.filename = filename
    self.CRC = crc
    self.uncompressed = uncompressed

def DumpAPK(apk):
  args = ['unzip', '-lv']
  args.append(apk)
  content = subprocess.check_output(args, universal_newlines=True)
  apk_entries = []
  with contextlib.closing(io.StringIO(content)) as f:
    for line in f:
      match = ZIP_ENTRY.match(line)
      if match:
        apk_entries.append(
            APKEntry(
                match.group('name'), match.group('crc'),
                match.group('cmpr') == 0))
  return apk_entries


def DeobfuscateFilename(obfuscated_filename, pathmap):
  path = pathmap.get(obfuscated_filename, obfuscated_filename)
  # Undo asset path prefixing. https://crbug.com/357131361
  if path.endswith('+'):
    suffix_idx = path.rfind('+', 0, len(path) - 1)
    if suffix_idx != -1:
      path = path[:suffix_idx]
  return path


class MonochromeApkCheckerTest(typ.TestCase):
  def VerifySameFile(self, monochrome_dict, apk, changes, apk_name):
    """Verify apk file content matches same files in monochrome.

    Verify files from apk are same as those in monochrome except files
    in changes.
    """
    diff = []
    for a in apk:
      # File may not exists due to exists_in_some_form().
      m = monochrome_dict.get(a.filename)
      if m and m.CRC != a.CRC and not changes.match(m.filename):
        diff.append(a.filename)
    self.assertEquals(len(diff), 0,  """\
Unless specifcially excepted, all files in {0} should be exactly the same as
the similarly named file in Monochrome. However these files were present in
both monochrome and {0}, but had different contents:
{1}
""".format(apk_name, '\n'.join(diff)))


  def VerifyUncompressed(self, monochrome, apk, apk_name):
    """Verify uncompressed files in apk are a subset of those in monochrome.

    Verify files not being compressed in apk are also uncompressed in
    Monochrome APK.
    """
    uncompressed = [i.filename for i in apk if i.uncompressed ]
    monochrome_uncompressed = [i.filename for i in monochrome if i.uncompressed]
    compressed = [u for u in uncompressed if u not in monochrome_uncompressed]
    self.assertEquals(len(compressed), 0, """\
Uncompressed files in {0} should also be uncompressed in Monochrome.
However these files were uncompressed in {0} but compressed in Monochrome:
{1}
""".format(apk_name, '\n'.join(compressed)))


  def SuperSetOf(self, monochrome, apk, apk_name):
    """Verify Monochrome is super set of apk."""

    def exists_in_some_form(f):
      if f in monochrome:
        return True
      # Chrome.apk may have an extra classes.dex due to jdk library desugaring.
      if f.startswith('classes') and f.endswith('.dex'):
        return True
      if not f.startswith('res/'):
        return False
      name = '/' + posixpath.basename(f)
      # Some resources will exists in apk but not in monochrome due to the
      # difference in minSdkVersion. https://crbug.com/794438
      # E.g.:
      # apk could have: res/drawable/foo.png, res/drawable-v23/foo.png
      # monochrome (minSdkVersion=24) would need only: res/drawable-v23/foo.png
      return any(x.endswith(name) for x in monochrome)

    missing_files = [f for f in apk if not exists_in_some_form(f)]
    self.assertEquals(len(missing_files), 0, """\
Monochrome is expected to have a superset of the files in {0}.
However these files were present in {0} but not in Monochrome:
{1}
""".format(apk_name, '\n'.join(missing_files)))


  def RemoveSpecific(self, apk_entries, specific):
    return [i for i in apk_entries
            if not specific.search(i.filename) ]


  def LoadPathmap(self, pathmap_path):
    """Load the pathmap of obfuscated resource paths.

    Returns: A dict mapping from obfuscated paths to original paths or an
           empty dict if passed a None |pathmap_path|.
    """
    if pathmap_path is None or not os.path.exists(pathmap_path):
      return {}

    pathmap = {}
    with open(pathmap_path, 'r') as f:
      for line in f:
        line = line.strip()
        if line.startswith('#') or line == '':
          continue
        original, renamed = line.split(' -> ')
        pathmap[renamed] = original
    return pathmap


  def testApkChecker(self):
    options = self.context

    monochrome = DumpAPK(options.monochrome_apk)
    monochrome_pathmap = self.LoadPathmap(options.monochrome_pathmap)
    monochrome_files = [
        DeobfuscateFilename(f.filename, monochrome_pathmap)
          for f in monochrome
    ]
    monochrome_dict = dict((DeobfuscateFilename(i.filename, monochrome_pathmap),
                            i) for i in monochrome)

    chrome = self.RemoveSpecific(DumpAPK(options.chrome_apk),
                                 CHROME_SPECIFIC)
    self.assertTrue(len(chrome) > 0,
        'Chrome should have common files with Monochrome. However the passed '
        'in APKs do not have any files in common. Are you sure you are passing '
        'in the right arguments?')

    webview = self.RemoveSpecific(DumpAPK(options.system_webview_apk),
                                  WEBVIEW_SPECIFIC)
    self.assertTrue(len(webview) > 0,
        'Webview should have common files with Monochrome. However the passed '
        'in APKs do not have any files in common. Are you sure you are passing '
        'in the right arguments?')

    def check_apk(apk, pathmap, apk_name):
      apk_files = [DeobfuscateFilename(f.filename, pathmap) for f in apk]
      self.SuperSetOf(monochrome_files, apk_files, apk_name)
      self.VerifyUncompressed(monochrome, apk, apk_name)

    chrome_pathmap = self.LoadPathmap(options.chrome_pathmap)
    check_apk(chrome, chrome_pathmap, 'Chrome')
    self.VerifySameFile(monochrome_dict, chrome, CHROME_CHANGES, 'Chrome')

    webview_pathmap = self.LoadPathmap(options.system_webview_pathmap)
    check_apk(webview, webview_pathmap, 'Webview')
    self.VerifySameFile(monochrome_dict, webview, WEBVIEW_CHANGES, 'Webview')