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