chiark / gitweb /
buildserver: remove Qt installer, its huge, outdated, and being replaced
[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         'r16': 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], cwd=self.local, output=False)
886                     if p2.returncode != 0:
887                         raise VCSException(_("Git remote set-head failed"), p.output + '\n' + p2.output)
888                 self.refreshed = True
889         # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
890         # a github repo. Most of the time this is the same as origin/master.
891         rev = rev or 'origin/HEAD'
892         p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
893         if p.returncode != 0:
894             raise VCSException(_("Git checkout of '%s' failed") % rev, p.output)
895         # Get rid of any uncontrolled files left behind
896         p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
897         if p.returncode != 0:
898             raise VCSException(_("Git clean failed"), p.output)
899
900     def initsubmodules(self):
901         self.checkrepo()
902         submfile = os.path.join(self.local, '.gitmodules')
903         if not os.path.isfile(submfile):
904             raise NoSubmodulesException(_("No git submodules available"))
905
906         # fix submodules not accessible without an account and public key auth
907         with open(submfile, 'r') as f:
908             lines = f.readlines()
909         with open(submfile, 'w') as f:
910             for line in lines:
911                 for domain in ('bitbucket.org', 'github.com', 'gitlab.com'):
912                     line = re.sub('git@' + domain + ':', 'https://u:p@' + domain + '/', line)
913                 f.write(line)
914
915         p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
916         if p.returncode != 0:
917             raise VCSException(_("Git submodule sync failed"), p.output)
918         p = self.git(['submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
919         if p.returncode != 0:
920             raise VCSException(_("Git submodule update failed"), p.output)
921
922     def _gettags(self):
923         self.checkrepo()
924         p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
925         return p.output.splitlines()
926
927     tag_format = re.compile(r'tag: ([^),]*)')
928
929     def latesttags(self):
930         self.checkrepo()
931         p = FDroidPopen(['git', 'log', '--tags',
932                          '--simplify-by-decoration', '--pretty=format:%d'],
933                         cwd=self.local, output=False)
934         tags = []
935         for line in p.output.splitlines():
936             for tag in self.tag_format.findall(line):
937                 tags.append(tag)
938         return tags
939
940
941 class vcs_gitsvn(vcs):
942
943     def repotype(self):
944         return 'git-svn'
945
946     def clientversioncmd(self):
947         return ['git', 'svn', '--version']
948
949     def checkrepo(self):
950         """If the local directory exists, but is somehow not a git repository,
951         git will traverse up the directory tree until it finds one that
952         is (i.e.  fdroidserver) and then we'll proceed to destory it!
953         This is called as a safety check.
954
955         """
956         p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
957         result = p.output.rstrip()
958         if not result.endswith(self.local):
959             raise VCSException('Repository mismatch')
960
961     def git(self, args, envs=dict(), cwd=None, output=True):
962         '''Prevent git fetch/clone/submodule from hanging at the username/password prompt
963         '''
964         # CVE-2017-1000117 block all SSH URLs (supported in git >= 2.3)
965         config = ['-c', 'core.sshCommand=false']
966         envs.update({
967             'GIT_TERMINAL_PROMPT': '0',
968             'GIT_SSH': 'false',  # for git < 2.3
969             'SVN_SSH': 'false',
970         })
971         return FDroidPopen(['git', ] + config + args,
972                            envs=envs, cwd=cwd, output=output)
973
974     def gotorevisionx(self, rev):
975         if not os.path.exists(self.local):
976             # Brand new checkout
977             gitsvn_args = ['svn', 'clone']
978             if ';' in self.remote:
979                 remote_split = self.remote.split(';')
980                 for i in remote_split[1:]:
981                     if i.startswith('trunk='):
982                         gitsvn_args.extend(['-T', i[6:]])
983                     elif i.startswith('tags='):
984                         gitsvn_args.extend(['-t', i[5:]])
985                     elif i.startswith('branches='):
986                         gitsvn_args.extend(['-b', i[9:]])
987                 gitsvn_args.extend([remote_split[0], self.local])
988                 p = self.git(gitsvn_args, output=False)
989                 if p.returncode != 0:
990                     self.clone_failed = True
991                     raise VCSException("Git svn clone failed", p.output)
992             else:
993                 gitsvn_args.extend([self.remote, self.local])
994                 p = self.git(gitsvn_args, output=False)
995                 if p.returncode != 0:
996                     self.clone_failed = True
997                     raise VCSException("Git svn clone failed", p.output)
998             self.checkrepo()
999         else:
1000             self.checkrepo()
1001             # Discard any working tree changes
1002             p = self.git(['reset', '--hard'], cwd=self.local, output=False)
1003             if p.returncode != 0:
1004                 raise VCSException("Git reset failed", p.output)
1005             # Remove untracked files now, in case they're tracked in the target
1006             # revision (it happens!)
1007             p = self.git(['clean', '-dffx'], cwd=self.local, output=False)
1008             if p.returncode != 0:
1009                 raise VCSException("Git clean failed", p.output)
1010             if not self.refreshed:
1011                 # Get new commits, branches and tags from repo
1012                 p = self.git(['svn', 'fetch'], cwd=self.local, output=False)
1013                 if p.returncode != 0:
1014                     raise VCSException("Git svn fetch failed")
1015                 p = self.git(['svn', 'rebase'], cwd=self.local, output=False)
1016                 if p.returncode != 0:
1017                     raise VCSException("Git svn rebase failed", p.output)
1018                 self.refreshed = True
1019
1020         rev = rev or 'master'
1021         if rev:
1022             nospaces_rev = rev.replace(' ', '%20')
1023             # Try finding a svn tag
1024             for treeish in ['origin/', '']:
1025                 p = self.git(['checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
1026                 if p.returncode == 0:
1027                     break
1028             if p.returncode != 0:
1029                 # No tag found, normal svn rev translation
1030                 # Translate svn rev into git format
1031                 rev_split = rev.split('/')
1032
1033                 p = None
1034                 for treeish in ['origin/', '']:
1035                     if len(rev_split) > 1:
1036                         treeish += rev_split[0]
1037                         svn_rev = rev_split[1]
1038
1039                     else:
1040                         # if no branch is specified, then assume trunk (i.e. 'master' branch):
1041                         treeish += 'master'
1042                         svn_rev = rev
1043
1044                     svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
1045
1046                     p = self.git(['svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
1047                     git_rev = p.output.rstrip()
1048
1049                     if p.returncode == 0 and git_rev:
1050                         break
1051
1052                 if p.returncode != 0 or not git_rev:
1053                     # Try a plain git checkout as a last resort
1054                     p = self.git(['checkout', rev], cwd=self.local, output=False)
1055                     if p.returncode != 0:
1056                         raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
1057                 else:
1058                     # Check out the git rev equivalent to the svn rev
1059                     p = self.git(['checkout', git_rev], cwd=self.local, output=False)
1060                     if p.returncode != 0:
1061                         raise VCSException(_("Git checkout of '%s' failed") % rev, p.output)
1062
1063         # Get rid of any uncontrolled files left behind
1064         p = self.git(['clean', '-dffx'], cwd=self.local, output=False)
1065         if p.returncode != 0:
1066             raise VCSException(_("Git clean failed"), p.output)
1067
1068     def _gettags(self):
1069         self.checkrepo()
1070         for treeish in ['origin/', '']:
1071             d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
1072             if os.path.isdir(d):
1073                 return os.listdir(d)
1074
1075     def getref(self):
1076         self.checkrepo()
1077         p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
1078         if p.returncode != 0:
1079             return None
1080         return p.output.strip()
1081
1082
1083 class vcs_hg(vcs):
1084
1085     def repotype(self):
1086         return 'hg'
1087
1088     def clientversioncmd(self):
1089         return ['hg', '--version']
1090
1091     def gotorevisionx(self, rev):
1092         if not os.path.exists(self.local):
1093             p = FDroidPopen(['hg', 'clone', '--ssh', 'false', self.remote, self.local], output=False)
1094             if p.returncode != 0:
1095                 self.clone_failed = True
1096                 raise VCSException("Hg clone failed", p.output)
1097         else:
1098             p = FDroidPopen(['hg', 'status', '-uS'], cwd=self.local, output=False)
1099             if p.returncode != 0:
1100                 raise VCSException("Hg status failed", p.output)
1101             for line in p.output.splitlines():
1102                 if not line.startswith('? '):
1103                     raise VCSException("Unexpected output from hg status -uS: " + line)
1104                 FDroidPopen(['rm', '-rf', line[2:]], cwd=self.local, output=False)
1105             if not self.refreshed:
1106                 p = FDroidPopen(['hg', 'pull', '--ssh', 'false'], cwd=self.local, output=False)
1107                 if p.returncode != 0:
1108                     raise VCSException("Hg pull failed", p.output)
1109                 self.refreshed = True
1110
1111         rev = rev or 'default'
1112         if not rev:
1113             return
1114         p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
1115         if p.returncode != 0:
1116             raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
1117         p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
1118         # Also delete untracked files, we have to enable purge extension for that:
1119         if "'purge' is provided by the following extension" in p.output:
1120             with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
1121                 myfile.write("\n[extensions]\nhgext.purge=\n")
1122             p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
1123             if p.returncode != 0:
1124                 raise VCSException("HG purge failed", p.output)
1125         elif p.returncode != 0:
1126             raise VCSException("HG purge failed", p.output)
1127
1128     def _gettags(self):
1129         p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
1130         return p.output.splitlines()[1:]
1131
1132
1133 class vcs_bzr(vcs):
1134
1135     def repotype(self):
1136         return 'bzr'
1137
1138     def clientversioncmd(self):
1139         return ['bzr', '--version']
1140
1141     def bzr(self, args, envs=dict(), cwd=None, output=True):
1142         '''Prevent bzr from ever using SSH to avoid security vulns'''
1143         envs.update({
1144             'BZR_SSH': 'false',
1145         })
1146         return FDroidPopen(['bzr', ] + args, envs=envs, cwd=cwd, output=output)
1147
1148     def gotorevisionx(self, rev):
1149         if not os.path.exists(self.local):
1150             p = self.bzr(['branch', self.remote, self.local], output=False)
1151             if p.returncode != 0:
1152                 self.clone_failed = True
1153                 raise VCSException("Bzr branch failed", p.output)
1154         else:
1155             p = self.bzr(['clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
1156             if p.returncode != 0:
1157                 raise VCSException("Bzr revert failed", p.output)
1158             if not self.refreshed:
1159                 p = self.bzr(['pull'], cwd=self.local, output=False)
1160                 if p.returncode != 0:
1161                     raise VCSException("Bzr update failed", p.output)
1162                 self.refreshed = True
1163
1164         revargs = list(['-r', rev] if rev else [])
1165         p = self.bzr(['revert'] + revargs, cwd=self.local, output=False)
1166         if p.returncode != 0:
1167             raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
1168
1169     def _gettags(self):
1170         p = self.bzr(['tags'], cwd=self.local, output=False)
1171         return [tag.split('   ')[0].strip() for tag in
1172                 p.output.splitlines()]
1173
1174
1175 def unescape_string(string):
1176     if len(string) < 2:
1177         return string
1178     if string[0] == '"' and string[-1] == '"':
1179         return string[1:-1]
1180
1181     return string.replace("\\'", "'")
1182
1183
1184 def retrieve_string(app_dir, string, xmlfiles=None):
1185
1186     if not string.startswith('@string/'):
1187         return unescape_string(string)
1188
1189     if xmlfiles is None:
1190         xmlfiles = []
1191         for res_dir in [
1192             os.path.join(app_dir, 'res'),
1193             os.path.join(app_dir, 'src', 'main', 'res'),
1194         ]:
1195             for root, dirs, files in os.walk(res_dir):
1196                 if os.path.basename(root) == 'values':
1197                     xmlfiles += [os.path.join(root, x) for x in files if x.endswith('.xml')]
1198
1199     name = string[len('@string/'):]
1200
1201     def element_content(element):
1202         if element.text is None:
1203             return ""
1204         s = XMLElementTree.tostring(element, encoding='utf-8', method='text')
1205         return s.decode('utf-8').strip()
1206
1207     for path in xmlfiles:
1208         if not os.path.isfile(path):
1209             continue
1210         xml = parse_xml(path)
1211         element = xml.find('string[@name="' + name + '"]')
1212         if element is not None:
1213             content = element_content(element)
1214             return retrieve_string(app_dir, content, xmlfiles)
1215
1216     return ''
1217
1218
1219 def retrieve_string_singleline(app_dir, string, xmlfiles=None):
1220     return retrieve_string(app_dir, string, xmlfiles).replace('\n', ' ').strip()
1221
1222
1223 def manifest_paths(app_dir, flavours):
1224     '''Return list of existing files that will be used to find the highest vercode'''
1225
1226     possible_manifests = \
1227         [os.path.join(app_dir, 'AndroidManifest.xml'),
1228          os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
1229          os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
1230          os.path.join(app_dir, 'build.gradle')]
1231
1232     for flavour in flavours:
1233         if flavour == 'yes':
1234             continue
1235         possible_manifests.append(
1236             os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
1237
1238     return [path for path in possible_manifests if os.path.isfile(path)]
1239
1240
1241 def fetch_real_name(app_dir, flavours):
1242     '''Retrieve the package name. Returns the name, or None if not found.'''
1243     for path in manifest_paths(app_dir, flavours):
1244         if not has_extension(path, 'xml') or not os.path.isfile(path):
1245             continue
1246         logging.debug("fetch_real_name: Checking manifest at " + path)
1247         xml = parse_xml(path)
1248         app = xml.find('application')
1249         if app is None:
1250             continue
1251         if "{http://schemas.android.com/apk/res/android}label" not in app.attrib:
1252             continue
1253         label = app.attrib["{http://schemas.android.com/apk/res/android}label"]
1254         result = retrieve_string_singleline(app_dir, label)
1255         if result:
1256             result = result.strip()
1257         return result
1258     return None
1259
1260
1261 def get_library_references(root_dir):
1262     libraries = []
1263     proppath = os.path.join(root_dir, 'project.properties')
1264     if not os.path.isfile(proppath):
1265         return libraries
1266     with open(proppath, 'r', encoding='iso-8859-1') as f:
1267         for line in f:
1268             if not line.startswith('android.library.reference.'):
1269                 continue
1270             path = line.split('=')[1].strip()
1271             relpath = os.path.join(root_dir, path)
1272             if not os.path.isdir(relpath):
1273                 continue
1274             logging.debug("Found subproject at %s" % path)
1275             libraries.append(path)
1276     return libraries
1277
1278
1279 def ant_subprojects(root_dir):
1280     subprojects = get_library_references(root_dir)
1281     for subpath in subprojects:
1282         subrelpath = os.path.join(root_dir, subpath)
1283         for p in get_library_references(subrelpath):
1284             relp = os.path.normpath(os.path.join(subpath, p))
1285             if relp not in subprojects:
1286                 subprojects.insert(0, relp)
1287     return subprojects
1288
1289
1290 def remove_debuggable_flags(root_dir):
1291     # Remove forced debuggable flags
1292     logging.debug("Removing debuggable flags from %s" % root_dir)
1293     for root, dirs, files in os.walk(root_dir):
1294         if 'AndroidManifest.xml' in files and os.path.isfile(os.path.join(root, 'AndroidManifest.xml')):
1295             regsub_file(r'android:debuggable="[^"]*"',
1296                         '',
1297                         os.path.join(root, 'AndroidManifest.xml'))
1298
1299
1300 vcsearch_g = re.compile(r'''.*[Vv]ersionCode[ =]+["']*([0-9]+)["']*''').search
1301 vnsearch_g = re.compile(r'.*[Vv]ersionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
1302 psearch_g = re.compile(r'.*(packageName|applicationId) *=* *["\']([^"]+)["\'].*').search
1303
1304
1305 def app_matches_packagename(app, package):
1306     if not package:
1307         return False
1308     appid = app.UpdateCheckName or app.id
1309     if appid is None or appid == "Ignore":
1310         return True
1311     return appid == package
1312
1313
1314 def parse_androidmanifests(paths, app):
1315     """
1316     Extract some information from the AndroidManifest.xml at the given path.
1317     Returns (version, vercode, package), any or all of which might be None.
1318     All values returned are strings.
1319     """
1320
1321     ignoreversions = app.UpdateCheckIgnore
1322     ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
1323
1324     if not paths:
1325         return (None, None, None)
1326
1327     max_version = None
1328     max_vercode = None
1329     max_package = None
1330
1331     for path in paths:
1332
1333         if not os.path.isfile(path):
1334             continue
1335
1336         logging.debug(_("Parsing manifest at '{path}'").format(path=path))
1337         version = None
1338         vercode = None
1339         package = None
1340
1341         flavour = None
1342         if app.builds and 'gradle' in app.builds[-1] and app.builds[-1].gradle:
1343             flavour = app.builds[-1].gradle[-1]
1344
1345         if has_extension(path, 'gradle'):
1346             with open(path, 'r') as f:
1347                 inside_flavour_group = 0
1348                 inside_required_flavour = 0
1349                 for line in f:
1350                     if gradle_comment.match(line):
1351                         continue
1352
1353                     if inside_flavour_group > 0:
1354                         if inside_required_flavour > 0:
1355                             matches = psearch_g(line)
1356                             if matches:
1357                                 s = matches.group(2)
1358                                 if app_matches_packagename(app, s):
1359                                     package = s
1360
1361                             matches = vnsearch_g(line)
1362                             if matches:
1363                                 version = matches.group(2)
1364
1365                             matches = vcsearch_g(line)
1366                             if matches:
1367                                 vercode = matches.group(1)
1368
1369                             if '{' in line:
1370                                 inside_required_flavour += 1
1371                             if '}' in line:
1372                                 inside_required_flavour -= 1
1373                         else:
1374                             if flavour and (flavour in line):
1375                                 inside_required_flavour = 1
1376
1377                         if '{' in line:
1378                             inside_flavour_group += 1
1379                         if '}' in line:
1380                             inside_flavour_group -= 1
1381                     else:
1382                         if "productFlavors" in line:
1383                             inside_flavour_group = 1
1384                         if not package:
1385                             matches = psearch_g(line)
1386                             if matches:
1387                                 s = matches.group(2)
1388                                 if app_matches_packagename(app, s):
1389                                     package = s
1390                         if not version:
1391                             matches = vnsearch_g(line)
1392                             if matches:
1393                                 version = matches.group(2)
1394                         if not vercode:
1395                             matches = vcsearch_g(line)
1396                             if matches:
1397                                 vercode = matches.group(1)
1398         else:
1399             try:
1400                 xml = parse_xml(path)
1401                 if "package" in xml.attrib:
1402                     s = xml.attrib["package"]
1403                     if app_matches_packagename(app, s):
1404                         package = s
1405                 if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1406                     version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"]
1407                     base_dir = os.path.dirname(path)
1408                     version = retrieve_string_singleline(base_dir, version)
1409                 if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1410                     a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"]
1411                     if string_is_integer(a):
1412                         vercode = a
1413             except Exception:
1414                 logging.warning(_("Problem with xml at '{path}'").format(path=path))
1415
1416         # Remember package name, may be defined separately from version+vercode
1417         if package is None:
1418             package = max_package
1419
1420         logging.debug("..got package={0}, version={1}, vercode={2}"
1421                       .format(package, version, vercode))
1422
1423         # Always grab the package name and version name in case they are not
1424         # together with the highest version code
1425         if max_package is None and package is not None:
1426             max_package = package
1427         if max_version is None and version is not None:
1428             max_version = version
1429
1430         if vercode is not None \
1431            and (max_vercode is None or vercode > max_vercode):
1432             if not ignoresearch or not ignoresearch(version):
1433                 if version is not None:
1434                     max_version = version
1435                 if vercode is not None:
1436                     max_vercode = vercode
1437                 if package is not None:
1438                     max_package = package
1439             else:
1440                 max_version = "Ignore"
1441
1442     if max_version is None:
1443         max_version = "Unknown"
1444
1445     if max_package and not is_valid_package_name(max_package):
1446         raise FDroidException(_("Invalid package name {0}").format(max_package))
1447
1448     return (max_version, max_vercode, max_package)
1449
1450
1451 def is_valid_package_name(name):
1452     return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1453
1454
1455 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1456               raw=False, prepare=True, preponly=False, refresh=True,
1457               build=None):
1458     """Get the specified source library.
1459
1460     Returns the path to it. Normally this is the path to be used when
1461     referencing it, which may be a subdirectory of the actual project. If
1462     you want the base directory of the project, pass 'basepath=True'.
1463
1464     """
1465     number = None
1466     subdir = None
1467     if raw:
1468         name = spec
1469         ref = None
1470     else:
1471         name, ref = spec.split('@')
1472         if ':' in name:
1473             number, name = name.split(':', 1)
1474         if '/' in name:
1475             name, subdir = name.split('/', 1)
1476
1477     if name not in fdroidserver.metadata.srclibs:
1478         raise VCSException('srclib ' + name + ' not found.')
1479
1480     srclib = fdroidserver.metadata.srclibs[name]
1481
1482     sdir = os.path.join(srclib_dir, name)
1483
1484     if not preponly:
1485         vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1486         vcs.srclib = (name, number, sdir)
1487         if ref:
1488             vcs.gotorevision(ref, refresh)
1489
1490         if raw:
1491             return vcs
1492
1493     libdir = None
1494     if subdir:
1495         libdir = os.path.join(sdir, subdir)
1496     elif srclib["Subdir"]:
1497         for subdir in srclib["Subdir"]:
1498             libdir_candidate = os.path.join(sdir, subdir)
1499             if os.path.exists(libdir_candidate):
1500                 libdir = libdir_candidate
1501                 break
1502
1503     if libdir is None:
1504         libdir = sdir
1505
1506     remove_signing_keys(sdir)
1507     remove_debuggable_flags(sdir)
1508
1509     if prepare:
1510
1511         if srclib["Prepare"]:
1512             cmd = replace_config_vars(srclib["Prepare"], build)
1513
1514             p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1515             if p.returncode != 0:
1516                 raise BuildException("Error running prepare command for srclib %s"
1517                                      % name, p.output)
1518
1519     if basepath:
1520         libdir = sdir
1521
1522     return (name, number, libdir)
1523
1524
1525 gradle_version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1526
1527
1528 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False, refresh=True):
1529     """ Prepare the source code for a particular build
1530
1531     :param vcs: the appropriate vcs object for the application
1532     :param app: the application details from the metadata
1533     :param build: the build details from the metadata
1534     :param build_dir: the path to the build directory, usually 'build/app.id'
1535     :param srclib_dir: the path to the source libraries directory, usually 'build/srclib'
1536     :param extlib_dir: the path to the external libraries directory, usually 'build/extlib'
1537
1538     Returns the (root, srclibpaths) where:
1539     :param root: is the root directory, which may be the same as 'build_dir' or may
1540                  be a subdirectory of it.
1541     :param srclibpaths: is information on the srclibs being used
1542     """
1543
1544     # Optionally, the actual app source can be in a subdirectory
1545     if build.subdir:
1546         root_dir = os.path.join(build_dir, build.subdir)
1547     else:
1548         root_dir = build_dir
1549
1550     # Get a working copy of the right revision
1551     logging.info("Getting source for revision " + build.commit)
1552     vcs.gotorevision(build.commit, refresh)
1553
1554     # Initialise submodules if required
1555     if build.submodules:
1556         logging.info(_("Initialising submodules"))
1557         vcs.initsubmodules()
1558
1559     # Check that a subdir (if we're using one) exists. This has to happen
1560     # after the checkout, since it might not exist elsewhere
1561     if not os.path.exists(root_dir):
1562         raise BuildException('Missing subdir ' + root_dir)
1563
1564     # Run an init command if one is required
1565     if build.init:
1566         cmd = replace_config_vars(build.init, build)
1567         logging.info("Running 'init' commands in %s" % root_dir)
1568
1569         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1570         if p.returncode != 0:
1571             raise BuildException("Error running init command for %s:%s" %
1572                                  (app.id, build.versionName), p.output)
1573
1574     # Apply patches if any
1575     if build.patch:
1576         logging.info("Applying patches")
1577         for patch in build.patch:
1578             patch = patch.strip()
1579             logging.info("Applying " + patch)
1580             patch_path = os.path.join('metadata', app.id, patch)
1581             p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1582             if p.returncode != 0:
1583                 raise BuildException("Failed to apply patch %s" % patch_path)
1584
1585     # Get required source libraries
1586     srclibpaths = []
1587     if build.srclibs:
1588         logging.info("Collecting source libraries")
1589         for lib in build.srclibs:
1590             srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver,
1591                                          refresh=refresh, build=build))
1592
1593     for name, number, libpath in srclibpaths:
1594         place_srclib(root_dir, int(number) if number else None, libpath)
1595
1596     basesrclib = vcs.getsrclib()
1597     # If one was used for the main source, add that too.
1598     if basesrclib:
1599         srclibpaths.append(basesrclib)
1600
1601     # Update the local.properties file
1602     localprops = [os.path.join(build_dir, 'local.properties')]
1603     if build.subdir:
1604         parts = build.subdir.split(os.sep)
1605         cur = build_dir
1606         for d in parts:
1607             cur = os.path.join(cur, d)
1608             localprops += [os.path.join(cur, 'local.properties')]
1609     for path in localprops:
1610         props = ""
1611         if os.path.isfile(path):
1612             logging.info("Updating local.properties file at %s" % path)
1613             with open(path, 'r', encoding='iso-8859-1') as f:
1614                 props += f.read()
1615             props += '\n'
1616         else:
1617             logging.info("Creating local.properties file at %s" % path)
1618         # Fix old-fashioned 'sdk-location' by copying
1619         # from sdk.dir, if necessary
1620         if build.oldsdkloc:
1621             sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1622                               re.S | re.M).group(1)
1623             props += "sdk-location=%s\n" % sdkloc
1624         else:
1625             props += "sdk.dir=%s\n" % config['sdk_path']
1626             props += "sdk-location=%s\n" % config['sdk_path']
1627         ndk_path = build.ndk_path()
1628         # if for any reason the path isn't valid or the directory
1629         # doesn't exist, some versions of Gradle will error with a
1630         # cryptic message (even if the NDK is not even necessary).
1631         # https://gitlab.com/fdroid/fdroidserver/issues/171
1632         if ndk_path and os.path.exists(ndk_path):
1633             # Add ndk location
1634             props += "ndk.dir=%s\n" % ndk_path
1635             props += "ndk-location=%s\n" % ndk_path
1636         # Add java.encoding if necessary
1637         if build.encoding:
1638             props += "java.encoding=%s\n" % build.encoding
1639         with open(path, 'w', encoding='iso-8859-1') as f:
1640             f.write(props)
1641
1642     flavours = []
1643     if build.build_method() == 'gradle':
1644         flavours = build.gradle
1645
1646         if build.target:
1647             n = build.target.split('-')[1]
1648             regsub_file(r'compileSdkVersion[ =]+[0-9]+',
1649                         r'compileSdkVersion %s' % n,
1650                         os.path.join(root_dir, 'build.gradle'))
1651
1652     # Remove forced debuggable flags
1653     remove_debuggable_flags(root_dir)
1654
1655     # Insert version code and number into the manifest if necessary
1656     if build.forceversion:
1657         logging.info("Changing the version name")
1658         for path in manifest_paths(root_dir, flavours):
1659             if not os.path.isfile(path):
1660                 continue
1661             if has_extension(path, 'xml'):
1662                 regsub_file(r'android:versionName="[^"]*"',
1663                             r'android:versionName="%s"' % build.versionName,
1664                             path)
1665             elif has_extension(path, 'gradle'):
1666                 regsub_file(r"""(\s*)versionName[\s'"=]+.*""",
1667                             r"""\1versionName '%s'""" % build.versionName,
1668                             path)
1669
1670     if build.forcevercode:
1671         logging.info("Changing the version code")
1672         for path in manifest_paths(root_dir, flavours):
1673             if not os.path.isfile(path):
1674                 continue
1675             if has_extension(path, 'xml'):
1676                 regsub_file(r'android:versionCode="[^"]*"',
1677                             r'android:versionCode="%s"' % build.versionCode,
1678                             path)
1679             elif has_extension(path, 'gradle'):
1680                 regsub_file(r'versionCode[ =]+[0-9]+',
1681                             r'versionCode %s' % build.versionCode,
1682                             path)
1683
1684     # Delete unwanted files
1685     if build.rm:
1686         logging.info(_("Removing specified files"))
1687         for part in getpaths(build_dir, build.rm):
1688             dest = os.path.join(build_dir, part)
1689             logging.info("Removing {0}".format(part))
1690             if os.path.lexists(dest):
1691                 # rmtree can only handle directories that are not symlinks, so catch anything else
1692                 if not os.path.isdir(dest) or os.path.islink(dest):
1693                     os.remove(dest)
1694                 else:
1695                     shutil.rmtree(dest)
1696             else:
1697                 logging.info("...but it didn't exist")
1698
1699     remove_signing_keys(build_dir)
1700
1701     # Add required external libraries
1702     if build.extlibs:
1703         logging.info("Collecting prebuilt libraries")
1704         libsdir = os.path.join(root_dir, 'libs')
1705         if not os.path.exists(libsdir):
1706             os.mkdir(libsdir)
1707         for lib in build.extlibs:
1708             lib = lib.strip()
1709             logging.info("...installing extlib {0}".format(lib))
1710             libf = os.path.basename(lib)
1711             libsrc = os.path.join(extlib_dir, lib)
1712             if not os.path.exists(libsrc):
1713                 raise BuildException("Missing extlib file {0}".format(libsrc))
1714             shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1715
1716     # Run a pre-build command if one is required
1717     if build.prebuild:
1718         logging.info("Running 'prebuild' commands in %s" % root_dir)
1719
1720         cmd = replace_config_vars(build.prebuild, build)
1721
1722         # Substitute source library paths into prebuild commands
1723         for name, number, libpath in srclibpaths:
1724             libpath = os.path.relpath(libpath, root_dir)
1725             cmd = cmd.replace('$$' + name + '$$', libpath)
1726
1727         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1728         if p.returncode != 0:
1729             raise BuildException("Error running prebuild command for %s:%s" %
1730                                  (app.id, build.versionName), p.output)
1731
1732     # Generate (or update) the ant build file, build.xml...
1733     if build.build_method() == 'ant' and build.androidupdate != ['no']:
1734         parms = ['android', 'update', 'lib-project']
1735         lparms = ['android', 'update', 'project']
1736
1737         if build.target:
1738             parms += ['-t', build.target]
1739             lparms += ['-t', build.target]
1740         if build.androidupdate:
1741             update_dirs = build.androidupdate
1742         else:
1743             update_dirs = ant_subprojects(root_dir) + ['.']
1744
1745         for d in update_dirs:
1746             subdir = os.path.join(root_dir, d)
1747             if d == '.':
1748                 logging.debug("Updating main project")
1749                 cmd = parms + ['-p', d]
1750             else:
1751                 logging.debug("Updating subproject %s" % d)
1752                 cmd = lparms + ['-p', d]
1753             p = SdkToolsPopen(cmd, cwd=root_dir)
1754             # Check to see whether an error was returned without a proper exit
1755             # code (this is the case for the 'no target set or target invalid'
1756             # error)
1757             if p.returncode != 0 or p.output.startswith("Error: "):
1758                 raise BuildException("Failed to update project at %s" % d, p.output)
1759             # Clean update dirs via ant
1760             if d != '.':
1761                 logging.info("Cleaning subproject %s" % d)
1762                 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1763
1764     return (root_dir, srclibpaths)
1765
1766
1767 def getpaths_map(build_dir, globpaths):
1768     """Extend via globbing the paths from a field and return them as a map from original path to resulting paths"""
1769     paths = dict()
1770     for p in globpaths:
1771         p = p.strip()
1772         full_path = os.path.join(build_dir, p)
1773         full_path = os.path.normpath(full_path)
1774         paths[p] = [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1775         if not paths[p]:
1776             raise FDroidException("glob path '%s' did not match any files/dirs" % p)
1777     return paths
1778
1779
1780 def getpaths(build_dir, globpaths):
1781     """Extend via globbing the paths from a field and return them as a set"""
1782     paths_map = getpaths_map(build_dir, globpaths)
1783     paths = set()
1784     for k, v in paths_map.items():
1785         for p in v:
1786             paths.add(p)
1787     return paths
1788
1789
1790 def natural_key(s):
1791     return [int(sp) if sp.isdigit() else sp for sp in re.split(r'(\d+)', s)]
1792
1793
1794 def check_system_clock(dt_obj, path):
1795     """Check if system clock is updated based on provided date
1796
1797     If an APK has files newer than the system time, suggest updating
1798     the system clock.  This is useful for offline systems, used for
1799     signing, which do not have another source of clock sync info. It
1800     has to be more than 24 hours newer because ZIP/APK files do not
1801     store timezone info
1802
1803     """
1804     checkdt = dt_obj - timedelta(1)
1805     if datetime.today() < checkdt:
1806         logging.warning(_('System clock is older than date in {path}!').format(path=path)
1807                         + '\n' + _('Set clock to that time using:') + '\n'
1808                         + 'sudo date -s "' + str(dt_obj) + '"')
1809
1810
1811 class KnownApks:
1812     """permanent store of existing APKs with the date they were added
1813
1814     This is currently the only way to permanently store the "updated"
1815     date of APKs.
1816     """
1817
1818     def __init__(self):
1819         '''Load filename/date info about previously seen APKs
1820
1821         Since the appid and date strings both will never have spaces,
1822         this is parsed as a list from the end to allow the filename to
1823         have any combo of spaces.
1824         '''
1825
1826         self.path = os.path.join('stats', 'known_apks.txt')
1827         self.apks = {}
1828         if os.path.isfile(self.path):
1829             with open(self.path, 'r', encoding='utf8') as f:
1830                 for line in f:
1831                     t = line.rstrip().split(' ')
1832                     if len(t) == 2:
1833                         self.apks[t[0]] = (t[1], None)
1834                     else:
1835                         appid = t[-2]
1836                         date = datetime.strptime(t[-1], '%Y-%m-%d')
1837                         filename = line[0:line.rfind(appid) - 1]
1838                         self.apks[filename] = (appid, date)
1839                         check_system_clock(date, self.path)
1840         self.changed = False
1841
1842     def writeifchanged(self):
1843         if not self.changed:
1844             return
1845
1846         if not os.path.exists('stats'):
1847             os.mkdir('stats')
1848
1849         lst = []
1850         for apk, app in self.apks.items():
1851             appid, added = app
1852             line = apk + ' ' + appid
1853             if added:
1854                 line += ' ' + added.strftime('%Y-%m-%d')
1855             lst.append(line)
1856
1857         with open(self.path, 'w', encoding='utf8') as f:
1858             for line in sorted(lst, key=natural_key):
1859                 f.write(line + '\n')
1860
1861     def recordapk(self, apkName, app, default_date=None):
1862         '''
1863         Record an apk (if it's new, otherwise does nothing)
1864         Returns the date it was added as a datetime instance
1865         '''
1866         if apkName not in self.apks:
1867             if default_date is None:
1868                 default_date = datetime.utcnow()
1869             self.apks[apkName] = (app, default_date)
1870             self.changed = True
1871         _ignored, added = self.apks[apkName]
1872         return added
1873
1874     def getapp(self, apkname):
1875         """Look up information - given the 'apkname', returns (app id, date added/None).
1876
1877         Or returns None for an unknown apk.
1878         """
1879         if apkname in self.apks:
1880             return self.apks[apkname]
1881         return None
1882
1883     def getlatest(self, num):
1884         """Get the most recent 'num' apps added to the repo, as a list of package ids with the most recent first"""
1885         apps = {}
1886         for apk, app in self.apks.items():
1887             appid, added = app
1888             if added:
1889                 if appid in apps:
1890                     if apps[appid] > added:
1891                         apps[appid] = added
1892                 else:
1893                     apps[appid] = added
1894         sortedapps = sorted(apps.items(), key=operator.itemgetter(1))[-num:]
1895         lst = [app for app, _ignored in sortedapps]
1896         lst.reverse()
1897         return lst
1898
1899
1900 def get_file_extension(filename):
1901     """get the normalized file extension, can be blank string but never None"""
1902     if isinstance(filename, bytes):
1903         filename = filename.decode('utf-8')
1904     return os.path.splitext(filename)[1].lower()[1:]
1905
1906
1907 def get_apk_debuggable_aapt(apkfile):
1908     p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1909                       output=False)
1910     if p.returncode != 0:
1911         raise FDroidException(_("Failed to get APK manifest information"))
1912     for line in p.output.splitlines():
1913         if 'android:debuggable' in line and not line.endswith('0x0'):
1914             return True
1915     return False
1916
1917
1918 def get_apk_debuggable_androguard(apkfile):
1919     try:
1920         from androguard.core.bytecodes.apk import APK
1921     except ImportError:
1922         raise FDroidException("androguard library is not installed and aapt not present")
1923
1924     apkobject = APK(apkfile)
1925     if apkobject.is_valid_APK():
1926         debuggable = apkobject.get_element("application", "debuggable")
1927         if debuggable is not None:
1928             return bool(strtobool(debuggable))
1929     return False
1930
1931
1932 def isApkAndDebuggable(apkfile):
1933     """Returns True if the given file is an APK and is debuggable
1934
1935     :param apkfile: full path to the apk to check"""
1936
1937     if get_file_extension(apkfile) != 'apk':
1938         return False
1939
1940     if SdkToolsPopen(['aapt', 'version'], output=False):
1941         return get_apk_debuggable_aapt(apkfile)
1942     else:
1943         return get_apk_debuggable_androguard(apkfile)
1944
1945
1946 def get_apk_id_aapt(apkfile):
1947     """Extrat identification information from APK using aapt.
1948
1949     :param apkfile: path to an APK file.
1950     :returns: triplet (appid, version code, version name)
1951     """
1952     r = re.compile("package: name='(?P<appid>.*)' versionCode='(?P<vercode>.*)' versionName='(?P<vername>.*)' platformBuildVersionName='.*'")
1953     p = SdkToolsPopen(['aapt', 'dump', 'badging', apkfile], output=False)
1954     for line in p.output.splitlines():
1955         m = r.match(line)
1956         if m:
1957             return m.group('appid'), m.group('vercode'), m.group('vername')
1958     raise FDroidException(_("Reading packageName/versionCode/versionName failed, APK invalid: '{apkfilename}'")
1959                           .format(apkfilename=apkfile))
1960
1961
1962 def get_minSdkVersion_aapt(apkfile):
1963     """Extract the minimum supported Android SDK from an APK using aapt
1964
1965     :param apkfile: path to an APK file.
1966     :returns: the integer representing the SDK version
1967     """
1968     r = re.compile(r"^sdkVersion:'([0-9]+)'")
1969     p = SdkToolsPopen(['aapt', 'dump', 'badging', apkfile], output=False)
1970     for line in p.output.splitlines():
1971         m = r.match(line)
1972         if m:
1973             return int(m.group(1))
1974     raise FDroidException(_('Reading minSdkVersion failed: "{apkfilename}"')
1975                           .format(apkfilename=apkfile))
1976
1977
1978 class PopenResult:
1979     def __init__(self):
1980         self.returncode = None
1981         self.output = None
1982
1983
1984 def SdkToolsPopen(commands, cwd=None, output=True):
1985     cmd = commands[0]
1986     if cmd not in config:
1987         config[cmd] = find_sdk_tools_cmd(commands[0])
1988     abscmd = config[cmd]
1989     if abscmd is None:
1990         raise FDroidException(_("Could not find '{command}' on your system").format(command=cmd))
1991     if cmd == 'aapt':
1992         test_aapt_version(config['aapt'])
1993     return FDroidPopen([abscmd] + commands[1:],
1994                        cwd=cwd, output=output)
1995
1996
1997 def FDroidPopenBytes(commands, cwd=None, envs=None, output=True, stderr_to_stdout=True):
1998     """
1999     Run a command and capture the possibly huge output as bytes.
2000
2001     :param commands: command and argument list like in subprocess.Popen
2002     :param cwd: optionally specifies a working directory
2003     :param envs: a optional dictionary of environment variables and their values
2004     :returns: A PopenResult.
2005     """
2006
2007     global env
2008     if env is None:
2009         set_FDroidPopen_env()
2010
2011     process_env = env.copy()
2012     if envs is not None and len(envs) > 0:
2013         process_env.update(envs)
2014
2015     if cwd:
2016         cwd = os.path.normpath(cwd)
2017         logging.debug("Directory: %s" % cwd)
2018     logging.debug("> %s" % ' '.join(commands))
2019
2020     stderr_param = subprocess.STDOUT if stderr_to_stdout else subprocess.PIPE
2021     result = PopenResult()
2022     p = None
2023     try:
2024         p = subprocess.Popen(commands, cwd=cwd, shell=False, env=process_env,
2025                              stdout=subprocess.PIPE, stderr=stderr_param)
2026     except OSError as e:
2027         raise BuildException("OSError while trying to execute " +
2028                              ' '.join(commands) + ': ' + str(e))
2029
2030     # TODO are these AsynchronousFileReader threads always exiting?
2031     if not stderr_to_stdout and options.verbose:
2032         stderr_queue = Queue()
2033         stderr_reader = AsynchronousFileReader(p.stderr, stderr_queue)
2034
2035         while not stderr_reader.eof():
2036             while not stderr_queue.empty():
2037                 line = stderr_queue.get()
2038                 sys.stderr.buffer.write(line)
2039                 sys.stderr.flush()
2040
2041             time.sleep(0.1)
2042
2043     stdout_queue = Queue()
2044     stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
2045     buf = io.BytesIO()
2046
2047     # Check the queue for output (until there is no more to get)
2048     while not stdout_reader.eof():
2049         while not stdout_queue.empty():
2050             line = stdout_queue.get()
2051             if output and options.verbose:
2052                 # Output directly to console
2053                 sys.stderr.buffer.write(line)
2054                 sys.stderr.flush()
2055             buf.write(line)
2056
2057         time.sleep(0.1)
2058
2059     result.returncode = p.wait()
2060     result.output = buf.getvalue()
2061     buf.close()
2062     # make sure all filestreams of the subprocess are closed
2063     for streamvar in ['stdin', 'stdout', 'stderr']:
2064         if hasattr(p, streamvar):
2065             stream = getattr(p, streamvar)
2066             if stream:
2067                 stream.close()
2068     return result
2069
2070
2071 def FDroidPopen(commands, cwd=None, envs=None, output=True, stderr_to_stdout=True):
2072     """
2073     Run a command and capture the possibly huge output as a str.
2074
2075     :param commands: command and argument list like in subprocess.Popen
2076     :param cwd: optionally specifies a working directory
2077     :param envs: a optional dictionary of environment variables and their values
2078     :returns: A PopenResult.
2079     """
2080     result = FDroidPopenBytes(commands, cwd, envs, output, stderr_to_stdout)
2081     result.output = result.output.decode('utf-8', 'ignore')
2082     return result
2083
2084
2085 gradle_comment = re.compile(r'[ ]*//')
2086 gradle_signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
2087 gradle_line_matches = [
2088     re.compile(r'^[\t ]*signingConfig [^ ]*$'),
2089     re.compile(r'.*android\.signingConfigs\.[^{]*$'),
2090     re.compile(r'.*\.readLine\(.*'),
2091 ]
2092
2093
2094 def remove_signing_keys(build_dir):
2095     for root, dirs, files in os.walk(build_dir):
2096         if 'build.gradle' in files:
2097             path = os.path.join(root, 'build.gradle')
2098
2099             with open(path, "r", encoding='utf8') as o:
2100                 lines = o.readlines()
2101
2102             changed = False
2103
2104             opened = 0
2105             i = 0
2106             with open(path, "w", encoding='utf8') as o:
2107                 while i < len(lines):
2108                     line = lines[i]
2109                     i += 1
2110                     while line.endswith('\\\n'):
2111                         line = line.rstrip('\\\n') + lines[i]
2112                         i += 1
2113
2114                     if gradle_comment.match(line):
2115                         o.write(line)
2116                         continue
2117
2118                     if opened > 0:
2119                         opened += line.count('{')
2120                         opened -= line.count('}')
2121                         continue
2122
2123                     if gradle_signing_configs.match(line):
2124                         changed = True
2125                         opened += 1
2126                         continue
2127
2128                     if any(s.match(line) for s in gradle_line_matches):
2129                         changed = True
2130                         continue
2131
2132                     if opened == 0:
2133                         o.write(line)
2134
2135             if changed:
2136                 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
2137
2138         for propfile in [
2139                 'project.properties',
2140                 'build.properties',
2141                 'default.properties',
2142                 'ant.properties', ]:
2143             if propfile in files:
2144                 path = os.path.join(root, propfile)
2145
2146                 with open(path, "r", encoding='iso-8859-1') as o:
2147                     lines = o.readlines()
2148
2149                 changed = False
2150
2151                 with open(path, "w", encoding='iso-8859-1') as o:
2152                     for line in lines:
2153                         if any(line.startswith(s) for s in ('key.store', 'key.alias')):
2154                             changed = True
2155                             continue
2156
2157                         o.write(line)
2158
2159                 if changed:
2160                     logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
2161
2162
2163 def set_FDroidPopen_env(build=None):
2164     '''
2165     set up the environment variables for the build environment
2166
2167     There is only a weak standard, the variables used by gradle, so also set
2168     up the most commonly used environment variables for SDK and NDK.  Also, if
2169     there is no locale set, this will set the locale (e.g. LANG) to en_US.UTF-8.
2170     '''
2171     global env, orig_path
2172
2173     if env is None:
2174         env = os.environ
2175         orig_path = env['PATH']
2176         for n in ['ANDROID_HOME', 'ANDROID_SDK']:
2177             env[n] = config['sdk_path']
2178         for k, v in config['java_paths'].items():
2179             env['JAVA%s_HOME' % k] = v
2180
2181     missinglocale = True
2182     for k, v in env.items():
2183         if k == 'LANG' and v != 'C':
2184             missinglocale = False
2185         elif k == 'LC_ALL':
2186             missinglocale = False
2187     if missinglocale:
2188         env['LANG'] = 'en_US.UTF-8'
2189
2190     if build is not None:
2191         path = build.ndk_path()
2192         paths = orig_path.split(os.pathsep)
2193         if path not in paths:
2194             paths = [path] + paths
2195             env['PATH'] = os.pathsep.join(paths)
2196         for n in ['ANDROID_NDK', 'NDK', 'ANDROID_NDK_HOME']:
2197             env[n] = build.ndk_path()
2198
2199
2200 def replace_build_vars(cmd, build):
2201     cmd = cmd.replace('$$COMMIT$$', build.commit)
2202     cmd = cmd.replace('$$VERSION$$', build.versionName)
2203     cmd = cmd.replace('$$VERCODE$$', build.versionCode)
2204     return cmd
2205
2206
2207 def replace_config_vars(cmd, build):
2208     cmd = cmd.replace('$$SDK$$', config['sdk_path'])
2209     cmd = cmd.replace('$$NDK$$', build.ndk_path())
2210     cmd = cmd.replace('$$MVN3$$', config['mvn3'])
2211     if build is not None:
2212         cmd = replace_build_vars(cmd, build)
2213     return cmd
2214
2215
2216 def place_srclib(root_dir, number, libpath):
2217     if not number:
2218         return
2219     relpath = os.path.relpath(libpath, root_dir)
2220     proppath = os.path.join(root_dir, 'project.properties')
2221
2222     lines = []
2223     if os.path.isfile(proppath):
2224         with open(proppath, "r", encoding='iso-8859-1') as o:
2225             lines = o.readlines()
2226
2227     with open(proppath, "w", encoding='iso-8859-1') as o:
2228         placed = False
2229         for line in lines:
2230             if line.startswith('android.library.reference.%d=' % number):
2231                 o.write('android.library.reference.%d=%s\n' % (number, relpath))
2232                 placed = True
2233             else:
2234                 o.write(line)
2235         if not placed:
2236             o.write('android.library.reference.%d=%s\n' % (number, relpath))
2237
2238
2239 apk_sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA|DSA|EC)')
2240
2241
2242 def signer_fingerprint_short(sig):
2243     """Obtain shortened sha256 signing-key fingerprint for pkcs7 signature.
2244
2245     Extracts the first 7 hexadecimal digits of sha256 signing-key fingerprint
2246     for a given pkcs7 signature.
2247
2248     :param sig: Contents of an APK signing certificate.
2249     :returns: shortened signing-key fingerprint.
2250     """
2251     return signer_fingerprint(sig)[:7]
2252
2253
2254 def signer_fingerprint(sig):
2255     """Obtain sha256 signing-key fingerprint for pkcs7 signature.
2256
2257     Extracts hexadecimal sha256 signing-key fingerprint string
2258     for a given pkcs7 signature.
2259
2260     :param: Contents of an APK signature.
2261     :returns: shortened signature fingerprint.
2262     """
2263     cert_encoded = get_certificate(sig)
2264     return hashlib.sha256(cert_encoded).hexdigest()
2265
2266
2267 def apk_signer_fingerprint(apk_path):
2268     """Obtain sha256 signing-key fingerprint for APK.
2269
2270     Extracts hexadecimal sha256 signing-key fingerprint string
2271     for a given APK.
2272
2273     :param apkpath: path to APK
2274     :returns: signature fingerprint
2275     """
2276
2277     with zipfile.ZipFile(apk_path, 'r') as apk:
2278         certs = [n for n in apk.namelist() if CERT_PATH_REGEX.match(n)]
2279
2280         if len(certs) < 1:
2281             logging.error("Found no signing certificates on %s" % apk_path)
2282             return None
2283         if len(certs) > 1:
2284             logging.error("Found multiple signing certificates on %s" % apk_path)
2285             return None
2286
2287         cert = apk.read(certs[0])
2288         return signer_fingerprint(cert)
2289
2290
2291 def apk_signer_fingerprint_short(apk_path):
2292     """Obtain shortened sha256 signing-key fingerprint for APK.
2293
2294     Extracts the first 7 hexadecimal digits of sha256 signing-key fingerprint
2295     for a given pkcs7 APK.
2296
2297     :param apk_path: path to APK
2298     :returns: shortened signing-key fingerprint
2299     """
2300     return apk_signer_fingerprint(apk_path)[:7]
2301
2302
2303 def metadata_get_sigdir(appid, vercode=None):
2304     """Get signature directory for app"""
2305     if vercode:
2306         return os.path.join('metadata', appid, 'signatures', vercode)
2307     else:
2308         return os.path.join('metadata', appid, 'signatures')
2309
2310
2311 def metadata_find_developer_signature(appid, vercode=None):
2312     """Tires to find the developer signature for given appid.
2313
2314     This picks the first signature file found in metadata an returns its
2315     signature.
2316
2317     :returns: sha256 signing key fingerprint of the developer signing key.
2318         None in case no signature can not be found."""
2319
2320     # fetch list of dirs for all versions of signatures
2321     appversigdirs = []
2322     if vercode:
2323         appversigdirs.append(metadata_get_sigdir(appid, vercode))
2324     else:
2325         appsigdir = metadata_get_sigdir(appid)
2326         if os.path.isdir(appsigdir):
2327             numre = re.compile('[0-9]+')
2328             for ver in os.listdir(appsigdir):
2329                 if numre.match(ver):
2330                     appversigdir = os.path.join(appsigdir, ver)
2331                     appversigdirs.append(appversigdir)
2332
2333     for sigdir in appversigdirs:
2334         sigs = glob.glob(os.path.join(sigdir, '*.DSA')) + \
2335             glob.glob(os.path.join(sigdir, '*.EC')) + \
2336             glob.glob(os.path.join(sigdir, '*.RSA'))
2337         if len(sigs) > 1:
2338             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))
2339         for sig in sigs:
2340             with open(sig, 'rb') as f:
2341                 return signer_fingerprint(f.read())
2342     return None
2343
2344
2345 def metadata_find_signing_files(appid, vercode):
2346     """Gets a list of singed manifests and signatures.
2347
2348     :param appid: app id string
2349     :param vercode: app version code
2350     :returns: a list of triplets for each signing key with following paths:
2351         (signature_file, singed_file, manifest_file)
2352     """
2353     ret = []
2354     sigdir = metadata_get_sigdir(appid, vercode)
2355     sigs = glob.glob(os.path.join(sigdir, '*.DSA')) + \
2356         glob.glob(os.path.join(sigdir, '*.EC')) + \
2357         glob.glob(os.path.join(sigdir, '*.RSA'))
2358     extre = re.compile('(\.DSA|\.EC|\.RSA)$')
2359     for sig in sigs:
2360         sf = extre.sub('.SF', sig)
2361         if os.path.isfile(sf):
2362             mf = os.path.join(sigdir, 'MANIFEST.MF')
2363             if os.path.isfile(mf):
2364                 ret.append((sig, sf, mf))
2365     return ret
2366
2367
2368 def metadata_find_developer_signing_files(appid, vercode):
2369     """Get developer signature files for specified app from metadata.
2370
2371     :returns: A triplet of paths for signing files from metadata:
2372         (signature_file, singed_file, manifest_file)
2373     """
2374     allsigningfiles = metadata_find_signing_files(appid, vercode)
2375     if allsigningfiles and len(allsigningfiles) == 1:
2376         return allsigningfiles[0]
2377     else:
2378         return None
2379
2380
2381 def apk_strip_signatures(signed_apk, strip_manifest=False):
2382     """Removes signatures from APK.
2383
2384     :param signed_apk: path to apk file.
2385     :param strip_manifest: when set to True also the manifest file will
2386         be removed from the APK.
2387     """
2388     with tempfile.TemporaryDirectory() as tmpdir:
2389         tmp_apk = os.path.join(tmpdir, 'tmp.apk')
2390         shutil.move(signed_apk, tmp_apk)
2391         with ZipFile(tmp_apk, 'r') as in_apk:
2392             with ZipFile(signed_apk, 'w') as out_apk:
2393                 for info in in_apk.infolist():
2394                     if not apk_sigfile.match(info.filename):
2395                         if strip_manifest:
2396                             if info.filename != 'META-INF/MANIFEST.MF':
2397                                 buf = in_apk.read(info.filename)
2398                                 out_apk.writestr(info, buf)
2399                         else:
2400                             buf = in_apk.read(info.filename)
2401                             out_apk.writestr(info, buf)
2402
2403
2404 def apk_implant_signatures(apkpath, signaturefile, signedfile, manifest):
2405     """Implats a signature from metadata into an APK.
2406
2407     Note: this changes there supplied APK in place. So copy it if you
2408     need the original to be preserved.
2409
2410     :param apkpath: location of the apk
2411     """
2412     # get list of available signature files in metadata
2413     with tempfile.TemporaryDirectory() as tmpdir:
2414         apkwithnewsig = os.path.join(tmpdir, 'newsig.apk')
2415         with ZipFile(apkpath, 'r') as in_apk:
2416             with ZipFile(apkwithnewsig, 'w') as out_apk:
2417                 for sig_file in [signaturefile, signedfile, manifest]:
2418                     with open(sig_file, 'rb') as fp:
2419                         buf = fp.read()
2420                     info = zipfile.ZipInfo('META-INF/' + os.path.basename(sig_file))
2421                     info.compress_type = zipfile.ZIP_DEFLATED
2422                     info.create_system = 0  # "Windows" aka "FAT", what Android SDK uses
2423                     out_apk.writestr(info, buf)
2424                 for info in in_apk.infolist():
2425                     if not apk_sigfile.match(info.filename):
2426                         if info.filename != 'META-INF/MANIFEST.MF':
2427                             buf = in_apk.read(info.filename)
2428                             out_apk.writestr(info, buf)
2429         os.remove(apkpath)
2430         p = SdkToolsPopen(['zipalign', '-v', '4', apkwithnewsig, apkpath])
2431         if p.returncode != 0:
2432             raise BuildException("Failed to align application")
2433
2434
2435 def apk_extract_signatures(apkpath, outdir, manifest=True):
2436     """Extracts a signature files from APK and puts them into target directory.
2437
2438     :param apkpath: location of the apk
2439     :param outdir: folder where the extracted signature files will be stored
2440     :param manifest: (optionally) disable extracting manifest file
2441     """
2442     with ZipFile(apkpath, 'r') as in_apk:
2443         for f in in_apk.infolist():
2444             if apk_sigfile.match(f.filename) or \
2445                     (manifest and f.filename == 'META-INF/MANIFEST.MF'):
2446                 newpath = os.path.join(outdir, os.path.basename(f.filename))
2447                 with open(newpath, 'wb') as out_file:
2448                     out_file.write(in_apk.read(f.filename))
2449
2450
2451 def sign_apk(unsigned_path, signed_path, keyalias):
2452     """Sign and zipalign an unsigned APK, then save to a new file, deleting the unsigned
2453
2454     android-18 (4.3) finally added support for reasonable hash
2455     algorithms, like SHA-256, before then, the only options were MD5
2456     and SHA1 :-/ This aims to use SHA-256 when the APK does not target
2457     older Android versions, and is therefore safe to do so.
2458
2459     https://issuetracker.google.com/issues/36956587
2460     https://android-review.googlesource.com/c/platform/libcore/+/44491
2461
2462     """
2463
2464     if get_minSdkVersion_aapt(unsigned_path) < 18:
2465         signature_algorithm = ['-sigalg', 'SHA1withRSA', '-digestalg', 'SHA1']
2466     else:
2467         signature_algorithm = ['-sigalg', 'SHA256withRSA', '-digestalg', 'SHA-256']
2468
2469     p = FDroidPopen([config['jarsigner'], '-keystore', config['keystore'],
2470                      '-storepass:env', 'FDROID_KEY_STORE_PASS',
2471                      '-keypass:env', 'FDROID_KEY_PASS']
2472                     + signature_algorithm + [unsigned_path, keyalias],
2473                     envs={
2474                         'FDROID_KEY_STORE_PASS': config['keystorepass'],
2475                         'FDROID_KEY_PASS': config['keypass'], })
2476     if p.returncode != 0:
2477         raise BuildException(_("Failed to sign application"), p.output)
2478
2479     p = SdkToolsPopen(['zipalign', '-v', '4', unsigned_path, signed_path])
2480     if p.returncode != 0:
2481         raise BuildException(_("Failed to zipalign application"))
2482     os.remove(unsigned_path)
2483
2484
2485 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
2486     """Verify that two apks are the same
2487
2488     One of the inputs is signed, the other is unsigned. The signature metadata
2489     is transferred from the signed to the unsigned apk, and then jarsigner is
2490     used to verify that the signature from the signed apk is also varlid for
2491     the unsigned one.  If the APK given as unsigned actually does have a
2492     signature, it will be stripped out and ignored.
2493
2494     There are two SHA1 git commit IDs that fdroidserver includes in the builds
2495     it makes: fdroidserverid and buildserverid.  Originally, these were inserted
2496     into AndroidManifest.xml, but that makes the build not reproducible. So
2497     instead they are included as separate files in the APK's META-INF/ folder.
2498     If those files exist in the signed APK, they will be part of the signature
2499     and need to also be included in the unsigned APK for it to validate.
2500
2501     :param signed_apk: Path to a signed apk file
2502     :param unsigned_apk: Path to an unsigned apk file expected to match it
2503     :param tmp_dir: Path to directory for temporary files
2504     :returns: None if the verification is successful, otherwise a string
2505               describing what went wrong.
2506     """
2507
2508     if not os.path.isfile(signed_apk):
2509         return 'can not verify: file does not exists: {}'.format(signed_apk)
2510
2511     if not os.path.isfile(unsigned_apk):
2512         return 'can not verify: file does not exists: {}'.format(unsigned_apk)
2513
2514     with ZipFile(signed_apk, 'r') as signed:
2515         meta_inf_files = ['META-INF/MANIFEST.MF']
2516         for f in signed.namelist():
2517             if apk_sigfile.match(f) \
2518                or f in ['META-INF/fdroidserverid', 'META-INF/buildserverid']:
2519                 meta_inf_files.append(f)
2520         if len(meta_inf_files) < 3:
2521             return "Signature files missing from {0}".format(signed_apk)
2522
2523         tmp_apk = os.path.join(tmp_dir, 'sigcp_' + os.path.basename(unsigned_apk))
2524         with ZipFile(unsigned_apk, 'r') as unsigned:
2525             # only read the signature from the signed APK, everything else from unsigned
2526             with ZipFile(tmp_apk, 'w') as tmp:
2527                 for filename in meta_inf_files:
2528                     tmp.writestr(signed.getinfo(filename), signed.read(filename))
2529                 for info in unsigned.infolist():
2530                     if info.filename in meta_inf_files:
2531                         logging.warning('Ignoring %s from %s',
2532                                         info.filename, unsigned_apk)
2533                         continue
2534                     if info.filename in tmp.namelist():
2535                         return "duplicate filename found: " + info.filename
2536                     tmp.writestr(info, unsigned.read(info.filename))
2537
2538     verified = verify_apk_signature(tmp_apk)
2539
2540     if not verified:
2541         logging.info("...NOT verified - {0}".format(tmp_apk))
2542         return compare_apks(signed_apk, tmp_apk, tmp_dir,
2543                             os.path.dirname(unsigned_apk))
2544
2545     logging.info("...successfully verified")
2546     return None
2547
2548
2549 def verify_jar_signature(jar):
2550     """Verifies the signature of a given JAR file.
2551
2552     jarsigner is very shitty: unsigned JARs pass as "verified"! So
2553     this has to turn on -strict then check for result 4, since this
2554     does not expect the signature to be from a CA-signed certificate.
2555
2556     :raises: VerificationException() if the JAR's signature could not be verified
2557
2558     """
2559
2560     error = _('JAR signature failed to verify: {path}').format(path=jar)
2561     try:
2562         output = subprocess.check_output([config['jarsigner'], '-strict', '-verify', jar],
2563                                          stderr=subprocess.STDOUT)
2564         raise VerificationException(error + '\n' + output.decode('utf-8'))
2565     except subprocess.CalledProcessError as e:
2566         if e.returncode == 4:
2567             logging.debug(_('JAR signature verified: {path}').format(path=jar))
2568         else:
2569             raise VerificationException(error + '\n' + e.output.decode('utf-8'))
2570
2571
2572 def verify_apk_signature(apk, min_sdk_version=None):
2573     """verify the signature on an APK
2574
2575     Try to use apksigner whenever possible since jarsigner is very
2576     shitty: unsigned APKs pass as "verified"!  Warning, this does
2577     not work on JARs with apksigner >= 0.7 (build-tools 26.0.1)
2578
2579     :returns: boolean whether the APK was verified
2580     """
2581     if set_command_in_config('apksigner'):
2582         args = [config['apksigner'], 'verify']
2583         if min_sdk_version:
2584             args += ['--min-sdk-version=' + min_sdk_version]
2585         if options.verbose:
2586             args += ['--verbose']
2587         try:
2588             output = subprocess.check_output(args + [apk])
2589             if options.verbose:
2590                 logging.debug(apk + ': ' + output.decode('utf-8'))
2591             return True
2592         except subprocess.CalledProcessError as e:
2593             logging.error('\n' + apk + ': ' + e.output.decode('utf-8'))
2594     else:
2595         if not config.get('jarsigner_warning_displayed'):
2596             config['jarsigner_warning_displayed'] = True
2597             logging.warning(_("Using Java's jarsigner, not recommended for verifying APKs! Use apksigner"))
2598         try:
2599             verify_jar_signature(apk)
2600             return True
2601         except Exception as e:
2602             logging.error(e)
2603     return False
2604
2605
2606 def verify_old_apk_signature(apk):
2607     """verify the signature on an archived APK, supporting deprecated algorithms
2608
2609     F-Droid aims to keep every single binary that it ever published.  Therefore,
2610     it needs to be able to verify APK signatures that include deprecated/removed
2611     algorithms.  For example, jarsigner treats an MD5 signature as unsigned.
2612
2613     jarsigner passes unsigned APKs as "verified"! So this has to turn
2614     on -strict then check for result 4.
2615
2616     :returns: boolean whether the APK was verified
2617     """
2618
2619     _java_security = os.path.join(os.getcwd(), '.java.security')
2620     with open(_java_security, 'w') as fp:
2621         fp.write('jdk.jar.disabledAlgorithms=MD2, RSA keySize < 1024')
2622
2623     try:
2624         cmd = [
2625             config['jarsigner'],
2626             '-J-Djava.security.properties=' + _java_security,
2627             '-strict', '-verify', apk
2628         ]
2629         output = subprocess.check_output(cmd, stderr=subprocess.STDOUT)
2630     except subprocess.CalledProcessError as e:
2631         if e.returncode != 4:
2632             output = e.output
2633         else:
2634             logging.debug(_('JAR signature verified: {path}').format(path=apk))
2635             return True
2636
2637     logging.error(_('Old APK signature failed to verify: {path}').format(path=apk)
2638                   + '\n' + output.decode('utf-8'))
2639     return False
2640
2641
2642 apk_badchars = re.compile('''[/ :;'"]''')
2643
2644
2645 def compare_apks(apk1, apk2, tmp_dir, log_dir=None):
2646     """Compare two apks
2647
2648     Returns None if the apk content is the same (apart from the signing key),
2649     otherwise a string describing what's different, or what went wrong when
2650     trying to do the comparison.
2651     """
2652
2653     if not log_dir:
2654         log_dir = tmp_dir
2655
2656     absapk1 = os.path.abspath(apk1)
2657     absapk2 = os.path.abspath(apk2)
2658
2659     if set_command_in_config('diffoscope'):
2660         logfilename = os.path.join(log_dir, os.path.basename(absapk1))
2661         htmlfile = logfilename + '.diffoscope.html'
2662         textfile = logfilename + '.diffoscope.txt'
2663         if subprocess.call([config['diffoscope'],
2664                             '--max-report-size', '12345678', '--max-diff-block-lines', '100',
2665                             '--html', htmlfile, '--text', textfile,
2666                             absapk1, absapk2]) != 0:
2667             return("Failed to unpack " + apk1)
2668
2669     apk1dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk1[0:-4]))  # trim .apk
2670     apk2dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk2[0:-4]))  # trim .apk
2671     for d in [apk1dir, apk2dir]:
2672         if os.path.exists(d):
2673             shutil.rmtree(d)
2674         os.mkdir(d)
2675         os.mkdir(os.path.join(d, 'jar-xf'))
2676
2677     if subprocess.call(['jar', 'xf',
2678                         os.path.abspath(apk1)],
2679                        cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
2680         return("Failed to unpack " + apk1)
2681     if subprocess.call(['jar', 'xf',
2682                         os.path.abspath(apk2)],
2683                        cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
2684         return("Failed to unpack " + apk2)
2685
2686     if set_command_in_config('apktool'):
2687         if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
2688                            cwd=apk1dir) != 0:
2689             return("Failed to unpack " + apk1)
2690         if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
2691                            cwd=apk2dir) != 0:
2692             return("Failed to unpack " + apk2)
2693
2694     p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
2695     lines = p.output.splitlines()
2696     if len(lines) != 1 or 'META-INF' not in lines[0]:
2697         if set_command_in_config('meld'):
2698             p = FDroidPopen([config['meld'], apk1dir, apk2dir], output=False)
2699         return("Unexpected diff output - " + p.output)
2700
2701     # since everything verifies, delete the comparison to keep cruft down
2702     shutil.rmtree(apk1dir)
2703     shutil.rmtree(apk2dir)
2704
2705     # If we get here, it seems like they're the same!
2706     return None
2707
2708
2709 def set_command_in_config(command):
2710     '''Try to find specified command in the path, if it hasn't been
2711     manually set in config.py.  If found, it is added to the config
2712     dict.  The return value says whether the command is available.
2713
2714     '''
2715     if command in config:
2716         return True
2717     else:
2718         tmp = find_command(command)
2719         if tmp is not None:
2720             config[command] = tmp
2721             return True
2722     return False
2723
2724
2725 def find_command(command):
2726     '''find the full path of a command, or None if it can't be found in the PATH'''
2727
2728     def is_exe(fpath):
2729         return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
2730
2731     fpath, fname = os.path.split(command)
2732     if fpath:
2733         if is_exe(command):
2734             return command
2735     else:
2736         for path in os.environ["PATH"].split(os.pathsep):
2737             path = path.strip('"')
2738             exe_file = os.path.join(path, command)
2739             if is_exe(exe_file):
2740                 return exe_file
2741
2742     return None
2743
2744
2745 def genpassword():
2746     '''generate a random password for when generating keys'''
2747     h = hashlib.sha256()
2748     h.update(os.urandom(16))  # salt
2749     h.update(socket.getfqdn().encode('utf-8'))
2750     passwd = base64.b64encode(h.digest()).strip()
2751     return passwd.decode('utf-8')
2752
2753
2754 def genkeystore(localconfig):
2755     """
2756     Generate a new key with password provided in :param localconfig and add it to new keystore
2757     :return: hexed public key, public key fingerprint
2758     """
2759     logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
2760     keystoredir = os.path.dirname(localconfig['keystore'])
2761     if keystoredir is None or keystoredir == '':
2762         keystoredir = os.path.join(os.getcwd(), keystoredir)
2763     if not os.path.exists(keystoredir):
2764         os.makedirs(keystoredir, mode=0o700)
2765
2766     env_vars = {
2767         'FDROID_KEY_STORE_PASS': localconfig['keystorepass'],
2768         'FDROID_KEY_PASS': localconfig['keypass'],
2769     }
2770     p = FDroidPopen([config['keytool'], '-genkey',
2771                      '-keystore', localconfig['keystore'],
2772                      '-alias', localconfig['repo_keyalias'],
2773                      '-keyalg', 'RSA', '-keysize', '4096',
2774                      '-sigalg', 'SHA256withRSA',
2775                      '-validity', '10000',
2776                      '-storepass:env', 'FDROID_KEY_STORE_PASS',
2777                      '-keypass:env', 'FDROID_KEY_PASS',
2778                      '-dname', localconfig['keydname']], envs=env_vars)
2779     if p.returncode != 0:
2780         raise BuildException("Failed to generate key", p.output)
2781     os.chmod(localconfig['keystore'], 0o0600)
2782     if not options.quiet:
2783         # now show the lovely key that was just generated
2784         p = FDroidPopen([config['keytool'], '-list', '-v',
2785                          '-keystore', localconfig['keystore'],
2786                          '-alias', localconfig['repo_keyalias'],
2787                          '-storepass:env', 'FDROID_KEY_STORE_PASS'], envs=env_vars)
2788         logging.info(p.output.strip() + '\n\n')
2789     # get the public key
2790     p = FDroidPopenBytes([config['keytool'], '-exportcert',
2791                           '-keystore', localconfig['keystore'],
2792                           '-alias', localconfig['repo_keyalias'],
2793                           '-storepass:env', 'FDROID_KEY_STORE_PASS']
2794                          + config['smartcardoptions'],
2795                          envs=env_vars, output=False, stderr_to_stdout=False)
2796     if p.returncode != 0 or len(p.output) < 20:
2797         raise BuildException("Failed to get public key", p.output)
2798     pubkey = p.output
2799     fingerprint = get_cert_fingerprint(pubkey)
2800     return hexlify(pubkey), fingerprint
2801
2802
2803 def get_cert_fingerprint(pubkey):
2804     """
2805     Generate a certificate fingerprint the same way keytool does it
2806     (but with slightly different formatting)
2807     """
2808     digest = hashlib.sha256(pubkey).digest()
2809     ret = [' '.join("%02X" % b for b in bytearray(digest))]
2810     return " ".join(ret)
2811
2812
2813 def get_certificate(certificate_file):
2814     """
2815     Extracts a certificate from the given file.
2816     :param certificate_file: file bytes (as string) representing the certificate
2817     :return: A binary representation of the certificate's public key, or None in case of error
2818     """
2819     content = decoder.decode(certificate_file, asn1Spec=rfc2315.ContentInfo())[0]
2820     if content.getComponentByName('contentType') != rfc2315.signedData:
2821         return None
2822     content = decoder.decode(content.getComponentByName('content'),
2823                              asn1Spec=rfc2315.SignedData())[0]
2824     try:
2825         certificates = content.getComponentByName('certificates')
2826         cert = certificates[0].getComponentByName('certificate')
2827     except PyAsn1Error:
2828         logging.error("Certificates not found.")
2829         return None
2830     return encoder.encode(cert)
2831
2832
2833 def load_stats_fdroid_signing_key_fingerprints():
2834     """Load list of signing-key fingerprints stored by fdroid publish from file.
2835
2836     :returns: list of dictionanryies containing the singing-key fingerprints.
2837     """
2838     jar_file = os.path.join('stats', 'publishsigkeys.jar')
2839     if not os.path.isfile(jar_file):
2840         return {}
2841     cmd = [config['jarsigner'], '-strict', '-verify', jar_file]
2842     p = FDroidPopen(cmd, output=False)
2843     if p.returncode != 4:
2844         raise FDroidException("Signature validation of '{}' failed! "
2845                               "Please run publish again to rebuild this file.".format(jar_file))
2846
2847     jar_sigkey = apk_signer_fingerprint(jar_file)
2848     repo_key_sig = config.get('repo_key_sha256')
2849     if repo_key_sig:
2850         if jar_sigkey != repo_key_sig:
2851             raise FDroidException("Signature key fingerprint of file '{}' does not match repo_key_sha256 in config.py (found fingerprint: '{}')".format(jar_file, jar_sigkey))
2852     else:
2853         logging.warning("repo_key_sha256 not in config.py, setting it to the signature key fingerprint of '{}'".format(jar_file))
2854         config['repo_key_sha256'] = jar_sigkey
2855         write_to_config(config, 'repo_key_sha256')
2856
2857     with zipfile.ZipFile(jar_file, 'r') as f:
2858         return json.loads(str(f.read('publishsigkeys.json'), 'utf-8'))
2859
2860
2861 def write_to_config(thisconfig, key, value=None, config_file=None):
2862     '''write a key/value to the local config.py
2863
2864     NOTE: only supports writing string variables.
2865
2866     :param thisconfig: config dictionary
2867     :param key: variable name in config.py to be overwritten/added
2868     :param value: optional value to be written, instead of fetched
2869         from 'thisconfig' dictionary.
2870     '''
2871     if value is None:
2872         origkey = key + '_orig'
2873         value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
2874     cfg = config_file if config_file else 'config.py'
2875
2876     # load config file, create one if it doesn't exist
2877     if not os.path.exists(cfg):
2878         open(cfg, 'a').close()
2879         logging.info("Creating empty " + cfg)
2880     with open(cfg, 'r', encoding="utf-8") as f:
2881         lines = f.readlines()
2882
2883     # make sure the file ends with a carraige return
2884     if len(lines) > 0:
2885         if not lines[-1].endswith('\n'):
2886             lines[-1] += '\n'
2887
2888     # regex for finding and replacing python string variable
2889     # definitions/initializations
2890     pattern = re.compile('^[\s#]*' + key + '\s*=\s*"[^"]*"')
2891     repl = key + ' = "' + value + '"'
2892     pattern2 = re.compile('^[\s#]*' + key + "\s*=\s*'[^']*'")
2893     repl2 = key + " = '" + value + "'"
2894
2895     # If we replaced this line once, we make sure won't be a
2896     # second instance of this line for this key in the document.
2897     didRepl = False
2898     # edit config file
2899     with open(cfg, 'w', encoding="utf-8") as f:
2900         for line in lines:
2901             if pattern.match(line) or pattern2.match(line):
2902                 if not didRepl:
2903                     line = pattern.sub(repl, line)
2904                     line = pattern2.sub(repl2, line)
2905                     f.write(line)
2906                     didRepl = True
2907             else:
2908                 f.write(line)
2909         if not didRepl:
2910             f.write('\n')
2911             f.write(repl)
2912             f.write('\n')
2913
2914
2915 def parse_xml(path):
2916     return XMLElementTree.parse(path).getroot()
2917
2918
2919 def string_is_integer(string):
2920     try:
2921         int(string)
2922         return True
2923     except ValueError:
2924         return False
2925
2926
2927 def local_rsync(options, fromdir, todir):
2928     '''Rsync method for local to local copying of things
2929
2930     This is an rsync wrapper with all the settings for safe use within
2931     the various fdroidserver use cases. This uses stricter rsync
2932     checking on all files since people using offline mode are already
2933     prioritizing security above ease and speed.
2934
2935     '''
2936     rsyncargs = ['rsync', '--recursive', '--safe-links', '--times', '--perms',
2937                  '--one-file-system', '--delete', '--chmod=Da+rx,Fa-x,a+r,u+w']
2938     if not options.no_checksum:
2939         rsyncargs.append('--checksum')
2940     if options.verbose:
2941         rsyncargs += ['--verbose']
2942     if options.quiet:
2943         rsyncargs += ['--quiet']
2944     logging.debug(' '.join(rsyncargs + [fromdir, todir]))
2945     if subprocess.call(rsyncargs + [fromdir, todir]) != 0:
2946         raise FDroidException()
2947
2948
2949 def get_per_app_repos():
2950     '''per-app repos are dirs named with the packageName of a single app'''
2951
2952     # Android packageNames are Java packages, they may contain uppercase or
2953     # lowercase letters ('A' through 'Z'), numbers, and underscores
2954     # ('_'). However, individual package name parts may only start with
2955     # letters. https://developer.android.com/guide/topics/manifest/manifest-element.html#package
2956     p = re.compile('^([a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*)?$')
2957
2958     repos = []
2959     for root, dirs, files in os.walk(os.getcwd()):
2960         for d in dirs:
2961             print('checking', root, 'for', d)
2962             if d in ('archive', 'metadata', 'repo', 'srclibs', 'tmp'):
2963                 # standard parts of an fdroid repo, so never packageNames
2964                 continue
2965             elif p.match(d) \
2966                     and os.path.exists(os.path.join(d, 'fdroid', 'repo', 'index.jar')):
2967                 repos.append(d)
2968         break
2969     return repos
2970
2971
2972 def is_repo_file(filename):
2973     '''Whether the file in a repo is a build product to be delivered to users'''
2974     if isinstance(filename, str):
2975         filename = filename.encode('utf-8', errors="surrogateescape")
2976     return os.path.isfile(filename) \
2977         and not filename.endswith(b'.asc') \
2978         and not filename.endswith(b'.sig') \
2979         and os.path.basename(filename) not in [
2980             b'index.jar',
2981             b'index_unsigned.jar',
2982             b'index.xml',
2983             b'index.html',
2984             b'index-v1.jar',
2985             b'index-v1.json',
2986             b'categories.txt',
2987         ]
2988
2989
2990 def get_examples_dir():
2991     '''Return the dir where the fdroidserver example files are available'''
2992     examplesdir = None
2993     tmp = os.path.dirname(sys.argv[0])
2994     if os.path.basename(tmp) == 'bin':
2995         egg_links = glob.glob(os.path.join(tmp, '..',
2996                                            'local/lib/python3.*/site-packages/fdroidserver.egg-link'))
2997         if egg_links:
2998             # installed from local git repo
2999             examplesdir = os.path.join(open(egg_links[0]).readline().rstrip(), 'examples')
3000         else:
3001             # try .egg layout
3002             examplesdir = os.path.dirname(os.path.dirname(__file__)) + '/share/doc/fdroidserver/examples'
3003             if not os.path.exists(examplesdir):  # use UNIX layout
3004                 examplesdir = os.path.dirname(tmp) + '/share/doc/fdroidserver/examples'
3005     else:
3006         # we're running straight out of the git repo
3007         prefix = os.path.normpath(os.path.join(os.path.dirname(__file__), '..'))
3008         examplesdir = prefix + '/examples'
3009
3010     return examplesdir
3011
3012
3013 def get_wiki_timestamp(timestamp=None):
3014     """Return current time in the standard format for posting to the wiki"""
3015
3016     if timestamp is None:
3017         timestamp = time.gmtime()
3018     return time.strftime("%Y-%m-%d %H:%M:%SZ", timestamp)
3019
3020
3021 def get_android_tools_versions(ndk_path=None):
3022     '''get a list of the versions of all installed Android SDK/NDK components'''
3023
3024     global config
3025     sdk_path = config['sdk_path']
3026     if sdk_path[-1] != '/':
3027         sdk_path += '/'
3028     components = []
3029     if ndk_path:
3030         ndk_release_txt = os.path.join(ndk_path, 'RELEASE.TXT')
3031         if os.path.isfile(ndk_release_txt):
3032             with open(ndk_release_txt, 'r') as fp:
3033                 components.append((os.path.basename(ndk_path), fp.read()[:-1]))
3034
3035     pattern = re.compile('^Pkg.Revision=(.+)', re.MULTILINE)
3036     for root, dirs, files in os.walk(sdk_path):
3037         if 'source.properties' in files:
3038             source_properties = os.path.join(root, 'source.properties')
3039             with open(source_properties, 'r') as fp:
3040                 m = pattern.search(fp.read())
3041                 if m:
3042                     components.append((root[len(sdk_path):], m.group(1)))
3043
3044     return components
3045
3046
3047 def get_android_tools_version_log(ndk_path=None):
3048     '''get a list of the versions of all installed Android SDK/NDK components'''
3049     log = '== Installed Android Tools ==\n\n'
3050     components = get_android_tools_versions(ndk_path)
3051     for name, version in sorted(components):
3052         log += '* ' + name + ' (' + version + ')\n'
3053
3054     return log