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