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