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