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