mirror of
				https://github.com/f-droid/fdroidserver.git
				synced 2025-11-04 06:30:27 +03:00 
			
		
		
		
	Merge branch 'unified-scanner-signatures' into 'master'
cached scanner signatures Closes #1008 See merge request fdroid/fdroidserver!1198
This commit is contained in:
		
						commit
						7accb96b9e
					
				
					 7 changed files with 833 additions and 259 deletions
				
			
		| 
						 | 
				
			
			@ -987,7 +987,7 @@ def main():
 | 
			
		|||
        if not options.appid and not options.all:
 | 
			
		||||
            parser.error("option %s: If you really want to build all the apps, use --all" % "all")
 | 
			
		||||
 | 
			
		||||
    config = common.read_config(options)
 | 
			
		||||
    config = common.read_config(opts=options)
 | 
			
		||||
 | 
			
		||||
    if config['build_server_always']:
 | 
			
		||||
        options.server = True
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -76,7 +76,7 @@ from fdroidserver.exception import FDroidException, VCSException, NoSubmodulesEx
 | 
			
		|||
    BuildException, VerificationException, MetaDataException
 | 
			
		||||
from .asynchronousfilereader import AsynchronousFileReader
 | 
			
		||||
 | 
			
		||||
from . import apksigcopier
 | 
			
		||||
from . import apksigcopier, common
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# The path to this fdroidserver distribution
 | 
			
		||||
