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