chiark / gitweb /
Merge branch 'remove-ndk-r9b' into 'master'
[fdroidserver.git] / fdroidserver / common.py
1 #!/usr/bin/env python3
2 #
3 # common.py - part of the FDroid server tools
4 # Copyright (C) 2010-13, Ciaran Gultnieks, ciaran@ciarang.com
5 # Copyright (C) 2013-2014 Daniel Martí <mvdan@mvdan.cc>
6 #
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11 #
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 # GNU Affero General Public License for more details.
16 #
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
19
20 # common.py is imported by all modules, so do not import third-party
21 # libraries here as they will become a requirement for all commands.
22
23 import io
24 import os
25 import sys
26 import re
27 import shutil
28 import glob
29 import stat
30 import subprocess
31 import time
32 import operator
33 import logging
34 import hashlib
35 import socket
36 import base64
37 import zipfile
38 import tempfile
39 import json
40 import xml.etree.ElementTree as XMLElementTree
41
42 from binascii import hexlify
43 from datetime import datetime, timedelta
44 from distutils.version import LooseVersion
45 from queue import Queue
46 from zipfile import ZipFile
47
48 from pyasn1.codec.der import decoder, encoder
49 from pyasn1_modules import rfc2315
50 from pyasn1.error import PyAsn1Error
51
52 from distutils.util import strtobool
53
54 import fdroidserver.metadata
55 from fdroidserver import _
56 from fdroidserver.exception import FDroidException, VCSException, NoSubmodulesException,\
57     BuildException, VerificationException
58 from .asynchronousfilereader import AsynchronousFileReader
59
60 # this is the build-tools version, aapt has a separate version that
61 # has to be manually set in test_aapt_version()
62 MINIMUM_AAPT_VERSION = '26.0.0'
63
64 # A signature block file with a .DSA, .RSA, or .EC extension
65 CERT_PATH_REGEX = re.compile(r'^META-INF/.*\.(DSA|EC|RSA)$')
66 APK_NAME_REGEX = re.compile(r'^([a-zA-Z][\w.]*)_(-?[0-9]+)_?([0-9a-f]{7})?\.apk')
67 STANDARD_FILE_NAME_REGEX = re.compile(r'^(\w[\w.]*)_(-?[0-9]+)\.\w+')
68
69 XMLElementTree.register_namespace('android', 'http://schemas.android.com/apk/res/android')
70
71 config = None
72 options = None
73 env = None
74 orig_path = None
75
76
77 default_config = {
78     'sdk_path': "$ANDROID_HOME",
79     'ndk_paths': {
80         'r10e': None,
81         'r11c': None,
82         'r12b': "$ANDROID_NDK",
83         'r13b': None,
84         'r14b': None,
85         'r15c': None,
86         'r16b': None,
87     },
88     'build_tools': MINIMUM_AAPT_VERSION,
89     'force_build_tools': False,
90     'java_paths': None,
91     'ant': "ant",
92     'mvn3': "mvn",
93     'gradle': 'gradle',
94     'accepted_formats': ['txt', 'yml'],
95     'sync_from_local_copy_dir': False,
96     'allow_disabled_algorithms': False,
97     'per_app_repos': False,
98     'make_current_version_link': True,
99     'current_version_name_source': 'Name',
100     'update_stats': False,
101     'stats_ignore': [],
102     'stats_server': None,
103     'stats_user': None,
104     'stats_to_carbon': False,
105     'repo_maxage': 0,
106     'build_server_always': False,
107     'keystore': 'keystore.jks',
108     'smartcardoptions': [],
109     'char_limits': {
110         'author': 256,
111         'name': 30,
112         'summary': 80,
113         'description': 4000,
114         'video': 256,
115         'whatsNew': 500,
116     },
117     'keyaliases': {},
118     'repo_url': "https://MyFirstFDroidRepo.org/fdroid/repo",
119     'repo_name': "My First FDroid Repo Demo",
120     'repo_icon': "fdroid-icon.png",
121     'repo_description': '''
122         This is a repository of apps to be used with FDroid. Applications in this
123         repository are either official binaries built by the original application
124         developers, or are binaries built from source by the admin of f-droid.org
125         using the tools on https://gitlab.com/u/fdroid.
126         ''',
127     'archive_older': 0,
128 }
129
130
131 def setup_global_opts(parser):
132     try:  # the buildserver VM might not have PIL installed
133         from PIL import PngImagePlugin
134         logger = logging.getLogger(PngImagePlugin.__name__)
135         logger.setLevel(logging.INFO)  # tame the "STREAM" debug messages
136     except ImportError:
137         pass
138
139     parser.add_argument("-v", "--verbose", action="store_true", default=False,
140                         help=_("Spew out even more information than normal"))
141     parser.add_argument("-q", "--quiet", action="store_true", default=False,
142                         help=_("Restrict output to warnings and errors"))
143
144
145 def _add_java_paths_to_config(pathlist, thisconfig):
146     def path_version_key(s):
147         versionlist = []
148         for u in re.split('[^0-9]+', s):
149             try:
150                 versionlist.append(int(u))
151             except ValueError:
152                 pass
153         return versionlist
154
155     for d in sorted(pathlist, key=path_version_key):
156         if os.path.islink(d):
157             continue
158         j = os.path.basename(d)
159         # the last one found will be the canonical one, so order appropriately
160         for regex in [
161                 r'^1\.([6-9])\.0\.jdk$',  # OSX
162                 r'^jdk1\.([6-9])\.0_[0-9]+.jdk$',  # OSX and Oracle tarball
163                 r'^jdk1\.([6-9])\.0_[0-9]+$',  # Oracle Windows
164                 r'^jdk([6-9])-openjdk$',  # Arch
165                 r'^java-([6-9])-openjdk$',  # Arch
166                 r'^java-([6-9])-jdk$',  # Arch (oracle)
167                 r'^java-1\.([6-9])\.0-.*$',  # RedHat
168                 r'^java-([6-9])-oracle$',  # Debian WebUpd8
169                 r'^jdk-([6-9])-oracle-.*$',  # Debian make-jpkg
170                 r'^java-([6-9])-openjdk-[^c][^o][^m].*$',  # Debian
171                 ]:
172             m = re.match(regex, j)
173             if not m:
174                 continue
175             for p in [d, os.path.join(d, 'Contents', 'Home')]:
176                 if os.path.exists(os.path.join(p, 'bin', 'javac')):
177                     thisconfig['java_paths'][m.group(1)] = p
178
179
180 def fill_config_defaults(thisconfig):
181     for k, v in default_config.items():
182         if k not in thisconfig:
183             thisconfig[k] = v
184
185     # Expand paths (~users and $vars)
186     def expand_path(path):
187         if path is None:
188             return None
189         orig = path
190         path = os.path.expanduser(path)
191         path = os.path.expandvars(path)
192         if orig == path:
193             return None
194         return path
195
196     for k in ['sdk_path', 'ant', 'mvn3', 'gradle', 'keystore', 'repo_icon']:
197         v = thisconfig[k]
198         exp = expand_path(v)
199         if exp is not None:
200             thisconfig[k] = exp
201             thisconfig[k + '_orig'] = v
202
203     # find all installed JDKs for keytool, jarsigner, and JAVA[6-9]_HOME env vars
204     if thisconfig['java_paths'] is None:
205         thisconfig['java_paths'] = dict()
206         pathlist = []
207         pathlist += glob.glob('/usr/lib/jvm/j*[6-9]*')
208         pathlist += glob.glob('/usr/java/jdk1.[6-9]*')
209         pathlist += glob.glob('/System/Library/Java/JavaVirtualMachines/1.[6-9].0.jdk')
210         pathlist += glob.glob('/Library/Java/JavaVirtualMachines/*jdk*[6-9]*')
211         if os.getenv('JAVA_HOME') is not None:
212             pathlist.append(os.getenv('JAVA_HOME'))
213         if os.getenv('PROGRAMFILES') is not None:
214             pathlist += glob.glob(os.path.join(os.getenv('PROGRAMFILES'), 'Java', 'jdk1.[6-9].*'))
215         _add_java_paths_to_config(pathlist, thisconfig)
216
217     for java_version in ('7', '8', '9'):
218         if java_version not in thisconfig['java_paths']:
219             continue
220         java_home = thisconfig['java_paths'][java_version]
221         jarsigner = os.path.join(java_home, 'bin', 'jarsigner')
222         if os.path.exists(jarsigner):
223             thisconfig['jarsigner'] = jarsigner
224             thisconfig['keytool'] = os.path.join(java_home, 'bin', 'keytool')
225             break  # Java7 is preferred, so quit if found
226
227     for k in ['ndk_paths', 'java_paths']:
228         d = thisconfig[k]
229         for k2 in d.copy():
230             v = d[k2]
231             exp = expand_path(v)
232             if exp is not None:
233                 thisconfig[k][k2] = exp
234                 thisconfig[k][k2 + '_orig'] = v
235
236
237 def regsub_file(pattern, repl, path):
238     with open(path, 'rb') as f:
239         text = f.read()
240     text = re.sub(bytes(pattern, 'utf8'), bytes(repl, 'utf8'), text)
241     with open(path, 'wb') as f:
242         f.write(text)
243
244
245 def read_config(opts, config_file='config.py'):
246     """Read the repository config
247
248     The config is read from config_file, which is in the current
249     directory when any of the repo management commands are used. If
250     there is a local metadata file in the git repo, then config.py is
251     not required, just use defaults.
252
253     """
254     global config, options
255
256     if config is not None:
257         return config
258
259     options = opts
260
261     config = {}
262
263     if os.path.isfile(config_file):
264         logging.debug(_("Reading '{config_file}'").format(config_file=config_file))
265         with io.open(config_file, "rb") as f:
266             code = compile(f.read(), config_file, 'exec')
267             exec(code, None, config)
268     else:
269         logging.warning(_("No 'config.py' found, using defaults."))
270
271     for k in ('mirrors', 'install_list', 'uninstall_list', 'serverwebroot', 'servergitroot'):
272         if k in config:
273             if not type(config[k]) in (str, list, tuple):
274                 logging.warning(
275                     _("'{field}' will be in random order! Use () or [] brackets if order is important!")
276                     .format(field=k))
277
278     # smartcardoptions must be a list since its command line args for Popen
279     if 'smartcardoptions' in config:
280         config['smartcardoptions'] = config['smartcardoptions'].split(' ')
281     elif 'keystore' in config and config['keystore'] == 'NONE':
282         # keystore='NONE' means use smartcard, these are required defaults
283         config['smartcardoptions'] = ['-storetype', 'PKCS11', '-providerName',
284                                       'SunPKCS11-OpenSC', '-providerClass',
285                                       'sun.security.pkcs11.SunPKCS11',
286                                       '-providerArg', 'opensc-fdroid.cfg']
287
288     if any(k in config for k in ["keystore", "keystorepass", "keypass"]):
289         st = os.stat(config_file)
290         if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
291             logging.warning(_("unsafe permissions on '{config_file}' (should be 0600)!")
292                             .format(config_file=config_file))
293
294     fill_config_defaults(config)
295
296     for k in ["repo_description", "archive_description"]:
297         if k in config:
298             config[k] = clean_description(config[k])
299
300     if 'serverwebroot' in config:
301         if isinstance(config['serverwebroot'], str):
302             roots = [config['serverwebroot']]
303         elif all(isinstance(item, str) for item in config['serverwebroot']):
304             roots = config['serverwebroot']
305         else:
306             raise TypeError(_('only accepts strings, lists, and tuples'))
307         rootlist = []
308         for rootstr in roots:
309             # since this is used with rsync, where trailing slashes have
310             # meaning, ensure there is always a trailing slash
311             if rootstr[-1] != '/':
312                 rootstr += '/'
313             rootlist.append(rootstr.replace('//', '/'))
314         config['serverwebroot'] = rootlist
315
316     if 'servergitmirrors' in config:
317         if isinstance(config['servergitmirrors'], str):
318             roots = [config['servergitmirrors']]
319         elif all(isinstance(item, str) for item in config['servergitmirrors']):
320             roots = config['servergitmirrors']
321         else:
322             raise TypeError(_('only accepts strings, lists, and tuples'))
323         config['servergitmirrors'] = roots
324
325     return config
326
327
328 def assert_config_keystore(config):
329     """Check weather keystore is configured correctly and raise exception if not."""
330
331     nosigningkey = False
332     if 'repo_keyalias' not in config:
333         nosigningkey = True
334         logging.critical(_("'repo_keyalias' not found in config.py!"))
335     if 'keystore' not in config:
336         nosigningkey = True
337         logging.critical(_("'keystore' not found in config.py!"))
338     elif not os.path.exists(config['keystore']):
339         nosigningkey = True
340         logging.critical("'" + config['keystore'] + "' does not exist!")
341     if 'keystorepass' not in config:
342         nosigningkey = True
343         logging.critical(_("'keystorepass' not found in config.py!"))
344     if 'keypass' not in config:
345         nosigningkey = True
346         logging.critical(_("'keypass' not found in config.py!"))
347     if nosigningkey:
348         raise FDroidException("This command requires a signing key, " +
349                               "you can create one using: fdroid update --create-key")
350
351
352 def find_sdk_tools_cmd(cmd):
353     '''find a working path to a tool from the Android SDK'''
354
355     tooldirs = []
356     if config is not None and 'sdk_path' in config and os.path.exists(config['sdk_path']):
357         # try to find a working path to this command, in all the recent possible paths
358         if 'build_tools' in config:
359             build_tools = os.path.join(config['sdk_path'], 'build-tools')
360             # if 'build_tools' was manually set and exists, check only that one
361             configed_build_tools = os.path.join(build_tools, config['build_tools'])
362             if os.path.exists(configed_build_tools):
363                 tooldirs.append(configed_build_tools)
364             else:
365                 # no configed version, so hunt known paths for it
366                 for f in sorted(os.listdir(build_tools), reverse=True):
367                     if os.path.isdir(os.path.join(build_tools, f)):
368                         tooldirs.append(os.path.join(build_tools, f))
369                 tooldirs.append(build_tools)
370         sdk_tools = os.path.join(config['sdk_path'], 'tools')
371         if os.path.exists(sdk_tools):
372             tooldirs.append(sdk_tools)
373         sdk_platform_tools = os.path.join(config['sdk_path'], 'platform-tools')
374         if os.path.exists(sdk_platform_tools):
375             tooldirs.append(sdk_platform_tools)
376     tooldirs.append('/usr/bin')
377     for d in tooldirs:
378         path = os.path.join(d, cmd)
379         if os.path.isfile(path):
380             if cmd == 'aapt':
381                 test_aapt_version(path)
382             return path
383     # did not find the command, exit with error message
384     ensure_build_tools_exists(config)
385
386
387 def test_aapt_version(aapt):
388     '''Check whether the version of aapt is new enough'''
389     output = subprocess.check_output([aapt, 'version'], universal_newlines=True)
390     if output is None or output == '':
391         logging.error(_("'{path}' failed to execute!").format(path=aapt))
392     else:
393         m = re.match(r'.*v([0-9]+)\.([0-9]+)[.-]?([0-9.-]*)', output)
394         if m:
395             major = m.group(1)
396             minor = m.group(2)
397             bugfix = m.group(3)
398             # the Debian package has the version string like "v0.2-23.0.2"
399             too_old = False
400             if '.' in bugfix:
401                 if LooseVersion(bugfix) < LooseVersion(MINIMUM_AAPT_VERSION):
402                     too_old = True
403             elif LooseVersion('.'.join((major, minor, bugfix))) < LooseVersion('0.2.4062713'):
404                 too_old = True
405             if too_old:
406                 logging.warning(_("'{aapt}' is too old, fdroid requires build-tools-{version} or newer!")
407                                 .format(aapt=aapt, version=MINIMUM_AAPT_VERSION))
408         else:
409             logging.warning(_('Unknown version of aapt, might cause problems: ') + output)
410
411
412 def test_sdk_exists(thisconfig):
413     if 'sdk_path' not in thisconfig:
414         if 'aapt' in thisconfig and os.path.isfile(thisconfig['aapt']):
415             test_aapt_version(thisconfig['aapt'])
416             return True
417         else:
418             logging.error(_("'sdk_path' not set in 'config.py'!"))
419             return False
420     if thisconfig['sdk_path'] == default_config['sdk_path']:
421         logging.error(_('No Android SDK found!'))
422         logging.error(_('You can use ANDROID_HOME to set the path to your SDK, i.e.:'))
423         logging.error('\texport ANDROID_HOME=/opt/android-sdk')
424         return False
425     if not os.path.exists(thisconfig['sdk_path']):
426         logging.critical(_("Android SDK path '{path}' does not exist!")
427                          .format(path=thisconfig['sdk_path']))
428         return False
429     if not os.path.isdir(thisconfig['sdk_path']):
430         logging.critical(_("Android SDK path '{path}' is not a directory!")
431                          .format(path=thisconfig['sdk_path']))
432         return False
433     for d in ['build-tools', 'platform-tools', 'tools']:
434         if not os.path.isdir(os.path.join(thisconfig['sdk_path'], d)):
435             logging.critical(_("Android SDK '{path}' does not have '{dirname}' installed!")
436                              .format(path=thisconfig['sdk_path'], dirname=d))
437             return False
438     return True
439
440
441 def ensure_build_tools_exists(thisconfig):
442     if not test_sdk_exists(thisconfig):
443         raise FDroidException(_("Android SDK not found!"))
444     build_tools = os.path.join(thisconfig['sdk_path'], 'build-tools')
445     versioned_build_tools = os.path.join(build_tools, thisconfig['build_tools'])
446     if not os.path.isdir(versioned_build_tools):
447         raise FDroidException(
448             _("Android build-tools path '{path}' does not exist!")
449             .format(path=versioned_build_tools))
450
451
452 def get_local_metadata_files():
453     '''get any metadata files local to an app's source repo
454
455     This tries to ignore anything that does not count as app metdata,
456     including emacs cruft ending in ~ and the .fdroid.key*pass.txt files.
457
458     '''
459     return glob.glob('.fdroid.[a-jl-z]*[a-rt-z]')
460
461
462 def read_pkg_args(appid_versionCode_pairs, allow_vercodes=False):
463     """
464     :param appids: arguments in the form of multiple appid:[vc] strings
465     :returns: a dictionary with the set of vercodes specified for each package
466     """
467     vercodes = {}
468     if not appid_versionCode_pairs:
469         return vercodes
470
471     for p in appid_versionCode_pairs:
472         if allow_vercodes and ':' in p:
473             package, vercode = p.split(':')
474         else:
475             package, vercode = p, None
476         if package not in vercodes:
477             vercodes[package] = [vercode] if vercode else []
478             continue
479         elif vercode and vercode not in vercodes[package]:
480             vercodes[package] += [vercode] if vercode else []
481
482     return vercodes
483
484
485 def read_app_args(appid_versionCode_pairs, allapps, allow_vercodes=False):
486     """Build a list of App instances for processing
487
488     On top of what read_pkg_args does, this returns the whole app
489     metadata, but limiting the builds list to the builds matching the
490     appid_versionCode_pairs and vercodes specified.  If no appid_versionCode_pairs are specified, then
491     all App and Build instances are returned.
492
493     """
494
495     vercodes = read_pkg_args(appid_versionCode_pairs, allow_vercodes)
496
497     if not vercodes:
498         return allapps
499
500     apps = {}
501     for appid, app in allapps.items():
502         if appid in vercodes:
503             apps[appid] = app
504
505     if len(apps) != len(vercodes):
506         for p in vercodes:
507             if p not in allapps:
508                 logging.critical(_("No such package: %s") % p)
509         raise FDroidException(_("Found invalid appids in arguments"))
510     if not apps:
511         raise FDroidException(_("No packages specified"))
512
513     error = False
514     for appid, app in apps.items():
515         vc = vercodes[appid]
516         if not vc:
517             continue
518         app.builds = [b for b in app.builds if b.versionCode in vc]
519         if len(app.builds) != len(vercodes[appid]):
520             error = True
521             allvcs = [b.versionCode for b in app.builds]
522             for v in vercodes[appid]:
523                 if v not in allvcs:
524                     logging.critical(_("No such versionCode {versionCode} for app {appid}")
525                                      .format(versionCode=v, appid=appid))
526
527     if error:
528         raise FDroidException(_("Found invalid versionCodes for some apps"))
529
530     return apps
531
532
533 def get_extension(filename):
534     base, ext = os.path.splitext(filename)
535     if not ext:
536         return base, ''
537     return base, ext.lower()[1:]
538
539
540 def has_extension(filename, ext):
541     _ignored, f_ext = get_extension(filename)
542     return ext == f_ext
543
544
545 publish_name_regex = re.compile(r"^(.+)_([0-9]+)\.(apk|zip)$")
546
547
548 def clean_description(description):
549     'Remove unneeded newlines and spaces from a block of description text'
550     returnstring = ''
551     # this is split up by paragraph to make removing the newlines easier
552     for paragraph in re.split(r'\n\n', description):
553         paragraph = re.sub('\r', '', paragraph)
554         paragraph = re.sub('\n', ' ', paragraph)
555         paragraph = re.sub(' {2,}', ' ', paragraph)
556         paragraph = re.sub('^\s*(\w)', r'\1', paragraph)
557         returnstring += paragraph + '\n\n'
558     return returnstring.rstrip('\n')
559
560
561 def publishednameinfo(filename):
562     filename = os.path.basename(filename)
563     m = publish_name_regex.match(filename)
564     try:
565         result = (m.group(1), m.group(2))
566     except AttributeError:
567         raise FDroidException(_("Invalid name for published file: %s") % filename)
568     return result
569
570
571 apk_release_filename = re.compile('(?P<appid>[a-zA-Z0-9_\.]+)_(?P<vercode>[0-9]+)\.apk')
572 apk_release_filename_with_sigfp = re.compile('(?P<appid>[a-zA-Z0-9_\.]+)_(?P<vercode>[0-9]+)_(?P<sigfp>[0-9a-f]{7})\.apk')
573
574
575 def apk_parse_release_filename(apkname):
576     """Parses the name of an APK file according the F-Droids APK naming
577     scheme and returns the tokens.
578
579     WARNING: Returned values don't necessarily represent the APKs actual
580     properties, the are just paresed from the file name.
581
582     :returns: A triplet containing (appid, versionCode, signer), where appid
583         should be the package name, versionCode should be the integer
584         represion of the APKs version and signer should be the first 7 hex
585         digists of the sha256 signing key fingerprint which was used to sign
586         this APK.
587     """
588     m = apk_release_filename_with_sigfp.match(apkname)
589     if m:
590         return m.group('appid'), m.group('vercode'), m.group('sigfp')
591     m = apk_release_filename.match(apkname)
592     if m:
593         return m.group('appid'), m.group('vercode'), None
594     return None, None, None
595
596
597 def get_release_filename(app, build):
598     if build.output:
599         return "%s_%s.%s" % (app.id, build.versionCode, get_file_extension(build.output))
600     else:
601         return "%s_%s.apk" % (app.id, build.versionCode)
602
603
604 def get_toolsversion_logname(app, build):
605     return "%s_%s_toolsversion.log" % (app.id, build.versionCode)
606
607
608 def getsrcname(app, build):
609     return "%s_%s_src.tar.gz" % (app.id, build.versionCode)
610
611
612 def getappname(app):
613     if app.Name:
614         return app.Name
615     if app.AutoName:
616         return app.AutoName
617     return app.id
618
619
620 def getcvname(app):
621     return '%s (%s)' % (app.CurrentVersion, app.CurrentVersionCode)
622
623
624 def get_build_dir(app):
625     '''get the dir that this app will be built in'''
626
627     if app.RepoType == 'srclib':
628         return os.path.join('build', 'srclib', app.Repo)
629
630     return os.path.join('build', app.id)
631
632
633 def setup_vcs(app):
634     '''checkout code from VCS and return instance of vcs and the build dir'''
635     build_dir = get_build_dir(app)
636
637     # Set up vcs interface and make sure we have the latest code...
638     logging.debug("Getting {0} vcs interface for {1}"
639                   .format(app.RepoType, app.Repo))
640     if app.RepoType == 'git' and os.path.exists('.fdroid.yml'):
641         remote = os.getcwd()
642     else:
643         remote = app.Repo
644     vcs = getvcs(app.RepoType, remote, build_dir)
645
646     return vcs, build_dir
647
648
649 def getvcs(vcstype, remote, local):
650     if vcstype == 'git':
651         return vcs_git(remote, local)
652     if vcstype == 'git-svn':
653         return vcs_gitsvn(remote, local)
654     if vcstype == 'hg':
655         return vcs_hg(remote, local)
656     if vcstype == 'bzr':
657         return vcs_bzr(remote, local)
658     if vcstype == 'srclib':
659         if local != os.path.join('build', 'srclib', remote):
660             raise VCSException("Error: srclib paths are hard-coded!")
661         return getsrclib(remote, os.path.join('build', 'srclib'), raw=True)
662     if vcstype == 'svn':
663         raise VCSException("Deprecated vcs type 'svn' - please use 'git-svn' instead")
664     raise VCSException("Invalid vcs type " + vcstype)
665
666
667 def getsrclibvcs(name):
668     if name not in fdroidserver.metadata.srclibs:
669         raise VCSException("Missing srclib " + name)
670     return fdroidserver.metadata.srclibs[name]['Repo Type']
671
672
673 class vcs:
674
675     def __init__(self, remote, local):
676
677         # svn, git-svn and bzr may require auth
678         self.username = None
679         if self.repotype() in ('git-svn', 'bzr'):
680             if '@' in remote:
681                 if self.repotype == 'git-svn':
682                     raise VCSException("Authentication is not supported for git-svn")
683                 self.username, remote = remote.split('@')
684                 if ':' not in self.username:
685                     raise VCSException(_("Password required with username"))
686                 self.username, self.password = self.username.split(':')
687
688         self.remote = remote
689         self.local = local
690         self.clone_failed = False
691         self.refreshed = False
692         self.srclib = None
693
694     def repotype(self):
695         return None
696
697     def clientversion(self):
698         versionstr = FDroidPopen(self.clientversioncmd()).output
699         return versionstr[0:versionstr.find('\n')]
700
701     def clientversioncmd(self):
702         return None
703
704     def gotorevision(self, rev, refresh=True):
705         """Take the local repository to a clean version of the given
706         revision, which is specificed in the VCS's native
707         format. Beforehand, the repository can be dirty, or even
708         non-existent. If the repository does already exist locally, it
709         will be updated from the origin, but only once in the lifetime
710         of the vcs object.  None is acceptable for 'rev' if you know
711         you are cloning a clean copy of the repo - otherwise it must
712         specify a valid revision.
713         """
714
715         if self.clone_failed:
716             raise VCSException(_("Downloading the repository already failed once, not trying again."))
717
718         # The .fdroidvcs-id file for a repo tells us what VCS type
719         # and remote that directory was created from, allowing us to drop it
720         # automatically if either of those things changes.
721         fdpath = os.path.join(self.local, '..',
722                               '.fdroidvcs-' + os.path.basename(self.local))
723         fdpath = os.path.normpath(fdpath)
724         cdata = self.repotype() + ' ' + self.remote
725         writeback = True
726         deleterepo = False
727         if os.path.exists(self.local):
728             if os.path.exists(fdpath):
729                 with open(fdpath, 'r') as f:
730                     fsdata = f.read().strip()
731                 if fsdata == cdata:
732                     writeback = False
733                 else:
734                     deleterepo = True
735                     logging.info("Repository details for %s changed - deleting" % (
736                         self.local))
737             else:
738                 deleterepo = True
739                 logging.info("Repository details for %s missing - deleting" % (
740                     self.local))
741         if deleterepo:
742             shutil.rmtree(self.local)
743
744         exc = None
745         if not refresh:
746             self.refreshed = True
747
748         try:
749             self.gotorevisionx(rev)
750         except FDroidException as e:
751             exc = e
752
753         # If necessary, write the .fdroidvcs file.
754         if writeback and not self.clone_failed:
755             os.makedirs(os.path.dirname(fdpath), exist_ok=True)
756             with open(fdpath, 'w+') as f:
757                 f.write(cdata)
758
759         if exc is not None:
760             raise exc
761
762     def gotorevisionx(self, rev):  # pylint: disable=unused-argument
763         """Derived classes need to implement this.
764
765         It's called once basic checking has been performed.
766         """
767         raise VCSException("This VCS type doesn't define gotorevisionx")
768
769     # Initialise and update submodules
770     def initsubmodules(self):
771         raise VCSException('Submodules not supported for this vcs type')
772
773     # Get a list of all known tags
774     def gettags(self):
775         if not self._gettags:
776             raise VCSException('gettags not supported for this vcs type')
777         rtags = []
778         for tag in self._gettags():
779             if re.match('[-A-Za-z0-9_. /]+$', tag):
780                 rtags.append(tag)
781         return rtags
782
783     def latesttags(self):
784         """Get a list of all the known tags, sorted from newest to oldest"""
785         raise VCSException('latesttags not supported for this vcs type')
786
787     def getref(self):
788         """Get current commit reference (hash, revision, etc)"""
789         raise VCSException('getref not supported for this vcs type')
790
791     def getsrclib(self):
792         """Returns the srclib (name, path) used in setting up the current revision, or None."""
793         return self.srclib
794
795
796 class vcs_git(vcs):
797
798     def repotype(self):
799         return 'git'
800
801     def clientversioncmd(self):
802         return ['git', '--version']
803
804     def git(self, args, envs=dict(), cwd=None, output=True):
805         '''Prevent git fetch/clone/submodule from hanging at the username/password prompt
806
807         While fetch/pull/clone respect the command line option flags,
808         it seems that submodule commands do not.  They do seem to
809         follow whatever is in env vars, if the version of git is new
810         enough.  So we just throw the kitchen sink at it to see what
811         sticks.
812
813         Also, because of CVE-2017-1000117, block all SSH URLs.
814         '''
815         #
816         # supported in git >= 2.3
817         git_config = [
818             '-c', 'core.sshCommand=false',
819             '-c', 'url.https://.insteadOf=ssh://',
820         ]
821         for domain in ('bitbucket.org', 'github.com', 'gitlab.com'):
822             git_config.append('-c')
823             git_config.append('url.https://u:p@' + domain + '/.insteadOf=git@' + domain + ':')
824             git_config.append('-c')
825             git_config.append('url.https://u:p@' + domain + '.insteadOf=git://' + domain)
826             git_config.append('-c')
827             git_config.append('url.https://u:p@' + domain + '.insteadOf=https://' + domain)
828         envs.update({
829             'GIT_TERMINAL_PROMPT': '0',
830             'GIT_SSH': 'false',  # for git < 2.3
831         })
832         return FDroidPopen(['git', ] + git_config + args,
833                            envs=envs, cwd=cwd, output=output)
834
835     def checkrepo(self):
836         """If the local directory exists, but is somehow not a git repository,
837         git will traverse up the directory tree until it finds one
838         that is (i.e.  fdroidserver) and then we'll proceed to destroy
839         it!  This is called as a safety check.
840
841         """
842
843         p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
844         result = p.output.rstrip()
845         if not result.endswith(self.local):
846             raise VCSException('Repository mismatch')
847
848     def gotorevisionx(self, rev):
849         if not os.path.exists(self.local):
850             # Brand new checkout
851             p = self.git(['clone', '--', self.remote, self.local])
852             if p.returncode != 0:
853                 self.clone_failed = True
854                 raise VCSException("Git clone failed", p.output)
855             self.checkrepo()
856         else:
857             self.checkrepo()
858             # Discard any working tree changes
859             p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
860                              'git', 'reset', '--hard'], cwd=self.local, output=False)
861             if p.returncode != 0:
862                 raise VCSException(_("Git reset failed"), p.output)
863             # Remove untracked files now, in case they're tracked in the target
864             # revision (it happens!)
865             p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
866                              'git', 'clean', '-dffx'], cwd=self.local, output=False)
867             if p.returncode != 0:
868                 raise VCSException(_("Git clean failed"), p.output)
869             if not self.refreshed:
870                 # Get latest commits and tags from remote
871                 p = self.git(['fetch', 'origin'], cwd=self.local)
872                 if p.returncode != 0:
873                     raise VCSException(_("Git fetch failed"), p.output)
874                 p = self.git(['fetch', '--prune', '--tags', 'origin'], output=False, cwd=self.local)
875                 if p.returncode != 0:
876                     raise VCSException(_("Git fetch failed"), p.output)
877                 # Recreate origin/HEAD as git clone would do it, in case it disappeared
878                 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
879                 if p.returncode != 0:
880                     lines = p.output.splitlines()
881                     if 'Multiple remote HEAD branches' not in lines[0]:
882                         raise VCSException(_("Git remote set-head failed"), p.output)
883                     branch = lines[1].split(' ')[-1]
884                     p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--', branch],
885                                      cwd=self.local, output=False)
886                     if p2.returncode != 0:
887                         raise VCSException(_("Git remote set-head failed"), p.output + '\n' + p2.output)
888                 self.refreshed = True
889         # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
890         # a github repo. Most of the time this is the same as origin/master.
891         rev = rev or 'origin/HEAD'
892         p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
893         if p.returncode != 0:
894             raise VCSException(_("Git checkout of '%s' failed") % rev, p.output)
895         # Get rid of any uncontrolled files left behind
896         p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
897         if p.returncode != 0:
898             raise VCSException(_("Git clean failed"), p.output)
899
900     def initsubmodules(self):
901         self.checkrepo()
902         submfile = os.path.join(self.local, '.gitmodules')
903         if not os.path.isfile(submfile):
904             raise NoSubmodulesException(_("No git submodules available"))
905
906         # fix submodules not accessible without an account and public key auth
907         with open(submfile, 'r') as f:
908             lines = f.readlines()
909         with open(submfile, 'w') as f:
910             for line in lines:
911                 for domain in ('bitbucket.org', 'github.com', 'gitlab.com'):
912                     line = re.sub('git@' + domain + ':', 'https://u:p@' + domain + '/', line)
913                 f.write(line)
914
915         p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
916         if p.returncode != 0:
917             raise VCSException(_("Git submodule sync failed"), p.output)
918         p = self.git(['submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
919         if p.returncode != 0:
920             raise VCSException(_("Git submodule update failed"), p.output)
921
922     def _gettags(self):
923         self.checkrepo()
924         p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
925         return p.output.splitlines()
926
927     tag_format = re.compile(r'tag: ([^),]*)')
928
929     def latesttags(self):
930         self.checkrepo()
931         p = FDroidPopen(['git', 'log', '--tags',
932                          '--simplify-by-decoration', '--pretty=format:%d'],
933                         cwd=self.local, output=False)
934         tags = []
935         for line in p.output.splitlines():
936             for tag in self.tag_format.findall(line):
937                 tags.append(tag)
938         return tags
939
940
941 class vcs_gitsvn(vcs):
942
943     def repotype(self):
944         return 'git-svn'
945
946     def clientversioncmd(self):
947         return ['git', 'svn', '--version']
948
949     def checkrepo(self):
950         """If the local directory exists, but is somehow not a git repository,
951         git will traverse up the directory tree until it finds one that
952         is (i.e.  fdroidserver) and then we'll proceed to destory it!
953         This is called as a safety check.
954
955         """
956         p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
957         result = p.output.rstrip()
958         if not result.endswith(self.local):
959             raise VCSException('Repository mismatch')
960
961     def git(self, args, envs=dict(), cwd=None, output=True):
962         '''Prevent git fetch/clone/submodule from hanging at the username/password prompt
963         '''
964         # CVE-2017-1000117 block all SSH URLs (supported in git >= 2.3)
965         config = ['-c', 'core.sshCommand=false']
966         envs.update({
967             'GIT_TERMINAL_PROMPT': '0',
968             'GIT_SSH': 'false',  # for git < 2.3
969             'SVN_SSH': 'false',
970         })
971         return FDroidPopen(['git', ] + config + args,
972                            envs=envs, cwd=cwd, output=output)
973
974     def gotorevisionx(self, rev):
975         if not os.path.exists(self.local):
976             # Brand new checkout
977             gitsvn_args = ['svn', 'clone']
978             if ';' in self.remote:
979                 remote_split = self.remote.split(';')
980                 for i in remote_split[1:]:
981                     if i.startswith('trunk='):
982                         gitsvn_args.extend(['-T', i[6:]])
983                     elif i.startswith('tags='):
984                         gitsvn_args.extend(['-t', i[5:]])
985                     elif i.startswith('branches='):
986                         gitsvn_args.extend(['-b', i[9:]])
987                 gitsvn_args.extend([remote_split[0], self.local])
988                 p = self.git(gitsvn_args, output=False)
989                 if p.returncode != 0:
990                     self.clone_failed = True
991                     raise VCSException("Git svn clone failed", p.output)
992             else:
993                 gitsvn_args.extend([self.remote, self.local])
994                 p = self.git(gitsvn_args, output=False)
995                 if p.returncode != 0:
996                     self.clone_failed = True
997                     raise VCSException("Git svn clone failed", p.output)
998             self.checkrepo()
999         else:
1000             self.checkrepo()
1001             # Discard any working tree changes
1002             p = self.git(['reset', '--hard'], cwd=self.local, output=False)
1003             if p.returncode != 0:
1004                 raise VCSException("Git reset failed", p.output)
1005             # Remove untracked files now, in case they're tracked in the target
1006             # revision (it happens!)
1007             p = self.git(['clean', '-dffx'], cwd=self.local, output=False)
1008             if p.returncode != 0:
1009                 raise VCSException("Git clean failed", p.output)
1010             if not self.refreshed:
1011                 # Get new commits, branches and tags from repo
1012                 p = self.git(['svn', 'fetch'], cwd=self.local, output=False)
1013                 if p.returncode != 0:
1014                     raise VCSException("Git svn fetch failed")
1015                 p = self.git(['svn', 'rebase'], cwd=self.local, output=False)
1016                 if p.returncode != 0:
1017                     raise VCSException("Git svn rebase failed", p.output)
1018                 self.refreshed = True
1019
1020         rev = rev or 'master'
1021         if rev:
1022             nospaces_rev = rev.replace(' ', '%20')
1023             # Try finding a svn tag
1024             for treeish in ['origin/', '']:
1025                 p = self.git(['checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
1026                 if p.returncode == 0:
1027                     break
1028             if p.returncode != 0:
1029                 # No tag found, normal svn rev translation
1030                 # Translate svn rev into git format
1031                 rev_split = rev.split('/')
1032
1033                 p = None
1034                 for treeish in ['origin/', '']:
1035                     if len(rev_split) > 1:
1036                         treeish += rev_split[0]
1037                         svn_rev = rev_split[1]
1038
1039                     else:
1040                         # if no branch is specified, then assume trunk (i.e. 'master' branch):
1041                         treeish += 'master'
1042                         svn_rev = rev
1043
1044                     svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
1045
1046                     p = self.git(['svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
1047                     git_rev = p.output.rstrip()
1048
1049                     if p.returncode == 0 and git_rev:
1050                         break
1051
1052                 if p.returncode != 0 or not git_rev:
1053                     # Try a plain git checkout as a last resort
1054                     p = self.git(['checkout', rev], cwd=self.local, output=False)
1055                     if p.returncode != 0:
1056                         raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
1057                 else:
1058                     # Check out the git rev equivalent to the svn rev
1059                     p = self.git(['checkout', git_rev], cwd=self.local, output=False)
1060                     if p.returncode != 0:
1061                         raise VCSException(_("Git checkout of '%s' failed") % rev, p.output)
1062
1063         # Get rid of any uncontrolled files left behind
1064         p = self.git(['clean', '-dffx'], cwd=self.local, output=False)
1065         if p.returncode != 0:
1066             raise VCSException(_("Git clean failed"), p.output)
1067
1068     def _gettags(self):
1069         self.checkrepo()
1070         for treeish in ['origin/', '']:
1071             d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
1072             if os.path.isdir(d):
1073                 return os.listdir(d)
1074
1075     def getref(self):
1076         self.checkrepo()
1077         p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
1078         if p.returncode != 0:
1079             return None
1080         return p.output.strip()
1081
1082
1083 class vcs_hg(vcs):
1084
1085     def repotype(self):
1086         return 'hg'
1087
1088     def clientversioncmd(self):
1089         return ['hg', '--version']
1090
1091     def gotorevisionx(self, rev):
1092         if not os.path.exists(self.local):
1093             p = FDroidPopen(['hg', 'clone', '--ssh', 'false', '--', self.remote, self.local],
1094                             output=False)
1095             if p.returncode != 0:
1096                 self.clone_failed = True
1097                 raise VCSException("Hg clone failed", p.output)
1098         else:
1099             p = FDroidPopen(['hg', 'status', '-uS'], cwd=self.local, output=False)
1100             if p.returncode != 0:
1101                 raise VCSException("Hg status failed", p.output)
1102             for line in p.output.splitlines():
1103                 if not line.startswith('? '):
1104                     raise VCSException("Unexpected output from hg status -uS: " + line)
1105                 FDroidPopen(['rm', '-rf', '--', line[2:]], cwd=self.local, output=False)
1106             if not self.refreshed:
1107                 p = FDroidPopen(['hg', 'pull', '--ssh', 'false'], cwd=self.local, output=False)
1108                 if p.returncode != 0:
1109                     raise VCSException("Hg pull failed", p.output)
1110                 self.refreshed = True
1111
1112         rev = rev or 'default'
1113         if not rev:
1114             return
1115         p = FDroidPopen(['hg', 'update', '-C', '--', rev], cwd=self.local, output=False)
1116         if p.returncode != 0:
1117             raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
1118         p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
1119         # Also delete untracked files, we have to enable purge extension for that:
1120         if "'purge' is provided by the following extension" in p.output:
1121             with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
1122                 myfile.write("\n[extensions]\nhgext.purge=\n")
1123             p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
1124             if p.returncode != 0:
1125                 raise VCSException("HG purge failed", p.output)
1126         elif p.returncode != 0:
1127             raise VCSException("HG purge failed", p.output)
1128
1129     def _gettags(self):
1130         p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
1131         return p.output.splitlines()[1:]
1132
1133
1134 class vcs_bzr(vcs):
1135
1136     def repotype(self):
1137         return 'bzr'
1138
1139     def clientversioncmd(self):
1140         return ['bzr', '--version']
1141
1142     def bzr(self, args, envs=dict(), cwd=None, output=True):
1143         '''Prevent bzr from ever using SSH to avoid security vulns'''
1144         envs.update({
1145             'BZR_SSH': 'false',
1146         })
1147         return FDroidPopen(['bzr', ] + args, envs=envs, cwd=cwd, output=output)
1148
1149     def gotorevisionx(self, rev):
1150         if not os.path.exists(self.local):
1151             p = self.bzr(['branch', self.remote, self.local], output=False)
1152             if p.returncode != 0:
1153                 self.clone_failed = True
1154                 raise VCSException("Bzr branch failed", p.output)
1155         else:
1156             p = self.bzr(['clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
1157             if p.returncode != 0:
1158                 raise VCSException("Bzr revert failed", p.output)
1159             if not self.refreshed:
1160                 p = self.bzr(['pull'], cwd=self.local, output=False)
1161                 if p.returncode != 0:
1162                     raise VCSException("Bzr update failed", p.output)
1163                 self.refreshed = True
1164
1165         revargs = list(['-r', rev] if rev else [])
1166         p = self.bzr(['revert'] + revargs, cwd=self.local, output=False)
1167         if p.returncode != 0:
1168             raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
1169
1170     def _gettags(self):
1171         p = self.bzr(['tags'], cwd=self.local, output=False)
1172         return [tag.split('   ')[0].strip() for tag in
1173                 p.output.splitlines()]
1174
1175
1176 def unescape_string(string):
1177     if len(string) < 2:
1178         return string
1179     if string[0] == '"' and string[-1] == '"':
1180         return string[1:-1]
1181
1182     return string.replace("\\'", "'")
1183
1184
1185 def retrieve_string(app_dir, string, xmlfiles=None):
1186
1187     if not string.startswith('@string/'):
1188         return unescape_string(string)
1189
1190     if xmlfiles is None:
1191         xmlfiles = []
1192         for res_dir in [
1193             os.path.join(app_dir, 'res'),
1194             os.path.join(app_dir, 'src', 'main', 'res'),
1195         ]:
1196             for root, dirs, files in os.walk(res_dir):
1197                 if os.path.basename(root) == 'values':
1198                     xmlfiles += [os.path.join(root, x) for x in files if x.endswith('.xml')]
1199
1200     name = string[len('@string/'):]
1201
1202     def element_content(element):
1203         if element.text is None:
1204             return ""
1205         s = XMLElementTree.tostring(element, encoding='utf-8', method='text')
1206         return s.decode('utf-8').strip()
1207
1208     for path in xmlfiles:
1209         if not os.path.isfile(path):
1210             continue
1211         xml = parse_xml(path)
1212         element = xml.find('string[@name="' + name + '"]')
1213         if element is not None:
1214             content = element_content(element)
1215             return retrieve_string(app_dir, content, xmlfiles)
1216
1217     return ''
1218
1219
1220 def retrieve_string_singleline(app_dir, string, xmlfiles=None):
1221     return retrieve_string(app_dir, string, xmlfiles).replace('\n', ' ').strip()
1222
1223
1224 def manifest_paths(app_dir, flavours):
1225     '''Return list of existing files that will be used to find the highest vercode'''
1226
1227     possible_manifests = \
1228         [os.path.join(app_dir, 'AndroidManifest.xml'),
1229          os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
1230          os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
1231          os.path.join(app_dir, 'build.gradle')]
1232
1233     for flavour in flavours:
1234         if flavour == 'yes':
1235             continue
1236         possible_manifests.append(
1237             os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
1238
1239     return [path for path in possible_manifests if os.path.isfile(path)]
1240
1241
1242 def fetch_real_name(app_dir, flavours):
1243     '''Retrieve the package name. Returns the name, or None if not found.'''
1244     for path in manifest_paths(app_dir, flavours):
1245         if not has_extension(path, 'xml') or not os.path.isfile(path):
1246             continue
1247         logging.debug("fetch_real_name: Checking manifest at " + path)
1248         xml = parse_xml(path)
1249         app = xml.find('application')
1250         if app is None:
1251             continue
1252         if "{http://schemas.android.com/apk/res/android}label" not in app.attrib:
1253             continue
1254         label = app.attrib["{http://schemas.android.com/apk/res/android}label"]
1255         result = retrieve_string_singleline(app_dir, label)
1256         if result:
1257             result = result.strip()
1258         return result
1259     return None
1260
1261
1262 def get_library_references(root_dir):
1263     libraries = []
1264     proppath = os.path.join(root_dir, 'project.properties')
1265     if not os.path.isfile(proppath):
1266         return libraries
1267     with open(proppath, 'r', encoding='iso-8859-1') as f:
1268         for line in f:
1269             if not line.startswith('android.library.reference.'):
1270                 continue
1271             path = line.split('=')[1].strip()
1272             relpath = os.path.join(root_dir, path)
1273             if not os.path.isdir(relpath):
1274                 continue
1275             logging.debug("Found subproject at %s" % path)
1276             libraries.append(path)
1277     return libraries
1278
1279
1280 def ant_subprojects(root_dir):
1281     subprojects = get_library_references(root_dir)
1282     for subpath in subprojects:
1283         subrelpath = os.path.join(root_dir, subpath)
1284         for p in get_library_references(subrelpath):
1285             relp = os.path.normpath(os.path.join(subpath, p))
1286             if relp not in subprojects:
1287                 subprojects.insert(0, relp)
1288     return subprojects
1289
1290
1291 def remove_debuggable_flags(root_dir):
1292     # Remove forced debuggable flags
1293     logging.debug("Removing debuggable flags from %s" % root_dir)
1294     for root, dirs, files in os.walk(root_dir):
1295         if 'AndroidManifest.xml' in files and os.path.isfile(os.path.join(root, 'AndroidManifest.xml')):
1296             regsub_file(r'android:debuggable="[^"]*"',
1297                         '',
1298                         os.path.join(root, 'AndroidManifest.xml'))
1299
1300
1301 vcsearch_g = re.compile(r'''.*[Vv]ersionCode[ =]+["']*([0-9]+)["']*''').search
1302 vnsearch_g = re.compile(r'.*[Vv]ersionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
1303 psearch_g = re.compile(r'.*(packageName|applicationId) *=* *["\']([^"]+)["\'].*').search
1304
1305
1306 def app_matches_packagename(app, package):
1307     if not package:
1308         return False
1309     appid = app.UpdateCheckName or app.id
1310     if appid is None or appid == "Ignore":
1311         return True
1312     return appid == package
1313
1314
1315 def parse_androidmanifests(paths, app):
1316     """
1317     Extract some information from the AndroidManifest.xml at the given path.
1318     Returns (version, vercode, package), any or all of which might be None.
1319     All values returned are strings.
1320     """
1321
1322     ignoreversions = app.UpdateCheckIgnore
1323     ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
1324
1325     if not paths:
1326         return (None, None, None)
1327
1328     max_version = None
1329     max_vercode = None
1330     max_package = None
1331
1332     for path in paths:
1333
1334         if not os.path.isfile(path):
1335             continue
1336
1337         logging.debug(_("Parsing manifest at '{path}'").format(path=path))
1338         version = None
1339         vercode = None
1340         package = None
1341
1342         flavour = None
1343         if app.builds and 'gradle' in app.builds[-1] and app.builds[-1].gradle:
1344             flavour = app.builds[-1].gradle[-1]
1345
1346         if has_extension(path, 'gradle'):
1347             with open(path, 'r') as f:
1348                 inside_flavour_group = 0
1349                 inside_required_flavour = 0
1350                 for line in f:
1351                     if gradle_comment.match(line):
1352                         continue
1353
1354                     if inside_flavour_group > 0:
1355                         if inside_required_flavour > 0:
1356                             matches = psearch_g(line)
1357                             if matches:
1358                                 s = matches.group(2)
1359                                 if app_matches_packagename(app, s):
1360                                     package = s
1361
1362                             matches = vnsearch_g(line)
1363                             if matches:
1364                                 version = matches.group(2)
1365
1366                             matches = vcsearch_g(line)
1367                             if matches:
1368                                 vercode = matches.group(1)
1369
1370                             if '{' in line:
1371                                 inside_required_flavour += 1
1372                             if '}' in line:
1373                                 inside_required_flavour -= 1
1374                         else:
1375                             if flavour and (flavour in line):
1376                                 inside_required_flavour = 1
1377
1378                         if '{' in line:
1379                             inside_flavour_group += 1
1380                         if '}' in line:
1381                             inside_flavour_group -= 1
1382                     else:
1383                         if "productFlavors" in line:
1384                             inside_flavour_group = 1
1385                         if not package:
1386                             matches = psearch_g(line)
1387                             if matches:
1388                                 s = matches.group(2)
1389                                 if app_matches_packagename(app, s):
1390                                     package = s
1391                         if not version:
1392                             matches = vnsearch_g(line)
1393                             if matches:
1394                                 version = matches.group(2)
1395                         if not vercode:
1396                             matches = vcsearch_g(line)
1397                             if matches:
1398                                 vercode = matches.group(1)
1399         else:
1400             try:
1401                 xml = parse_xml(path)
1402                 if "package" in xml.attrib:
1403                     s = xml.attrib["package"]
1404                     if app_matches_packagename(app, s):
1405                         package = s
1406                 if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1407                     version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"]
1408                     base_dir = os.path.dirname(path)
1409                     version = retrieve_string_singleline(base_dir, version)
1410                 if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1411                     a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"]
1412                     if string_is_integer(a):
1413                         vercode = a
1414             except Exception:
1415                 logging.warning(_("Problem with xml at '{path}'").format(path=path))
1416
1417         # Remember package name, may be defined separately from version+vercode
1418         if package is None:
1419             package = max_package
1420
1421         logging.debug("..got package={0}, version={1}, vercode={2}"
1422                       .format(package, version, vercode))
1423
1424         # Always grab the package name and version name in case they are not
1425         # together with the highest version code
1426         if max_package is None and package is not None:
1427             max_package = package
1428         if max_version is None and version is not None:
1429             max_version = version
1430
1431         if vercode is not None \
1432            and (max_vercode is None or vercode > max_vercode):
1433             if not ignoresearch or not ignoresearch(version):
1434                 if version is not None:
1435                     max_version = version
1436                 if vercode is not None:
1437                     max_vercode = vercode
1438                 if package is not None:
1439                     max_package = package
1440             else:
1441                 max_version = "Ignore"
1442
1443     if max_version is None:
1444         max_version = "Unknown"
1445
1446     if max_package and not is_valid_package_name(max_package):
1447         raise FDroidException(_("Invalid package name {0}").format(max_package))
1448
1449     return (max_version, max_vercode, max_package)
1450
1451
1452 def is_valid_package_name(name):
1453     return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1454
1455
1456 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1457               raw=False, prepare=True, preponly=False, refresh=True,
1458               build=None):
1459     """Get the specified source library.
1460
1461     Returns the path to it. Normally this is the path to be used when
1462     referencing it, which may be a subdirectory of the actual project. If
1463     you want the base directory of the project, pass 'basepath=True'.
1464
1465     """
1466     number = None
1467     subdir = None
1468     if raw:
1469         name = spec
1470         ref = None
1471     else:
1472         name, ref = spec.split('@')
1473         if ':' in name:
1474             number, name = name.split(':', 1)
1475         if '/' in name:
1476             name, subdir = name.split('/', 1)
1477
1478     if name not in fdroidserver.metadata.srclibs:
1479         raise VCSException('srclib ' + name + ' not found.')
1480
1481     srclib = fdroidserver.metadata.srclibs[name]
1482
1483     sdir = os.path.join(srclib_dir, name)
1484
1485     if not preponly:
1486         vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1487         vcs.srclib = (name, number, sdir)
1488         if ref:
1489             vcs.gotorevision(ref, refresh)
1490
1491         if raw:
1492             return vcs
1493
1494     libdir = None
1495     if subdir:
1496         libdir = os.path.join(sdir, subdir)
1497     elif srclib["Subdir"]:
1498         for subdir in srclib["Subdir"]:
1499             libdir_candidate = os.path.join(sdir, subdir)
1500             if os.path.exists(libdir_candidate):
1501                 libdir = libdir_candidate
1502                 break
1503
1504     if libdir is None:
1505         libdir = sdir
1506
1507     remove_signing_keys(sdir)
1508     remove_debuggable_flags(sdir)
1509
1510     if prepare:
1511
1512         if srclib["Prepare"]:
1513             cmd = replace_config_vars(srclib["Prepare"], build)
1514
1515             p = FDroidPopen(['bash', '-x', '-c', '--', cmd], cwd=libdir)
1516             if p.returncode != 0:
1517                 raise BuildException("Error running prepare command for srclib %s"
1518                                      % name, p.output)
1519
1520     if basepath:
1521         libdir = sdir
1522
1523     return (name, number, libdir)
1524
1525
1526 gradle_version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1527
1528
1529 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False, refresh=True):
1530     """ Prepare the source code for a particular build
1531
1532     :param vcs: the appropriate vcs object for the application
1533     :param app: the application details from the metadata
1534     :param build: the build details from the metadata
1535     :param build_dir: the path to the build directory, usually 'build/app.id'
1536     :param srclib_dir: the path to the source libraries directory, usually 'build/srclib'
1537     :param extlib_dir: the path to the external libraries directory, usually 'build/extlib'
1538
1539     Returns the (root, srclibpaths) where:
1540     :param root: is the root directory, which may be the same as 'build_dir' or may
1541                  be a subdirectory of it.
1542     :param srclibpaths: is information on the srclibs being used
1543     """
1544
1545     # Optionally, the actual app source can be in a subdirectory
1546     if build.subdir:
1547         root_dir = os.path.join(build_dir, build.subdir)
1548     else:
1549         root_dir = build_dir
1550
1551     # Get a working copy of the right revision
1552     logging.info("Getting source for revision " + build.commit)
1553     vcs.gotorevision(build.commit, refresh)
1554
1555     # Initialise submodules if required
1556     if build.submodules:
1557         logging.info(_("Initialising submodules"))
1558         vcs.initsubmodules()
1559
1560     # Check that a subdir (if we're using one) exists. This has to happen
1561     # after the checkout, since it might not exist elsewhere
1562     if not os.path.exists(root_dir):
1563         raise BuildException('Missing subdir ' + root_dir)
1564
1565     # Run an init command if one is required
1566     if build.init:
1567         cmd = replace_config_vars(build.init, build)
1568         logging.info("Running 'init' commands in %s" % root_dir)
1569
1570         p = FDroidPopen(['bash', '-x', '-c', '--', cmd], cwd=root_dir)
1571         if p.returncode != 0:
1572             raise BuildException("Error running init command for %s:%s" %
1573                                  (app.id, build.versionName), p.output)
1574
1575     # Apply patches if any
1576     if build.patch:
1577         logging.info("Applying patches")
1578         for patch in build.patch:
1579             patch = patch.strip()
1580             logging.info("Applying " + patch)
1581             patch_path = os.path.join('metadata', app.id, patch)
1582             p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1583             if p.returncode != 0:
1584                 raise BuildException("Failed to apply patch %s" % patch_path)
1585
1586     # Get required source libraries
1587     srclibpaths = []
1588     if build.srclibs:
1589         logging.info("Collecting source libraries")
1590         for lib in build.srclibs:
1591             srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver,
1592                                          refresh=refresh, build=build))
1593
1594     for name, number, libpath in srclibpaths:
1595         place_srclib(root_dir, int(number) if number else None, libpath)
1596
1597     basesrclib = vcs.getsrclib()
1598     # If one was used for the main source, add that too.
1599     if basesrclib:
1600         srclibpaths.append(basesrclib)
1601
1602     # Update the local.properties file
1603     localprops = [os.path.join(build_dir, 'local.properties')]
1604     if build.subdir:
1605         parts = build.subdir.split(os.sep)
1606         cur = build_dir
1607         for d in parts:
1608             cur = os.path.join(cur, d)
1609             localprops += [os.path.join(cur, 'local.properties')]
1610     for path in localprops:
1611         props = ""
1612         if os.path.isfile(path):
1613             logging.info("Updating local.properties file at %s" % path)
1614             with open(path, 'r', encoding='iso-8859-1') as f:
1615                 props += f.read()
1616             props += '\n'
1617         else:
1618             logging.info("Creating local.properties file at %s" % path)
1619         # Fix old-fashioned 'sdk-location' by copying
1620         # from sdk.dir, if necessary
1621         if build.oldsdkloc:
1622             sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1623                               re.S | re.M).group(1)
1624             props += "sdk-location=%s\n" % sdkloc
1625         else:
1626             props += "sdk.dir=%s\n" % config['sdk_path']
1627             props += "sdk-location=%s\n" % config['sdk_path']
1628         ndk_path = build.ndk_path()
1629         # if for any reason the path isn't valid or the directory
1630         # doesn't exist, some versions of Gradle will error with a
1631         # cryptic message (even if the NDK is not even necessary).
1632         # https://gitlab.com/fdroid/fdroidserver/issues/171
1633         if ndk_path and os.path.exists(ndk_path):
1634             # Add ndk location
1635             props += "ndk.dir=%s\n" % ndk_path
1636             props += "ndk-location=%s\n" % ndk_path
1637         # Add java.encoding if necessary
1638         if build.encoding:
1639             props += "java.encoding=%s\n" % build.encoding
1640         with open(path, 'w', encoding='iso-8859-1') as f:
1641             f.write(props)
1642
1643     flavours = []
1644     if build.build_method() == 'gradle':
1645         flavours = build.gradle
1646
1647         if build.target:
1648             n = build.target.split('-')[1]
1649             regsub_file(r'compileSdkVersion[ =]+[0-9]+',
1650                         r'compileSdkVersion %s' % n,
1651                         os.path.join(root_dir, 'build.gradle'))
1652
1653     # Remove forced debuggable flags
1654     remove_debuggable_flags(root_dir)
1655
1656     # Insert version code and number into the manifest if necessary
1657     if build.forceversion:
1658         logging.info("Changing the version name")
1659         for path in manifest_paths(root_dir, flavours):
1660             if not os.path.isfile(path):
1661                 continue
1662             if has_extension(path, 'xml'):
1663                 regsub_file(r'android:versionName="[^"]*"',
1664                             r'android:versionName="%s"' % build.versionName,
1665                             path)
1666             elif has_extension(path, 'gradle'):
1667                 regsub_file(r"""(\s*)versionName[\s'"=]+.*""",
1668                             r"""\1versionName '%s'""" % build.versionName,
1669                             path)
1670
1671     if build.forcevercode:
1672         logging.info("Changing the version code")
1673         for path in manifest_paths(root_dir, flavours):
1674             if not os.path.isfile(path):
1675                 continue
1676             if has_extension(path, 'xml'):
1677                 regsub_file(r'android:versionCode="[^"]*"',
1678                             r'android:versionCode="%s"' % build.versionCode,
1679                             path)
1680             elif has_extension(path, 'gradle'):
1681                 regsub_file(r'versionCode[ =]+[0-9]+',
1682                             r'versionCode %s' % build.versionCode,
1683                             path)
1684
1685     # Delete unwanted files
1686     if build.rm:
1687         logging.info(_("Removing specified files"))
1688         for part in getpaths(build_dir, build.rm):
1689             dest = os.path.join(build_dir, part)
1690             logging.info("Removing {0}".format(part))
1691             if os.path.lexists(dest):
1692                 # rmtree can only handle directories that are not symlinks, so catch anything else
1693                 if not os.path.isdir(dest) or os.path.islink(dest):
1694                     os.remove(dest)
1695                 else:
1696                     shutil.rmtree(dest)
1697             else:
1698                 logging.info("...but it didn't exist")
1699
1700     remove_signing_keys(build_dir)
1701
1702     # Add required external libraries
1703     if build.extlibs:
1704         logging.info("Collecting prebuilt libraries")
1705         libsdir = os.path.join(root_dir, 'libs')
1706         if not os.path.exists(libsdir):
1707             os.mkdir(libsdir)
1708         for lib in build.extlibs:
1709             lib = lib.strip()
1710             logging.info("...installing extlib {0}".format(lib))
1711             libf = os.path.basename(lib)
1712             libsrc = os.path.join(extlib_dir, lib)
1713             if not os.path.exists(libsrc):
1714                 raise BuildException("Missing extlib file {0}".format(libsrc))
1715             shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1716
1717     # Run a pre-build command if one is required
1718     if build.prebuild:
1719         logging.info("Running 'prebuild' commands in %s" % root_dir)
1720
1721         cmd = replace_config_vars(build.prebuild, build)
1722
1723         # Substitute source library paths into prebuild commands
1724         for name, number, libpath in srclibpaths:
1725             libpath = os.path.relpath(libpath, root_dir)
1726             cmd = cmd.replace('$$' + name + '$$', libpath)
1727
1728         p = FDroidPopen(['bash', '-x', '-c', '--', cmd], cwd=root_dir)
1729         if p.returncode != 0:
1730             raise BuildException("Error running prebuild command for %s:%s" %
1731                                  (app.id, build.versionName), p.output)
1732
1733     # Generate (or update) the ant build file, build.xml...
1734     if build.build_method() == 'ant' and build.androidupdate != ['no']:
1735         parms = ['android', 'update', 'lib-project']
1736         lparms = ['android', 'update', 'project']
1737
1738         if build.target:
1739             parms += ['-t', build.target]
1740             lparms += ['-t', build.target]
1741         if build.androidupdate:
1742             update_dirs = build.androidupdate
1743         else:
1744             update_dirs = ant_subprojects(root_dir) + ['.']
1745
1746         for d in update_dirs:
1747             subdir = os.path.join(root_dir, d)
1748             if d == '.':
1749                 logging.debug("Updating main project")
1750                 cmd = parms + ['-p', d]
1751             else:
1752                 logging.debug("Updating subproject %s" % d)
1753                 cmd = lparms + ['-p', d]
1754             p = SdkToolsPopen(cmd, cwd=root_dir)
1755             # Check to see whether an error was returned without a proper exit
1756             # code (this is the case for the 'no target set or target invalid'
1757             # error)
1758             if p.returncode != 0 or p.output.startswith("Error: "):
1759                 raise BuildException("Failed to update project at %s" % d, p.output)
1760             # Clean update dirs via ant
1761             if d != '.':
1762                 logging.info("Cleaning subproject %s" % d)
1763                 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1764
1765     return (root_dir, srclibpaths)
1766
1767
1768 def getpaths_map(build_dir, globpaths):
1769     """Extend via globbing the paths from a field and return them as a map from original path to resulting paths"""
1770     paths = dict()
1771     for p in globpaths:
1772         p = p.strip()
1773         full_path = os.path.join(build_dir, p)
1774         full_path = os.path.normpath(full_path)
1775         paths[p] = [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1776         if not paths[p]:
1777             raise FDroidException("glob path '%s' did not match any files/dirs" % p)
1778     return paths
1779
1780
1781 def getpaths(build_dir, globpaths):
1782     """Extend via globbing the paths from a field and return them as a set"""
1783     paths_map = getpaths_map(build_dir, globpaths)
1784     paths = set()
1785     for k, v in paths_map.items():
1786         for p in v:
1787             paths.add(p)
1788     return paths
1789
1790
1791 def natural_key(s):
1792     return [int(sp) if sp.isdigit() else sp for sp in re.split(r'(\d+)', s)]
1793
1794
1795 def check_system_clock(dt_obj, path):
1796     """Check if system clock is updated based on provided date
1797
1798     If an APK has files newer than the system time, suggest updating
1799     the system clock.  This is useful for offline systems, used for
1800     signing, which do not have another source of clock sync info. It
1801     has to be more than 24 hours newer because ZIP/APK files do not
1802     store timezone info
1803
1804     """
1805     checkdt = dt_obj - timedelta(1)
1806     if datetime.today() < checkdt:
1807         logging.warning(_('System clock is older than date in {path}!').format(path=path)
1808                         + '\n' + _('Set clock to that time using:') + '\n'
1809                         + 'sudo date -s "' + str(dt_obj) + '"')
1810
1811
1812 class KnownApks:
1813     """permanent store of existing APKs with the date they were added
1814
1815     This is currently the only way to permanently store the "updated"
1816     date of APKs.
1817     """
1818
1819     def __init__(self):
1820         '''Load filename/date info about previously seen APKs
1821
1822         Since the appid and date strings both will never have spaces,
1823         this is parsed as a list from the end to allow the filename to
1824         have any combo of spaces.
1825         '''
1826
1827         self.path = os.path.join('stats', 'known_apks.txt')
1828         self.apks = {}
1829         if os.path.isfile(self.path):
1830             with open(self.path, 'r', encoding='utf8') as f:
1831                 for line in f:
1832                     t = line.rstrip().split(' ')
1833                     if len(t) == 2:
1834                         self.apks[t[0]] = (t[1], None)
1835                     else:
1836                         appid = t[-2]
1837                         date = datetime.strptime(t[-1], '%Y-%m-%d')
1838                         filename = line[0:line.rfind(appid) - 1]
1839                         self.apks[filename] = (appid, date)
1840                         check_system_clock(date, self.path)
1841         self.changed = False
1842
1843     def writeifchanged(self):
1844         if not self.changed:
1845             return
1846
1847         if not os.path.exists('stats'):
1848             os.mkdir('stats')
1849
1850         lst = []
1851         for apk, app in self.apks.items():
1852             appid, added = app
1853             line = apk + ' ' + appid
1854             if added:
1855                 line += ' ' + added.strftime('%Y-%m-%d')
1856             lst.append(line)
1857
1858         with open(self.path, 'w', encoding='utf8') as f:
1859             for line in sorted(lst, key=natural_key):
1860                 f.write(line + '\n')
1861
1862     def recordapk(self, apkName, app, default_date=None):
1863         '''
1864         Record an apk (if it's new, otherwise does nothing)
1865         Returns the date it was added as a datetime instance
1866         '''
1867         if apkName not in self.apks:
1868             if default_date is None:
1869                 default_date = datetime.utcnow()
1870             self.apks[apkName] = (app, default_date)
1871             self.changed = True
1872         _ignored, added = self.apks[apkName]
1873         return added
1874
1875     def getapp(self, apkname):
1876         """Look up information - given the 'apkname', returns (app id, date added/None).
1877
1878         Or returns None for an unknown apk.
1879         """
1880         if apkname in self.apks:
1881             return self.apks[apkname]
1882         return None
1883
1884     def getlatest(self, num):
1885         """Get the most recent 'num' apps added to the repo, as a list of package ids with the most recent first"""
1886         apps = {}
1887         for apk, app in self.apks.items():
1888             appid, added = app
1889             if added:
1890                 if appid in apps:
1891                     if apps[appid] > added:
1892                         apps[appid] = added
1893                 else:
1894                     apps[appid] = added
1895         sortedapps = sorted(apps.items(), key=operator.itemgetter(1))[-num:]
1896         lst = [app for app, _ignored in sortedapps]
1897         lst.reverse()
1898         return lst
1899
1900
1901 def get_file_extension(filename):
1902     """get the normalized file extension, can be blank string but never None"""
1903     if isinstance(filename, bytes):
1904         filename = filename.decode('utf-8')
1905     return os.path.splitext(filename)[1].lower()[1:]
1906
1907
1908 def get_apk_debuggable_aapt(apkfile):
1909     p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1910                       output=False)
1911     if p.returncode != 0:
1912         raise FDroidException(_("Failed to get APK manifest information"))
1913     for line in p.output.splitlines():
1914         if 'android:debuggable' in line and not line.endswith('0x0'):
1915             return True
1916     return False
1917
1918
1919 def get_apk_debuggable_androguard(apkfile):
1920     try:
1921         from androguard.core.bytecodes.apk import APK
1922     except ImportError:
1923         raise FDroidException("androguard library is not installed and aapt not present")
1924
1925     apkobject = APK(apkfile)
1926     if apkobject.is_valid_APK():
1927         debuggable = apkobject.get_element("application", "debuggable")
1928         if debuggable is not None:
1929             return bool(strtobool(debuggable))
1930     return False
1931
1932
1933 def isApkAndDebuggable(apkfile):
1934     """Returns True if the given file is an APK and is debuggable
1935
1936     :param apkfile: full path to the apk to check"""
1937
1938     if get_file_extension(apkfile) != 'apk':
1939         return False
1940
1941     if SdkToolsPopen(['aapt', 'version'], output=False):
1942         return get_apk_debuggable_aapt(apkfile)
1943     else:
1944         return get_apk_debuggable_androguard(apkfile)
1945
1946
1947 def get_apk_id_aapt(apkfile):
1948     """Extrat identification information from APK using aapt.
1949
1950     :param apkfile: path to an APK file.
1951     :returns: triplet (appid, version code, version name)
1952     """
1953     r = re.compile("package: name='(?P<appid>.*)' versionCode='(?P<vercode>.*)' versionName='(?P<vername>.*)' platformBuildVersionName='.*'")
1954     p = SdkToolsPopen(['aapt', 'dump', 'badging', apkfile], output=False)
1955     for line in p.output.splitlines():
1956         m = r.match(line)
1957         if m:
1958             return m.group('appid'), m.group('vercode'), m.group('vername')
1959     raise FDroidException(_("Reading packageName/versionCode/versionName failed, APK invalid: '{apkfilename}'")
1960                           .format(apkfilename=apkfile))
1961
1962
1963 def get_minSdkVersion_aapt(apkfile):
1964     """Extract the minimum supported Android SDK from an APK using aapt
1965
1966     :param apkfile: path to an APK file.
1967     :returns: the integer representing the SDK version
1968     """
1969     r = re.compile(r"^sdkVersion:'([0-9]+)'")
1970     p = SdkToolsPopen(['aapt', 'dump', 'badging', apkfile], output=False)
1971     for line in p.output.splitlines():
1972         m = r.match(line)
1973         if m:
1974             return int(m.group(1))
1975     raise FDroidException(_('Reading minSdkVersion failed: "{apkfilename}"')
1976                           .format(apkfilename=apkfile))
1977
1978
1979 class PopenResult:
1980     def __init__(self):
1981         self.returncode = None
1982         self.output = None
1983
1984
1985 def SdkToolsPopen(commands, cwd=None, output=True):
1986     cmd = commands[0]
1987     if cmd not in config:
1988         config[cmd] = find_sdk_tools_cmd(commands[0])
1989     abscmd = config[cmd]
1990     if abscmd is None:
1991         raise FDroidException(_("Could not find '{command}' on your system").format(command=cmd))
1992     if cmd == 'aapt':
1993         test_aapt_version(config['aapt'])
1994     return FDroidPopen([abscmd] + commands[1:],
1995                        cwd=cwd, output=output)
1996
1997
1998 def FDroidPopenBytes(commands, cwd=None, envs=None, output=True, stderr_to_stdout=True):
1999     """
2000     Run a command and capture the possibly huge output as bytes.
2001
2002     :param commands: command and argument list like in subprocess.Popen
2003     :param cwd: optionally specifies a working directory
2004     :param envs: a optional dictionary of environment variables and their values
2005     :returns: A PopenResult.
2006     """
2007
2008     global env
2009     if env is None:
2010         set_FDroidPopen_env()
2011
2012     process_env = env.copy()
2013     if envs is not None and len(envs) > 0:
2014         process_env.update(envs)
2015
2016     if cwd:
2017         cwd = os.path.normpath(cwd)
2018         logging.debug("Directory: %s" % cwd)
2019     logging.debug("> %s" % ' '.join(commands))
2020
2021     stderr_param = subprocess.STDOUT if stderr_to_stdout else subprocess.PIPE
2022     result = PopenResult()
2023     p = None
2024     try:
2025         p = subprocess.Popen(commands, cwd=cwd, shell=False, env=process_env,
2026                              stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
2027                              stderr=stderr_param)
2028     except OSError as e:
2029         raise BuildException("OSError while trying to execute " +
2030                              ' '.join(commands) + ': ' + str(e))
2031
2032     # TODO are these AsynchronousFileReader threads always exiting?
2033     if not stderr_to_stdout and options.verbose:
2034         stderr_queue = Queue()
2035         stderr_reader = AsynchronousFileReader(p.stderr, stderr_queue)
2036
2037         while not stderr_reader.eof():
2038             while not stderr_queue.empty():
2039                 line = stderr_queue.get()
2040                 sys.stderr.buffer.write(line)
2041                 sys.stderr.flush()
2042
2043             time.sleep(0.1)
2044
2045     stdout_queue = Queue()
2046     stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
2047     buf = io.BytesIO()
2048
2049     # Check the queue for output (until there is no more to get)
2050     while not stdout_reader.eof():
2051         while not stdout_queue.empty():
2052             line = stdout_queue.get()
2053             if output and options.verbose:
2054                 # Output directly to console
2055                 sys.stderr.buffer.write(line)
2056                 sys.stderr.flush()
2057             buf.write(line)
2058
2059         time.sleep(0.1)
2060
2061     result.returncode = p.wait()
2062     result.output = buf.getvalue()
2063     buf.close()
2064     # make sure all filestreams of the subprocess are closed
2065     for streamvar in ['stdin', 'stdout', 'stderr']:
2066         if hasattr(p, streamvar):
2067             stream = getattr(p, streamvar)
2068             if stream:
2069                 stream.close()
2070     return result
2071
2072
2073 def FDroidPopen(commands, cwd=None, envs=None, output=True, stderr_to_stdout=True):
2074     """
2075     Run a command and capture the possibly huge output as a str.
2076
2077     :param commands: command and argument list like in subprocess.Popen
2078     :param cwd: optionally specifies a working directory
2079     :param envs: a optional dictionary of environment variables and their values
2080     :returns: A PopenResult.
2081     """
2082     result = FDroidPopenBytes(commands, cwd, envs, output, stderr_to_stdout)
2083     result.output = result.output.decode('utf-8', 'ignore')
2084     return result
2085
2086
2087 gradle_comment = re.compile(r'[ ]*//')
2088 gradle_signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
2089 gradle_line_matches = [
2090     re.compile(r'^[\t ]*signingConfig [^ ]*$'),
2091     re.compile(r'.*android\.signingConfigs\.[^{]*$'),
2092     re.compile(r'.*\.readLine\(.*'),
2093 ]
2094
2095
2096 def remove_signing_keys(build_dir):
2097     for root, dirs, files in os.walk(build_dir):
2098         if 'build.gradle' in files:
2099             path = os.path.join(root, 'build.gradle')
2100
2101             with open(path, "r", encoding='utf8') as o:
2102                 lines = o.readlines()
2103
2104             changed = False
2105
2106             opened = 0
2107             i = 0
2108             with open(path, "w", encoding='utf8') as o:
2109                 while i < len(lines):
2110                     line = lines[i]
2111                     i += 1
2112                     while line.endswith('\\\n'):
2113                         line = line.rstrip('\\\n') + lines[i]
2114                         i += 1
2115
2116                     if gradle_comment.match(line):
2117                         o.write(line)
2118                         continue
2119
2120                     if opened > 0:
2121                         opened += line.count('{')
2122                         opened -= line.count('}')
2123                         continue
2124
2125                     if gradle_signing_configs.match(line):
2126                         changed = True
2127                         opened += 1
2128                         continue
2129
2130                     if any(s.match(line) for s in gradle_line_matches):
2131                         changed = True
2132                         continue
2133
2134                     if opened == 0:
2135                         o.write(line)
2136
2137             if changed:
2138                 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
2139
2140         for propfile in [
2141                 'project.properties',
2142                 'build.properties',
2143                 'default.properties',
2144                 'ant.properties', ]:
2145             if propfile in files:
2146                 path = os.path.join(root, propfile)
2147
2148                 with open(path, "r", encoding='iso-8859-1') as o:
2149                     lines = o.readlines()
2150
2151                 changed = False
2152
2153                 with open(path, "w", encoding='iso-8859-1') as o:
2154                     for line in lines:
2155                         if any(line.startswith(s) for s in ('key.store', 'key.alias')):
2156                             changed = True
2157                             continue
2158
2159                         o.write(line)
2160
2161                 if changed:
2162                     logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
2163
2164
2165 def set_FDroidPopen_env(build=None):
2166     '''
2167     set up the environment variables for the build environment
2168
2169     There is only a weak standard, the variables used by gradle, so also set
2170     up the most commonly used environment variables for SDK and NDK.  Also, if
2171     there is no locale set, this will set the locale (e.g. LANG) to en_US.UTF-8.
2172     '''
2173     global env, orig_path
2174
2175     if env is None:
2176         env = os.environ
2177         orig_path = env['PATH']
2178         for n in ['ANDROID_HOME', 'ANDROID_SDK']:
2179             env[n] = config['sdk_path']
2180         for k, v in config['java_paths'].items():
2181             env['JAVA%s_HOME' % k] = v
2182
2183     missinglocale = True
2184     for k, v in env.items():
2185         if k == 'LANG' and v != 'C':
2186             missinglocale = False
2187         elif k == 'LC_ALL':
2188             missinglocale = False
2189     if missinglocale:
2190         env['LANG'] = 'en_US.UTF-8'
2191
2192     if build is not None:
2193         path = build.ndk_path()
2194         paths = orig_path.split(os.pathsep)
2195         if path not in paths:
2196             paths = [path] + paths
2197             env['PATH'] = os.pathsep.join(paths)
2198         for n in ['ANDROID_NDK', 'NDK', 'ANDROID_NDK_HOME']:
2199             env[n] = build.ndk_path()
2200
2201
2202 def replace_build_vars(cmd, build):
2203     cmd = cmd.replace('$$COMMIT$$', build.commit)
2204     cmd = cmd.replace('$$VERSION$$', build.versionName)
2205     cmd = cmd.replace('$$VERCODE$$', build.versionCode)
2206     return cmd
2207
2208
2209 def replace_config_vars(cmd, build):
2210     cmd = cmd.replace('$$SDK$$', config['sdk_path'])
2211     cmd = cmd.replace('$$NDK$$', build.ndk_path())
2212     cmd = cmd.replace('$$MVN3$$', config['mvn3'])
2213     if build is not None:
2214         cmd = replace_build_vars(cmd, build)
2215     return cmd
2216
2217
2218 def place_srclib(root_dir, number, libpath):
2219     if not number:
2220         return
2221     relpath = os.path.relpath(libpath, root_dir)
2222     proppath = os.path.join(root_dir, 'project.properties')
2223
2224     lines = []
2225     if os.path.isfile(proppath):
2226         with open(proppath, "r", encoding='iso-8859-1') as o:
2227             lines = o.readlines()
2228
2229     with open(proppath, "w", encoding='iso-8859-1') as o:
2230         placed = False
2231         for line in lines:
2232             if line.startswith('android.library.reference.%d=' % number):
2233                 o.write('android.library.reference.%d=%s\n' % (number, relpath))
2234                 placed = True
2235             else:
2236                 o.write(line)
2237         if not placed:
2238             o.write('android.library.reference.%d=%s\n' % (number, relpath))
2239
2240
2241 apk_sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA|DSA|EC)')
2242
2243
2244 def signer_fingerprint_short(sig):
2245     """Obtain shortened sha256 signing-key fingerprint for pkcs7 signature.
2246
2247     Extracts the first 7 hexadecimal digits of sha256 signing-key fingerprint
2248     for a given pkcs7 signature.
2249
2250     :param sig: Contents of an APK signing certificate.
2251     :returns: shortened signing-key fingerprint.
2252     """
2253     return signer_fingerprint(sig)[:7]
2254
2255
2256 def signer_fingerprint(sig):
2257     """Obtain sha256 signing-key fingerprint for pkcs7 signature.
2258
2259     Extracts hexadecimal sha256 signing-key fingerprint string
2260     for a given pkcs7 signature.
2261
2262     :param: Contents of an APK signature.
2263     :returns: shortened signature fingerprint.
2264     """
2265     cert_encoded = get_certificate(sig)
2266     return hashlib.sha256(cert_encoded).hexdigest()
2267
2268
2269 def apk_signer_fingerprint(apk_path):
2270     """Obtain sha256 signing-key fingerprint for APK.
2271
2272     Extracts hexadecimal sha256 signing-key fingerprint string
2273     for a given APK.
2274
2275     :param apkpath: path to APK
2276     :returns: signature fingerprint
2277     """
2278
2279     with zipfile.ZipFile(apk_path, 'r') as apk:
2280         certs = [n for n in apk.namelist() if CERT_PATH_REGEX.match(n)]
2281
2282         if len(certs) < 1:
2283             logging.error("Found no signing certificates on %s" % apk_path)
2284             return None
2285         if len(certs) > 1:
2286             logging.error("Found multiple signing certificates on %s" % apk_path)
2287             return None
2288
2289         cert = apk.read(certs[0])
2290         return signer_fingerprint(cert)
2291
2292
2293 def apk_signer_fingerprint_short(apk_path):
2294     """Obtain shortened sha256 signing-key fingerprint for APK.
2295
2296     Extracts the first 7 hexadecimal digits of sha256 signing-key fingerprint
2297     for a given pkcs7 APK.
2298
2299     :param apk_path: path to APK
2300     :returns: shortened signing-key fingerprint
2301     """
2302     return apk_signer_fingerprint(apk_path)[:7]
2303
2304
2305 def metadata_get_sigdir(appid, vercode=None):
2306     """Get signature directory for app"""
2307     if vercode:
2308         return os.path.join('metadata', appid, 'signatures', vercode)
2309     else:
2310         return os.path.join('metadata', appid, 'signatures')
2311
2312
2313 def metadata_find_developer_signature(appid, vercode=None):
2314     """Tires to find the developer signature for given appid.
2315
2316     This picks the first signature file found in metadata an returns its
2317     signature.
2318
2319     :returns: sha256 signing key fingerprint of the developer signing key.
2320         None in case no signature can not be found."""
2321
2322     # fetch list of dirs for all versions of signatures
2323     appversigdirs = []
2324     if vercode:
2325         appversigdirs.append(metadata_get_sigdir(appid, vercode))
2326     else:
2327         appsigdir = metadata_get_sigdir(appid)
2328         if os.path.isdir(appsigdir):
2329             numre = re.compile('[0-9]+')
2330             for ver in os.listdir(appsigdir):
2331                 if numre.match(ver):
2332                     appversigdir = os.path.join(appsigdir, ver)
2333                     appversigdirs.append(appversigdir)
2334
2335     for sigdir in appversigdirs:
2336         sigs = glob.glob(os.path.join(sigdir, '*.DSA')) + \
2337             glob.glob(os.path.join(sigdir, '*.EC')) + \
2338             glob.glob(os.path.join(sigdir, '*.RSA'))
2339         if len(sigs) > 1:
2340             raise FDroidException('ambiguous signatures, please make sure there is only one signature in \'{}\'. (The signature has to be the App maintainers signature for version of the APK.)'.format(sigdir))
2341         for sig in sigs:
2342             with open(sig, 'rb') as f:
2343                 return signer_fingerprint(f.read())
2344     return None
2345
2346
2347 def metadata_find_signing_files(appid, vercode):
2348     """Gets a list of singed manifests and signatures.
2349
2350     :param appid: app id string
2351     :param vercode: app version code
2352     :returns: a list of triplets for each signing key with following paths:
2353         (signature_file, singed_file, manifest_file)
2354     """
2355     ret = []
2356     sigdir = metadata_get_sigdir(appid, vercode)
2357     sigs = glob.glob(os.path.join(sigdir, '*.DSA')) + \
2358         glob.glob(os.path.join(sigdir, '*.EC')) + \
2359         glob.glob(os.path.join(sigdir, '*.RSA'))
2360     extre = re.compile('(\.DSA|\.EC|\.RSA)$')
2361     for sig in sigs:
2362         sf = extre.sub('.SF', sig)
2363         if os.path.isfile(sf):
2364             mf = os.path.join(sigdir, 'MANIFEST.MF')
2365             if os.path.isfile(mf):
2366                 ret.append((sig, sf, mf))
2367     return ret
2368
2369
2370 def metadata_find_developer_signing_files(appid, vercode):
2371     """Get developer signature files for specified app from metadata.
2372
2373     :returns: A triplet of paths for signing files from metadata:
2374         (signature_file, singed_file, manifest_file)
2375     """
2376     allsigningfiles = metadata_find_signing_files(appid, vercode)
2377     if allsigningfiles and len(allsigningfiles) == 1:
2378         return allsigningfiles[0]
2379     else:
2380         return None
2381
2382
2383 def apk_strip_signatures(signed_apk, strip_manifest=False):
2384     """Removes signatures from APK.
2385
2386     :param signed_apk: path to apk file.
2387     :param strip_manifest: when set to True also the manifest file will
2388         be removed from the APK.
2389     """
2390     with tempfile.TemporaryDirectory() as tmpdir:
2391         tmp_apk = os.path.join(tmpdir, 'tmp.apk')
2392         shutil.move(signed_apk, tmp_apk)
2393         with ZipFile(tmp_apk, 'r') as in_apk:
2394             with ZipFile(signed_apk, 'w') as out_apk:
2395                 for info in in_apk.infolist():
2396                     if not apk_sigfile.match(info.filename):
2397                         if strip_manifest:
2398                             if info.filename != 'META-INF/MANIFEST.MF':
2399                                 buf = in_apk.read(info.filename)
2400                                 out_apk.writestr(info, buf)
2401                         else:
2402                             buf = in_apk.read(info.filename)
2403                             out_apk.writestr(info, buf)
2404
2405
2406 def apk_implant_signatures(apkpath, signaturefile, signedfile, manifest):
2407     """Implats a signature from metadata into an APK.
2408
2409     Note: this changes there supplied APK in place. So copy it if you
2410     need the original to be preserved.
2411
2412     :param apkpath: location of the apk
2413     """
2414     # get list of available signature files in metadata
2415     with tempfile.TemporaryDirectory() as tmpdir:
2416         apkwithnewsig = os.path.join(tmpdir, 'newsig.apk')
2417         with ZipFile(apkpath, 'r') as in_apk:
2418             with ZipFile(apkwithnewsig, 'w') as out_apk:
2419                 for sig_file in [signaturefile, signedfile, manifest]:
2420                     with open(sig_file, 'rb') as fp:
2421                         buf = fp.read()
2422                     info = zipfile.ZipInfo('META-INF/' + os.path.basename(sig_file))
2423                     info.compress_type = zipfile.ZIP_DEFLATED
2424                     info.create_system = 0  # "Windows" aka "FAT", what Android SDK uses
2425                     out_apk.writestr(info, buf)
2426                 for info in in_apk.infolist():
2427                     if not apk_sigfile.match(info.filename):
2428                         if info.filename != 'META-INF/MANIFEST.MF':
2429                             buf = in_apk.read(info.filename)
2430                             out_apk.writestr(info, buf)
2431         os.remove(apkpath)
2432         p = SdkToolsPopen(['zipalign', '-v', '4', apkwithnewsig, apkpath])
2433         if p.returncode != 0:
2434             raise BuildException("Failed to align application")
2435
2436
2437 def apk_extract_signatures(apkpath, outdir, manifest=True):
2438     """Extracts a signature files from APK and puts them into target directory.
2439
2440     :param apkpath: location of the apk
2441     :param outdir: folder where the extracted signature files will be stored
2442     :param manifest: (optionally) disable extracting manifest file
2443     """
2444     with ZipFile(apkpath, 'r') as in_apk:
2445         for f in in_apk.infolist():
2446             if apk_sigfile.match(f.filename) or \
2447                     (manifest and f.filename == 'META-INF/MANIFEST.MF'):
2448                 newpath = os.path.join(outdir, os.path.basename(f.filename))
2449                 with open(newpath, 'wb') as out_file:
2450                     out_file.write(in_apk.read(f.filename))
2451
2452
2453 def sign_apk(unsigned_path, signed_path, keyalias):
2454     """Sign and zipalign an unsigned APK, then save to a new file, deleting the unsigned
2455
2456     android-18 (4.3) finally added support for reasonable hash
2457     algorithms, like SHA-256, before then, the only options were MD5
2458     and SHA1 :-/ This aims to use SHA-256 when the APK does not target
2459     older Android versions, and is therefore safe to do so.
2460
2461     https://issuetracker.google.com/issues/36956587
2462     https://android-review.googlesource.com/c/platform/libcore/+/44491
2463
2464     """
2465
2466     if get_minSdkVersion_aapt(unsigned_path) < 18:
2467         signature_algorithm = ['-sigalg', 'SHA1withRSA', '-digestalg', 'SHA1']
2468     else:
2469         signature_algorithm = ['-sigalg', 'SHA256withRSA', '-digestalg', 'SHA-256']
2470
2471     p = FDroidPopen([config['jarsigner'], '-keystore', config['keystore'],
2472                      '-storepass:env', 'FDROID_KEY_STORE_PASS',
2473                      '-keypass:env', 'FDROID_KEY_PASS']
2474                     + signature_algorithm + [unsigned_path, keyalias],
2475                     envs={
2476                         'FDROID_KEY_STORE_PASS': config['keystorepass'],
2477                         'FDROID_KEY_PASS': config['keypass'], })
2478     if p.returncode != 0:
2479         raise BuildException(_("Failed to sign application"), p.output)
2480
2481     p = SdkToolsPopen(['zipalign', '-v', '4', unsigned_path, signed_path])
2482     if p.returncode != 0:
2483         raise BuildException(_("Failed to zipalign application"))
2484     os.remove(unsigned_path)
2485
2486
2487 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
2488     """Verify that two apks are the same
2489
2490     One of the inputs is signed, the other is unsigned. The signature metadata
2491     is transferred from the signed to the unsigned apk, and then jarsigner is
2492     used to verify that the signature from the signed apk is also varlid for
2493     the unsigned one.  If the APK given as unsigned actually does have a
2494     signature, it will be stripped out and ignored.
2495
2496     There are two SHA1 git commit IDs that fdroidserver includes in the builds
2497     it makes: fdroidserverid and buildserverid.  Originally, these were inserted
2498     into AndroidManifest.xml, but that makes the build not reproducible. So
2499     instead they are included as separate files in the APK's META-INF/ folder.
2500     If those files exist in the signed APK, they will be part of the signature
2501     and need to also be included in the unsigned APK for it to validate.
2502
2503     :param signed_apk: Path to a signed apk file
2504     :param unsigned_apk: Path to an unsigned apk file expected to match it
2505     :param tmp_dir: Path to directory for temporary files
2506     :returns: None if the verification is successful, otherwise a string
2507               describing what went wrong.
2508     """
2509
2510     if not os.path.isfile(signed_apk):
2511         return 'can not verify: file does not exists: {}'.format(signed_apk)
2512
2513     if not os.path.isfile(unsigned_apk):
2514         return 'can not verify: file does not exists: {}'.format(unsigned_apk)
2515
2516     with ZipFile(signed_apk, 'r') as signed:
2517         meta_inf_files = ['META-INF/MANIFEST.MF']
2518         for f in signed.namelist():
2519             if apk_sigfile.match(f) \
2520                or f in ['META-INF/fdroidserverid', 'META-INF/buildserverid']:
2521                 meta_inf_files.append(f)
2522         if len(meta_inf_files) < 3:
2523             return "Signature files missing from {0}".format(signed_apk)
2524
2525         tmp_apk = os.path.join(tmp_dir, 'sigcp_' + os.path.basename(unsigned_apk))
2526         with ZipFile(unsigned_apk, 'r') as unsigned:
2527             # only read the signature from the signed APK, everything else from unsigned
2528             with ZipFile(tmp_apk, 'w') as tmp:
2529                 for filename in meta_inf_files:
2530                     tmp.writestr(signed.getinfo(filename), signed.read(filename))
2531                 for info in unsigned.infolist():
2532                     if info.filename in meta_inf_files:
2533                         logging.warning('Ignoring %s from %s',
2534                                         info.filename, unsigned_apk)
2535                         continue
2536                     if info.filename in tmp.namelist():
2537                         return "duplicate filename found: " + info.filename
2538                     tmp.writestr(info, unsigned.read(info.filename))
2539
2540     verified = verify_apk_signature(tmp_apk)
2541
2542     if not verified:
2543         logging.info("...NOT verified - {0}".format(tmp_apk))
2544         return compare_apks(signed_apk, tmp_apk, tmp_dir,
2545                             os.path.dirname(unsigned_apk))
2546
2547     logging.info("...successfully verified")
2548     return None
2549
2550
2551 def verify_jar_signature(jar):
2552     """Verifies the signature of a given JAR file.
2553
2554     jarsigner is very shitty: unsigned JARs pass as "verified"! So
2555     this has to turn on -strict then check for result 4, since this
2556     does not expect the signature to be from a CA-signed certificate.
2557
2558     :raises: VerificationException() if the JAR's signature could not be verified
2559
2560     """
2561
2562     error = _('JAR signature failed to verify: {path}').format(path=jar)
2563     try:
2564         output = subprocess.check_output([config['jarsigner'], '-strict', '-verify', jar],
2565                                          stderr=subprocess.STDOUT)
2566         raise VerificationException(error + '\n' + output.decode('utf-8'))
2567     except subprocess.CalledProcessError as e:
2568         if e.returncode == 4:
2569             logging.debug(_('JAR signature verified: {path}').format(path=jar))
2570         else:
2571             raise VerificationException(error + '\n' + e.output.decode('utf-8'))
2572
2573
2574 def verify_apk_signature(apk, min_sdk_version=None):
2575     """verify the signature on an APK
2576
2577     Try to use apksigner whenever possible since jarsigner is very
2578     shitty: unsigned APKs pass as "verified"!  Warning, this does
2579     not work on JARs with apksigner >= 0.7 (build-tools 26.0.1)
2580
2581     :returns: boolean whether the APK was verified
2582     """
2583     if set_command_in_config('apksigner'):
2584         args = [config['apksigner'], 'verify']
2585         if min_sdk_version:
2586             args += ['--min-sdk-version=' + min_sdk_version]
2587         if options.verbose:
2588             args += ['--verbose']
2589         try:
2590             output = subprocess.check_output(args + [apk])
2591             if options.verbose:
2592                 logging.debug(apk + ': ' + output.decode('utf-8'))
2593             return True
2594         except subprocess.CalledProcessError as e:
2595             logging.error('\n' + apk + ': ' + e.output.decode('utf-8'))
2596     else:
2597         if not config.get('jarsigner_warning_displayed'):
2598             config['jarsigner_warning_displayed'] = True
2599             logging.warning(_("Using Java's jarsigner, not recommended for verifying APKs! Use apksigner"))
2600         try:
2601             verify_jar_signature(apk)
2602             return True
2603         except Exception as e:
2604             logging.error(e)
2605     return False
2606
2607
2608 def verify_old_apk_signature(apk):
2609     """verify the signature on an archived APK, supporting deprecated algorithms
2610
2611     F-Droid aims to keep every single binary that it ever published.  Therefore,
2612     it needs to be able to verify APK signatures that include deprecated/removed
2613     algorithms.  For example, jarsigner treats an MD5 signature as unsigned.
2614
2615     jarsigner passes unsigned APKs as "verified"! So this has to turn
2616     on -strict then check for result 4.
2617
2618     :returns: boolean whether the APK was verified
2619     """
2620
2621     _java_security = os.path.join(os.getcwd(), '.java.security')
2622     with open(_java_security, 'w') as fp:
2623         fp.write('jdk.jar.disabledAlgorithms=MD2, RSA keySize < 1024')
2624
2625     try:
2626         cmd = [
2627             config['jarsigner'],
2628             '-J-Djava.security.properties=' + _java_security,
2629             '-strict', '-verify', apk
2630         ]
2631         output = subprocess.check_output(cmd, stderr=subprocess.STDOUT)
2632     except subprocess.CalledProcessError as e:
2633         if e.returncode != 4:
2634             output = e.output
2635         else:
2636             logging.debug(_('JAR signature verified: {path}').format(path=apk))
2637             return True
2638
2639     logging.error(_('Old APK signature failed to verify: {path}').format(path=apk)
2640                   + '\n' + output.decode('utf-8'))
2641     return False
2642
2643
2644 apk_badchars = re.compile('''[/ :;'"]''')
2645
2646
2647 def compare_apks(apk1, apk2, tmp_dir, log_dir=None):
2648     """Compare two apks
2649
2650     Returns None if the apk content is the same (apart from the signing key),
2651     otherwise a string describing what's different, or what went wrong when
2652     trying to do the comparison.
2653     """
2654
2655     if not log_dir:
2656         log_dir = tmp_dir
2657
2658     absapk1 = os.path.abspath(apk1)
2659     absapk2 = os.path.abspath(apk2)
2660
2661     if set_command_in_config('diffoscope'):
2662         logfilename = os.path.join(log_dir, os.path.basename(absapk1))
2663         htmlfile = logfilename + '.diffoscope.html'
2664         textfile = logfilename + '.diffoscope.txt'
2665         if subprocess.call([config['diffoscope'],
2666                             '--max-report-size', '12345678', '--max-diff-block-lines', '100',
2667                             '--html', htmlfile, '--text', textfile,
2668                             absapk1, absapk2]) != 0:
2669             return("Failed to unpack " + apk1)
2670
2671     apk1dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk1[0:-4]))  # trim .apk
2672     apk2dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk2[0:-4]))  # trim .apk
2673     for d in [apk1dir, apk2dir]:
2674         if os.path.exists(d):
2675             shutil.rmtree(d)
2676         os.mkdir(d)
2677         os.mkdir(os.path.join(d, 'jar-xf'))
2678
2679     if subprocess.call(['jar', 'xf',
2680                         os.path.abspath(apk1)],
2681                        cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
2682         return("Failed to unpack " + apk1)
2683     if subprocess.call(['jar', 'xf',
2684                         os.path.abspath(apk2)],
2685                        cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
2686         return("Failed to unpack " + apk2)
2687
2688     if set_command_in_config('apktool'):
2689         if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
2690                            cwd=apk1dir) != 0:
2691             return("Failed to unpack " + apk1)
2692         if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
2693                            cwd=apk2dir) != 0:
2694             return("Failed to unpack " + apk2)
2695
2696     p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
2697     lines = p.output.splitlines()
2698     if len(lines) != 1 or 'META-INF' not in lines[0]:
2699         if set_command_in_config('meld'):
2700             p = FDroidPopen([config['meld'], apk1dir, apk2dir], output=False)
2701         return("Unexpected diff output - " + p.output)
2702
2703     # since everything verifies, delete the comparison to keep cruft down
2704     shutil.rmtree(apk1dir)
2705     shutil.rmtree(apk2dir)
2706
2707     # If we get here, it seems like they're the same!
2708     return None
2709
2710
2711 def set_command_in_config(command):
2712     '''Try to find specified command in the path, if it hasn't been
2713     manually set in config.py.  If found, it is added to the config
2714     dict.  The return value says whether the command is available.
2715
2716     '''
2717     if command in config:
2718         return True
2719     else:
2720         tmp = find_command(command)
2721         if tmp is not None:
2722             config[command] = tmp
2723             return True
2724     return False
2725
2726
2727 def find_command(command):
2728     '''find the full path of a command, or None if it can't be found in the PATH'''
2729
2730     def is_exe(fpath):
2731         return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
2732
2733     fpath, fname = os.path.split(command)
2734     if fpath:
2735         if is_exe(command):
2736             return command
2737     else:
2738         for path in os.environ["PATH"].split(os.pathsep):
2739             path = path.strip('"')
2740             exe_file = os.path.join(path, command)
2741             if is_exe(exe_file):
2742                 return exe_file
2743
2744     return None
2745
2746
2747 def genpassword():
2748     '''generate a random password for when generating keys'''
2749     h = hashlib.sha256()
2750     h.update(os.urandom(16))  # salt
2751     h.update(socket.getfqdn().encode('utf-8'))
2752     passwd = base64.b64encode(h.digest()).strip()
2753     return passwd.decode('utf-8')
2754
2755
2756 def genkeystore(localconfig):
2757     """
2758     Generate a new key with password provided in :param localconfig and add it to new keystore
2759     :return: hexed public key, public key fingerprint
2760     """
2761     logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
2762     keystoredir = os.path.dirname(localconfig['keystore'])
2763     if keystoredir is None or keystoredir == '':
2764         keystoredir = os.path.join(os.getcwd(), keystoredir)
2765     if not os.path.exists(keystoredir):
2766         os.makedirs(keystoredir, mode=0o700)
2767
2768     env_vars = {
2769         'FDROID_KEY_STORE_PASS': localconfig['keystorepass'],
2770         'FDROID_KEY_PASS': localconfig['keypass'],
2771     }
2772     p = FDroidPopen([config['keytool'], '-genkey',
2773                      '-keystore', localconfig['keystore'],
2774                      '-alias', localconfig['repo_keyalias'],
2775                      '-keyalg', 'RSA', '-keysize', '4096',
2776                      '-sigalg', 'SHA256withRSA',
2777                      '-validity', '10000',
2778                      '-storepass:env', 'FDROID_KEY_STORE_PASS',
2779                      '-keypass:env', 'FDROID_KEY_PASS',
2780                      '-dname', localconfig['keydname']], envs=env_vars)
2781     if p.returncode != 0:
2782         raise BuildException("Failed to generate key", p.output)
2783     os.chmod(localconfig['keystore'], 0o0600)
2784     if not options.quiet:
2785         # now show the lovely key that was just generated
2786         p = FDroidPopen([config['keytool'], '-list', '-v',
2787                          '-keystore', localconfig['keystore'],
2788                          '-alias', localconfig['repo_keyalias'],
2789                          '-storepass:env', 'FDROID_KEY_STORE_PASS'], envs=env_vars)
2790         logging.info(p.output.strip() + '\n\n')
2791     # get the public key
2792     p = FDroidPopenBytes([config['keytool'], '-exportcert',
2793                           '-keystore', localconfig['keystore'],
2794                           '-alias', localconfig['repo_keyalias'],
2795                           '-storepass:env', 'FDROID_KEY_STORE_PASS']
2796                          + config['smartcardoptions'],
2797                          envs=env_vars, output=False, stderr_to_stdout=False)
2798     if p.returncode != 0 or len(p.output) < 20:
2799         raise BuildException("Failed to get public key", p.output)
2800     pubkey = p.output
2801     fingerprint = get_cert_fingerprint(pubkey)
2802     return hexlify(pubkey), fingerprint
2803
2804
2805 def get_cert_fingerprint(pubkey):
2806     """
2807     Generate a certificate fingerprint the same way keytool does it
2808     (but with slightly different formatting)
2809     """
2810     digest = hashlib.sha256(pubkey).digest()
2811     ret = [' '.join("%02X" % b for b in bytearray(digest))]
2812     return " ".join(ret)
2813
2814
2815 def get_certificate(certificate_file):
2816     """
2817     Extracts a certificate from the given file.
2818     :param certificate_file: file bytes (as string) representing the certificate
2819     :return: A binary representation of the certificate's public key, or None in case of error
2820     """
2821     content = decoder.decode(certificate_file, asn1Spec=rfc2315.ContentInfo())[0]
2822     if content.getComponentByName('contentType') != rfc2315.signedData:
2823         return None
2824     content = decoder.decode(content.getComponentByName('content'),
2825                              asn1Spec=rfc2315.SignedData())[0]
2826     try:
2827         certificates = content.getComponentByName('certificates')
2828         cert = certificates[0].getComponentByName('certificate')
2829     except PyAsn1Error:
2830         logging.error("Certificates not found.")
2831         return None
2832     return encoder.encode(cert)
2833
2834
2835 def load_stats_fdroid_signing_key_fingerprints():
2836     """Load list of signing-key fingerprints stored by fdroid publish from file.
2837
2838     :returns: list of dictionanryies containing the singing-key fingerprints.
2839     """
2840     jar_file = os.path.join('stats', 'publishsigkeys.jar')
2841     if not os.path.isfile(jar_file):
2842         return {}
2843     cmd = [config['jarsigner'], '-strict', '-verify', jar_file]
2844     p = FDroidPopen(cmd, output=False)
2845     if p.returncode != 4:
2846         raise FDroidException("Signature validation of '{}' failed! "
2847                               "Please run publish again to rebuild this file.".format(jar_file))
2848
2849     jar_sigkey = apk_signer_fingerprint(jar_file)
2850     repo_key_sig = config.get('repo_key_sha256')
2851     if repo_key_sig:
2852         if jar_sigkey != repo_key_sig:
2853             raise FDroidException("Signature key fingerprint of file '{}' does not match repo_key_sha256 in config.py (found fingerprint: '{}')".format(jar_file, jar_sigkey))
2854     else:
2855         logging.warning("repo_key_sha256 not in config.py, setting it to the signature key fingerprint of '{}'".format(jar_file))
2856         config['repo_key_sha256'] = jar_sigkey
2857         write_to_config(config, 'repo_key_sha256')
2858
2859     with zipfile.ZipFile(jar_file, 'r') as f:
2860         return json.loads(str(f.read('publishsigkeys.json'), 'utf-8'))
2861
2862
2863 def write_to_config(thisconfig, key, value=None, config_file=None):
2864     '''write a key/value to the local config.py
2865
2866     NOTE: only supports writing string variables.
2867
2868     :param thisconfig: config dictionary
2869     :param key: variable name in config.py to be overwritten/added
2870     :param value: optional value to be written, instead of fetched
2871         from 'thisconfig' dictionary.
2872     '''
2873     if value is None:
2874         origkey = key + '_orig'
2875         value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
2876     cfg = config_file if config_file else 'config.py'
2877
2878     # load config file, create one if it doesn't exist
2879     if not os.path.exists(cfg):
2880         open(cfg, 'a').close()
2881         logging.info("Creating empty " + cfg)
2882     with open(cfg, 'r', encoding="utf-8") as f:
2883         lines = f.readlines()
2884
2885     # make sure the file ends with a carraige return
2886     if len(lines) > 0:
2887         if not lines[-1].endswith('\n'):
2888             lines[-1] += '\n'
2889
2890     # regex for finding and replacing python string variable
2891     # definitions/initializations
2892     pattern = re.compile('^[\s#]*' + key + '\s*=\s*"[^"]*"')
2893     repl = key + ' = "' + value + '"'
2894     pattern2 = re.compile('^[\s#]*' + key + "\s*=\s*'[^']*'")
2895     repl2 = key + " = '" + value + "'"
2896
2897     # If we replaced this line once, we make sure won't be a
2898     # second instance of this line for this key in the document.
2899     didRepl = False
2900     # edit config file
2901     with open(cfg, 'w', encoding="utf-8") as f:
2902         for line in lines:
2903             if pattern.match(line) or pattern2.match(line):
2904                 if not didRepl:
2905                     line = pattern.sub(repl, line)
2906                     line = pattern2.sub(repl2, line)
2907                     f.write(line)
2908                     didRepl = True
2909             else:
2910                 f.write(line)
2911         if not didRepl:
2912             f.write('\n')
2913             f.write(repl)
2914             f.write('\n')
2915
2916
2917 def parse_xml(path):
2918     return XMLElementTree.parse(path).getroot()
2919
2920
2921 def string_is_integer(string):
2922     try:
2923         int(string)
2924         return True
2925     except ValueError:
2926         return False
2927
2928
2929 def local_rsync(options, fromdir, todir):
2930     '''Rsync method for local to local copying of things
2931
2932     This is an rsync wrapper with all the settings for safe use within
2933     the various fdroidserver use cases. This uses stricter rsync
2934     checking on all files since people using offline mode are already
2935     prioritizing security above ease and speed.
2936
2937     '''
2938     rsyncargs = ['rsync', '--recursive', '--safe-links', '--times', '--perms',
2939                  '--one-file-system', '--delete', '--chmod=Da+rx,Fa-x,a+r,u+w']
2940     if not options.no_checksum:
2941         rsyncargs.append('--checksum')
2942     if options.verbose:
2943         rsyncargs += ['--verbose']
2944     if options.quiet:
2945         rsyncargs += ['--quiet']
2946     logging.debug(' '.join(rsyncargs + [fromdir, todir]))
2947     if subprocess.call(rsyncargs + [fromdir, todir]) != 0:
2948         raise FDroidException()
2949
2950
2951 def get_per_app_repos():
2952     '''per-app repos are dirs named with the packageName of a single app'''
2953
2954     # Android packageNames are Java packages, they may contain uppercase or
2955     # lowercase letters ('A' through 'Z'), numbers, and underscores
2956     # ('_'). However, individual package name parts may only start with
2957     # letters. https://developer.android.com/guide/topics/manifest/manifest-element.html#package
2958     p = re.compile('^([a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*)?$')
2959
2960     repos = []
2961     for root, dirs, files in os.walk(os.getcwd()):
2962         for d in dirs:
2963             print('checking', root, 'for', d)
2964             if d in ('archive', 'metadata', 'repo', 'srclibs', 'tmp'):
2965                 # standard parts of an fdroid repo, so never packageNames
2966                 continue
2967             elif p.match(d) \
2968                     and os.path.exists(os.path.join(d, 'fdroid', 'repo', 'index.jar')):
2969                 repos.append(d)
2970         break
2971     return repos
2972
2973
2974 def is_repo_file(filename):
2975     '''Whether the file in a repo is a build product to be delivered to users'''
2976     if isinstance(filename, str):
2977         filename = filename.encode('utf-8', errors="surrogateescape")
2978     return os.path.isfile(filename) \
2979         and not filename.endswith(b'.asc') \
2980         and not filename.endswith(b'.sig') \
2981         and os.path.basename(filename) not in [
2982             b'index.jar',
2983             b'index_unsigned.jar',
2984             b'index.xml',
2985             b'index.html',
2986             b'index-v1.jar',
2987             b'index-v1.json',
2988             b'categories.txt',
2989         ]
2990
2991
2992 def get_examples_dir():
2993     '''Return the dir where the fdroidserver example files are available'''
2994     examplesdir = None
2995     tmp = os.path.dirname(sys.argv[0])
2996     if os.path.basename(tmp) == 'bin':
2997         egg_links = glob.glob(os.path.join(tmp, '..',
2998                                            'local/lib/python3.*/site-packages/fdroidserver.egg-link'))
2999         if egg_links:
3000             # installed from local git repo
3001             examplesdir = os.path.join(open(egg_links[0]).readline().rstrip(), 'examples')
3002         else:
3003             # try .egg layout
3004             examplesdir = os.path.dirname(os.path.dirname(__file__)) + '/share/doc/fdroidserver/examples'
3005             if not os.path.exists(examplesdir):  # use UNIX layout
3006                 examplesdir = os.path.dirname(tmp) + '/share/doc/fdroidserver/examples'
3007     else:
3008         # we're running straight out of the git repo
3009         prefix = os.path.normpath(os.path.join(os.path.dirname(__file__), '..'))
3010         examplesdir = prefix + '/examples'
3011
3012     return examplesdir
3013
3014
3015 def get_wiki_timestamp(timestamp=None):
3016     """Return current time in the standard format for posting to the wiki"""
3017
3018     if timestamp is None:
3019         timestamp = time.gmtime()
3020     return time.strftime("%Y-%m-%d %H:%M:%SZ", timestamp)
3021
3022
3023 def get_android_tools_versions(ndk_path=None):
3024     '''get a list of the versions of all installed Android SDK/NDK components'''
3025
3026     global config
3027     sdk_path = config['sdk_path']
3028     if sdk_path[-1] != '/':
3029         sdk_path += '/'
3030     components = []
3031     if ndk_path:
3032         ndk_release_txt = os.path.join(ndk_path, 'RELEASE.TXT')
3033         if os.path.isfile(ndk_release_txt):
3034             with open(ndk_release_txt, 'r') as fp:
3035                 components.append((os.path.basename(ndk_path), fp.read()[:-1]))
3036
3037     pattern = re.compile('^Pkg.Revision=(.+)', re.MULTILINE)
3038     for root, dirs, files in os.walk(sdk_path):
3039         if 'source.properties' in files:
3040             source_properties = os.path.join(root, 'source.properties')
3041             with open(source_properties, 'r') as fp:
3042                 m = pattern.search(fp.read())
3043                 if m:
3044                     components.append((root[len(sdk_path):], m.group(1)))
3045
3046     return components
3047
3048
3049 def get_android_tools_version_log(ndk_path=None):
3050     '''get a list of the versions of all installed Android SDK/NDK components'''
3051     log = '== Installed Android Tools ==\n\n'
3052     components = get_android_tools_versions(ndk_path)
3053     for name, version in sorted(components):
3054         log += '* ' + name + ' (' + version + ')\n'
3055
3056     return log