chiark / gitweb /
common: fix bug in new SHA-256 signatures for >= android-18
[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         'r9b': None,
81         'r10e': None,
82         'r11c': None,
83         'r12b': "$ANDROID_NDK",
84         'r13b': None,
85         'r14b': None,
86         'r15c': None,
87         'r16': None,
88     },
89     'qt_sdk_path': None,
90     'build_tools': MINIMUM_AAPT_VERSION,
91     'force_build_tools': False,
92     'java_paths': None,
93     'ant': "ant",
94     'mvn3': "mvn",
95     'gradle': 'gradle',
96     'accepted_formats': ['txt', 'yml'],
97     'sync_from_local_copy_dir': False,
98     'allow_disabled_algorithms': False,
99     'per_app_repos': False,
100     'make_current_version_link': True,
101     'current_version_name_source': 'Name',
102     'update_stats': False,
103     'stats_ignore': [],
104     'stats_server': None,
105     'stats_user': None,
106     'stats_to_carbon': False,
107     'repo_maxage': 0,
108     'build_server_always': False,
109     'keystore': 'keystore.jks',
110     'smartcardoptions': [],
111     'char_limits': {
112         'author': 256,
113         'name': 30,
114         'summary': 80,
115         'description': 4000,
116         'video': 256,
117         'whatsNew': 500,
118     },
119     'keyaliases': {},
120     'repo_url': "https://MyFirstFDroidRepo.org/fdroid/repo",
121     'repo_name': "My First FDroid Repo Demo",
122     'repo_icon': "fdroid-icon.png",
123     'repo_description': '''
124         This is a repository of apps to be used with FDroid. Applications in this
125         repository are either official binaries built by the original application
126         developers, or are binaries built from source by the admin of f-droid.org
127         using the tools on https://gitlab.com/u/fdroid.
128         ''',
129     'archive_older': 0,
130 }
131
132
133 def setup_global_opts(parser):
134     try:  # the buildserver VM might not have PIL installed
135         from PIL import PngImagePlugin
136         logger = logging.getLogger(PngImagePlugin.__name__)
137         logger.setLevel(logging.INFO)  # tame the "STREAM" debug messages
138     except ImportError:
139         pass
140
141     parser.add_argument("-v", "--verbose", action="store_true", default=False,
142                         help=_("Spew out even more information than normal"))
143     parser.add_argument("-q", "--quiet", action="store_true", default=False,
144                         help=_("Restrict output to warnings and errors"))
145
146
147 def _add_java_paths_to_config(pathlist, thisconfig):
148     def path_version_key(s):
149         versionlist = []
150         for u in re.split('[^0-9]+', s):
151             try:
152                 versionlist.append(int(u))
153             except ValueError:
154                 pass
155         return versionlist
156
157     for d in sorted(pathlist, key=path_version_key):
158         if os.path.islink(d):
159             continue
160         j = os.path.basename(d)
161         # the last one found will be the canonical one, so order appropriately
162         for regex in [
163                 r'^1\.([6-9])\.0\.jdk$',  # OSX
164                 r'^jdk1\.([6-9])\.0_[0-9]+.jdk$',  # OSX and Oracle tarball
165                 r'^jdk1\.([6-9])\.0_[0-9]+$',  # Oracle Windows
166                 r'^jdk([6-9])-openjdk$',  # Arch
167                 r'^java-([6-9])-openjdk$',  # Arch
168                 r'^java-([6-9])-jdk$',  # Arch (oracle)
169                 r'^java-1\.([6-9])\.0-.*$',  # RedHat
170                 r'^java-([6-9])-oracle$',  # Debian WebUpd8
171                 r'^jdk-([6-9])-oracle-.*$',  # Debian make-jpkg
172                 r'^java-([6-9])-openjdk-[^c][^o][^m].*$',  # Debian
173                 ]:
174             m = re.match(regex, j)
175             if not m:
176                 continue
177             for p in [d, os.path.join(d, 'Contents', 'Home')]:
178                 if os.path.exists(os.path.join(p, 'bin', 'javac')):
179                     thisconfig['java_paths'][m.group(1)] = p
180
181
182 def fill_config_defaults(thisconfig):
183     for k, v in default_config.items():
184         if k not in thisconfig:
185             thisconfig[k] = v
186
187     # Expand paths (~users and $vars)
188     def expand_path(path):
189         if path is None:
190             return None
191         orig = path
192         path = os.path.expanduser(path)
193         path = os.path.expandvars(path)
194         if orig == path:
195             return None
196         return path
197
198     for k in ['sdk_path', 'ant', 'mvn3', 'gradle', 'keystore', 'repo_icon']:
199         v = thisconfig[k]
200         exp = expand_path(v)
201         if exp is not None:
202             thisconfig[k] = exp
203             thisconfig[k + '_orig'] = v
204
205     # find all installed JDKs for keytool, jarsigner, and JAVA[6-9]_HOME env vars
206     if thisconfig['java_paths'] is None:
207         thisconfig['java_paths'] = dict()
208         pathlist = []
209         pathlist += glob.glob('/usr/lib/jvm/j*[6-9]*')
210         pathlist += glob.glob('/usr/java/jdk1.[6-9]*')
211         pathlist += glob.glob('/System/Library/Java/JavaVirtualMachines/1.[6-9].0.jdk')
212         pathlist += glob.glob('/Library/Java/JavaVirtualMachines/*jdk*[6-9]*')
213         if os.getenv('JAVA_HOME') is not None:
214             pathlist.append(os.getenv('JAVA_HOME'))
215         if os.getenv('PROGRAMFILES') is not None:
216             pathlist += glob.glob(os.path.join(os.getenv('PROGRAMFILES'), 'Java', 'jdk1.[6-9].*'))
217         _add_java_paths_to_config(pathlist, thisconfig)
218
219     for java_version in ('7', '8', '9'):
220         if java_version not in thisconfig['java_paths']:
221             continue
222         java_home = thisconfig['java_paths'][java_version]
223         jarsigner = os.path.join(java_home, 'bin', 'jarsigner')
224         if os.path.exists(jarsigner):
225             thisconfig['jarsigner'] = jarsigner
226             thisconfig['keytool'] = os.path.join(java_home, 'bin', 'keytool')
227             break  # Java7 is preferred, so quit if found
228
229     for k in ['ndk_paths', 'java_paths']:
230         d = thisconfig[k]
231         for k2 in d.copy():
232             v = d[k2]
233             exp = expand_path(v)
234             if exp is not None:
235                 thisconfig[k][k2] = exp
236                 thisconfig[k][k2 + '_orig'] = v
237
238
239 def regsub_file(pattern, repl, path):
240     with open(path, 'rb') as f:
241         text = f.read()
242     text = re.sub(bytes(pattern, 'utf8'), bytes(repl, 'utf8'), text)
243     with open(path, 'wb') as f:
244         f.write(text)
245
246
247 def read_config(opts, config_file='config.py'):
248     """Read the repository config
249
250     The config is read from config_file, which is in the current
251     directory when any of the repo management commands are used. If
252     there is a local metadata file in the git repo, then config.py is
253     not required, just use defaults.
254
255     """
256     global config, options
257
258     if config is not None:
259         return config
260
261     options = opts
262
263     config = {}
264
265     if os.path.isfile(config_file):
266         logging.debug(_("Reading '{config_file}'").format(config_file=config_file))
267         with io.open(config_file, "rb") as f:
268             code = compile(f.read(), config_file, 'exec')
269             exec(code, None, config)
270     else:
271         logging.warning(_("No 'config.py' found, using defaults."))
272
273     for k in ('mirrors', 'install_list', 'uninstall_list', 'serverwebroot', 'servergitroot'):
274         if k in config:
275             if not type(config[k]) in (str, list, tuple):
276                 logging.warning(
277                     _("'{field}' will be in random order! Use () or [] brackets if order is important!")
278                     .format(field=k))
279
280     # smartcardoptions must be a list since its command line args for Popen
281     if 'smartcardoptions' in config:
282         config['smartcardoptions'] = config['smartcardoptions'].split(' ')
283     elif 'keystore' in config and config['keystore'] == 'NONE':
284         # keystore='NONE' means use smartcard, these are required defaults
285         config['smartcardoptions'] = ['-storetype', 'PKCS11', '-providerName',
286                                       'SunPKCS11-OpenSC', '-providerClass',
287                                       'sun.security.pkcs11.SunPKCS11',
288                                       '-providerArg', 'opensc-fdroid.cfg']
289
290     if any(k in config for k in ["keystore", "keystorepass", "keypass"]):
291         st = os.stat(config_file)
292         if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
293             logging.warning(_("unsafe permissions on '{config_file}' (should be 0600)!")
294                             .format(config_file=config_file))
295
296     fill_config_defaults(config)
297
298     for k in ["repo_description", "archive_description"]:
299         if k in config:
300             config[k] = clean_description(config[k])
301
302     if 'serverwebroot' in config:
303         if isinstance(config['serverwebroot'], str):
304             roots = [config['serverwebroot']]
305         elif all(isinstance(item, str) for item in config['serverwebroot']):
306             roots = config['serverwebroot']
307         else:
308             raise TypeError(_('only accepts strings, lists, and tuples'))
309         rootlist = []
310         for rootstr in roots:
311             # since this is used with rsync, where trailing slashes have
312             # meaning, ensure there is always a trailing slash
313             if rootstr[-1] != '/':
314                 rootstr += '/'
315             rootlist.append(rootstr.replace('//', '/'))
316         config['serverwebroot'] = rootlist
317
318     if 'servergitmirrors' in config:
319         if isinstance(config['servergitmirrors'], str):
320             roots = [config['servergitmirrors']]
321         elif all(isinstance(item, str) for item in config['servergitmirrors']):
322             roots = config['servergitmirrors']
323         else:
324             raise TypeError(_('only accepts strings, lists, and tuples'))
325         config['servergitmirrors'] = roots
326
327     return config
328
329
330 def assert_config_keystore(config):
331     """Check weather keystore is configured correctly and raise exception if not."""
332
333     nosigningkey = False
334     if 'repo_keyalias' not in config:
335         nosigningkey = True
336         logging.critical(_("'repo_keyalias' not found in config.py!"))
337     if 'keystore' not in config:
338         nosigningkey = True
339         logging.critical(_("'keystore' not found in config.py!"))
340     elif not os.path.exists(config['keystore']):
341         nosigningkey = True
342         logging.critical("'" + config['keystore'] + "' does not exist!")
343     if 'keystorepass' not in config:
344         nosigningkey = True
345         logging.critical(_("'keystorepass' not found in config.py!"))
346     if 'keypass' not in config:
347         nosigningkey = True
348         logging.critical(_("'keypass' not found in config.py!"))
349     if nosigningkey:
350         raise FDroidException("This command requires a signing key, " +
351                               "you can create one using: fdroid update --create-key")
352
353
354 def find_sdk_tools_cmd(cmd):
355     '''find a working path to a tool from the Android SDK'''
356
357     tooldirs = []
358     if config is not None and 'sdk_path' in config and os.path.exists(config['sdk_path']):
359         # try to find a working path to this command, in all the recent possible paths
360         if 'build_tools' in config:
361             build_tools = os.path.join(config['sdk_path'], 'build-tools')
362             # if 'build_tools' was manually set and exists, check only that one
363             configed_build_tools = os.path.join(build_tools, config['build_tools'])
364             if os.path.exists(configed_build_tools):
365                 tooldirs.append(configed_build_tools)
366             else:
367                 # no configed version, so hunt known paths for it
368                 for f in sorted(os.listdir(build_tools), reverse=True):
369                     if os.path.isdir(os.path.join(build_tools, f)):
370                         tooldirs.append(os.path.join(build_tools, f))
371                 tooldirs.append(build_tools)
372         sdk_tools = os.path.join(config['sdk_path'], 'tools')
373         if os.path.exists(sdk_tools):
374             tooldirs.append(sdk_tools)
375         sdk_platform_tools = os.path.join(config['sdk_path'], 'platform-tools')
376         if os.path.exists(sdk_platform_tools):
377             tooldirs.append(sdk_platform_tools)
378     tooldirs.append('/usr/bin')
379     for d in tooldirs:
380         path = os.path.join(d, cmd)
381         if os.path.isfile(path):
382             if cmd == 'aapt':
383                 test_aapt_version(path)
384             return path
385     # did not find the command, exit with error message
386     ensure_build_tools_exists(config)
387
388
389 def test_aapt_version(aapt):
390     '''Check whether the version of aapt is new enough'''
391     output = subprocess.check_output([aapt, 'version'], universal_newlines=True)
392     if output is None or output == '':
393         logging.error(_("'{path}' failed to execute!").format(path=aapt))
394     else:
395         m = re.match(r'.*v([0-9]+)\.([0-9]+)[.-]?([0-9.-]*)', output)
396         if m:
397             major = m.group(1)
398             minor = m.group(2)
399             bugfix = m.group(3)
400             # the Debian package has the version string like "v0.2-23.0.2"
401             too_old = False
402             if '.' in bugfix:
403                 if LooseVersion(bugfix) < LooseVersion(MINIMUM_AAPT_VERSION):
404                     too_old = True
405             elif LooseVersion('.'.join((major, minor, bugfix))) < LooseVersion('0.2.4062713'):
406                 too_old = True
407             if too_old:
408                 logging.warning(_("'{aapt}' is too old, fdroid requires build-tools-{version} or newer!")
409                                 .format(aapt=aapt, version=MINIMUM_AAPT_VERSION))
410         else:
411             logging.warning(_('Unknown version of aapt, might cause problems: ') + output)
412
413
414 def test_sdk_exists(thisconfig):
415     if 'sdk_path' not in thisconfig:
416         if 'aapt' in thisconfig and os.path.isfile(thisconfig['aapt']):
417             test_aapt_version(thisconfig['aapt'])
418             return True
419         else:
420             logging.error(_("'sdk_path' not set in 'config.py'!"))
421             return False
422     if thisconfig['sdk_path'] == default_config['sdk_path']:
423         logging.error(_('No Android SDK found!'))
424         logging.error(_('You can use ANDROID_HOME to set the path to your SDK, i.e.:'))
425         logging.error('\texport ANDROID_HOME=/opt/android-sdk')
426         return False
427     if not os.path.exists(thisconfig['sdk_path']):
428         logging.critical(_("Android SDK path '{path}' does not exist!")
429                          .format(path=thisconfig['sdk_path']))
430         return False
431     if not os.path.isdir(thisconfig['sdk_path']):
432         logging.critical(_("Android SDK path '{path}' is not a directory!")
433                          .format(path=thisconfig['sdk_path']))
434         return False
435     for d in ['build-tools', 'platform-tools', 'tools']:
436         if not os.path.isdir(os.path.join(thisconfig['sdk_path'], d)):
437             logging.critical(_("Android SDK '{path}' does not have '{dirname}' installed!")
438                              .format(path=thisconfig['sdk_path'], dirname=d))
439             return False
440     return True
441
442
443 def ensure_build_tools_exists(thisconfig):
444     if not test_sdk_exists(thisconfig):
445         raise FDroidException(_("Android SDK not found!"))
446     build_tools = os.path.join(thisconfig['sdk_path'], 'build-tools')
447     versioned_build_tools = os.path.join(build_tools, thisconfig['build_tools'])
448     if not os.path.isdir(versioned_build_tools):
449         raise FDroidException(
450             _("Android build-tools path '{path}' does not exist!")
451             .format(path=versioned_build_tools))
452
453
454 def get_local_metadata_files():
455     '''get any metadata files local to an app's source repo
456
457     This tries to ignore anything that does not count as app metdata,
458     including emacs cruft ending in ~ and the .fdroid.key*pass.txt files.
459
460     '''
461     return glob.glob('.fdroid.[a-jl-z]*[a-rt-z]')
462
463
464 def read_pkg_args(appid_versionCode_pairs, allow_vercodes=False):
465     """
466     :param appids: arguments in the form of multiple appid:[vc] strings
467     :returns: a dictionary with the set of vercodes specified for each package
468     """
469     vercodes = {}
470     if not appid_versionCode_pairs:
471         return vercodes
472
473     for p in appid_versionCode_pairs:
474         if allow_vercodes and ':' in p:
475             package, vercode = p.split(':')
476         else:
477             package, vercode = p, None
478         if package not in vercodes:
479             vercodes[package] = [vercode] if vercode else []
480             continue
481         elif vercode and vercode not in vercodes[package]:
482             vercodes[package] += [vercode] if vercode else []
483
484     return vercodes
485
486
487 def read_app_args(appid_versionCode_pairs, allapps, allow_vercodes=False):
488     """Build a list of App instances for processing
489
490     On top of what read_pkg_args does, this returns the whole app
491     metadata, but limiting the builds list to the builds matching the
492     appid_versionCode_pairs and vercodes specified.  If no appid_versionCode_pairs are specified, then
493     all App and Build instances are returned.
494
495     """
496
497     vercodes = read_pkg_args(appid_versionCode_pairs, allow_vercodes)
498
499     if not vercodes:
500         return allapps
501
502     apps = {}
503     for appid, app in allapps.items():
504         if appid in vercodes:
505             apps[appid] = app
506
507     if len(apps) != len(vercodes):
508         for p in vercodes:
509             if p not in allapps:
510                 logging.critical(_("No such package: %s") % p)
511         raise FDroidException(_("Found invalid appids in arguments"))
512     if not apps:
513         raise FDroidException(_("No packages specified"))
514
515     error = False
516     for appid, app in apps.items():
517         vc = vercodes[appid]
518         if not vc:
519             continue
520         app.builds = [b for b in app.builds if b.versionCode in vc]
521         if len(app.builds) != len(vercodes[appid]):
522             error = True
523             allvcs = [b.versionCode for b in app.builds]
524             for v in vercodes[appid]:
525                 if v not in allvcs:
526                     logging.critical(_("No such versionCode {versionCode} for app {appid}")
527                                      .format(versionCode=v, appid=appid))
528
529     if error:
530         raise FDroidException(_("Found invalid versionCodes for some apps"))
531
532     return apps
533
534
535 def get_extension(filename):
536     base, ext = os.path.splitext(filename)
537     if not ext:
538         return base, ''
539     return base, ext.lower()[1:]
540
541
542 def has_extension(filename, ext):
543     _ignored, f_ext = get_extension(filename)
544     return ext == f_ext
545
546
547 publish_name_regex = re.compile(r"^(.+)_([0-9]+)\.(apk|zip)$")
548
549
550 def clean_description(description):
551     'Remove unneeded newlines and spaces from a block of description text'
552     returnstring = ''
553     # this is split up by paragraph to make removing the newlines easier
554     for paragraph in re.split(r'\n\n', description):
555         paragraph = re.sub('\r', '', paragraph)
556         paragraph = re.sub('\n', ' ', paragraph)
557         paragraph = re.sub(' {2,}', ' ', paragraph)
558         paragraph = re.sub('^\s*(\w)', r'\1', paragraph)
559         returnstring += paragraph + '\n\n'
560     return returnstring.rstrip('\n')
561
562
563 def publishednameinfo(filename):
564     filename = os.path.basename(filename)
565     m = publish_name_regex.match(filename)
566     try:
567         result = (m.group(1), m.group(2))
568     except AttributeError:
569         raise FDroidException(_("Invalid name for published file: %s") % filename)
570     return result
571
572
573 apk_release_filename = re.compile('(?P<appid>[a-zA-Z0-9_\.]+)_(?P<vercode>[0-9]+)\.apk')
574 apk_release_filename_with_sigfp = re.compile('(?P<appid>[a-zA-Z0-9_\.]+)_(?P<vercode>[0-9]+)_(?P<sigfp>[0-9a-f]{7})\.apk')
575
576
577 def apk_parse_release_filename(apkname):
578     """Parses the name of an APK file according the F-Droids APK naming
579     scheme and returns the tokens.
580
581     WARNING: Returned values don't necessarily represent the APKs actual
582     properties, the are just paresed from the file name.
583
584     :returns: A triplet containing (appid, versionCode, signer), where appid
585         should be the package name, versionCode should be the integer
586         represion of the APKs version and signer should be the first 7 hex
587         digists of the sha256 signing key fingerprint which was used to sign
588         this APK.
589     """
590     m = apk_release_filename_with_sigfp.match(apkname)
591     if m:
592         return m.group('appid'), m.group('vercode'), m.group('sigfp')
593     m = apk_release_filename.match(apkname)
594     if m:
595         return m.group('appid'), m.group('vercode'), None
596     return None, None, None
597
598
599 def get_release_filename(app, build):
600     if build.output:
601         return "%s_%s.%s" % (app.id, build.versionCode, get_file_extension(build.output))
602     else:
603         return "%s_%s.apk" % (app.id, build.versionCode)
604
605
606 def get_toolsversion_logname(app, build):
607     return "%s_%s_toolsversion.log" % (app.id, build.versionCode)
608
609
610 def getsrcname(app, build):
611     return "%s_%s_src.tar.gz" % (app.id, build.versionCode)
612
613
614 def getappname(app):
615     if app.Name:
616         return app.Name
617     if app.AutoName:
618         return app.AutoName
619     return app.id
620
621
622 def getcvname(app):
623     return '%s (%s)' % (app.CurrentVersion, app.CurrentVersionCode)
624
625
626 def get_build_dir(app):
627     '''get the dir that this app will be built in'''
628
629     if app.RepoType == 'srclib':
630         return os.path.join('build', 'srclib', app.Repo)
631
632     return os.path.join('build', app.id)
633
634
635 def setup_vcs(app):
636     '''checkout code from VCS and return instance of vcs and the build dir'''
637     build_dir = get_build_dir(app)
638
639     # Set up vcs interface and make sure we have the latest code...
640     logging.debug("Getting {0} vcs interface for {1}"
641                   .format(app.RepoType, app.Repo))
642     if app.RepoType == 'git' and os.path.exists('.fdroid.yml'):
643         remote = os.getcwd()
644     else:
645         remote = app.Repo
646     vcs = getvcs(app.RepoType, remote, build_dir)
647
648     return vcs, build_dir
649
650
651 def getvcs(vcstype, remote, local):
652     if vcstype == 'git':
653         return vcs_git(remote, local)
654     if vcstype == 'git-svn':
655         return vcs_gitsvn(remote, local)
656     if vcstype == 'hg':
657         return vcs_hg(remote, local)
658     if vcstype == 'bzr':
659         return vcs_bzr(remote, local)
660     if vcstype == 'srclib':
661         if local != os.path.join('build', 'srclib', remote):
662             raise VCSException("Error: srclib paths are hard-coded!")
663         return getsrclib(remote, os.path.join('build', 'srclib'), raw=True)
664     if vcstype == 'svn':
665         raise VCSException("Deprecated vcs type 'svn' - please use 'git-svn' instead")
666     raise VCSException("Invalid vcs type " + vcstype)
667
668
669 def getsrclibvcs(name):
670     if name not in fdroidserver.metadata.srclibs:
671         raise VCSException("Missing srclib " + name)
672     return fdroidserver.metadata.srclibs[name]['Repo Type']
673
674
675 class vcs:
676
677     def __init__(self, remote, local):
678
679         # svn, git-svn and bzr may require auth
680         self.username = None
681         if self.repotype() in ('git-svn', 'bzr'):
682             if '@' in remote:
683                 if self.repotype == 'git-svn':
684                     raise VCSException("Authentication is not supported for git-svn")
685                 self.username, remote = remote.split('@')
686                 if ':' not in self.username:
687                     raise VCSException(_("Password required with username"))
688                 self.username, self.password = self.username.split(':')
689
690         self.remote = remote
691         self.local = local
692         self.clone_failed = False
693         self.refreshed = False
694         self.srclib = None
695
696     def repotype(self):
697         return None
698
699     def clientversion(self):
700         versionstr = FDroidPopen(self.clientversioncmd()).output
701         return versionstr[0:versionstr.find('\n')]
702
703     def clientversioncmd(self):
704         return None
705
706     def gotorevision(self, rev, refresh=True):
707         """Take the local repository to a clean version of the given
708         revision, which is specificed in the VCS's native
709         format. Beforehand, the repository can be dirty, or even
710         non-existent. If the repository does already exist locally, it
711         will be updated from the origin, but only once in the lifetime
712         of the vcs object.  None is acceptable for 'rev' if you know
713         you are cloning a clean copy of the repo - otherwise it must
714         specify a valid revision.
715         """
716
717         if self.clone_failed:
718             raise VCSException(_("Downloading the repository already failed once, not trying again."))
719
720         # The .fdroidvcs-id file for a repo tells us what VCS type
721         # and remote that directory was created from, allowing us to drop it
722         # automatically if either of those things changes.
723         fdpath = os.path.join(self.local, '..',
724                               '.fdroidvcs-' + os.path.basename(self.local))
725         fdpath = os.path.normpath(fdpath)
726         cdata = self.repotype() + ' ' + self.remote
727         writeback = True
728         deleterepo = False
729         if os.path.exists(self.local):
730             if os.path.exists(fdpath):
731                 with open(fdpath, 'r') as f:
732                     fsdata = f.read().strip()
733                 if fsdata == cdata:
734                     writeback = False
735                 else:
736                     deleterepo = True
737                     logging.info("Repository details for %s changed - deleting" % (
738                         self.local))
739             else:
740                 deleterepo = True
741                 logging.info("Repository details for %s missing - deleting" % (
742                     self.local))
743         if deleterepo:
744             shutil.rmtree(self.local)
745
746         exc = None
747         if not refresh:
748             self.refreshed = True
749
750         try:
751             self.gotorevisionx(rev)
752         except FDroidException as e:
753             exc = e
754
755         # If necessary, write the .fdroidvcs file.
756         if writeback and not self.clone_failed:
757             os.makedirs(os.path.dirname(fdpath), exist_ok=True)
758             with open(fdpath, 'w+') as f:
759                 f.write(cdata)
760
761         if exc is not None:
762             raise exc
763
764     def gotorevisionx(self, rev):  # pylint: disable=unused-argument
765         """Derived classes need to implement this.
766
767         It's called once basic checking has been performed.
768         """
769         raise VCSException("This VCS type doesn't define gotorevisionx")
770
771     # Initialise and update submodules
772     def initsubmodules(self):
773         raise VCSException('Submodules not supported for this vcs type')
774
775     # Get a list of all known tags
776     def gettags(self):
777         if not self._gettags:
778             raise VCSException('gettags not supported for this vcs type')
779         rtags = []
780         for tag in self._gettags():
781             if re.match('[-A-Za-z0-9_. /]+$', tag):
782                 rtags.append(tag)
783         return rtags
784
785     def latesttags(self):
786         """Get a list of all the known tags, sorted from newest to oldest"""
787         raise VCSException('latesttags not supported for this vcs type')
788
789     def getref(self):
790         """Get current commit reference (hash, revision, etc)"""
791         raise VCSException('getref not supported for this vcs type')
792
793     def getsrclib(self):
794         """Returns the srclib (name, path) used in setting up the current revision, or None."""
795         return self.srclib
796
797
798 class vcs_git(vcs):
799
800     def repotype(self):
801         return 'git'
802
803     def clientversioncmd(self):
804         return ['git', '--version']
805
806     def git(self, args, envs=dict(), cwd=None, output=True):
807         '''Prevent git fetch/clone/submodule from hanging at the username/password prompt
808
809         While fetch/pull/clone respect the command line option flags,
810         it seems that submodule commands do not.  They do seem to
811         follow whatever is in env vars, if the version of git is new
812         enough.  So we just throw the kitchen sink at it to see what
813         sticks.
814
815         Also, because of CVE-2017-1000117, block all SSH URLs.
816         '''
817         #
818         # supported in git >= 2.3
819         git_config = [
820             '-c', 'core.sshCommand=false',
821             '-c', 'url.https://.insteadOf=ssh://',
822         ]
823         for domain in ('bitbucket.org', 'github.com', 'gitlab.com'):
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=git://' + domain)
828             git_config.append('-c')
829             git_config.append('url.https://u:p@' + domain + '.insteadOf=https://' + domain)
830         envs.update({
831             'GIT_TERMINAL_PROMPT': '0',
832             'GIT_SSH': 'false',  # for git < 2.3
833         })
834         return FDroidPopen(['git', ] + git_config + args,
835                            envs=envs, cwd=cwd, output=output)
836
837     def checkrepo(self):
838         """If the local directory exists, but is somehow not a git repository,
839         git will traverse up the directory tree until it finds one
840         that is (i.e.  fdroidserver) and then we'll proceed to destroy
841         it!  This is called as a safety check.
842
843         """
844
845         p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
846         result = p.output.rstrip()
847         if not result.endswith(self.local):
848             raise VCSException('Repository mismatch')
849
850     def gotorevisionx(self, rev):
851         if not os.path.exists(self.local):
852             # Brand new checkout
853             p = self.git(['clone', self.remote, self.local])
854             if p.returncode != 0:
855                 self.clone_failed = True
856                 raise VCSException("Git clone failed", p.output)
857             self.checkrepo()
858         else:
859             self.checkrepo()
860             # Discard any working tree changes
861             p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
862                              'git', 'reset', '--hard'], cwd=self.local, output=False)
863             if p.returncode != 0:
864                 raise VCSException(_("Git reset failed"), p.output)
865             # Remove untracked files now, in case they're tracked in the target
866             # revision (it happens!)
867             p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
868                              'git', 'clean', '-dffx'], cwd=self.local, output=False)
869             if p.returncode != 0:
870                 raise VCSException(_("Git clean failed"), p.output)
871             if not self.refreshed:
872                 # Get latest commits and tags from remote
873                 p = self.git(['fetch', 'origin'], cwd=self.local)
874                 if p.returncode != 0:
875                     raise VCSException(_("Git fetch failed"), p.output)
876                 p = self.git(['fetch', '--prune', '--tags', 'origin'], output=False, cwd=self.local)
877                 if p.returncode != 0:
878                     raise VCSException(_("Git fetch failed"), p.output)
879                 # Recreate origin/HEAD as git clone would do it, in case it disappeared
880                 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
881                 if p.returncode != 0:
882                     lines = p.output.splitlines()
883                     if 'Multiple remote HEAD branches' not in lines[0]:
884                         raise VCSException(_("Git remote set-head failed"), p.output)
885                     branch = lines[1].split(' ')[-1]
886                     p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local, output=False)
887                     if p2.returncode != 0:
888                         raise VCSException(_("Git remote set-head failed"), p.output + '\n' + p2.output)
889                 self.refreshed = True
890         # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
891         # a github repo. Most of the time this is the same as origin/master.
892         rev = rev or 'origin/HEAD'
893         p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
894         if p.returncode != 0:
895             raise VCSException(_("Git checkout of '%s' failed") % rev, p.output)
896         # Get rid of any uncontrolled files left behind
897         p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
898         if p.returncode != 0:
899             raise VCSException(_("Git clean failed"), p.output)
900
901     def initsubmodules(self):
902         self.checkrepo()
903         submfile = os.path.join(self.local, '.gitmodules')
904         if not os.path.isfile(submfile):
905             raise NoSubmodulesException(_("No git submodules available"))
906
907         # fix submodules not accessible without an account and public key auth
908         with open(submfile, 'r') as f:
909             lines = f.readlines()
910         with open(submfile, 'w') as f:
911             for line in lines:
912                 for domain in ('bitbucket.org', 'github.com', 'gitlab.com'):
913                     line = re.sub('git@' + domain + ':', 'https://u:p@' + domain + '/', line)
914                 f.write(line)
915
916         p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
917         if p.returncode != 0:
918             raise VCSException(_("Git submodule sync failed"), p.output)
919         p = self.git(['submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
920         if p.returncode != 0:
921             raise VCSException(_("Git submodule update failed"), p.output)
922
923     def _gettags(self):
924         self.checkrepo()
925         p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
926         return p.output.splitlines()
927
928     tag_format = re.compile(r'tag: ([^),]*)')
929
930     def latesttags(self):
931         self.checkrepo()
932         p = FDroidPopen(['git', 'log', '--tags',
933                          '--simplify-by-decoration', '--pretty=format:%d'],
934                         cwd=self.local, output=False)
935         tags = []
936         for line in p.output.splitlines():
937             for tag in self.tag_format.findall(line):
938                 tags.append(tag)
939         return tags
940
941
942 class vcs_gitsvn(vcs):
943
944     def repotype(self):
945         return 'git-svn'
946
947     def clientversioncmd(self):
948         return ['git', 'svn', '--version']
949
950     def checkrepo(self):
951         """If the local directory exists, but is somehow not a git repository,
952         git will traverse up the directory tree until it finds one that
953         is (i.e.  fdroidserver) and then we'll proceed to destory it!
954         This is called as a safety check.
955
956         """
957         p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
958         result = p.output.rstrip()
959         if not result.endswith(self.local):
960             raise VCSException('Repository mismatch')
961
962     def git(self, args, envs=dict(), cwd=None, output=True):
963         '''Prevent git fetch/clone/submodule from hanging at the username/password prompt
964         '''
965         # CVE-2017-1000117 block all SSH URLs (supported in git >= 2.3)
966         config = ['-c', 'core.sshCommand=false']
967         envs.update({
968             'GIT_TERMINAL_PROMPT': '0',
969             'GIT_SSH': 'false',  # for git < 2.3
970             'SVN_SSH': 'false',
971         })
972         return FDroidPopen(['git', ] + config + args,
973                            envs=envs, cwd=cwd, output=output)
974
975     def gotorevisionx(self, rev):
976         if not os.path.exists(self.local):
977             # Brand new checkout
978             gitsvn_args = ['svn', 'clone']
979             if ';' in self.remote:
980                 remote_split = self.remote.split(';')
981                 for i in remote_split[1:]:
982                     if i.startswith('trunk='):
983                         gitsvn_args.extend(['-T', i[6:]])
984                     elif i.startswith('tags='):
985                         gitsvn_args.extend(['-t', i[5:]])
986                     elif i.startswith('branches='):
987                         gitsvn_args.extend(['-b', i[9:]])
988                 gitsvn_args.extend([remote_split[0], self.local])
989                 p = self.git(gitsvn_args, output=False)
990                 if p.returncode != 0:
991                     self.clone_failed = True
992                     raise VCSException("Git svn clone failed", p.output)
993             else:
994                 gitsvn_args.extend([self.remote, self.local])
995                 p = self.git(gitsvn_args, output=False)
996                 if p.returncode != 0:
997                     self.clone_failed = True
998                     raise VCSException("Git svn clone failed", p.output)
999             self.checkrepo()
1000         else:
1001             self.checkrepo()
1002             # Discard any working tree changes
1003             p = self.git(['reset', '--hard'], cwd=self.local, output=False)
1004             if p.returncode != 0:
1005                 raise VCSException("Git reset failed", p.output)
1006             # Remove untracked files now, in case they're tracked in the target
1007             # revision (it happens!)
1008             p = self.git(['clean', '-dffx'], cwd=self.local, output=False)
1009             if p.returncode != 0:
1010                 raise VCSException("Git clean failed", p.output)
1011             if not self.refreshed:
1012                 # Get new commits, branches and tags from repo
1013                 p = self.git(['svn', 'fetch'], cwd=self.local, output=False)
1014                 if p.returncode != 0:
1015                     raise VCSException("Git svn fetch failed")
1016                 p = self.git(['svn', 'rebase'], cwd=self.local, output=False)
1017                 if p.returncode != 0:
1018                     raise VCSException("Git svn rebase failed", p.output)
1019                 self.refreshed = True
1020
1021         rev = rev or 'master'
1022         if rev:
1023             nospaces_rev = rev.replace(' ', '%20')
1024             # Try finding a svn tag
1025             for treeish in ['origin/', '']:
1026                 p = self.git(['checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
1027                 if p.returncode == 0:
1028                     break
1029             if p.returncode != 0:
1030                 # No tag found, normal svn rev translation
1031                 # Translate svn rev into git format
1032                 rev_split = rev.split('/')
1033
1034                 p = None
1035                 for treeish in ['origin/', '']:
1036                     if len(rev_split) > 1:
1037                         treeish += rev_split[0]
1038                         svn_rev = rev_split[1]
1039
1040                     else:
1041                         # if no branch is specified, then assume trunk (i.e. 'master' branch):
1042                         treeish += 'master'
1043                         svn_rev = rev
1044
1045                     svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
1046
1047                     p = self.git(['svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
1048                     git_rev = p.output.rstrip()
1049
1050                     if p.returncode == 0 and git_rev:
1051                         break
1052
1053                 if p.returncode != 0 or not git_rev:
1054                     # Try a plain git checkout as a last resort
1055                     p = self.git(['checkout', rev], cwd=self.local, output=False)
1056                     if p.returncode != 0:
1057                         raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
1058                 else:
1059                     # Check out the git rev equivalent to the svn rev
1060                     p = self.git(['checkout', git_rev], cwd=self.local, output=False)
1061                     if p.returncode != 0:
1062                         raise VCSException(_("Git checkout of '%s' failed") % rev, p.output)
1063
1064         # Get rid of any uncontrolled files left behind
1065         p = self.git(['clean', '-dffx'], cwd=self.local, output=False)
1066         if p.returncode != 0:
1067             raise VCSException(_("Git clean failed"), p.output)
1068
1069     def _gettags(self):
1070         self.checkrepo()
1071         for treeish in ['origin/', '']:
1072             d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
1073             if os.path.isdir(d):
1074                 return os.listdir(d)
1075
1076     def getref(self):
1077         self.checkrepo()
1078         p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
1079         if p.returncode != 0:
1080             return None
1081         return p.output.strip()
1082
1083
1084 class vcs_hg(vcs):
1085
1086     def repotype(self):
1087         return 'hg'
1088
1089     def clientversioncmd(self):
1090         return ['hg', '--version']
1091
1092     def gotorevisionx(self, rev):
1093         if not os.path.exists(self.local):
1094             p = FDroidPopen(['hg', 'clone', '--ssh', 'false', self.remote, self.local], 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                              stdout=subprocess.PIPE, stderr=stderr_param)
2027     except OSError as e:
2028         raise BuildException("OSError while trying to execute " +
2029                              ' '.join(commands) + ': ' + str(e))
2030
2031     # TODO are these AsynchronousFileReader threads always exiting?
2032     if not stderr_to_stdout and options.verbose:
2033         stderr_queue = Queue()
2034         stderr_reader = AsynchronousFileReader(p.stderr, stderr_queue)
2035
2036         while not stderr_reader.eof():
2037             while not stderr_queue.empty():
2038                 line = stderr_queue.get()
2039                 sys.stderr.buffer.write(line)
2040                 sys.stderr.flush()
2041
2042             time.sleep(0.1)
2043
2044     stdout_queue = Queue()
2045     stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
2046     buf = io.BytesIO()
2047
2048     # Check the queue for output (until there is no more to get)
2049     while not stdout_reader.eof():
2050         while not stdout_queue.empty():
2051             line = stdout_queue.get()
2052             if output and options.verbose:
2053                 # Output directly to console
2054                 sys.stderr.buffer.write(line)
2055                 sys.stderr.flush()
2056             buf.write(line)
2057
2058         time.sleep(0.1)
2059
2060     result.returncode = p.wait()
2061     result.output = buf.getvalue()
2062     buf.close()
2063     # make sure all filestreams of the subprocess are closed
2064     for streamvar in ['stdin', 'stdout', 'stderr']:
2065         if hasattr(p, streamvar):
2066             stream = getattr(p, streamvar)
2067             if stream:
2068                 stream.close()
2069     return result
2070
2071
2072 def FDroidPopen(commands, cwd=None, envs=None, output=True, stderr_to_stdout=True):
2073     """
2074     Run a command and capture the possibly huge output as a str.
2075
2076     :param commands: command and argument list like in subprocess.Popen
2077     :param cwd: optionally specifies a working directory
2078     :param envs: a optional dictionary of environment variables and their values
2079     :returns: A PopenResult.
2080     """
2081     result = FDroidPopenBytes(commands, cwd, envs, output, stderr_to_stdout)
2082     result.output = result.output.decode('utf-8', 'ignore')
2083     return result
2084
2085
2086 gradle_comment = re.compile(r'[ ]*//')
2087 gradle_signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
2088 gradle_line_matches = [
2089     re.compile(r'^[\t ]*signingConfig [^ ]*$'),
2090     re.compile(r'.*android\.signingConfigs\.[^{]*$'),
2091     re.compile(r'.*\.readLine\(.*'),
2092 ]
2093
2094
2095 def remove_signing_keys(build_dir):
2096     for root, dirs, files in os.walk(build_dir):
2097         if 'build.gradle' in files:
2098             path = os.path.join(root, 'build.gradle')
2099
2100             with open(path, "r", encoding='utf8') as o:
2101                 lines = o.readlines()
2102
2103             changed = False
2104
2105             opened = 0
2106             i = 0
2107             with open(path, "w", encoding='utf8') as o:
2108                 while i < len(lines):
2109                     line = lines[i]
2110                     i += 1
2111                     while line.endswith('\\\n'):
2112                         line = line.rstrip('\\\n') + lines[i]
2113                         i += 1
2114
2115                     if gradle_comment.match(line):
2116                         o.write(line)
2117                         continue
2118
2119                     if opened > 0:
2120                         opened += line.count('{')
2121                         opened -= line.count('}')
2122                         continue
2123
2124                     if gradle_signing_configs.match(line):
2125                         changed = True
2126                         opened += 1
2127                         continue
2128
2129                     if any(s.match(line) for s in gradle_line_matches):
2130                         changed = True
2131                         continue
2132
2133                     if opened == 0:
2134                         o.write(line)
2135
2136             if changed:
2137                 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
2138
2139         for propfile in [
2140                 'project.properties',
2141                 'build.properties',
2142                 'default.properties',
2143                 'ant.properties', ]:
2144             if propfile in files:
2145                 path = os.path.join(root, propfile)
2146
2147                 with open(path, "r", encoding='iso-8859-1') as o:
2148                     lines = o.readlines()
2149
2150                 changed = False
2151
2152                 with open(path, "w", encoding='iso-8859-1') as o:
2153                     for line in lines:
2154                         if any(line.startswith(s) for s in ('key.store', 'key.alias')):
2155                             changed = True
2156                             continue
2157
2158                         o.write(line)
2159
2160                 if changed:
2161                     logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
2162
2163
2164 def set_FDroidPopen_env(build=None):
2165     '''
2166     set up the environment variables for the build environment
2167
2168     There is only a weak standard, the variables used by gradle, so also set
2169     up the most commonly used environment variables for SDK and NDK.  Also, if
2170     there is no locale set, this will set the locale (e.g. LANG) to en_US.UTF-8.
2171     '''
2172     global env, orig_path
2173
2174     if env is None:
2175         env = os.environ
2176         orig_path = env['PATH']
2177         for n in ['ANDROID_HOME', 'ANDROID_SDK']:
2178             env[n] = config['sdk_path']
2179         for k, v in config['java_paths'].items():
2180             env['JAVA%s_HOME' % k] = v
2181
2182     missinglocale = True
2183     for k, v in env.items():
2184         if k == 'LANG' and v != 'C':
2185             missinglocale = False
2186         elif k == 'LC_ALL':
2187             missinglocale = False
2188     if missinglocale:
2189         env['LANG'] = 'en_US.UTF-8'
2190
2191     if build is not None:
2192         path = build.ndk_path()
2193         paths = orig_path.split(os.pathsep)
2194         if path not in paths:
2195             paths = [path] + paths
2196             env['PATH'] = os.pathsep.join(paths)
2197         for n in ['ANDROID_NDK', 'NDK', 'ANDROID_NDK_HOME']:
2198             env[n] = build.ndk_path()
2199
2200
2201 def replace_build_vars(cmd, build):
2202     cmd = cmd.replace('$$COMMIT$$', build.commit)
2203     cmd = cmd.replace('$$VERSION$$', build.versionName)
2204     cmd = cmd.replace('$$VERCODE$$', build.versionCode)
2205     return cmd
2206
2207
2208 def replace_config_vars(cmd, build):
2209     cmd = cmd.replace('$$SDK$$', config['sdk_path'])
2210     cmd = cmd.replace('$$NDK$$', build.ndk_path())
2211     cmd = cmd.replace('$$MVN3$$', config['mvn3'])
2212     cmd = cmd.replace('$$QT$$', config['qt_sdk_path'] or '')
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