chiark / gitweb /
common: use python instead of calling out to 'rm'
[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     _ignored, 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
627     return vcs, build_dir
628
629
630 def getvcs(vcstype, remote, local):
631     if vcstype == 'git':
632         return vcs_git(remote, local)
633     if vcstype == 'git-svn':
634         return vcs_gitsvn(remote, local)
635     if vcstype == 'hg':
636         return vcs_hg(remote, local)
637     if vcstype == 'bzr':
638         return vcs_bzr(remote, local)
639     if vcstype == 'srclib':
640         if local != os.path.join('build', 'srclib', remote):
641             raise VCSException("Error: srclib paths are hard-coded!")
642         return getsrclib(remote, os.path.join('build', 'srclib'), raw=True)
643     if vcstype == 'svn':
644         raise VCSException("Deprecated vcs type 'svn' - please use 'git-svn' instead")
645     raise VCSException("Invalid vcs type " + vcstype)
646
647
648 def getsrclibvcs(name):
649     if name not in fdroidserver.metadata.srclibs:
650         raise VCSException("Missing srclib " + name)
651     return fdroidserver.metadata.srclibs[name]['Repo Type']
652
653
654 class vcs:
655
656     def __init__(self, remote, local):
657
658         # svn, git-svn and bzr may require auth
659         self.username = None
660         if self.repotype() in ('git-svn', 'bzr'):
661             if '@' in remote:
662                 if self.repotype == 'git-svn':
663                     raise VCSException("Authentication is not supported for git-svn")
664                 self.username, remote = remote.split('@')
665                 if ':' not in self.username:
666                     raise VCSException(_("Password required with username"))
667                 self.username, self.password = self.username.split(':')
668
669         self.remote = remote
670         self.local = local
671         self.clone_failed = False
672         self.refreshed = False
673         self.srclib = None
674
675     def repotype(self):
676         return None
677
678     def clientversion(self):
679         versionstr = FDroidPopen(self.clientversioncmd()).output
680         return versionstr[0:versionstr.find('\n')]
681
682     def clientversioncmd(self):
683         return None
684
685     def gotorevision(self, rev, refresh=True):
686         """Take the local repository to a clean version of the given
687         revision, which is specificed in the VCS's native
688         format. Beforehand, the repository can be dirty, or even
689         non-existent. If the repository does already exist locally, it
690         will be updated from the origin, but only once in the lifetime
691         of the vcs object.  None is acceptable for 'rev' if you know
692         you are cloning a clean copy of the repo - otherwise it must
693         specify a valid revision.
694         """
695
696         if self.clone_failed:
697             raise VCSException(_("Downloading the repository already failed once, not trying again."))
698
699         # The .fdroidvcs-id file for a repo tells us what VCS type
700         # and remote that directory was created from, allowing us to drop it
701         # automatically if either of those things changes.
702         fdpath = os.path.join(self.local, '..',
703                               '.fdroidvcs-' + os.path.basename(self.local))
704         fdpath = os.path.normpath(fdpath)
705         cdata = self.repotype() + ' ' + self.remote
706         writeback = True
707         deleterepo = False
708         if os.path.exists(self.local):
709             if os.path.exists(fdpath):
710                 with open(fdpath, 'r') as f:
711                     fsdata = f.read().strip()
712                 if fsdata == cdata:
713                     writeback = False
714                 else:
715                     deleterepo = True
716                     logging.info("Repository details for %s changed - deleting" % (
717                         self.local))
718             else:
719                 deleterepo = True
720                 logging.info("Repository details for %s missing - deleting" % (
721                     self.local))
722         if deleterepo:
723             shutil.rmtree(self.local)
724
725         exc = None
726         if not refresh:
727             self.refreshed = True
728
729         try:
730             self.gotorevisionx(rev)
731         except FDroidException as e:
732             exc = e
733
734         # If necessary, write the .fdroidvcs file.
735         if writeback and not self.clone_failed:
736             os.makedirs(os.path.dirname(fdpath), exist_ok=True)
737             with open(fdpath, 'w+') as f:
738                 f.write(cdata)
739
740         if exc is not None:
741             raise exc
742
743     def gotorevisionx(self, rev):  # pylint: disable=unused-argument
744         """Derived classes need to implement this.
745
746         It's called once basic checking has been performed.
747         """
748         raise VCSException("This VCS type doesn't define gotorevisionx")
749
750     # Initialise and update submodules
751     def initsubmodules(self):
752         raise VCSException('Submodules not supported for this vcs type')
753
754     # Get a list of all known tags
755     def gettags(self):
756         if not self._gettags:
757             raise VCSException('gettags not supported for this vcs type')
758         rtags = []
759         for tag in self._gettags():
760             if re.match('[-A-Za-z0-9_. /]+$', tag):
761                 rtags.append(tag)
762         return rtags
763
764     def latesttags(self):
765         """Get a list of all the known tags, sorted from newest to oldest"""
766         raise VCSException('latesttags not supported for this vcs type')
767
768     def getref(self):
769         """Get current commit reference (hash, revision, etc)"""
770         raise VCSException('getref not supported for this vcs type')
771
772     def getsrclib(self):
773         """Returns the srclib (name, path) used in setting up the current revision, or None."""
774         return self.srclib
775
776
777 class vcs_git(vcs):
778
779     def repotype(self):
780         return 'git'
781
782     def clientversioncmd(self):
783         return ['git', '--version']
784
785     def checkrepo(self):
786         """If the local directory exists, but is somehow not a git repository,
787         git will traverse up the directory tree until it finds one
788         that is (i.e.  fdroidserver) and then we'll proceed to destroy
789         it!  This is called as a safety check.
790
791         """
792
793         p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
794         result = p.output.rstrip()
795         if not result.endswith(self.local):
796             raise VCSException('Repository mismatch')
797
798     def gotorevisionx(self, rev):
799         if not os.path.exists(self.local):
800             # Brand new checkout
801             p = FDroidPopen(['git', 'clone', self.remote, self.local])
802             if p.returncode != 0:
803                 self.clone_failed = True
804                 raise VCSException("Git clone failed", p.output)
805             self.checkrepo()
806         else:
807             self.checkrepo()
808             # Discard any working tree changes
809             p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
810                              'git', 'reset', '--hard'], cwd=self.local, output=False)
811             if p.returncode != 0:
812                 raise VCSException(_("Git reset failed"), p.output)
813             # Remove untracked files now, in case they're tracked in the target
814             # revision (it happens!)
815             p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
816                              'git', 'clean', '-dffx'], cwd=self.local, output=False)
817             if p.returncode != 0:
818                 raise VCSException(_("Git clean failed"), p.output)
819             if not self.refreshed:
820                 # Get latest commits and tags from remote
821                 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
822                 if p.returncode != 0:
823                     raise VCSException(_("Git fetch failed"), p.output)
824                 p = FDroidPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local, output=False)
825                 if p.returncode != 0:
826                     raise VCSException(_("Git fetch failed"), p.output)
827                 # Recreate origin/HEAD as git clone would do it, in case it disappeared
828                 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
829                 if p.returncode != 0:
830                     lines = p.output.splitlines()
831                     if 'Multiple remote HEAD branches' not in lines[0]:
832                         raise VCSException(_("Git remote set-head failed"), p.output)
833                     branch = lines[1].split(' ')[-1]
834                     p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local, output=False)
835                     if p2.returncode != 0:
836                         raise VCSException(_("Git remote set-head failed"), p.output + '\n' + p2.output)
837                 self.refreshed = True
838         # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
839         # a github repo. Most of the time this is the same as origin/master.
840         rev = rev or 'origin/HEAD'
841         p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
842         if p.returncode != 0:
843             raise VCSException(_("Git checkout of '%s' failed") % rev, p.output)
844         # Get rid of any uncontrolled files left behind
845         p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
846         if p.returncode != 0:
847             raise VCSException(_("Git clean failed"), p.output)
848
849     def initsubmodules(self):
850         self.checkrepo()
851         submfile = os.path.join(self.local, '.gitmodules')
852         if not os.path.isfile(submfile):
853             raise VCSException(_("No git submodules available"))
854
855         # fix submodules not accessible without an account and public key auth
856         with open(submfile, 'r') as f:
857             lines = f.readlines()
858         with open(submfile, 'w') as f:
859             for line in lines:
860                 if 'git@github.com' in line:
861                     line = line.replace('git@github.com:', 'https://github.com/')
862                 if 'git@gitlab.com' in line:
863                     line = line.replace('git@gitlab.com:', 'https://gitlab.com/')
864                 f.write(line)
865
866         p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
867         if p.returncode != 0:
868             raise VCSException(_("Git submodule sync failed"), p.output)
869         p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
870         if p.returncode != 0:
871             raise VCSException(_("Git submodule update failed"), p.output)
872
873     def _gettags(self):
874         self.checkrepo()
875         p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
876         return p.output.splitlines()
877
878     tag_format = re.compile(r'tag: ([^),]*)')
879
880     def latesttags(self):
881         self.checkrepo()
882         p = FDroidPopen(['git', 'log', '--tags',
883                          '--simplify-by-decoration', '--pretty=format:%d'],
884                         cwd=self.local, output=False)
885         tags = []
886         for line in p.output.splitlines():
887             for tag in self.tag_format.findall(line):
888                 tags.append(tag)
889         return tags
890
891
892 class vcs_gitsvn(vcs):
893
894     def repotype(self):
895         return 'git-svn'
896
897     def clientversioncmd(self):
898         return ['git', 'svn', '--version']
899
900     def checkrepo(self):
901         """If the local directory exists, but is somehow not a git repository,
902         git will traverse up the directory tree until it finds one that
903         is (i.e.  fdroidserver) and then we'll proceed to destory it!
904         This is called as a safety check.
905
906         """
907         p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
908         result = p.output.rstrip()
909         if not result.endswith(self.local):
910             raise VCSException('Repository mismatch')
911
912     def gotorevisionx(self, rev):
913         if not os.path.exists(self.local):
914             # Brand new checkout
915             gitsvn_args = ['git', 'svn', 'clone']
916             if ';' in self.remote:
917                 remote_split = self.remote.split(';')
918                 for i in remote_split[1:]:
919                     if i.startswith('trunk='):
920                         gitsvn_args.extend(['-T', i[6:]])
921                     elif i.startswith('tags='):
922                         gitsvn_args.extend(['-t', i[5:]])
923                     elif i.startswith('branches='):
924                         gitsvn_args.extend(['-b', i[9:]])
925                 gitsvn_args.extend([remote_split[0], self.local])
926                 p = FDroidPopen(gitsvn_args, output=False)
927                 if p.returncode != 0:
928                     self.clone_failed = True
929                     raise VCSException("Git svn clone failed", p.output)
930             else:
931                 gitsvn_args.extend([self.remote, self.local])
932                 p = FDroidPopen(gitsvn_args, output=False)
933                 if p.returncode != 0:
934                     self.clone_failed = True
935                     raise VCSException("Git svn clone failed", p.output)
936             self.checkrepo()
937         else:
938             self.checkrepo()
939             # Discard any working tree changes
940             p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
941             if p.returncode != 0:
942                 raise VCSException("Git reset failed", p.output)
943             # Remove untracked files now, in case they're tracked in the target
944             # revision (it happens!)
945             p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
946             if p.returncode != 0:
947                 raise VCSException("Git clean failed", p.output)
948             if not self.refreshed:
949                 # Get new commits, branches and tags from repo
950                 p = FDroidPopen(['git', 'svn', 'fetch'], cwd=self.local, output=False)
951                 if p.returncode != 0:
952                     raise VCSException("Git svn fetch failed")
953                 p = FDroidPopen(['git', 'svn', 'rebase'], cwd=self.local, output=False)
954                 if p.returncode != 0:
955                     raise VCSException("Git svn rebase failed", p.output)
956                 self.refreshed = True
957
958         rev = rev or 'master'
959         if rev:
960             nospaces_rev = rev.replace(' ', '%20')
961             # Try finding a svn tag
962             for treeish in ['origin/', '']:
963                 p = FDroidPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
964                 if p.returncode == 0:
965                     break
966             if p.returncode != 0:
967                 # No tag found, normal svn rev translation
968                 # Translate svn rev into git format
969                 rev_split = rev.split('/')
970
971                 p = None
972                 for treeish in ['origin/', '']:
973                     if len(rev_split) > 1:
974                         treeish += rev_split[0]
975                         svn_rev = rev_split[1]
976
977                     else:
978                         # if no branch is specified, then assume trunk (i.e. 'master' branch):
979                         treeish += 'master'
980                         svn_rev = rev
981
982                     svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
983
984                     p = FDroidPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
985                     git_rev = p.output.rstrip()
986
987                     if p.returncode == 0 and git_rev:
988                         break
989
990                 if p.returncode != 0 or not git_rev:
991                     # Try a plain git checkout as a last resort
992                     p = FDroidPopen(['git', 'checkout', rev], cwd=self.local, output=False)
993                     if p.returncode != 0:
994                         raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
995                 else:
996                     # Check out the git rev equivalent to the svn rev
997                     p = FDroidPopen(['git', 'checkout', git_rev], cwd=self.local, output=False)
998                     if p.returncode != 0:
999                         raise VCSException(_("Git checkout of '%s' failed") % rev, p.output)
1000
1001         # Get rid of any uncontrolled files left behind
1002         p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
1003         if p.returncode != 0:
1004             raise VCSException(_("Git clean failed"), p.output)
1005
1006     def _gettags(self):
1007         self.checkrepo()
1008         for treeish in ['origin/', '']:
1009             d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
1010             if os.path.isdir(d):
1011                 return os.listdir(d)
1012
1013     def getref(self):
1014         self.checkrepo()
1015         p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
1016         if p.returncode != 0:
1017             return None
1018         return p.output.strip()
1019
1020
1021 class vcs_hg(vcs):
1022
1023     def repotype(self):
1024         return 'hg'
1025
1026     def clientversioncmd(self):
1027         return ['hg', '--version']
1028
1029     def gotorevisionx(self, rev):
1030         if not os.path.exists(self.local):
1031             p = FDroidPopen(['hg', 'clone', self.remote, self.local], output=False)
1032             if p.returncode != 0:
1033                 self.clone_failed = True
1034                 raise VCSException("Hg clone failed", p.output)
1035         else:
1036             p = FDroidPopen(['hg', 'status', '-uS'], cwd=self.local, output=False)
1037             if p.returncode != 0:
1038                 raise VCSException("Hg status failed", p.output)
1039             for line in p.output.splitlines():
1040                 if not line.startswith('? '):
1041                     raise VCSException("Unexpected output from hg status -uS: " + line)
1042                 FDroidPopen(['rm', '-rf', line[2:]], cwd=self.local, output=False)
1043             if not self.refreshed:
1044                 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
1045                 if p.returncode != 0:
1046                     raise VCSException("Hg pull failed", p.output)
1047                 self.refreshed = True
1048
1049         rev = rev or 'default'
1050         if not rev:
1051             return
1052         p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
1053         if p.returncode != 0:
1054             raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
1055         p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
1056         # Also delete untracked files, we have to enable purge extension for that:
1057         if "'purge' is provided by the following extension" in p.output:
1058             with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
1059                 myfile.write("\n[extensions]\nhgext.purge=\n")
1060             p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
1061             if p.returncode != 0:
1062                 raise VCSException("HG purge failed", p.output)
1063         elif p.returncode != 0:
1064             raise VCSException("HG purge failed", p.output)
1065
1066     def _gettags(self):
1067         p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
1068         return p.output.splitlines()[1:]
1069
1070
1071 class vcs_bzr(vcs):
1072
1073     def repotype(self):
1074         return 'bzr'
1075
1076     def clientversioncmd(self):
1077         return ['bzr', '--version']
1078
1079     def gotorevisionx(self, rev):
1080         if not os.path.exists(self.local):
1081             p = FDroidPopen(['bzr', 'branch', self.remote, self.local], output=False)
1082             if p.returncode != 0:
1083                 self.clone_failed = True
1084                 raise VCSException("Bzr branch failed", p.output)
1085         else:
1086             p = FDroidPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
1087             if p.returncode != 0:
1088                 raise VCSException("Bzr revert failed", p.output)
1089             if not self.refreshed:
1090                 p = FDroidPopen(['bzr', 'pull'], cwd=self.local, output=False)
1091                 if p.returncode != 0:
1092                     raise VCSException("Bzr update failed", p.output)
1093                 self.refreshed = True
1094
1095         revargs = list(['-r', rev] if rev else [])
1096         p = FDroidPopen(['bzr', 'revert'] + revargs, cwd=self.local, output=False)
1097         if p.returncode != 0:
1098             raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
1099
1100     def _gettags(self):
1101         p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
1102         return [tag.split('   ')[0].strip() for tag in
1103                 p.output.splitlines()]
1104
1105
1106 def unescape_string(string):
1107     if len(string) < 2:
1108         return string
1109     if string[0] == '"' and string[-1] == '"':
1110         return string[1:-1]
1111
1112     return string.replace("\\'", "'")
1113
1114
1115 def retrieve_string(app_dir, string, xmlfiles=None):
1116
1117     if not string.startswith('@string/'):
1118         return unescape_string(string)
1119
1120     if xmlfiles is None:
1121         xmlfiles = []
1122         for res_dir in [
1123             os.path.join(app_dir, 'res'),
1124             os.path.join(app_dir, 'src', 'main', 'res'),
1125         ]:
1126             for root, dirs, files in os.walk(res_dir):
1127                 if os.path.basename(root) == 'values':
1128                     xmlfiles += [os.path.join(root, x) for x in files if x.endswith('.xml')]
1129
1130     name = string[len('@string/'):]
1131
1132     def element_content(element):
1133         if element.text is None:
1134             return ""
1135         s = XMLElementTree.tostring(element, encoding='utf-8', method='text')
1136         return s.decode('utf-8').strip()
1137
1138     for path in xmlfiles:
1139         if not os.path.isfile(path):
1140             continue
1141         xml = parse_xml(path)
1142         element = xml.find('string[@name="' + name + '"]')
1143         if element is not None:
1144             content = element_content(element)
1145             return retrieve_string(app_dir, content, xmlfiles)
1146
1147     return ''
1148
1149
1150 def retrieve_string_singleline(app_dir, string, xmlfiles=None):
1151     return retrieve_string(app_dir, string, xmlfiles).replace('\n', ' ').strip()
1152
1153
1154 def manifest_paths(app_dir, flavours):
1155     '''Return list of existing files that will be used to find the highest vercode'''
1156
1157     possible_manifests = \
1158         [os.path.join(app_dir, 'AndroidManifest.xml'),
1159          os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
1160          os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
1161          os.path.join(app_dir, 'build.gradle')]
1162
1163     for flavour in flavours:
1164         if flavour == 'yes':
1165             continue
1166         possible_manifests.append(
1167             os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
1168
1169     return [path for path in possible_manifests if os.path.isfile(path)]
1170
1171
1172 def fetch_real_name(app_dir, flavours):
1173     '''Retrieve the package name. Returns the name, or None if not found.'''
1174     for path in manifest_paths(app_dir, flavours):
1175         if not has_extension(path, 'xml') or not os.path.isfile(path):
1176             continue
1177         logging.debug("fetch_real_name: Checking manifest at " + path)
1178         xml = parse_xml(path)
1179         app = xml.find('application')
1180         if app is None:
1181             continue
1182         if "{http://schemas.android.com/apk/res/android}label" not in app.attrib:
1183             continue
1184         label = app.attrib["{http://schemas.android.com/apk/res/android}label"]
1185         result = retrieve_string_singleline(app_dir, label)
1186         if result:
1187             result = result.strip()
1188         return result
1189     return None
1190
1191
1192 def get_library_references(root_dir):
1193     libraries = []
1194     proppath = os.path.join(root_dir, 'project.properties')
1195     if not os.path.isfile(proppath):
1196         return libraries
1197     with open(proppath, 'r', encoding='iso-8859-1') as f:
1198         for line in f:
1199             if not line.startswith('android.library.reference.'):
1200                 continue
1201             path = line.split('=')[1].strip()
1202             relpath = os.path.join(root_dir, path)
1203             if not os.path.isdir(relpath):
1204                 continue
1205             logging.debug("Found subproject at %s" % path)
1206             libraries.append(path)
1207     return libraries
1208
1209
1210 def ant_subprojects(root_dir):
1211     subprojects = get_library_references(root_dir)
1212     for subpath in subprojects:
1213         subrelpath = os.path.join(root_dir, subpath)
1214         for p in get_library_references(subrelpath):
1215             relp = os.path.normpath(os.path.join(subpath, p))
1216             if relp not in subprojects:
1217                 subprojects.insert(0, relp)
1218     return subprojects
1219
1220
1221 def remove_debuggable_flags(root_dir):
1222     # Remove forced debuggable flags
1223     logging.debug("Removing debuggable flags from %s" % root_dir)
1224     for root, dirs, files in os.walk(root_dir):
1225         if 'AndroidManifest.xml' in files and os.path.isfile(os.path.join(root, 'AndroidManifest.xml')):
1226             regsub_file(r'android:debuggable="[^"]*"',
1227                         '',
1228                         os.path.join(root, 'AndroidManifest.xml'))
1229
1230
1231 vcsearch_g = re.compile(r'''.*[Vv]ersionCode[ =]+["']*([0-9]+)["']*''').search
1232 vnsearch_g = re.compile(r'.*[Vv]ersionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
1233 psearch_g = re.compile(r'.*(packageName|applicationId) *=* *["\']([^"]+)["\'].*').search
1234
1235
1236 def app_matches_packagename(app, package):
1237     if not package:
1238         return False
1239     appid = app.UpdateCheckName or app.id
1240     if appid is None or appid == "Ignore":
1241         return True
1242     return appid == package
1243
1244
1245 def parse_androidmanifests(paths, app):
1246     """
1247     Extract some information from the AndroidManifest.xml at the given path.
1248     Returns (version, vercode, package), any or all of which might be None.
1249     All values returned are strings.
1250     """
1251
1252     ignoreversions = app.UpdateCheckIgnore
1253     ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
1254
1255     if not paths:
1256         return (None, None, None)
1257
1258     max_version = None
1259     max_vercode = None
1260     max_package = None
1261
1262     for path in paths:
1263
1264         if not os.path.isfile(path):
1265             continue
1266
1267         logging.debug(_("Parsing manifest at '{path}'").format(path=path))
1268         version = None
1269         vercode = None
1270         package = None
1271
1272         if has_extension(path, 'gradle'):
1273             with open(path, 'r') as f:
1274                 for line in f:
1275                     if gradle_comment.match(line):
1276                         continue
1277                     # Grab first occurence of each to avoid running into
1278                     # alternative flavours and builds.
1279                     if not package:
1280                         matches = psearch_g(line)
1281                         if matches:
1282                             s = matches.group(2)
1283                             if app_matches_packagename(app, s):
1284                                 package = s
1285                     if not version:
1286                         matches = vnsearch_g(line)
1287                         if matches:
1288                             version = matches.group(2)
1289                     if not vercode:
1290                         matches = vcsearch_g(line)
1291                         if matches:
1292                             vercode = matches.group(1)
1293         else:
1294             try:
1295                 xml = parse_xml(path)
1296                 if "package" in xml.attrib:
1297                     s = xml.attrib["package"]
1298                     if app_matches_packagename(app, s):
1299                         package = s
1300                 if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1301                     version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"]
1302                     base_dir = os.path.dirname(path)
1303                     version = retrieve_string_singleline(base_dir, version)
1304                 if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1305                     a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"]
1306                     if string_is_integer(a):
1307                         vercode = a
1308             except Exception:
1309                 logging.warning(_("Problem with xml at '{path}'").format(path=path))
1310
1311         # Remember package name, may be defined separately from version+vercode
1312         if package is None:
1313             package = max_package
1314
1315         logging.debug("..got package={0}, version={1}, vercode={2}"
1316                       .format(package, version, vercode))
1317
1318         # Always grab the package name and version name in case they are not
1319         # together with the highest version code
1320         if max_package is None and package is not None:
1321             max_package = package
1322         if max_version is None and version is not None:
1323             max_version = version
1324
1325         if vercode is not None \
1326            and (max_vercode is None or vercode > max_vercode):
1327             if not ignoresearch or not ignoresearch(version):
1328                 if version is not None:
1329                     max_version = version
1330                 if vercode is not None:
1331                     max_vercode = vercode
1332                 if package is not None:
1333                     max_package = package
1334             else:
1335                 max_version = "Ignore"
1336
1337     if max_version is None:
1338         max_version = "Unknown"
1339
1340     if max_package and not is_valid_package_name(max_package):
1341         raise FDroidException(_("Invalid package name {0}").format(max_package))
1342
1343     return (max_version, max_vercode, max_package)
1344
1345
1346 def is_valid_package_name(name):
1347     return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1348
1349
1350 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1351               raw=False, prepare=True, preponly=False, refresh=True,
1352               build=None):
1353     """Get the specified source library.
1354
1355     Returns the path to it. Normally this is the path to be used when
1356     referencing it, which may be a subdirectory of the actual project. If
1357     you want the base directory of the project, pass 'basepath=True'.
1358
1359     """
1360     number = None
1361     subdir = None
1362     if raw:
1363         name = spec
1364         ref = None
1365     else:
1366         name, ref = spec.split('@')
1367         if ':' in name:
1368             number, name = name.split(':', 1)
1369         if '/' in name:
1370             name, subdir = name.split('/', 1)
1371
1372     if name not in fdroidserver.metadata.srclibs:
1373         raise VCSException('srclib ' + name + ' not found.')
1374
1375     srclib = fdroidserver.metadata.srclibs[name]
1376
1377     sdir = os.path.join(srclib_dir, name)
1378
1379     if not preponly:
1380         vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1381         vcs.srclib = (name, number, sdir)
1382         if ref:
1383             vcs.gotorevision(ref, refresh)
1384
1385         if raw:
1386             return vcs
1387
1388     libdir = None
1389     if subdir:
1390         libdir = os.path.join(sdir, subdir)
1391     elif srclib["Subdir"]:
1392         for subdir in srclib["Subdir"]:
1393             libdir_candidate = os.path.join(sdir, subdir)
1394             if os.path.exists(libdir_candidate):
1395                 libdir = libdir_candidate
1396                 break
1397
1398     if libdir is None:
1399         libdir = sdir
1400
1401     remove_signing_keys(sdir)
1402     remove_debuggable_flags(sdir)
1403
1404     if prepare:
1405
1406         if srclib["Prepare"]:
1407             cmd = replace_config_vars(srclib["Prepare"], build)
1408
1409             p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1410             if p.returncode != 0:
1411                 raise BuildException("Error running prepare command for srclib %s"
1412                                      % name, p.output)
1413
1414     if basepath:
1415         libdir = sdir
1416
1417     return (name, number, libdir)
1418
1419
1420 gradle_version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1421
1422
1423 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False, refresh=True):
1424     """ Prepare the source code for a particular build
1425
1426     :param vcs: the appropriate vcs object for the application
1427     :param app: the application details from the metadata
1428     :param build: the build details from the metadata
1429     :param build_dir: the path to the build directory, usually 'build/app.id'
1430     :param srclib_dir: the path to the source libraries directory, usually 'build/srclib'
1431     :param extlib_dir: the path to the external libraries directory, usually 'build/extlib'
1432
1433     Returns the (root, srclibpaths) where:
1434     :param root: is the root directory, which may be the same as 'build_dir' or may
1435                  be a subdirectory of it.
1436     :param srclibpaths: is information on the srclibs being used
1437     """
1438
1439     # Optionally, the actual app source can be in a subdirectory
1440     if build.subdir:
1441         root_dir = os.path.join(build_dir, build.subdir)
1442     else:
1443         root_dir = build_dir
1444
1445     # Get a working copy of the right revision
1446     logging.info("Getting source for revision " + build.commit)
1447     vcs.gotorevision(build.commit, refresh)
1448
1449     # Initialise submodules if required
1450     if build.submodules:
1451         logging.info(_("Initialising submodules"))
1452         vcs.initsubmodules()
1453
1454     # Check that a subdir (if we're using one) exists. This has to happen
1455     # after the checkout, since it might not exist elsewhere
1456     if not os.path.exists(root_dir):
1457         raise BuildException('Missing subdir ' + root_dir)
1458
1459     # Run an init command if one is required
1460     if build.init:
1461         cmd = replace_config_vars(build.init, build)
1462         logging.info("Running 'init' commands in %s" % root_dir)
1463
1464         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1465         if p.returncode != 0:
1466             raise BuildException("Error running init command for %s:%s" %
1467                                  (app.id, build.versionName), p.output)
1468
1469     # Apply patches if any
1470     if build.patch:
1471         logging.info("Applying patches")
1472         for patch in build.patch:
1473             patch = patch.strip()
1474             logging.info("Applying " + patch)
1475             patch_path = os.path.join('metadata', app.id, patch)
1476             p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1477             if p.returncode != 0:
1478                 raise BuildException("Failed to apply patch %s" % patch_path)
1479
1480     # Get required source libraries
1481     srclibpaths = []
1482     if build.srclibs:
1483         logging.info("Collecting source libraries")
1484         for lib in build.srclibs:
1485             srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver,
1486                                          refresh=refresh, build=build))
1487
1488     for name, number, libpath in srclibpaths:
1489         place_srclib(root_dir, int(number) if number else None, libpath)
1490
1491     basesrclib = vcs.getsrclib()
1492     # If one was used for the main source, add that too.
1493     if basesrclib:
1494         srclibpaths.append(basesrclib)
1495
1496     # Update the local.properties file
1497     localprops = [os.path.join(build_dir, 'local.properties')]
1498     if build.subdir:
1499         parts = build.subdir.split(os.sep)
1500         cur = build_dir
1501         for d in parts:
1502             cur = os.path.join(cur, d)
1503             localprops += [os.path.join(cur, 'local.properties')]
1504     for path in localprops:
1505         props = ""
1506         if os.path.isfile(path):
1507             logging.info("Updating local.properties file at %s" % path)
1508             with open(path, 'r', encoding='iso-8859-1') as f:
1509                 props += f.read()
1510             props += '\n'
1511         else:
1512             logging.info("Creating local.properties file at %s" % path)
1513         # Fix old-fashioned 'sdk-location' by copying
1514         # from sdk.dir, if necessary
1515         if build.oldsdkloc:
1516             sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1517                               re.S | re.M).group(1)
1518             props += "sdk-location=%s\n" % sdkloc
1519         else:
1520             props += "sdk.dir=%s\n" % config['sdk_path']
1521             props += "sdk-location=%s\n" % config['sdk_path']
1522         ndk_path = build.ndk_path()
1523         # if for any reason the path isn't valid or the directory
1524         # doesn't exist, some versions of Gradle will error with a
1525         # cryptic message (even if the NDK is not even necessary).
1526         # https://gitlab.com/fdroid/fdroidserver/issues/171
1527         if ndk_path and os.path.exists(ndk_path):
1528             # Add ndk location
1529             props += "ndk.dir=%s\n" % ndk_path
1530             props += "ndk-location=%s\n" % ndk_path
1531         # Add java.encoding if necessary
1532         if build.encoding:
1533             props += "java.encoding=%s\n" % build.encoding
1534         with open(path, 'w', encoding='iso-8859-1') as f:
1535             f.write(props)
1536
1537     flavours = []
1538     if build.build_method() == 'gradle':
1539         flavours = build.gradle
1540
1541         if build.target:
1542             n = build.target.split('-')[1]
1543             regsub_file(r'compileSdkVersion[ =]+[0-9]+',
1544                         r'compileSdkVersion %s' % n,
1545                         os.path.join(root_dir, 'build.gradle'))
1546
1547     # Remove forced debuggable flags
1548     remove_debuggable_flags(root_dir)
1549
1550     # Insert version code and number into the manifest if necessary
1551     if build.forceversion:
1552         logging.info("Changing the version name")
1553         for path in manifest_paths(root_dir, flavours):
1554             if not os.path.isfile(path):
1555                 continue
1556             if has_extension(path, 'xml'):
1557                 regsub_file(r'android:versionName="[^"]*"',
1558                             r'android:versionName="%s"' % build.versionName,
1559                             path)
1560             elif has_extension(path, 'gradle'):
1561                 regsub_file(r"""(\s*)versionName[\s'"=]+.*""",
1562                             r"""\1versionName '%s'""" % build.versionName,
1563                             path)
1564
1565     if build.forcevercode:
1566         logging.info("Changing the version code")
1567         for path in manifest_paths(root_dir, flavours):
1568             if not os.path.isfile(path):
1569                 continue
1570             if has_extension(path, 'xml'):
1571                 regsub_file(r'android:versionCode="[^"]*"',
1572                             r'android:versionCode="%s"' % build.versionCode,
1573                             path)
1574             elif has_extension(path, 'gradle'):
1575                 regsub_file(r'versionCode[ =]+[0-9]+',
1576                             r'versionCode %s' % build.versionCode,
1577                             path)
1578
1579     # Delete unwanted files
1580     if build.rm:
1581         logging.info(_("Removing specified files"))
1582         for part in getpaths(build_dir, build.rm):
1583             dest = os.path.join(build_dir, part)
1584             logging.info("Removing {0}".format(part))
1585             if os.path.lexists(dest):
1586                 # rmtree can only handle directories that are not symlinks, so catch anything else
1587                 if not os.path.isdir(dest) or os.path.islink(dest):
1588                     os.remove(dest)
1589                 else:
1590                     shutil.rmtree(dest)
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         _ignored, 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 info in in_apk.infolist():
2255                     if not apk_sigfile.match(info.filename):
2256                         if strip_manifest:
2257                             if info.filename != 'META-INF/MANIFEST.MF':
2258                                 buf = in_apk.read(info.filename)
2259                                 out_apk.writestr(info, buf)
2260                         else:
2261                             buf = in_apk.read(info.filename)
2262                             out_apk.writestr(info, 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         apkwithnewsig = os.path.join(tmpdir, 'newsig.apk')
2276         with ZipFile(apkpath, 'r') as in_apk:
2277             with ZipFile(apkwithnewsig, 'w') as out_apk:
2278                 for sig_file in [signaturefile, signedfile, manifest]:
2279                     with open(sig_file, 'rb') as fp:
2280                         buf = fp.read()
2281                     info = zipfile.ZipInfo('META-INF/' + os.path.basename(sig_file))
2282                     info.compress_type = zipfile.ZIP_DEFLATED
2283                     info.create_system = 0  # "Windows" aka "FAT", what Android SDK uses
2284                     out_apk.writestr(info, buf)
2285                 for info in in_apk.infolist():
2286                     if not apk_sigfile.match(info.filename):
2287                         if info.filename != 'META-INF/MANIFEST.MF':
2288                             buf = in_apk.read(info.filename)
2289                             out_apk.writestr(info, buf)
2290         os.remove(apkpath)
2291         p = SdkToolsPopen(['zipalign', '-v', '4', apkwithnewsig, apkpath])
2292         if p.returncode != 0:
2293             raise BuildException("Failed to align application")
2294
2295
2296 def apk_extract_signatures(apkpath, outdir, manifest=True):
2297     """Extracts a signature files from APK and puts them into target directory.
2298
2299     :param apkpath: location of the apk
2300     :param outdir: folder where the extracted signature files will be stored
2301     :param manifest: (optionally) disable extracting manifest file
2302     """
2303     with ZipFile(apkpath, 'r') as in_apk:
2304         for f in in_apk.infolist():
2305             if apk_sigfile.match(f.filename) or \
2306                     (manifest and f.filename == 'META-INF/MANIFEST.MF'):
2307                 newpath = os.path.join(outdir, os.path.basename(f.filename))
2308                 with open(newpath, 'wb') as out_file:
2309                     out_file.write(in_apk.read(f.filename))
2310
2311
2312 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
2313     """Verify that two apks are the same
2314
2315     One of the inputs is signed, the other is unsigned. The signature metadata
2316     is transferred from the signed to the unsigned apk, and then jarsigner is
2317     used to verify that the signature from the signed apk is also varlid for
2318     the unsigned one.  If the APK given as unsigned actually does have a
2319     signature, it will be stripped out and ignored.
2320
2321     There are two SHA1 git commit IDs that fdroidserver includes in the builds
2322     it makes: fdroidserverid and buildserverid.  Originally, these were inserted
2323     into AndroidManifest.xml, but that makes the build not reproducible. So
2324     instead they are included as separate files in the APK's META-INF/ folder.
2325     If those files exist in the signed APK, they will be part of the signature
2326     and need to also be included in the unsigned APK for it to validate.
2327
2328     :param signed_apk: Path to a signed apk file
2329     :param unsigned_apk: Path to an unsigned apk file expected to match it
2330     :param tmp_dir: Path to directory for temporary files
2331     :returns: None if the verification is successful, otherwise a string
2332               describing what went wrong.
2333     """
2334
2335     if not os.path.isfile(signed_apk):
2336         return 'can not verify: file does not exists: {}'.format(signed_apk)
2337
2338     if not os.path.isfile(unsigned_apk):
2339         return 'can not verify: file does not exists: {}'.format(unsigned_apk)
2340
2341     with ZipFile(signed_apk, 'r') as signed:
2342         meta_inf_files = ['META-INF/MANIFEST.MF']
2343         for f in signed.namelist():
2344             if apk_sigfile.match(f) \
2345                or f in ['META-INF/fdroidserverid', 'META-INF/buildserverid']:
2346                 meta_inf_files.append(f)
2347         if len(meta_inf_files) < 3:
2348             return "Signature files missing from {0}".format(signed_apk)
2349
2350         tmp_apk = os.path.join(tmp_dir, 'sigcp_' + os.path.basename(unsigned_apk))
2351         with ZipFile(unsigned_apk, 'r') as unsigned:
2352             # only read the signature from the signed APK, everything else from unsigned
2353             with ZipFile(tmp_apk, 'w') as tmp:
2354                 for filename in meta_inf_files:
2355                     tmp.writestr(signed.getinfo(filename), signed.read(filename))
2356                 for info in unsigned.infolist():
2357                     if info.filename in meta_inf_files:
2358                         logging.warning('Ignoring %s from %s',
2359                                         info.filename, unsigned_apk)
2360                         continue
2361                     if info.filename in tmp.namelist():
2362                         return "duplicate filename found: " + info.filename
2363                     tmp.writestr(info, unsigned.read(info.filename))
2364
2365     verified = verify_apk_signature(tmp_apk)
2366
2367     if not verified:
2368         logging.info("...NOT verified - {0}".format(tmp_apk))
2369         return compare_apks(signed_apk, tmp_apk, tmp_dir,
2370                             os.path.dirname(unsigned_apk))
2371
2372     logging.info("...successfully verified")
2373     return None
2374
2375
2376 def verify_jar_signature(jar):
2377     """Verifies the signature of a given JAR file.
2378
2379     jarsigner is very shitty: unsigned JARs pass as "verified"! So
2380     this has to turn on -strict then check for result 4, since this
2381     does not expect the signature to be from a CA-signed certificate.
2382
2383     :raises: VerificationException() if the JAR's signature could not be verified
2384
2385     """
2386
2387     if subprocess.call([config['jarsigner'], '-strict', '-verify', jar]) != 4:
2388         raise VerificationException(_("The repository's index could not be verified."))
2389
2390
2391 def verify_apk_signature(apk, min_sdk_version=None):
2392     """verify the signature on an APK
2393
2394     Try to use apksigner whenever possible since jarsigner is very
2395     shitty: unsigned APKs pass as "verified"!  Warning, this does
2396     not work on JARs with apksigner >= 0.7 (build-tools 26.0.1)
2397
2398     :returns: boolean whether the APK was verified
2399     """
2400     if set_command_in_config('apksigner'):
2401         args = [config['apksigner'], 'verify']
2402         if min_sdk_version:
2403             args += ['--min-sdk-version=' + min_sdk_version]
2404         return subprocess.call(args + [apk]) == 0
2405     else:
2406         logging.warning("Using Java's jarsigner, not recommended for verifying APKs! Use apksigner")
2407         try:
2408             verify_jar_signature(apk)
2409             return True
2410         except Exception:
2411             pass
2412     return False
2413
2414
2415 def verify_old_apk_signature(apk):
2416     """verify the signature on an archived APK, supporting deprecated algorithms
2417
2418     F-Droid aims to keep every single binary that it ever published.  Therefore,
2419     it needs to be able to verify APK signatures that include deprecated/removed
2420     algorithms.  For example, jarsigner treats an MD5 signature as unsigned.
2421
2422     jarsigner passes unsigned APKs as "verified"! So this has to turn
2423     on -strict then check for result 4.
2424
2425     :returns: boolean whether the APK was verified
2426     """
2427
2428     _java_security = os.path.join(os.getcwd(), '.java.security')
2429     with open(_java_security, 'w') as fp:
2430         fp.write('jdk.jar.disabledAlgorithms=MD2, RSA keySize < 1024')
2431
2432     return subprocess.call([config['jarsigner'], '-J-Djava.security.properties=' + _java_security,
2433                             '-strict', '-verify', apk]) == 4
2434
2435
2436 apk_badchars = re.compile('''[/ :;'"]''')
2437
2438
2439 def compare_apks(apk1, apk2, tmp_dir, log_dir=None):
2440     """Compare two apks
2441
2442     Returns None if the apk content is the same (apart from the signing key),
2443     otherwise a string describing what's different, or what went wrong when
2444     trying to do the comparison.
2445     """
2446
2447     if not log_dir:
2448         log_dir = tmp_dir
2449
2450     absapk1 = os.path.abspath(apk1)
2451     absapk2 = os.path.abspath(apk2)
2452
2453     if set_command_in_config('diffoscope'):
2454         logfilename = os.path.join(log_dir, os.path.basename(absapk1))
2455         htmlfile = logfilename + '.diffoscope.html'
2456         textfile = logfilename + '.diffoscope.txt'
2457         if subprocess.call([config['diffoscope'],
2458                             '--max-report-size', '12345678', '--max-diff-block-lines', '100',
2459                             '--html', htmlfile, '--text', textfile,
2460                             absapk1, absapk2]) != 0:
2461             return("Failed to unpack " + apk1)
2462
2463     apk1dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk1[0:-4]))  # trim .apk
2464     apk2dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk2[0:-4]))  # trim .apk
2465     for d in [apk1dir, apk2dir]:
2466         if os.path.exists(d):
2467             shutil.rmtree(d)
2468         os.mkdir(d)
2469         os.mkdir(os.path.join(d, 'jar-xf'))
2470
2471     if subprocess.call(['jar', 'xf',
2472                         os.path.abspath(apk1)],
2473                        cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
2474         return("Failed to unpack " + apk1)
2475     if subprocess.call(['jar', 'xf',
2476                         os.path.abspath(apk2)],
2477                        cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
2478         return("Failed to unpack " + apk2)
2479
2480     if set_command_in_config('apktool'):
2481         if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
2482                            cwd=apk1dir) != 0:
2483             return("Failed to unpack " + apk1)
2484         if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
2485                            cwd=apk2dir) != 0:
2486             return("Failed to unpack " + apk2)
2487
2488     p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
2489     lines = p.output.splitlines()
2490     if len(lines) != 1 or 'META-INF' not in lines[0]:
2491         if set_command_in_config('meld'):
2492             p = FDroidPopen([config['meld'], apk1dir, apk2dir], output=False)
2493         return("Unexpected diff output - " + p.output)
2494
2495     # since everything verifies, delete the comparison to keep cruft down
2496     shutil.rmtree(apk1dir)
2497     shutil.rmtree(apk2dir)
2498
2499     # If we get here, it seems like they're the same!
2500     return None
2501
2502
2503 def set_command_in_config(command):
2504     '''Try to find specified command in the path, if it hasn't been
2505     manually set in config.py.  If found, it is added to the config
2506     dict.  The return value says whether the command is available.
2507
2508     '''
2509     if command in config:
2510         return True
2511     else:
2512         tmp = find_command(command)
2513         if tmp is not None:
2514             config[command] = tmp
2515             return True
2516     return False
2517
2518
2519 def find_command(command):
2520     '''find the full path of a command, or None if it can't be found in the PATH'''
2521
2522     def is_exe(fpath):
2523         return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
2524
2525     fpath, fname = os.path.split(command)
2526     if fpath:
2527         if is_exe(command):
2528             return command
2529     else:
2530         for path in os.environ["PATH"].split(os.pathsep):
2531             path = path.strip('"')
2532             exe_file = os.path.join(path, command)
2533             if is_exe(exe_file):
2534                 return exe_file
2535
2536     return None
2537
2538
2539 def genpassword():
2540     '''generate a random password for when generating keys'''
2541     h = hashlib.sha256()
2542     h.update(os.urandom(16))  # salt
2543     h.update(socket.getfqdn().encode('utf-8'))
2544     passwd = base64.b64encode(h.digest()).strip()
2545     return passwd.decode('utf-8')
2546
2547
2548 def genkeystore(localconfig):
2549     """
2550     Generate a new key with password provided in :param localconfig and add it to new keystore
2551     :return: hexed public key, public key fingerprint
2552     """
2553     logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
2554     keystoredir = os.path.dirname(localconfig['keystore'])
2555     if keystoredir is None or keystoredir == '':
2556         keystoredir = os.path.join(os.getcwd(), keystoredir)
2557     if not os.path.exists(keystoredir):
2558         os.makedirs(keystoredir, mode=0o700)
2559
2560     env_vars = {
2561         'FDROID_KEY_STORE_PASS': localconfig['keystorepass'],
2562         'FDROID_KEY_PASS': localconfig['keypass'],
2563     }
2564     p = FDroidPopen([config['keytool'], '-genkey',
2565                      '-keystore', localconfig['keystore'],
2566                      '-alias', localconfig['repo_keyalias'],
2567                      '-keyalg', 'RSA', '-keysize', '4096',
2568                      '-sigalg', 'SHA256withRSA',
2569                      '-validity', '10000',
2570                      '-storepass:env', 'FDROID_KEY_STORE_PASS',
2571                      '-keypass:env', 'FDROID_KEY_PASS',
2572                      '-dname', localconfig['keydname']], envs=env_vars)
2573     if p.returncode != 0:
2574         raise BuildException("Failed to generate key", p.output)
2575     os.chmod(localconfig['keystore'], 0o0600)
2576     if not options.quiet:
2577         # now show the lovely key that was just generated
2578         p = FDroidPopen([config['keytool'], '-list', '-v',
2579                          '-keystore', localconfig['keystore'],
2580                          '-alias', localconfig['repo_keyalias'],
2581                          '-storepass:env', 'FDROID_KEY_STORE_PASS'], envs=env_vars)
2582         logging.info(p.output.strip() + '\n\n')
2583     # get the public key
2584     p = FDroidPopenBytes([config['keytool'], '-exportcert',
2585                           '-keystore', localconfig['keystore'],
2586                           '-alias', localconfig['repo_keyalias'],
2587                           '-storepass:env', 'FDROID_KEY_STORE_PASS']
2588                          + config['smartcardoptions'],
2589                          envs=env_vars, output=False, stderr_to_stdout=False)
2590     if p.returncode != 0 or len(p.output) < 20:
2591         raise BuildException("Failed to get public key", p.output)
2592     pubkey = p.output
2593     fingerprint = get_cert_fingerprint(pubkey)
2594     return hexlify(pubkey), fingerprint
2595
2596
2597 def get_cert_fingerprint(pubkey):
2598     """
2599     Generate a certificate fingerprint the same way keytool does it
2600     (but with slightly different formatting)
2601     """
2602     digest = hashlib.sha256(pubkey).digest()
2603     ret = [' '.join("%02X" % b for b in bytearray(digest))]
2604     return " ".join(ret)
2605
2606
2607 def get_certificate(certificate_file):
2608     """
2609     Extracts a certificate from the given file.
2610     :param certificate_file: file bytes (as string) representing the certificate
2611     :return: A binary representation of the certificate's public key, or None in case of error
2612     """
2613     content = decoder.decode(certificate_file, asn1Spec=rfc2315.ContentInfo())[0]
2614     if content.getComponentByName('contentType') != rfc2315.signedData:
2615         return None
2616     content = decoder.decode(content.getComponentByName('content'),
2617                              asn1Spec=rfc2315.SignedData())[0]
2618     try:
2619         certificates = content.getComponentByName('certificates')
2620         cert = certificates[0].getComponentByName('certificate')
2621     except PyAsn1Error:
2622         logging.error("Certificates not found.")
2623         return None
2624     return encoder.encode(cert)
2625
2626
2627 def load_stats_fdroid_signing_key_fingerprints():
2628     """Load list of signing-key fingerprints stored by fdroid publish from file.
2629
2630     :returns: list of dictionanryies containing the singing-key fingerprints.
2631     """
2632     jar_file = os.path.join('stats', 'publishsigkeys.jar')
2633     if not os.path.isfile(jar_file):
2634         return {}
2635     cmd = [config['jarsigner'], '-strict', '-verify', jar_file]
2636     p = FDroidPopen(cmd, output=False)
2637     if p.returncode != 4:
2638         raise FDroidException("Signature validation of '{}' failed! "
2639                               "Please run publish again to rebuild this file.".format(jar_file))
2640
2641     jar_sigkey = apk_signer_fingerprint(jar_file)
2642     repo_key_sig = config.get('repo_key_sha256')
2643     if repo_key_sig:
2644         if jar_sigkey != repo_key_sig:
2645             raise FDroidException("Signature key fingerprint of file '{}' does not match repo_key_sha256 in config.py (found fingerprint: '{}')".format(jar_file, jar_sigkey))
2646     else:
2647         logging.warning("repo_key_sha256 not in config.py, setting it to the signature key fingerprint of '{}'".format(jar_file))
2648         config['repo_key_sha256'] = jar_sigkey
2649         write_to_config(config, 'repo_key_sha256')
2650
2651     with zipfile.ZipFile(jar_file, 'r') as f:
2652         return json.loads(str(f.read('publishsigkeys.json'), 'utf-8'))
2653
2654
2655 def write_to_config(thisconfig, key, value=None, config_file=None):
2656     '''write a key/value to the local config.py
2657
2658     NOTE: only supports writing string variables.
2659
2660     :param thisconfig: config dictionary
2661     :param key: variable name in config.py to be overwritten/added
2662     :param value: optional value to be written, instead of fetched
2663         from 'thisconfig' dictionary.
2664     '''
2665     if value is None:
2666         origkey = key + '_orig'
2667         value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
2668     cfg = config_file if config_file else 'config.py'
2669
2670     # load config file, create one if it doesn't exist
2671     if not os.path.exists(cfg):
2672         open(cfg, 'a').close()
2673         logging.info("Creating empty " + cfg)
2674     with open(cfg, 'r', encoding="utf-8") as f:
2675         lines = f.readlines()
2676
2677     # make sure the file ends with a carraige return
2678     if len(lines) > 0:
2679         if not lines[-1].endswith('\n'):
2680             lines[-1] += '\n'
2681
2682     # regex for finding and replacing python string variable
2683     # definitions/initializations
2684     pattern = re.compile('^[\s#]*' + key + '\s*=\s*"[^"]*"')
2685     repl = key + ' = "' + value + '"'
2686     pattern2 = re.compile('^[\s#]*' + key + "\s*=\s*'[^']*'")
2687     repl2 = key + " = '" + value + "'"
2688
2689     # If we replaced this line once, we make sure won't be a
2690     # second instance of this line for this key in the document.
2691     didRepl = False
2692     # edit config file
2693     with open(cfg, 'w', encoding="utf-8") as f:
2694         for line in lines:
2695             if pattern.match(line) or pattern2.match(line):
2696                 if not didRepl:
2697                     line = pattern.sub(repl, line)
2698                     line = pattern2.sub(repl2, line)
2699                     f.write(line)
2700                     didRepl = True
2701             else:
2702                 f.write(line)
2703         if not didRepl:
2704             f.write('\n')
2705             f.write(repl)
2706             f.write('\n')
2707
2708
2709 def parse_xml(path):
2710     return XMLElementTree.parse(path).getroot()
2711
2712
2713 def string_is_integer(string):
2714     try:
2715         int(string)
2716         return True
2717     except ValueError:
2718         return False
2719
2720
2721 def local_rsync(options, fromdir, todir):
2722     '''Rsync method for local to local copying of things
2723
2724     This is an rsync wrapper with all the settings for safe use within
2725     the various fdroidserver use cases. This uses stricter rsync
2726     checking on all files since people using offline mode are already
2727     prioritizing security above ease and speed.
2728
2729     '''
2730     rsyncargs = ['rsync', '--recursive', '--safe-links', '--times', '--perms',
2731                  '--one-file-system', '--delete', '--chmod=Da+rx,Fa-x,a+r,u+w']
2732     if not options.no_checksum:
2733         rsyncargs.append('--checksum')
2734     if options.verbose:
2735         rsyncargs += ['--verbose']
2736     if options.quiet:
2737         rsyncargs += ['--quiet']
2738     logging.debug(' '.join(rsyncargs + [fromdir, todir]))
2739     if subprocess.call(rsyncargs + [fromdir, todir]) != 0:
2740         raise FDroidException()
2741
2742
2743 def get_per_app_repos():
2744     '''per-app repos are dirs named with the packageName of a single app'''
2745
2746     # Android packageNames are Java packages, they may contain uppercase or
2747     # lowercase letters ('A' through 'Z'), numbers, and underscores
2748     # ('_'). However, individual package name parts may only start with
2749     # letters. https://developer.android.com/guide/topics/manifest/manifest-element.html#package
2750     p = re.compile('^([a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*)?$')
2751
2752     repos = []
2753     for root, dirs, files in os.walk(os.getcwd()):
2754         for d in dirs:
2755             print('checking', root, 'for', d)
2756             if d in ('archive', 'metadata', 'repo', 'srclibs', 'tmp'):
2757                 # standard parts of an fdroid repo, so never packageNames
2758                 continue
2759             elif p.match(d) \
2760                     and os.path.exists(os.path.join(d, 'fdroid', 'repo', 'index.jar')):
2761                 repos.append(d)
2762         break
2763     return repos
2764
2765
2766 def is_repo_file(filename):
2767     '''Whether the file in a repo is a build product to be delivered to users'''
2768     if isinstance(filename, str):
2769         filename = filename.encode('utf-8', errors="surrogateescape")
2770     return os.path.isfile(filename) \
2771         and not filename.endswith(b'.asc') \
2772         and not filename.endswith(b'.sig') \
2773         and os.path.basename(filename) not in [
2774             b'index.jar',
2775             b'index_unsigned.jar',
2776             b'index.xml',
2777             b'index.html',
2778             b'index-v1.jar',
2779             b'index-v1.json',
2780             b'categories.txt',
2781         ]
2782
2783
2784 def get_examples_dir():
2785     '''Return the dir where the fdroidserver example files are available'''
2786     examplesdir = None
2787     tmp = os.path.dirname(sys.argv[0])
2788     if os.path.basename(tmp) == 'bin':
2789         egg_links = glob.glob(os.path.join(tmp, '..',
2790                                            'local/lib/python3.*/site-packages/fdroidserver.egg-link'))
2791         if egg_links:
2792             # installed from local git repo
2793             examplesdir = os.path.join(open(egg_links[0]).readline().rstrip(), 'examples')
2794         else:
2795             # try .egg layout
2796             examplesdir = os.path.dirname(os.path.dirname(__file__)) + '/share/doc/fdroidserver/examples'
2797             if not os.path.exists(examplesdir):  # use UNIX layout
2798                 examplesdir = os.path.dirname(tmp) + '/share/doc/fdroidserver/examples'
2799     else:
2800         # we're running straight out of the git repo
2801         prefix = os.path.normpath(os.path.join(os.path.dirname(__file__), '..'))
2802         examplesdir = prefix + '/examples'
2803
2804     return examplesdir