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