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