| 
						 | 
				
			
			@ -130,7 +130,6 @@ default_config = {
 | 
			
		|||
    'ant': "ant",
 | 
			
		||||
    'mvn3': "mvn",
 | 
			
		||||
    'gradle': os.path.join(FDROID_PATH, 'gradlew-fdroid'),
 | 
			
		||||
    'gradle_version_dir': str(Path.home() / '.cache/fdroidserver/gradle'),
 | 
			
		||||
    'sync_from_local_copy_dir': False,
 | 
			
		||||
    'allow_disabled_algorithms': False,
 | 
			
		||||
    'per_app_repos': False,
 | 
			
		||||
| 
						 | 
				
			
			@ -319,6 +318,31 @@ def fill_config_defaults(thisconfig):
 | 
			
		|||
                    ndk_paths[ndkdict['release']] = ndk_paths.pop(k)
 | 
			
		||||
                    break
 | 
			
		||||
 | 
			
		||||
    if 'cachedir_scanner' not in thisconfig:
 | 
			
		||||
        thisconfig['cachedir_scanner'] = str(Path(thisconfig['cachedir']) / 'scanner')
 | 
			
		||||
    if 'gradle_version_dir' not in thisconfig:
 | 
			
		||||
        thisconfig['gradle_version_dir'] = str(Path(thisconfig['cachedir']) / 'gradle')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_config(opts=None):
 | 
			
		||||
    """Get config instace. This function takes care of initializing config data before returning it."""
 | 
			
		||||
    global config, options
 | 
			
		||||
 | 
			
		||||
    if config is not None:
 | 
			
		||||
        return config
 | 
			
		||||
 | 
			
		||||
    config = {}
 | 
			
		||||
    common.fill_config_defaults(config)
 | 
			
		||||
    common.read_config(opts=opts)
 | 
			
		||||
 | 
			
		||||
    # make sure these values are available in common.py even if they didn't
 | 
			
		||||
    # declare global in a scope
 | 
			
		||||
    common.config = config
 | 
			
		||||
    if opts is not None:
 | 
			
		||||
        common.options = opts
 | 
			
		||||
 | 
			
		||||
    return config
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def regsub_file(pattern, repl, path):
 | 
			
		||||
    with open(path, 'rb') as f:
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -45,3 +45,10 @@ class BuildException(FDroidException):
 | 
			
		|||
 | 
			
		||||
class VerificationException(FDroidException):
 | 
			
		||||
    pass
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ConfigurationException(FDroidException):
 | 
			
		||||
    def __init__(self, value=None, detail=None):
 | 
			
		||||
        super().__init__()
 | 
			
		||||
        self.value = value
 | 
			
		||||
        self.detail = detail
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -16,29 +16,28 @@
 | 
			
		|||
# You should have received a copy of the GNU Affero General Public License
 | 
			
		||||
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
 | 
			
		||||
 | 
			
		||||
import imghdr
 | 
			
		||||
import json
 | 
			
		||||
import os
 | 
			
		||||
import re
 | 
			
		||||
import sys
 | 
			
		||||
import traceback
 | 
			
		||||
import json
 | 
			
		||||
import imghdr
 | 
			
		||||
import logging
 | 
			
		||||
import zipfile
 | 
			
		||||
import itertools
 | 
			
		||||
import traceback
 | 
			
		||||
import urllib.request
 | 
			
		||||
from argparse import ArgumentParser
 | 
			
		||||
from collections import namedtuple
 | 
			
		||||
from copy import deepcopy
 | 
			
		||||
from tempfile import TemporaryDirectory
 | 
			
		||||
import logging
 | 
			
		||||
import itertools
 | 
			
		||||
 | 
			
		||||
import requests
 | 
			
		||||
from pathlib import Path
 | 
			
		||||
from datetime import datetime, timedelta
 | 
			
		||||
 | 
			
		||||
from . import _
 | 
			
		||||
from . import common
 | 
			
		||||
from . import metadata
 | 
			
		||||
from .exception import BuildException, VCSException
 | 
			
		||||
from .exception import BuildException, VCSException, ConfigurationException
 | 
			
		||||
from . import scanner
 | 
			
		||||
 | 
			
		||||
config = None
 | 
			
		||||
options = None
 | 
			
		||||
 | 
			
		||||
DEFAULT_JSON_PER_BUILD = {'errors': [], 'warnings': [], 'infos': []}  # type: ignore
 | 
			
		||||
| 
						 | 
				
			
			@ -47,66 +46,8 @@ json_per_build = deepcopy(DEFAULT_JSON_PER_BUILD)
 | 
			
		|||
MAVEN_URL_REGEX = re.compile(r"""\smaven\s*(?:{.*?(?:setUrl|url)|\((?:url)?)\s*=?\s*(?:uri)?\(?\s*["']?([^\s"']+)["']?[^})]*[)}]""",
 | 
			
		||||
                             re.DOTALL)
 | 
			
		||||
 | 
			
		||||
CODE_SIGNATURES = {
 | 
			
		||||
    exp: re.compile(r'.*' + exp, re.IGNORECASE) for exp in [
 | 
			
		||||
        r'com/google/firebase',
 | 
			
		||||
        r'com/google/android/gms',
 | 
			
		||||
        r'com/google/android/play/core',
 | 
			
		||||
        r'com/google/tagmanager',
 | 
			
		||||
        r'com/google/analytics',
 | 
			
		||||
        r'com/android/billing',
 | 
			
		||||
    ]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# Common known non-free blobs (always lower case):
 | 
			
		||||
NON_FREE_GRADLE_LINES = {
 | 
			
		||||
    exp: re.compile(r'.*' + exp, re.IGNORECASE) for exp in [
 | 
			
		||||
        r'flurryagent',
 | 
			
		||||
        r'paypal.*mpl',
 | 
			
		||||
        r'admob.*sdk.*android',
 | 
			
		||||
        r'google.*ad.*view',
 | 
			
		||||
        r'google.*admob',
 | 
			
		||||
        r'google.*play.*services',
 | 
			
		||||
        r'com.google.android.play:core.*',
 | 
			
		||||
        r'com.google.android.play:app-update',
 | 
			
		||||
        r'com.google.android.libraries.places:places',
 | 
			
		||||
        r'com.google.mlkit',
 | 
			
		||||
        r'com.android.billingclient',
 | 
			
		||||
        r'androidx.work:work-gcm',
 | 
			
		||||
        r'crittercism',
 | 
			
		||||
        r'heyzap',
 | 
			
		||||
        r'jpct.*ae',
 | 
			
		||||
        r'youtube.*android.*player.*api',
 | 
			
		||||
        r'bugsense',
 | 
			
		||||
        r'crashlytics',
 | 
			
		||||
        r'ouya.*sdk',
 | 
			
		||||
        r'libspen23',
 | 
			
		||||
        r'firebase',
 | 
			
		||||
        r'''["']com.facebook.android['":]''',
 | 
			
		||||
        r'cloudrail',
 | 
			
		||||
        r'com.tencent.bugly',
 | 
			
		||||
        r'appcenter-push',
 | 
			
		||||
        r'com.github.junrar:junrar',
 | 
			
		||||
        r'androidx.navigation:navigation-dynamic-features',
 | 
			
		||||
        r'xyz.belvi.mobilevision:barcodescanner',
 | 
			
		||||
        r'org.jetbrains.kotlinx:kotlinx-coroutines-play-services',
 | 
			
		||||
        r'me.pushy:sdk',
 | 
			
		||||
        r'io.github.sinaweibosdk',
 | 
			
		||||
        r'com.umeng.umsdk',
 | 
			
		||||
        r'com.google.android.exoplayer:extension-cast',
 | 
			
		||||
        r'io.objectbox:objectbox-gradle-plugin',
 | 
			
		||||
        r'com.evernote:android-job',
 | 
			
		||||
        r'com.yayandroid:LocationManager',
 | 
			
		||||
        r'com.onesignal:OneSignal',
 | 
			
		||||
        r'com.cloudinary:cloudinary-android',
 | 
			
		||||
        r'com.google.android.exoplayer:extension-cronet',
 | 
			
		||||
        r'com.anjlab.android.iab.v3:library',
 | 
			
		||||
        r'com.github.penn5:donations',
 | 
			
		||||
        r'com.mapbox',
 | 
			
		||||
        r'com.yandex.android',
 | 
			
		||||
        r'com.hypertrack',
 | 
			
		||||
    ]
 | 
			
		||||
}
 | 
			
		||||
SCANNER_CACHE_VERSION = 1
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_gradle_compile_commands(build):
 | 
			
		||||
| 
						 | 
				
			
			@ -171,78 +112,272 @@ def get_embedded_classes(apkfile, depth=0):
 | 
			
		|||
    return classes
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# taken from exodus_core
 | 
			
		||||
def _exodus_compile_signatures(signatures):
 | 
			
		||||
    """
 | 
			
		||||
    Compiles the regex associated to each signature, in order to speed up the trackers detection.
 | 
			
		||||
 | 
			
		||||
    :return: A compiled list of signatures.
 | 
			
		||||
    """
 | 
			
		||||
    compiled_tracker_signature = []
 | 
			
		||||
    try:
 | 
			
		||||
        compiled_tracker_signature = [
 | 
			
		||||
            re.compile(track.code_signature) for track in signatures
 | 
			
		||||
        ]
 | 
			
		||||
    except TypeError:
 | 
			
		||||
        print("signatures is not iterable")
 | 
			
		||||
    return compiled_tracker_signature
 | 
			
		||||
def _datetime_now():
 | 
			
		||||
    """Get datetime.now(), using this funciton allows mocking it for testing."""
 | 
			
		||||
    return datetime.utcnow()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# taken from exodus_core
 | 
			
		||||
def load_exodus_trackers_signatures():
 | 
			
		||||
    """
 | 
			
		||||
    Load trackers signatures from the official Exodus database.
 | 
			
		||||
def _scanner_cachedir():
 | 
			
		||||
    """Get `Path` to fdroidserver cache dir."""
 | 
			
		||||
    cfg = common.get_config()
 | 
			
		||||
    if not cfg:
 | 
			
		||||
        raise ConfigurationException('config not initialized')
 | 
			
		||||
    if "cachedir_scanner" not in cfg:
 | 
			
		||||
        raise ConfigurationException("could not load 'cachedir_scanner' from config")
 | 
			
		||||
    cachedir = Path(cfg["cachedir_scanner"])
 | 
			
		||||
    cachedir.mkdir(exist_ok=True, parents=True)
 | 
			
		||||
    return cachedir
 | 
			
		||||
 | 
			
		||||
    :return: a dictionary containing signatures.
 | 
			
		||||
    """
 | 
			
		||||
    signatures = []
 | 
			
		||||
    exodus_url = "https://reports.exodus-privacy.eu.org/api/trackers"
 | 
			
		||||
    r = requests.get(exodus_url, timeout=300)
 | 
			
		||||
    data = r.json()
 | 
			
		||||
    for e in data['trackers']:
 | 
			
		||||
        signatures.append(
 | 
			
		||||
            namedtuple('tracker', data['trackers'][e].keys())(
 | 
			
		||||
                *data['trackers'][e].values()
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
class SignatureDataMalformedException(Exception):
 | 
			
		||||
    pass
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class SignatureDataOutdatedException(Exception):
 | 
			
		||||
    pass
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class SignatureDataCacheMissException(Exception):
 | 
			
		||||
    pass
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class SignatureDataVersionMismatchException(Exception):
 | 
			
		||||
    pass
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class SignatureDataController:
 | 
			
		||||
    def __init__(self, name, filename, url):
 | 
			
		||||
        self.name = name
 | 
			
		||||
        self.filename = filename
 | 
			
		||||
        self.url = url
 | 
			
		||||
        # by default we assume cache is valid indefinitely
 | 
			
		||||
        self.cache_duration = timedelta(days=999999)
 | 
			
		||||
        self.data = {}
 | 
			
		||||
 | 
			
		||||
    def check_data_version(self):
 | 
			
		||||
        if self.data.get("version") != SCANNER_CACHE_VERSION:
 | 
			
		||||
            raise SignatureDataVersionMismatchException()
 | 
			
		||||
 | 
			
		||||
    def check_last_updated(self):
 | 
			
		||||
        """
 | 
			
		||||
        Check if the last_updated value is ok and raise an exception if expired or inaccessible.
 | 
			
		||||
 | 
			
		||||
        :raises SignatureDataMalformedException: when timestamp value is
 | 
			
		||||
                                                 inaccessible or not parse-able
 | 
			
		||||
        :raises SignatureDataOutdatedException: when timestamp is older then
 | 
			
		||||
                                                `self.cache_duration`
 | 
			
		||||
        """
 | 
			
		||||
        last_updated = self.data.get("last_updated", None)
 | 
			
		||||
        if last_updated:
 | 
			
		||||
            try:
 | 
			
		||||
                last_updated = datetime.fromtimestamp(last_updated)
 | 
			
		||||
            except ValueError as e:
 | 
			
		||||
                raise SignatureDataMalformedException() from e
 | 
			
		||||
            except TypeError as e:
 | 
			
		||||
                raise SignatureDataMalformedException() from e
 | 
			
		||||
            delta = (last_updated + self.cache_duration) - scanner._datetime_now()
 | 
			
		||||
            if delta > timedelta(seconds=0):
 | 
			
		||||
                logging.debug(_('next {name} cache update due in {time}').format(
 | 
			
		||||
                    name=self.filename, time=delta
 | 
			
		||||
                ))
 | 
			
		||||
            else:
 | 
			
		||||
                raise SignatureDataOutdatedException()
 | 
			
		||||
 | 
			
		||||
    def fetch(self):
 | 
			
		||||
        try:
 | 
			
		||||
            self.fetch_signatures_from_web()
 | 
			
		||||
            self.write_to_cache()
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            raise Exception(_("downloading scanner signatures from '{}' failed").format(self.url)) from e
 | 
			
		||||
 | 
			
		||||
    def load(self):
 | 
			
		||||
        try:
 | 
			
		||||
            try:
 | 
			
		||||
                self.load_from_cache()
 | 
			
		||||
                self.verify_data()
 | 
			
		||||
                self.check_last_updated()
 | 
			
		||||
            except SignatureDataCacheMissException:
 | 
			
		||||
                self.load_from_defaults()
 | 
			
		||||
        except SignatureDataOutdatedException:
 | 
			
		||||
            self.fetch_signatures_from_web()
 | 
			
		||||
            self.write_to_cache()
 | 
			
		||||
        except (SignatureDataMalformedException, SignatureDataVersionMismatchException) as e:
 | 
			
		||||
            logging.critical(_("scanner cache is malformed! You can clear it with: '{clear}'").format(
 | 
			
		||||
                clear='rm -r {}'.format(common.get_config()['cachedir_scanner'])
 | 
			
		||||
            ))
 | 
			
		||||
            raise e
 | 
			
		||||
 | 
			
		||||
    def load_from_defaults(self):
 | 
			
		||||
        sig_file = (Path(__file__).parent / 'data' / 'scanner' / self.filename).resolve()
 | 
			
		||||
        with open(sig_file) as f:
 | 
			
		||||
            self.set_data(json.load(f))
 | 
			
		||||
 | 
			
		||||
    def load_from_cache(self):
 | 
			
		||||
        sig_file = scanner._scanner_cachedir() / self.filename
 | 
			
		||||
        if not sig_file.exists():
 | 
			
		||||
            raise SignatureDataCacheMissException()
 | 
			
		||||
        with open(sig_file) as f:
 | 
			
		||||
            self.set_data(json.load(f))
 | 
			
		||||
 | 
			
		||||
    def write_to_cache(self):
 | 
			
		||||
        sig_file = scanner._scanner_cachedir() / self.filename
 | 
			
		||||
        with open(sig_file, "w", encoding="utf-8") as f:
 | 
			
		||||
            json.dump(self.data, f, indent=2)
 | 
			
		||||
        logging.debug("write '{}' to cache".format(self.filename))
 | 
			
		||||
 | 
			
		||||
    def verify_data(self):
 | 
			
		||||
        """
 | 
			
		||||
        Clean and validate `self.data`.
 | 
			
		||||
 | 
			
		||||
        Right now this function does just a basic key sanitation.
 | 
			
		||||
        """
 | 
			
		||||
        self.check_data_version()
 | 
			
		||||
        valid_keys = ['timestamp', 'last_updated', 'version', 'signatures', 'cache_duration']
 | 
			
		||||
 | 
			
		||||
        for k in list(self.data.keys()):
 | 
			
		||||
            if k not in valid_keys:
 | 
			
		||||
                del self.data[k]
 | 
			
		||||
 | 
			
		||||
    def set_data(self, new_data):
 | 
			
		||||
        self.data = new_data
 | 
			
		||||
        if 'cache_duration' in new_data:
 | 
			
		||||
            self.cache_duration = timedelta(seconds=new_data['cache_duration'])
 | 
			
		||||
 | 
			
		||||
    def fetch_signatures_from_web(self):
 | 
			
		||||
        if not self.url.startswith("https://"):
 | 
			
		||||
            raise Exception(_("can't open non-https url: '{};".format(self.url)))
 | 
			
		||||
        logging.debug(_("downloading '{}'").format(self.url))
 | 
			
		||||
        with urllib.request.urlopen(self.url) as f:  # nosec B310 scheme filtered above
 | 
			
		||||
            self.set_data(json.load(f))
 | 
			
		||||
        self.data['last_updated'] = scanner._datetime_now().timestamp()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ExodusSignatureDataController(SignatureDataController):
 | 
			
		||||
    def __init__(self):
 | 
			
		||||
        super().__init__('Exodus signatures', 'exodus.yml', 'https://reports.exodus-privacy.eu.org/api/trackers')
 | 
			
		||||
        self.cache_duration = timedelta(days=1)  # refresh exodus cache after one day
 | 
			
		||||
 | 
			
		||||
    def fetch_signatures_from_web(self):
 | 
			
		||||
        logging.debug(_("downloading '{}'").format(self.url))
 | 
			
		||||
 | 
			
		||||
        data = {
 | 
			
		||||
            "signatures": {},
 | 
			
		||||
            "timestamp": scanner._datetime_now().timestamp(),
 | 
			
		||||
            "last_updated": scanner._datetime_now().timestamp(),
 | 
			
		||||
            "version": SCANNER_CACHE_VERSION,
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if not self.url.startswith("https://"):
 | 
			
		||||
            raise Exception(_("can't open non-https url: '{};".format(self.url)))
 | 
			
		||||
        with urllib.request.urlopen(self.url) as f:  # nosec B310 scheme filtered above
 | 
			
		||||
            d = json.load(f)
 | 
			
		||||
            for tracker in d["trackers"].values():
 | 
			
		||||
                if tracker.get('code_signature'):
 | 
			
		||||
                    data["signatures"][tracker["name"]] = {
 | 
			
		||||
                        "name": tracker["name"],
 | 
			
		||||
                        "warn_code_signatures": [tracker["code_signature"]],
 | 
			
		||||
                        # exodus also provides network signatures, unused atm.
 | 
			
		||||
                        # "network_signatures": [tracker["network_signature"]],
 | 
			
		||||
                        "AntiFeatures": ["Tracking"],
 | 
			
		||||
                        "license": "NonFree"  # We assume all trackers in exodus
 | 
			
		||||
                                              # are non-free, although free
 | 
			
		||||
                                              # trackers like piwik, acra,
 | 
			
		||||
                                              # etc. might be listed by exodus
 | 
			
		||||
                                              # too.
 | 
			
		||||
                    }
 | 
			
		||||
        self.set_data(data)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class SUSSDataController(SignatureDataController):
 | 
			
		||||
    def __init__(self):
 | 
			
		||||
        super().__init__(
 | 
			
		||||
            'SUSS',
 | 
			
		||||
            'suss.json',
 | 
			
		||||
            'https://fdroid.gitlab.io/fdroid-suss/suss.json'
 | 
			
		||||
        )
 | 
			
		||||
    logging.debug('{} trackers signatures loaded'.format(len(signatures)))
 | 
			
		||||
    return signatures, scanner._exodus_compile_signatures(signatures)
 | 
			
		||||
 | 
			
		||||
    def load_from_defaults(self):
 | 
			
		||||
        self.set_data(json.loads(SUSS_DEFAULT))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def scan_binary(apkfile, extract_signatures=None):
 | 
			
		||||
class ScannerTool():
 | 
			
		||||
    def __init__(self):
 | 
			
		||||
        self.sdcs = [
 | 
			
		||||
            SUSSDataController(),
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
        # we could add support for loading additional signature source
 | 
			
		||||
        # definitions from config.yml here
 | 
			
		||||
 | 
			
		||||
        self.load()
 | 
			
		||||
        self.compile_regexes()
 | 
			
		||||
 | 
			
		||||
    def load(self):
 | 
			
		||||
        for sdc in self.sdcs:
 | 
			
		||||
            sdc.load()
 | 
			
		||||
 | 
			
		||||
    def compile_regexes(self):
 | 
			
		||||
        self.regexs = {
 | 
			
		||||
            'err_code_signatures': {},
 | 
			
		||||
            'err_gradle_signatures': {},
 | 
			
		||||
            'warn_code_signatures': {},
 | 
			
		||||
            'warn_gradle_signatures': {},
 | 
			
		||||
        }
 | 
			
		||||
        for sdc in self.sdcs:
 | 
			
		||||
            for signame, sigdef in sdc.data.get('signatures', {}).items():
 | 
			
		||||
                for sig in sigdef.get('code_signatures', []):
 | 
			
		||||
                    self.regexs['err_code_signatures'][sig] = re.compile('.*' + sig, re.IGNORECASE)
 | 
			
		||||
                for sig in sigdef.get('gradle_signatures', []):
 | 
			
		||||
                    self.regexs['err_gradle_signatures'][sig] = re.compile('.*' + sig, re.IGNORECASE)
 | 
			
		||||
                for sig in sigdef.get('warn_code_signatures', []):
 | 
			
		||||
                    self.regexs['warn_code_signatures'][sig] = re.compile('.*' + sig, re.IGNORECASE)
 | 
			
		||||
                for sig in sigdef.get('warn_gradle_signatures', []):
 | 
			
		||||
                    self.regexs['warn_gradle_signatures'][sig] = re.compile('.*' + sig, re.IGNORECASE)
 | 
			
		||||
 | 
			
		||||
    def refresh(self):
 | 
			
		||||
        for sdc in self.sdcs:
 | 
			
		||||
            sdc.fetch_signatures_from_web()
 | 
			
		||||
            sdc.write_to_cache()
 | 
			
		||||
 | 
			
		||||
    def add(self, new_controller: SignatureDataController):
 | 
			
		||||
        self.sdcs.append(new_controller)
 | 
			
		||||
        self.compile_regexes()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# TODO: change this from singleton instance to dependency injection
 | 
			
		||||
# use `_get_tool()` instead of accessing this directly
 | 
			
		||||
_SCANNER_TOOL = None
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _get_tool():
 | 
			
		||||
    """
 | 
			
		||||
    Lazy loading function for getting a ScannerTool instance.
 | 
			
		||||
 | 
			
		||||
    ScannerTool initialization need to access `common.config` values. Those are only available after initialization through `common.read_config()`. So this factory assumes config was called at an erlier point in time.
 | 
			
		||||
    """
 | 
			
		||||
    if not scanner._SCANNER_TOOL:
 | 
			
		||||
        scanner._SCANNER_TOOL = ScannerTool()
 | 
			
		||||
    return scanner._SCANNER_TOOL
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def scan_binary(apkfile):
 | 
			
		||||
    """Scan output of dexdump for known non-free classes."""
 | 
			
		||||
    logging.info(_('Scanning APK with dexdump for known non-free classes.'))
 | 
			
		||||
    result = get_embedded_classes(apkfile)
 | 
			
		||||
    problems = 0
 | 
			
		||||
    problems, warnings = 0, 0
 | 
			
		||||
    for classname in result:
 | 
			
		||||
        for suspect, regexp in CODE_SIGNATURES.items():
 | 
			
		||||
        for suspect, regexp in _get_tool().regexs['warn_code_signatures'].items():
 | 
			
		||||
            if regexp.match(classname):
 | 
			
		||||
                logging.debug("Found class '%s'" % classname)
 | 
			
		||||
                logging.debug("Warning: found class '%s'" % classname)
 | 
			
		||||
                warnings += 1
 | 
			
		||||
        for suspect, regexp in _get_tool().regexs['err_code_signatures'].items():
 | 
			
		||||
            if regexp.match(classname):
 | 
			
		||||
                logging.debug("Problem: found class '%s'" % classname)
 | 
			
		||||
                problems += 1
 | 
			
		||||
 | 
			
		||||
    if extract_signatures:
 | 
			
		||||
 | 
			
		||||
        def _detect_tracker(sig, tracker, class_list):
 | 
			
		||||
            for clazz in class_list:
 | 
			
		||||
                if sig.search(clazz):
 | 
			
		||||
                    logging.debug("Found tracker, class {} matching {}".format(clazz, tracker.code_signature))
 | 
			
		||||
                    return tracker
 | 
			
		||||
            return None
 | 
			
		||||
 | 
			
		||||
        results = []
 | 
			
		||||
        args = [(extract_signatures[1][index], tracker, result)
 | 
			
		||||
                for (index, tracker) in enumerate(extract_signatures[0]) if
 | 
			
		||||
                len(tracker.code_signature) > 3]
 | 
			
		||||
 | 
			
		||||
        for res in itertools.starmap(_detect_tracker, args):
 | 
			
		||||
            if res:
 | 
			
		||||
                results.append(res)
 | 
			
		||||
 | 
			
		||||
        trackers = [t for t in results if t is not None]
 | 
			
		||||
        problems += len(trackers)
 | 
			
		||||
 | 
			
		||||
    if warnings:
 | 
			
		||||
        logging.warning(_("Found {count} warnings in {filename}").format(count=warnings, filename=apkfile))
 | 
			
		||||
    if problems:
 | 
			
		||||
        logging.critical("Found problems in %s" % apkfile)
 | 
			
		||||
        logging.critical(_("Found {count} problems in {filename}").format(count=problems, filename=apkfile))
 | 
			
		||||
    return problems
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -255,20 +390,9 @@ def scan_source(build_dir, build=metadata.Build()):
 | 
			
		|||
    """
 | 
			
		||||
    count = 0
 | 
			
		||||
 | 
			
		||||
    allowlisted = [
 | 
			
		||||
        'firebase-jobdispatcher',  # https://github.com/firebase/firebase-jobdispatcher-android/blob/master/LICENSE
 | 
			
		||||
        'com.firebaseui',  # https://github.com/firebase/FirebaseUI-Android/blob/master/LICENSE
 | 
			
		||||
        'geofire-android',  # https://github.com/firebase/geofire-java/blob/master/LICENSE
 | 
			
		||||
        'com.yandex.android:authsdk',  # https://github.com/yandexmobile/yandex-login-sdk-android/blob/master/LICENSE.txt
 | 
			
		||||
        'com.hypertrack:hyperlog',  # https://github.com/hypertrack/hyperlog-android#license
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    def is_allowlisted(s):
 | 
			
		||||
        return any(al in s for al in allowlisted)
 | 
			
		||||
 | 
			
		||||
    def suspects_found(s):
 | 
			
		||||
        for n, r in NON_FREE_GRADLE_LINES.items():
 | 
			
		||||
            if r.match(s) and not is_allowlisted(s):
 | 
			
		||||
        for n, r in _get_tool().regexs['err_gradle_signatures'].items():
 | 
			
		||||
            if r.match(s):
 | 
			
		||||
                yield n
 | 
			
		||||
 | 
			
		||||
    allowed_repos = [re.compile(r'^https://' + re.escape(repo) + r'/*') for repo in [
 | 
			
		||||
| 
						 | 
				
			
			@ -540,7 +664,7 @@ def scan_source(build_dir, build=metadata.Build()):
 | 
			
		|||
 | 
			
		||||
 | 
			
		||||
def main():
 | 
			
		||||
    global config, options, json_per_build
 | 
			
		||||
    global options, json_per_build
 | 
			
		||||
 | 
			
		||||
    # Parse command line...
 | 
			
		||||
    parser = ArgumentParser(
 | 
			
		||||
| 
						 | 
				
			
			@ -557,6 +681,8 @@ def main():
 | 
			
		|||
                        help=_("Force scan of disabled apps and builds."))
 | 
			
		||||
    parser.add_argument("--json", action="store_true", default=False,
 | 
			
		||||
                        help=_("Output JSON to stdout."))
 | 
			
		||||
    parser.add_argument("--refresh", "-r", action="store_true", default=False,
 | 
			
		||||
                        help=_("fetch the latest version of signatures from the web"))
 | 
			
		||||
    metadata.add_metadata_arguments(parser)
 | 
			
		||||
    options = parser.parse_args()
 | 
			
		||||
    metadata.warnings_action = options.W
 | 
			
		||||
| 
						 | 
				
			
			@ -568,21 +694,28 @@ def main():
 | 
			
		|||
        else:
 | 
			
		||||
            logging.getLogger().setLevel(logging.ERROR)
 | 
			
		||||
 | 
			
		||||
    config = common.read_config(options)
 | 
			
		||||
    # initialize/load configuration values
 | 
			
		||||
    common.get_config(opts=options)
 | 
			
		||||
 | 
			
		||||
    if options.refresh:
 | 
			
		||||
        scanner._get_tool().refresh()
 | 
			
		||||
    if options.exodus:
 | 
			
		||||
        c = ExodusSignatureDataController()
 | 
			
		||||
        if options.refresh:
 | 
			
		||||
            c.fetch_signatures_from_web()
 | 
			
		||||
        else:
 | 
			
		||||
            c.fetch()
 | 
			
		||||
        scanner._get_tool().add(c)
 | 
			
		||||
 | 
			
		||||
    probcount = 0
 | 
			
		||||
 | 
			
		||||
    exodus = []
 | 
			
		||||
    if options.exodus:
 | 
			
		||||
        exodus = load_exodus_trackers_signatures()
 | 
			
		||||
 | 
			
		||||
    appids = []
 | 
			
		||||
    for apk in options.appid:
 | 
			
		||||
        if os.path.isfile(apk):
 | 
			
		||||
            count = scanner.scan_binary(apk, exodus)
 | 
			
		||||
            count = scanner.scan_binary(apk)
 | 
			
		||||
            if count > 0:
 | 
			
		||||
                logging.warning(
 | 
			
		||||
                    _('Scanner found {count} problems in {apk}:').format(
 | 
			
		||||
                    _('Scanner found {count} problems in {apk}').format(
 | 
			
		||||
                        count=count, apk=apk
 | 
			
		||||
                    )
 | 
			
		||||
                )
 | 
			
		||||
| 
						 | 
				
			
			@ -683,3 +816,299 @@ def main():
 | 
			
		|||
 | 
			
		||||
if __name__ == "__main__":
 | 
			
		||||
    main()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
SUSS_DEFAULT = '''{
 | 
			
		||||
  "cache_duration": 86400,
 | 
			
		||||
  "signatures": {
 | 
			
		||||
    "admob": {
 | 
			
		||||
      "gradle_signatures": [
 | 
			
		||||
        "admob.*sdk.*android"
 | 
			
		||||
      ],
 | 
			
		||||
      "license": "NonFree"
 | 
			
		||||
    },
 | 
			
		||||
    "androidx": {
 | 
			
		||||
      "gradle_signatures": [
 | 
			
		||||
        "androidx.navigation:navigation-dynamic-features",
 | 
			
		||||
        "androidx.work:work-gcm"
 | 
			
		||||
      ],
 | 
			
		||||
      "license": "NonFree"
 | 
			
		||||
    },
 | 
			
		||||
    "appcenter-push": {
 | 
			
		||||
      "gradle_signatures": [
 | 
			
		||||
        "appcenter-push"
 | 
			
		||||
      ],
 | 
			
		||||
      "license": "NonFree"
 | 
			
		||||
    },
 | 
			
		||||
    "bugsense": {
 | 
			
		||||
      "gradle_signatures": [
 | 
			
		||||
        "bugsense"
 | 
			
		||||
      ],
 | 
			
		||||
      "license": "NonFree"
 | 
			
		||||
    },
 | 
			
		||||
    "cloudrail": {
 | 
			
		||||
      "gradle_signatures": [
 | 
			
		||||
        "cloudrail"
 | 
			
		||||
      ],
 | 
			
		||||
      "license": "NonFree"
 | 
			
		||||
    },
 | 
			
		||||
    "com.android.billing": {
 | 
			
		||||
      "code_signatures": [
 | 
			
		||||
        "com/android/billing"
 | 
			
		||||
      ],
 | 
			
		||||
      "license": "NonFree"
 | 
			
		||||
    },
 | 
			
		||||
    "com.android.billingclient": {
 | 
			
		||||
      "gradle_signatures": [
 | 
			
		||||
        "com.android.billingclient"
 | 
			
		||||
      ],
 | 
			
		||||
      "license": "NonFree"
 | 
			
		||||
    },
 | 
			
		||||
    "com.anjlab.android.iab.v3": {
 | 
			
		||||
      "gradle_signatures": [
 | 
			
		||||
        "com.anjlab.android.iab.v3:library"
 | 
			
		||||
      ],
 | 
			
		||||
      "license": "NonFree"
 | 
			
		||||
    },
 | 
			
		||||
    "com.cloudinary": {
 | 
			
		||||
      "gradle_signatures": [
 | 
			
		||||
        "com.cloudinary:cloudinary-android"
 | 
			
		||||
      ],
 | 
			
		||||
      "license": "NonFree"
 | 
			
		||||
    },
 | 
			
		||||
    "com.evernote": {
 | 
			
		||||
      "gradle_signatures": [
 | 
			
		||||
        "com.evernote:android-job"
 | 
			
		||||
      ],
 | 
			
		||||
      "license": "NonFree"
 | 
			
		||||
    },
 | 
			
		||||
    "com.facebook": {
 | 
			
		||||
      "gradle_signatures": [
 | 
			
		||||
        "[\\"']com.facebook.android['\\":]"
 | 
			
		||||
      ],
 | 
			
		||||
      "license": "NonFree"
 | 
			
		||||
    },
 | 
			
		||||
    "com.github.junrar": {
 | 
			
		||||
      "gradle_signatures": [
 | 
			
		||||
        "com.github.junrar:junrar"
 | 
			
		||||
      ],
 | 
			
		||||
      "license": "NonFree"
 | 
			
		||||
    },
 | 
			
		||||
    "com.github.penn5": {
 | 
			
		||||
      "gradle_signatures": [
 | 
			
		||||
        "com.github.penn5:donations"
 | 
			
		||||
      ],
 | 
			
		||||
      "license": "NonFree"
 | 
			
		||||
    },
 | 
			
		||||
    "com.google.analytics": {
 | 
			
		||||
      "code_signatures": [
 | 
			
		||||
        "com/google/analytics"
 | 
			
		||||
      ],
 | 
			
		||||
      "license": "NonFree"
 | 
			
		||||
    },
 | 
			
		||||
    "com.google.android.exoplayer": {
 | 
			
		||||
      "gradle_signatures": [
 | 
			
		||||
        "com.google.android.exoplayer:extension-cast",
 | 
			
		||||
        "com.google.android.exoplayer:extension-cronet"
 | 
			
		||||
      ],
 | 
			
		||||
      "license": "NonFree"
 | 
			
		||||
    },
 | 
			
		||||
    "com.google.android.gms": {
 | 
			
		||||
      "code_signatures": [
 | 
			
		||||
        "com/google/android/gms"
 | 
			
		||||
      ],
 | 
			
		||||
      "license": "NonFree"
 | 
			
		||||
    },
 | 
			
		||||
    "com.google.android.libraries.places": {
 | 
			
		||||
      "gradle_signatures": [
 | 
			
		||||
        "com.google.android.libraries.places:places"
 | 
			
		||||
      ],
 | 
			
		||||
      "license": "NonFree"
 | 
			
		||||
    },
 | 
			
		||||
    "com.google.android.play": {
 | 
			
		||||
      "gradle_signatures": [
 | 
			
		||||
        "com.google.android.play:app-update",
 | 
			
		||||
        "com.google.android.play:core.*"
 | 
			
		||||
      ],
 | 
			
		||||
      "license": "NonFree"
 | 
			
		||||
    },
 | 
			
		||||
    "com.google.android.play.core": {
 | 
			
		||||
      "code_signatures": [
 | 
			
		||||
        "com/google/android/play/core"
 | 
			
		||||
      ],
 | 
			
		||||
      "license": "NonFree"
 | 
			
		||||
    },
 | 
			
		||||
    "com.google.firebase": {
 | 
			
		||||
      "code_signatures": [
 | 
			
		||||
        "com/google/firebase"
 | 
			
		||||
      ],
 | 
			
		||||
      "license": "NonFree"
 | 
			
		||||
    },
 | 
			
		||||
    "com.google.mlkit": {
 | 
			
		||||
      "gradle_signatures": [
 | 
			
		||||
        "com.google.mlkit"
 | 
			
		||||
      ],
 | 
			
		||||
      "license": "NonFree"
 | 
			
		||||
    },
 | 
			
		||||
    "com.google.tagmanager": {
 | 
			
		||||
      "code_signatures": [
 | 
			
		||||
        "com/google/tagmanager"
 | 
			
		||||
      ],
 | 
			
		||||
      "license": "NonFree"
 | 
			
		||||
    },
 | 
			
		||||
    "com.hypertrack": {
 | 
			
		||||
      "gradle_signatures": [
 | 
			
		||||
        "com\\\\.hypertrack(?!:hyperlog)"
 | 
			
		||||
      ],
 | 
			
		||||
      "license": "NonFree"
 | 
			
		||||
    },
 | 
			
		||||
    "com.mapbox": {
 | 
			
		||||
      "MaintainerNotes": "com.mapbox.mapboxsdk:mapbox-sdk-services seems to be fully under this license:\\nhttps://github.com/mapbox/mapbox-java/blob/main/LICENSE\\n",
 | 
			
		||||
      "gradle_signatures": [
 | 
			
		||||
        "com\\\\.mapbox(?!\\\\.mapboxsdk:mapbox-sdk-services)"
 | 
			
		||||
      ],
 | 
			
		||||
      "license": "NonFree"
 | 
			
		||||
    },
 | 
			
		||||
    "com.onesignal": {
 | 
			
		||||
      "gradle_signatures": [
 | 
			
		||||
        "com.onesignal:OneSignal"
 | 
			
		||||
      ],
 | 
			
		||||
      "license": "NonFree"
 | 
			
		||||
    },
 | 
			
		||||
    "com.tencent.bugly": {
 | 
			
		||||
      "gradle_signatures": [
 | 
			
		||||
        "com.tencent.bugly"
 | 
			
		||||
      ],
 | 
			
		||||
      "license": "NonFree"
 | 
			
		||||
    },
 | 
			
		||||
    "com.umeng.umsdk": {
 | 
			
		||||
      "gradle_signatures": [
 | 
			
		||||
        "com.umeng.umsdk"
 | 
			
		||||
      ],
 | 
			
		||||
      "license": "NonFree"
 | 
			
		||||
    },
 | 
			
		||||
    "com.yandex.android": {
 | 
			
		||||
      "gradle_signatures": [
 | 
			
		||||
        "com\\\\.yandex\\\\.android(?!:authsdk)"
 | 
			
		||||
      ],
 | 
			
		||||
      "license": "NonFree"
 | 
			
		||||
    },
 | 
			
		||||
    "com.yayandroid": {
 | 
			
		||||
      "gradle_signatures": [
 | 
			
		||||
        "com.yayandroid:LocationManager"
 | 
			
		||||
      ],
 | 
			
		||||
      "license": "NonFree"
 | 
			
		||||
    },
 | 
			
		||||
    "crashlytics": {
 | 
			
		||||
      "gradle_signatures": [
 | 
			
		||||
        "crashlytics"
 | 
			
		||||
      ],
 | 
			
		||||
      "license": "NonFree"
 | 
			
		||||
    },
 | 
			
		||||
    "crittercism": {
 | 
			
		||||
      "gradle_signatures": [
 | 
			
		||||
        "crittercism"
 | 
			
		||||
      ],
 | 
			
		||||
      "license": "NonFree"
 | 
			
		||||
    },
 | 
			
		||||
    "firebase": {
 | 
			
		||||
      "gradle_signatures": [
 | 
			
		||||
        "com(\\\\.google)?\\\\.firebase[.:](?!firebase-jobdispatcher|geofire-java)"
 | 
			
		||||
      ],
 | 
			
		||||
      "license": "NonFree"
 | 
			
		||||
    },
 | 
			
		||||
    "flurryagent": {
 | 
			
		||||
      "gradle_signatures": [
 | 
			
		||||
        "flurryagent"
 | 
			
		||||
      ],
 | 
			
		||||
      "license": "NonFree"
 | 
			
		||||
    },
 | 
			
		||||
    "google-ad": {
 | 
			
		||||
      "gradle_signatures": [
 | 
			
		||||
        "google.*ad.*view"
 | 
			
		||||
      ],
 | 
			
		||||
      "license": "NonFree"
 | 
			
		||||
    },
 | 
			
		||||
    "google.admob": {
 | 
			
		||||
      "gradle_signatures": [
 | 
			
		||||
        "google.*admob"
 | 
			
		||||
      ],
 | 
			
		||||
      "license": "NonFree"
 | 
			
		||||
    },
 | 
			
		||||
    "google.play.services": {
 | 
			
		||||
      "gradle_signatures": [
 | 
			
		||||
        "google.*play.*services"
 | 
			
		||||
      ],
 | 
			
		||||
      "license": "NonFree"
 | 
			
		||||
    },
 | 
			
		||||
    "heyzap": {
 | 
			
		||||
      "gradle_signatures": [
 | 
			
		||||
        "heyzap"
 | 
			
		||||
      ],
 | 
			
		||||
      "license": "NonFree"
 | 
			
		||||
    },
 | 
			
		||||
    "io.github.sinaweibosdk": {
 | 
			
		||||
      "gradle_signatures": [
 | 
			
		||||
        "io.github.sinaweibosdk"
 | 
			
		||||
      ],
 | 
			
		||||
      "license": "NonFree"
 | 
			
		||||
    },
 | 
			
		||||
    "io.objectbox": {
 | 
			
		||||
      "gradle_signatures": [
 | 
			
		||||
        "io.objectbox:objectbox-gradle-plugin"
 | 
			
		||||
      ],
 | 
			
		||||
      "license": "NonFree"
 | 
			
		||||
    },
 | 
			
		||||
    "jpct": {
 | 
			
		||||
      "gradle_signatures": [
 | 
			
		||||
        "jpct.*ae"
 | 
			
		||||
      ],
 | 
			
		||||
      "license": "NonFree"
 | 
			
		||||
    },
 | 
			
		||||
    "libspen23": {
 | 
			
		||||
      "gradle_signatures": [
 | 
			
		||||
        "libspen23"
 | 
			
		||||
      ],
 | 
			
		||||
      "license": "NonFree"
 | 
			
		||||
    },
 | 
			
		||||
    "me.pushy": {
 | 
			
		||||
      "gradle_signatures": [
 | 
			
		||||
        "me.pushy:sdk"
 | 
			
		||||
      ],
 | 
			
		||||
      "license": "NonFree"
 | 
			
		||||
    },
 | 
			
		||||
    "org.jetbrains.kotlinx": {
 | 
			
		||||
      "gradle_signatures": [
 | 
			
		||||
        "org.jetbrains.kotlinx:kotlinx-coroutines-play-services"
 | 
			
		||||
      ],
 | 
			
		||||
      "license": "NonFree"
 | 
			
		||||
    },
 | 
			
		||||
    "ouya": {
 | 
			
		||||
      "gradle_signatures": [
 | 
			
		||||
        "ouya.*sdk"
 | 
			
		||||
      ],
 | 
			
		||||
      "license": "NonFree"
 | 
			
		||||
    },
 | 
			
		||||
    "paypal": {
 | 
			
		||||
      "gradle_signatures": [
 | 
			
		||||
        "paypal.*mpl"
 | 
			
		||||
      ],
 | 
			
		||||
      "license": "NonFree"
 | 
			
		||||
    },
 | 
			
		||||
    "xyz.belvi.mobilevision": {
 | 
			
		||||
      "gradle_signatures": [
 | 
			
		||||
        "xyz.belvi.mobilevision:barcodescanner"
 | 
			
		||||
      ],
 | 
			
		||||
      "license": "NonFree"
 | 
			
		||||
    },
 | 
			
		||||
    "youtube": {
 | 
			
		||||
      "gradle_signatures": [
 | 
			
		||||
        "youtube.*android.*player.*api"
 | 
			
		||||
      ],
 | 
			
		||||
      "license": "NonFree"
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  "timestamp": 1664480104.875586,
 | 
			
		||||
  "version": 1,
 | 
			
		||||
  "last_updated": 1664480104.875586
 | 
			
		||||
}'''
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -402,7 +402,7 @@ class BuildTest(unittest.TestCase):
 | 
			
		|||
        os.chdir(testdir)
 | 
			
		||||
        os.mkdir("build")
 | 
			
		||||
 | 
			
		||||
        config = dict()
 | 
			
		||||
        config = fdroidserver.common.get_config()
 | 
			
		||||
        config['sdk_path'] = os.getenv('ANDROID_HOME')
 | 
			
		||||
        config['ndk_paths'] = {'r10d': os.getenv('ANDROID_NDK_HOME')}
 | 
			
		||||
        fdroidserver.common.config = config
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -17,6 +17,7 @@ import zipfile
 | 
			
		|||
import collections
 | 
			
		||||
import pathlib
 | 
			
		||||
from unittest import mock
 | 
			
		||||
from datetime import datetime, timedelta
 | 
			
		||||
 | 
			
		||||
localmodule = os.path.realpath(
 | 
			
		||||
    os.path.join(os.path.dirname(inspect.getfile(inspect.currentframe())), '..')
 | 
			
		||||
| 
						 | 
				
			
			@ -29,7 +30,7 @@ import fdroidserver.build
 | 
			
		|||
import fdroidserver.common
 | 
			
		||||
import fdroidserver.metadata
 | 
			
		||||
import fdroidserver.scanner
 | 
			
		||||
from testcommon import TmpCwd
 | 
			
		||||
from testcommon import TmpCwd, mock_open_to_str
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ScannerTest(unittest.TestCase):
 | 
			
		||||
| 
						 | 
				
			
			@ -53,8 +54,8 @@ class ScannerTest(unittest.TestCase):
 | 
			
		|||
            'com.integreight.onesheeld': 11,
 | 
			
		||||
            'com.jens.automation2': 2,
 | 
			
		||||
            'firebase-suspect': 1,
 | 
			
		||||
            'org.mozilla.rocket': 3,
 | 
			
		||||
            'org.tasks': 3,
 | 
			
		||||
            'org.mozilla.rocket': 1,
 | 
			
		||||
            'org.tasks': 2,
 | 
			
		||||
            'realm': 1,
 | 
			
		||||
            'se.manyver': 2,
 | 
			
		||||
        }
 | 
			
		||||
| 
						 | 
				
			
			@ -446,21 +447,27 @@ class Test_scan_binary(unittest.TestCase):
 | 
			
		|||
        fdroidserver.common.config = config
 | 
			
		||||
        fdroidserver.common.options = mock.Mock()
 | 
			
		||||
 | 
			
		||||
    def test_code_signature_match(self):
 | 
			
		||||
        apkfile = os.path.join(self.basedir, 'no_targetsdk_minsdk1_unsigned.apk')
 | 
			
		||||
        mock_code_signatures = {
 | 
			
		||||
        fdroidserver.scanner._SCANNER_TOOL = mock.Mock()
 | 
			
		||||
        fdroidserver.scanner._SCANNER_TOOL.regexs = {}
 | 
			
		||||
        fdroidserver.scanner._SCANNER_TOOL.regexs['err_code_signatures'] = {
 | 
			
		||||
            "java/lang/Object": re.compile(
 | 
			
		||||
                r'.*java/lang/Object', re.IGNORECASE | re.UNICODE
 | 
			
		||||
            )
 | 
			
		||||
        }
 | 
			
		||||
        with mock.patch("fdroidserver.scanner.CODE_SIGNATURES", mock_code_signatures):
 | 
			
		||||
            self.assertEqual(
 | 
			
		||||
                1,
 | 
			
		||||
                fdroidserver.scanner.scan_binary(apkfile),
 | 
			
		||||
                "Did not find expected code signature '{}' in binary '{}'".format(
 | 
			
		||||
                    fdroidserver.scanner.CODE_SIGNATURES.values(), apkfile
 | 
			
		||||
                ),
 | 
			
		||||
            )
 | 
			
		||||
        fdroidserver.scanner._SCANNER_TOOL.regexs['warn_code_signatures'] = {}
 | 
			
		||||
 | 
			
		||||
    def test_code_signature_match(self):
 | 
			
		||||
        apkfile = os.path.join(self.basedir, 'no_targetsdk_minsdk1_unsigned.apk')
 | 
			
		||||
        self.assertEqual(
 | 
			
		||||
            1,
 | 
			
		||||
            fdroidserver.scanner.scan_binary(apkfile),
 | 
			
		||||
            "Did not find expected code signature '{}' in binary '{}'".format(
 | 
			
		||||
                fdroidserver.scanner._SCANNER_TOOL.regexs[
 | 
			
		||||
                    'err_code_signatures'
 | 
			
		||||
                ].values(),
 | 
			
		||||
                apkfile,
 | 
			
		||||
            ),
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    @unittest.skipIf(
 | 
			
		||||
        sys.version_info < (3, 9),
 | 
			
		||||
| 
						 | 
				
			
			@ -470,115 +477,213 @@ class Test_scan_binary(unittest.TestCase):
 | 
			
		|||
    )
 | 
			
		||||
    def test_bottom_level_embedded_apk_code_signature(self):
 | 
			
		||||
        apkfile = os.path.join(self.basedir, 'apk.embedded_1.apk')
 | 
			
		||||
        mock_code_signatures = {
 | 
			
		||||
        fdroidserver.scanner._SCANNER_TOOL.regexs['err_code_signatures'] = {
 | 
			
		||||
            "org/bitbucket/tickytacky/mirrormirror/MainActivity": re.compile(
 | 
			
		||||
                r'.*org/bitbucket/tickytacky/mirrormirror/MainActivity',
 | 
			
		||||
                re.IGNORECASE | re.UNICODE,
 | 
			
		||||
            )
 | 
			
		||||
        }
 | 
			
		||||
        with mock.patch("fdroidserver.scanner.CODE_SIGNATURES", mock_code_signatures):
 | 
			
		||||
            self.assertEqual(
 | 
			
		||||
                1,
 | 
			
		||||
                fdroidserver.scanner.scan_binary(apkfile),
 | 
			
		||||
                "Did not find expected code signature '{}' in binary '{}'".format(
 | 
			
		||||
                    fdroidserver.scanner.CODE_SIGNATURES.values(), apkfile
 | 
			
		||||
                ),
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        self.assertEqual(
 | 
			
		||||
            1,
 | 
			
		||||
            fdroidserver.scanner.scan_binary(apkfile),
 | 
			
		||||
            "Did not find expected code signature '{}' in binary '{}'".format(
 | 
			
		||||
                fdroidserver.scanner._SCANNER_TOOL.regexs[
 | 
			
		||||
                    'err_code_signatures'
 | 
			
		||||
                ].values(),
 | 
			
		||||
                apkfile,
 | 
			
		||||
            ),
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def test_top_level_signature_embedded_apk_present(self):
 | 
			
		||||
        apkfile = os.path.join(self.basedir, 'apk.embedded_1.apk')
 | 
			
		||||
        mock_code_signatures = {
 | 
			
		||||
        fdroidserver.scanner._SCANNER_TOOL.regexs['err_code_signatures'] = {
 | 
			
		||||
            "org/fdroid/ci/BuildConfig": re.compile(
 | 
			
		||||
                r'.*org/fdroid/ci/BuildConfig', re.IGNORECASE | re.UNICODE
 | 
			
		||||
            )
 | 
			
		||||
        }
 | 
			
		||||
        with mock.patch("fdroidserver.scanner.CODE_SIGNATURES", mock_code_signatures):
 | 
			
		||||
            self.assertEqual(
 | 
			
		||||
                1,
 | 
			
		||||
                fdroidserver.scanner.scan_binary(apkfile),
 | 
			
		||||
                "Did not find expected code signature '{}' in binary '{}'".format(
 | 
			
		||||
                    fdroidserver.scanner.CODE_SIGNATURES.values(), apkfile
 | 
			
		||||
                ),
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
    def test_no_match(self):
 | 
			
		||||
        apkfile = os.path.join(self.basedir, 'no_targetsdk_minsdk1_unsigned.apk')
 | 
			
		||||
        result = fdroidserver.scanner.scan_binary(apkfile)
 | 
			
		||||
        self.assertEqual(
 | 
			
		||||
            0, result, "Found false positives in binary '{}'".format(apkfile)
 | 
			
		||||
            1,
 | 
			
		||||
            fdroidserver.scanner.scan_binary(apkfile),
 | 
			
		||||
            "Did not find expected code signature '{}' in binary '{}'".format(
 | 
			
		||||
                fdroidserver.scanner._SCANNER_TOOL.regexs[
 | 
			
		||||
                    'err_code_signatures'
 | 
			
		||||
                ].values(),
 | 
			
		||||
                apkfile,
 | 
			
		||||
            ),
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Test__exodus_compile_signatures(unittest.TestCase):
 | 
			
		||||
    def setUp(self):
 | 
			
		||||
        self.m1 = mock.Mock()
 | 
			
		||||
        self.m1.code_signature = r"^random\sregex$"
 | 
			
		||||
        self.m2 = mock.Mock()
 | 
			
		||||
        self.m2.code_signature = r"^another.+regex$"
 | 
			
		||||
        self.mock_sigs = [self.m1, self.m2]
 | 
			
		||||
 | 
			
		||||
    def test_ok(self):
 | 
			
		||||
        result = fdroidserver.scanner._exodus_compile_signatures(self.mock_sigs)
 | 
			
		||||
        self.assertListEqual(
 | 
			
		||||
            result,
 | 
			
		||||
            [
 | 
			
		||||
                re.compile(self.m1.code_signature),
 | 
			
		||||
                re.compile(self.m2.code_signature),
 | 
			
		||||
            ],
 | 
			
		||||
class Test_SignatureDataController(unittest.TestCase):
 | 
			
		||||
    # __init__
 | 
			
		||||
    def test_init(self):
 | 
			
		||||
        sdc = fdroidserver.scanner.SignatureDataController(
 | 
			
		||||
            'nnn', 'fff.yml', 'https://example.com/test.json'
 | 
			
		||||
        )
 | 
			
		||||
        self.assertEqual(sdc.name, 'nnn')
 | 
			
		||||
        self.assertEqual(sdc.filename, 'fff.yml')
 | 
			
		||||
        self.assertEqual(sdc.cache_duration, timedelta(999999))
 | 
			
		||||
        self.assertDictEqual(sdc.data, {})
 | 
			
		||||
 | 
			
		||||
    def test_not_iterable(self):
 | 
			
		||||
        result = fdroidserver.scanner._exodus_compile_signatures(123)
 | 
			
		||||
        self.assertListEqual(result, [])
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Test_load_exodus_trackers_signatures(unittest.TestCase):
 | 
			
		||||
    def setUp(self):
 | 
			
		||||
        self.requests_ret = mock.Mock()
 | 
			
		||||
        self.requests_ret.json = mock.Mock(
 | 
			
		||||
            return_value={
 | 
			
		||||
                "trackers": {
 | 
			
		||||
                    "1": {
 | 
			
		||||
                        "id": 1,
 | 
			
		||||
                        "name": "Steyer Puch 1",
 | 
			
		||||
                        "description": "blah blah blah",
 | 
			
		||||
                        "creation_date": "1956-01-01",
 | 
			
		||||
                        "code_signature": "com.puch.|com.steyer.",
 | 
			
		||||
                        "network_signature": "pst\\.com",
 | 
			
		||||
                        "website": "https://pst.com",
 | 
			
		||||
                        "categories": ["tracker"],
 | 
			
		||||
                        "documentation": [],
 | 
			
		||||
                    },
 | 
			
		||||
                    "2": {
 | 
			
		||||
                        "id": 2,
 | 
			
		||||
                        "name": "Steyer Puch 2",
 | 
			
		||||
                        "description": "blah blah blah",
 | 
			
		||||
                        "creation_date": "1956-01-01",
 | 
			
		||||
                        "code_signature": "com.puch.|com.steyer.",
 | 
			
		||||
                        "network_signature": "pst\\.com",
 | 
			
		||||
                        "website": "https://pst.com",
 | 
			
		||||
                        "categories": ["tracker"],
 | 
			
		||||
                        "documentation": [],
 | 
			
		||||
                    },
 | 
			
		||||
                },
 | 
			
		||||
            }
 | 
			
		||||
    # check_last_updated
 | 
			
		||||
    def test_check_last_updated_ok(self):
 | 
			
		||||
        sdc = fdroidserver.scanner.SignatureDataController(
 | 
			
		||||
            'nnn', 'fff.yml', 'https://example.com/test.json'
 | 
			
		||||
        )
 | 
			
		||||
        self.requests_func = mock.Mock(return_value=self.requests_ret)
 | 
			
		||||
        self.compilesig_func = mock.Mock(return_value="mocked return value")
 | 
			
		||||
        sdc.data['last_updated'] = datetime.utcnow().timestamp()
 | 
			
		||||
        sdc.check_last_updated()
 | 
			
		||||
 | 
			
		||||
    def test_ok(self):
 | 
			
		||||
        with mock.patch("requests.get", self.requests_func), mock.patch(
 | 
			
		||||
            "fdroidserver.scanner._exodus_compile_signatures", self.compilesig_func
 | 
			
		||||
    def test_check_last_updated_exception_cache_outdated(self):
 | 
			
		||||
        sdc = fdroidserver.scanner.SignatureDataController(
 | 
			
		||||
            'nnn', 'fff.yml', 'https://example.com/test.json'
 | 
			
		||||
        )
 | 
			
		||||
        sdc.cache_duration = timedelta(days=7)
 | 
			
		||||
        sdc.data['last_updated'] = (datetime.utcnow() - timedelta(days=30)).timestamp()
 | 
			
		||||
        with self.assertRaises(fdroidserver.scanner.SignatureDataOutdatedException):
 | 
			
		||||
            sdc.check_last_updated()
 | 
			
		||||
 | 
			
		||||
    def test_check_last_updated_exception_not_string(self):
 | 
			
		||||
        sdc = fdroidserver.scanner.SignatureDataController(
 | 
			
		||||
            'nnn', 'fff.yml', 'https://example.com/test.json'
 | 
			
		||||
        )
 | 
			
		||||
        sdc.data['last_updated'] = 'sepp'
 | 
			
		||||
        with self.assertRaises(fdroidserver.scanner.SignatureDataMalformedException):
 | 
			
		||||
            sdc.check_last_updated()
 | 
			
		||||
 | 
			
		||||
    def test_check_last_updated_exception_not_iso_formatted_string(self):
 | 
			
		||||
        sdc = fdroidserver.scanner.SignatureDataController(
 | 
			
		||||
            'nnn', 'fff.yml', 'https://example.com/test.json'
 | 
			
		||||
        )
 | 
			
		||||
        sdc.data['last_updated'] = '01/09/2002 10:11'
 | 
			
		||||
        with self.assertRaises(fdroidserver.scanner.SignatureDataMalformedException):
 | 
			
		||||
            sdc.check_last_updated()
 | 
			
		||||
 | 
			
		||||
    def test_check_last_updated_no_exception_missing_when_last_updated_not_set(self):
 | 
			
		||||
        sdc = fdroidserver.scanner.SignatureDataController(
 | 
			
		||||
            'nnn', 'fff.yml', 'https://example.com/test.json'
 | 
			
		||||
        )
 | 
			
		||||
        sdc.check_last_updated()
 | 
			
		||||
 | 
			
		||||
    # check_data_version
 | 
			
		||||
    def test_check_data_version_ok(self):
 | 
			
		||||
        sdc = fdroidserver.scanner.SignatureDataController(
 | 
			
		||||
            'nnn', 'fff.yml', 'https://example.com/test.json'
 | 
			
		||||
        )
 | 
			
		||||
        sdc.data['version'] = fdroidserver.scanner.SCANNER_CACHE_VERSION
 | 
			
		||||
        sdc.check_data_version()
 | 
			
		||||
 | 
			
		||||
    def test_check_data_version_exception(self):
 | 
			
		||||
        sdc = fdroidserver.scanner.SignatureDataController(
 | 
			
		||||
            'nnn', 'fff.yml', 'https://example.com/test.json'
 | 
			
		||||
        )
 | 
			
		||||
        with self.assertRaises(
 | 
			
		||||
            fdroidserver.scanner.SignatureDataVersionMismatchException
 | 
			
		||||
        ):
 | 
			
		||||
            sigs, regex = fdroidserver.scanner.load_exodus_trackers_signatures()
 | 
			
		||||
            self.requests_func.assert_called_once_with(
 | 
			
		||||
                "https://reports.exodus-privacy.eu.org/api/trackers", timeout=300
 | 
			
		||||
            )
 | 
			
		||||
            self.assertEqual(len(sigs), 2)
 | 
			
		||||
            self.assertListEqual([1, 2], sorted([x.id for x in sigs]))
 | 
			
		||||
            sdc.check_data_version()
 | 
			
		||||
 | 
			
		||||
            self.compilesig_func.assert_called_once_with(sigs)
 | 
			
		||||
            self.assertEqual(regex, "mocked return value")
 | 
			
		||||
    def test_load_ok(self):
 | 
			
		||||
        sdc = fdroidserver.scanner.SignatureDataController(
 | 
			
		||||
            'nnn', 'fff.yml', 'https://example.com/test.json'
 | 
			
		||||
        )
 | 
			
		||||
        func_lfc = mock.Mock()
 | 
			
		||||
        func_vd = mock.Mock()
 | 
			
		||||
        func_clu = mock.Mock()
 | 
			
		||||
        with mock.patch(
 | 
			
		||||
            'fdroidserver.scanner.SignatureDataController.load_from_cache',
 | 
			
		||||
            func_lfc,
 | 
			
		||||
        ), mock.patch(
 | 
			
		||||
            'fdroidserver.scanner.SignatureDataController.verify_data',
 | 
			
		||||
            func_vd,
 | 
			
		||||
        ), mock.patch(
 | 
			
		||||
            'fdroidserver.scanner.SignatureDataController.check_last_updated',
 | 
			
		||||
            func_clu,
 | 
			
		||||
        ):
 | 
			
		||||
            sdc.load()
 | 
			
		||||
        func_lfc.assert_called_once_with()
 | 
			
		||||
        func_vd.assert_called_once_with()
 | 
			
		||||
        func_clu.assert_called_once_with()
 | 
			
		||||
 | 
			
		||||
    def test_load_initial_cache_miss(self):
 | 
			
		||||
        sdc = fdroidserver.scanner.SignatureDataController(
 | 
			
		||||
            'nnn', 'fff.yml', 'https://example.com/test.json'
 | 
			
		||||
        )
 | 
			
		||||
        func_lfc = mock.Mock(
 | 
			
		||||
            side_effect=fdroidserver.scanner.SignatureDataCacheMissException
 | 
			
		||||
        )
 | 
			
		||||
        func_lfd = mock.Mock()
 | 
			
		||||
        with mock.patch(
 | 
			
		||||
            'fdroidserver.scanner.SignatureDataController.load_from_cache',
 | 
			
		||||
            func_lfc,
 | 
			
		||||
        ), mock.patch(
 | 
			
		||||
            'fdroidserver.scanner.SignatureDataController.load_from_defaults',
 | 
			
		||||
            func_lfd,
 | 
			
		||||
        ):
 | 
			
		||||
            sdc.load()
 | 
			
		||||
        func_lfc.assert_called_once_with()
 | 
			
		||||
        func_lfd.assert_called_once_with()
 | 
			
		||||
 | 
			
		||||
    def test_load_cache_auto_refresh(self):
 | 
			
		||||
        sdc = fdroidserver.scanner.SignatureDataController(
 | 
			
		||||
            'nnn', 'fff.yml', 'https://example.com/test.json'
 | 
			
		||||
        )
 | 
			
		||||
        func_lfc = mock.Mock()
 | 
			
		||||
        func_vd = mock.Mock()
 | 
			
		||||
        func_clu = mock.Mock(
 | 
			
		||||
            side_effect=fdroidserver.scanner.SignatureDataOutdatedException()
 | 
			
		||||
        )
 | 
			
		||||
        func_fsfw = mock.Mock()
 | 
			
		||||
        func_wtc = mock.Mock()
 | 
			
		||||
        with mock.patch(
 | 
			
		||||
            'fdroidserver.scanner.SignatureDataController.load_from_cache',
 | 
			
		||||
            func_lfc,
 | 
			
		||||
        ), mock.patch(
 | 
			
		||||
            'fdroidserver.scanner.SignatureDataController.verify_data',
 | 
			
		||||
            func_vd,
 | 
			
		||||
        ), mock.patch(
 | 
			
		||||
            'fdroidserver.scanner.SignatureDataController.check_last_updated',
 | 
			
		||||
            func_clu,
 | 
			
		||||
        ), mock.patch(
 | 
			
		||||
            'fdroidserver.scanner.SignatureDataController.fetch_signatures_from_web',
 | 
			
		||||
            func_fsfw,
 | 
			
		||||
        ), mock.patch(
 | 
			
		||||
            'fdroidserver.scanner.SignatureDataController.write_to_cache',
 | 
			
		||||
            func_wtc,
 | 
			
		||||
        ):
 | 
			
		||||
            sdc.load()
 | 
			
		||||
        func_lfc.assert_called_once_with()
 | 
			
		||||
        func_vd.assert_called_once_with()
 | 
			
		||||
        func_clu.assert_called_once_with()
 | 
			
		||||
        func_fsfw.assert_called_once_with()
 | 
			
		||||
        func_wtc.assert_called_once_with()
 | 
			
		||||
 | 
			
		||||
    @unittest.skipIf(
 | 
			
		||||
        sys.version_info < (3, 9, 0),
 | 
			
		||||
        "mock_open doesn't allow easy access to written data in older python versions",
 | 
			
		||||
    )
 | 
			
		||||
    def test_write_to_cache(self):
 | 
			
		||||
        open_func = mock.mock_open()
 | 
			
		||||
        sdc = fdroidserver.scanner.SignatureDataController(
 | 
			
		||||
            'nnn', 'fff.yml', 'https://example.com/test.json'
 | 
			
		||||
        )
 | 
			
		||||
        sdc.data = {"mocked": "data"}
 | 
			
		||||
 | 
			
		||||
        with mock.patch("builtins.open", open_func), mock.patch(
 | 
			
		||||
            "fdroidserver.scanner._scanner_cachedir",
 | 
			
		||||
            return_value=pathlib.Path('.'),
 | 
			
		||||
        ):
 | 
			
		||||
            sdc.write_to_cache()
 | 
			
		||||
 | 
			
		||||
        open_func.assert_called_with(pathlib.Path('fff.yml'), 'w', encoding="utf-8")
 | 
			
		||||
        self.assertEqual(mock_open_to_str(open_func), """{\n  "mocked": "data"\n}""")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Test_ScannerTool(unittest.TestCase):
 | 
			
		||||
    def test_load(self):
 | 
			
		||||
        st = mock.Mock()
 | 
			
		||||
        st.sdcs = [mock.Mock(), mock.Mock()]
 | 
			
		||||
        fdroidserver.scanner.ScannerTool.load(st)
 | 
			
		||||
        st.sdcs[0].load.assert_called_once_with()
 | 
			
		||||
        st.sdcs[1].load.assert_called_once_with()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Test_main(unittest.TestCase):
 | 
			
		||||
| 
						 | 
				
			
			@ -627,7 +732,7 @@ class Test_main(unittest.TestCase):
 | 
			
		|||
 | 
			
		||||
            self.exit_func.assert_not_called()
 | 
			
		||||
            self.read_app_args_func.assert_not_called()
 | 
			
		||||
            self.scan_binary_func.assert_called_once_with('local.application.apk', [])
 | 
			
		||||
            self.scan_binary_func.assert_called_once_with('local.application.apk')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
if __name__ == "__main__":
 | 
			
		||||
| 
						 | 
				
			
			@ -648,8 +753,7 @@ if __name__ == "__main__":
 | 
			
		|||
        [
 | 
			
		||||
            unittest.makeSuite(ScannerTest),
 | 
			
		||||
            unittest.makeSuite(Test_scan_binary),
 | 
			
		||||
            unittest.makeSuite(Test__exodus_compile_signatures),
 | 
			
		||||
            unittest.makeSuite(Test_load_exodus_trackers_signatures),
 | 
			
		||||
            unittest.makeSuite(Test_SignatureDataController),
 | 
			
		||||
            unittest.makeSuite(Test_main),
 | 
			
		||||
        ]
 | 
			
		||||
    )
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -36,8 +36,7 @@ class TmpCwd():
 | 
			
		|||
 | 
			
		||||
 | 
			
		||||
class TmpPyPath():
 | 
			
		||||
    """Context-manager for temporarily changing the current working
 | 
			
		||||
    directory.
 | 
			
		||||
    """Context-manager for temporarily adding a direcory to python path
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    def __init__(self, additional_path):
 | 
			
		||||
| 
						 | 
				
			
			@ -48,3 +47,14 @@ class TmpPyPath():
 | 
			
		|||
 | 
			
		||||
    def __exit__(self, a, b, c):
 | 
			
		||||
        sys.path.remove(self.additional_path)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def mock_open_to_str(mock):
 | 
			
		||||
    """
 | 
			
		||||
    helper function for accessing all data written into a
 | 
			
		||||
    unittest.mock.mock_open() instance as a string.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    return "".join([
 | 
			
		||||
        x.args[0] for x in mock.mock_calls if str(x).startswith("call().write(")
 | 
			
		||||
    ])
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue