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