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