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