chiark / gitweb /
Merge branch 'stop-hanging-on-git-ssh' 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                 if os.path.islink(dest):
1616                     FDroidPopen(['unlink', dest], output=False)
1617                 else:
1618                     FDroidPopen(['rm', '-rf', dest], output=False)
1619             else:
1620                 logging.info("...but it didn't exist")
1621
1622     remove_signing_keys(build_dir)
1623
1624     # Add required external libraries
1625     if build.extlibs:
1626         logging.info("Collecting prebuilt libraries")
1627         libsdir = os.path.join(root_dir, 'libs')
1628         if not os.path.exists(libsdir):
1629             os.mkdir(libsdir)
1630         for lib in build.extlibs:
1631             lib = lib.strip()
1632             logging.info("...installing extlib {0}".format(lib))
1633             libf = os.path.basename(lib)
1634             libsrc = os.path.join(extlib_dir, lib)
1635             if not os.path.exists(libsrc):
1636                 raise BuildException("Missing extlib file {0}".format(libsrc))
1637             shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1638
1639     # Run a pre-build command if one is required
1640     if build.prebuild:
1641         logging.info("Running 'prebuild' commands in %s" % root_dir)
1642
1643         cmd = replace_config_vars(build.prebuild, build)
1644
1645         # Substitute source library paths into prebuild commands
1646         for name, number, libpath in srclibpaths:
1647             libpath = os.path.relpath(libpath, root_dir)
1648             cmd = cmd.replace('$$' + name + '$$', libpath)
1649
1650         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1651         if p.returncode != 0:
1652             raise BuildException("Error running prebuild command for %s:%s" %
1653                                  (app.id, build.versionName), p.output)
1654
1655     # Generate (or update) the ant build file, build.xml...
1656     if build.build_method() == 'ant' and build.androidupdate != ['no']:
1657         parms = ['android', 'update', 'lib-project']
1658         lparms = ['android', 'update', 'project']
1659
1660         if build.target:
1661             parms += ['-t', build.target]
1662             lparms += ['-t', build.target]
1663         if build.androidupdate:
1664             update_dirs = build.androidupdate
1665         else:
1666             update_dirs = ant_subprojects(root_dir) + ['.']
1667
1668         for d in update_dirs:
1669             subdir = os.path.join(root_dir, d)
1670             if d == '.':
1671                 logging.debug("Updating main project")
1672                 cmd = parms + ['-p', d]
1673             else:
1674                 logging.debug("Updating subproject %s" % d)
1675                 cmd = lparms + ['-p', d]
1676             p = SdkToolsPopen(cmd, cwd=root_dir)
1677             # Check to see whether an error was returned without a proper exit
1678             # code (this is the case for the 'no target set or target invalid'
1679             # error)
1680             if p.returncode != 0 or p.output.startswith("Error: "):
1681                 raise BuildException("Failed to update project at %s" % d, p.output)
1682             # Clean update dirs via ant
1683             if d != '.':
1684                 logging.info("Cleaning subproject %s" % d)
1685                 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1686
1687     return (root_dir, srclibpaths)
1688
1689
1690 def getpaths_map(build_dir, globpaths):
1691     """Extend via globbing the paths from a field and return them as a map from original path to resulting paths"""
1692     paths = dict()
1693     for p in globpaths:
1694         p = p.strip()
1695         full_path = os.path.join(build_dir, p)
1696         full_path = os.path.normpath(full_path)
1697         paths[p] = [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1698         if not paths[p]:
1699             raise FDroidException("glob path '%s' did not match any files/dirs" % p)
1700     return paths
1701
1702
1703 def getpaths(build_dir, globpaths):
1704     """Extend via globbing the paths from a field and return them as a set"""
1705     paths_map = getpaths_map(build_dir, globpaths)
1706     paths = set()
1707     for k, v in paths_map.items():
1708         for p in v:
1709             paths.add(p)
1710     return paths
1711
1712
1713 def natural_key(s):
1714     return [int(sp) if sp.isdigit() else sp for sp in re.split(r'(\d+)', s)]
1715
1716
1717 class KnownApks:
1718     """permanent store of existing APKs with the date they were added
1719
1720     This is currently the only way to permanently store the "updated"
1721     date of APKs.
1722     """
1723
1724     def __init__(self):
1725         '''Load filename/date info about previously seen APKs
1726
1727         Since the appid and date strings both will never have spaces,
1728         this is parsed as a list from the end to allow the filename to
1729         have any combo of spaces.
1730         '''
1731
1732         self.path = os.path.join('stats', 'known_apks.txt')
1733         self.apks = {}
1734         if os.path.isfile(self.path):
1735             with open(self.path, 'r', encoding='utf8') as f:
1736                 for line in f:
1737                     t = line.rstrip().split(' ')
1738                     if len(t) == 2:
1739                         self.apks[t[0]] = (t[1], None)
1740                     else:
1741                         appid = t[-2]
1742                         date = datetime.strptime(t[-1], '%Y-%m-%d')
1743                         filename = line[0:line.rfind(appid) - 1]
1744                         self.apks[filename] = (appid, date)
1745         self.changed = False
1746
1747     def writeifchanged(self):
1748         if not self.changed:
1749             return
1750
1751         if not os.path.exists('stats'):
1752             os.mkdir('stats')
1753
1754         lst = []
1755         for apk, app in self.apks.items():
1756             appid, added = app
1757             line = apk + ' ' + appid
1758             if added:
1759                 line += ' ' + added.strftime('%Y-%m-%d')
1760             lst.append(line)
1761
1762         with open(self.path, 'w', encoding='utf8') as f:
1763             for line in sorted(lst, key=natural_key):
1764                 f.write(line + '\n')
1765
1766     def recordapk(self, apkName, app, default_date=None):
1767         '''
1768         Record an apk (if it's new, otherwise does nothing)
1769         Returns the date it was added as a datetime instance
1770         '''
1771         if apkName not in self.apks:
1772             if default_date is None:
1773                 default_date = datetime.utcnow()
1774             self.apks[apkName] = (app, default_date)
1775             self.changed = True
1776         _ignored, added = self.apks[apkName]
1777         return added
1778
1779     def getapp(self, apkname):
1780         """Look up information - given the 'apkname', returns (app id, date added/None).
1781
1782         Or returns None for an unknown apk.
1783         """
1784         if apkname in self.apks:
1785             return self.apks[apkname]
1786         return None
1787
1788     def getlatest(self, num):
1789         """Get the most recent 'num' apps added to the repo, as a list of package ids with the most recent first"""
1790         apps = {}
1791         for apk, app in self.apks.items():
1792             appid, added = app
1793             if added:
1794                 if appid in apps:
1795                     if apps[appid] > added:
1796                         apps[appid] = added
1797                 else:
1798                     apps[appid] = added
1799         sortedapps = sorted(apps.items(), key=operator.itemgetter(1))[-num:]
1800         lst = [app for app, _ignored in sortedapps]
1801         lst.reverse()
1802         return lst
1803
1804
1805 def get_file_extension(filename):
1806     """get the normalized file extension, can be blank string but never None"""
1807     if isinstance(filename, bytes):
1808         filename = filename.decode('utf-8')
1809     return os.path.splitext(filename)[1].lower()[1:]
1810
1811
1812 def get_apk_debuggable_aapt(apkfile):
1813     p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1814                       output=False)
1815     if p.returncode != 0:
1816         raise FDroidException(_("Failed to get APK manifest information"))
1817     for line in p.output.splitlines():
1818         if 'android:debuggable' in line and not line.endswith('0x0'):
1819             return True
1820     return False
1821
1822
1823 def get_apk_debuggable_androguard(apkfile):
1824     try:
1825         from androguard.core.bytecodes.apk import APK
1826     except ImportError:
1827         raise FDroidException("androguard library is not installed and aapt not present")
1828
1829     apkobject = APK(apkfile)
1830     if apkobject.is_valid_APK():
1831         debuggable = apkobject.get_element("application", "debuggable")
1832         if debuggable is not None:
1833             return bool(strtobool(debuggable))
1834     return False
1835
1836
1837 def isApkAndDebuggable(apkfile):
1838     """Returns True if the given file is an APK and is debuggable
1839
1840     :param apkfile: full path to the apk to check"""
1841
1842     if get_file_extension(apkfile) != 'apk':
1843         return False
1844
1845     if SdkToolsPopen(['aapt', 'version'], output=False):
1846         return get_apk_debuggable_aapt(apkfile)
1847     else:
1848         return get_apk_debuggable_androguard(apkfile)
1849
1850
1851 def get_apk_id_aapt(apkfile):
1852     """Extrat identification information from APK using aapt.
1853
1854     :param apkfile: path to an APK file.
1855     :returns: triplet (appid, version code, version name)
1856     """
1857     r = re.compile("package: name='(?P<appid>.*)' versionCode='(?P<vercode>.*)' versionName='(?P<vername>.*)' platformBuildVersionName='.*'")
1858     p = SdkToolsPopen(['aapt', 'dump', 'badging', apkfile], output=False)
1859     for line in p.output.splitlines():
1860         m = r.match(line)
1861         if m:
1862             return m.group('appid'), m.group('vercode'), m.group('vername')
1863     raise FDroidException(_("Reading packageName/versionCode/versionName failed, APK invalid: '{apkfilename}'")
1864                           .format(apkfilename=apkfile))
1865
1866
1867 class PopenResult:
1868     def __init__(self):
1869         self.returncode = None
1870         self.output = None
1871
1872
1873 def SdkToolsPopen(commands, cwd=None, output=True):
1874     cmd = commands[0]
1875     if cmd not in config:
1876         config[cmd] = find_sdk_tools_cmd(commands[0])
1877     abscmd = config[cmd]
1878     if abscmd is None:
1879         raise FDroidException(_("Could not find '{command}' on your system").format(command=cmd))
1880     if cmd == 'aapt':
1881         test_aapt_version(config['aapt'])
1882     return FDroidPopen([abscmd] + commands[1:],
1883                        cwd=cwd, output=output)
1884
1885
1886 def FDroidPopenBytes(commands, cwd=None, envs=None, output=True, stderr_to_stdout=True):
1887     """
1888     Run a command and capture the possibly huge output as bytes.
1889
1890     :param commands: command and argument list like in subprocess.Popen
1891     :param cwd: optionally specifies a working directory
1892     :param envs: a optional dictionary of environment variables and their values
1893     :returns: A PopenResult.
1894     """
1895
1896     global env
1897     if env is None:
1898         set_FDroidPopen_env()
1899
1900     process_env = env.copy()
1901     if envs is not None and len(envs) > 0:
1902         process_env.update(envs)
1903
1904     if cwd:
1905         cwd = os.path.normpath(cwd)
1906         logging.debug("Directory: %s" % cwd)
1907     logging.debug("> %s" % ' '.join(commands))
1908
1909     stderr_param = subprocess.STDOUT if stderr_to_stdout else subprocess.PIPE
1910     result = PopenResult()
1911     p = None
1912     try:
1913         p = subprocess.Popen(commands, cwd=cwd, shell=False, env=process_env,
1914                              stdout=subprocess.PIPE, stderr=stderr_param)
1915     except OSError as e:
1916         raise BuildException("OSError while trying to execute " +
1917                              ' '.join(commands) + ': ' + str(e))
1918
1919     if not stderr_to_stdout and options.verbose:
1920         stderr_queue = Queue()
1921         stderr_reader = AsynchronousFileReader(p.stderr, stderr_queue)
1922
1923         while not stderr_reader.eof():
1924             while not stderr_queue.empty():
1925                 line = stderr_queue.get()
1926                 sys.stderr.buffer.write(line)
1927                 sys.stderr.flush()
1928
1929             time.sleep(0.1)
1930
1931     stdout_queue = Queue()
1932     stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1933     buf = io.BytesIO()
1934
1935     # Check the queue for output (until there is no more to get)
1936     while not stdout_reader.eof():
1937         while not stdout_queue.empty():
1938             line = stdout_queue.get()
1939             if output and options.verbose:
1940                 # Output directly to console
1941                 sys.stderr.buffer.write(line)
1942                 sys.stderr.flush()
1943             buf.write(line)
1944
1945         time.sleep(0.1)
1946
1947     result.returncode = p.wait()
1948     result.output = buf.getvalue()
1949     buf.close()
1950     # make sure all filestreams of the subprocess are closed
1951     for streamvar in ['stdin', 'stdout', 'stderr']:
1952         if hasattr(p, streamvar):
1953             stream = getattr(p, streamvar)
1954             if stream:
1955                 stream.close()
1956     return result
1957
1958
1959 def FDroidPopen(commands, cwd=None, envs=None, output=True, stderr_to_stdout=True):
1960     """
1961     Run a command and capture the possibly huge output as a str.
1962
1963     :param commands: command and argument list like in subprocess.Popen
1964     :param cwd: optionally specifies a working directory
1965     :param envs: a optional dictionary of environment variables and their values
1966     :returns: A PopenResult.
1967     """
1968     result = FDroidPopenBytes(commands, cwd, envs, output, stderr_to_stdout)
1969     result.output = result.output.decode('utf-8', 'ignore')
1970     return result
1971
1972
1973 gradle_comment = re.compile(r'[ ]*//')
1974 gradle_signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1975 gradle_line_matches = [
1976     re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1977     re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1978     re.compile(r'.*\.readLine\(.*'),
1979 ]
1980
1981
1982 def remove_signing_keys(build_dir):
1983     for root, dirs, files in os.walk(build_dir):
1984         if 'build.gradle' in files:
1985             path = os.path.join(root, 'build.gradle')
1986
1987             with open(path, "r", encoding='utf8') as o:
1988                 lines = o.readlines()
1989
1990             changed = False
1991
1992             opened = 0
1993             i = 0
1994             with open(path, "w", encoding='utf8') as o:
1995                 while i < len(lines):
1996                     line = lines[i]
1997                     i += 1
1998                     while line.endswith('\\\n'):
1999                         line = line.rstrip('\\\n') + lines[i]
2000                         i += 1
2001
2002                     if gradle_comment.match(line):
2003                         o.write(line)
2004                         continue
2005
2006                     if opened > 0:
2007                         opened += line.count('{')
2008                         opened -= line.count('}')
2009                         continue
2010
2011                     if gradle_signing_configs.match(line):
2012                         changed = True
2013                         opened += 1
2014                         continue
2015
2016                     if any(s.match(line) for s in gradle_line_matches):
2017                         changed = True
2018                         continue
2019
2020                     if opened == 0:
2021                         o.write(line)
2022
2023             if changed:
2024                 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
2025
2026         for propfile in [
2027                 'project.properties',
2028                 'build.properties',
2029                 'default.properties',
2030                 'ant.properties', ]:
2031             if propfile in files:
2032                 path = os.path.join(root, propfile)
2033
2034                 with open(path, "r", encoding='iso-8859-1') as o:
2035                     lines = o.readlines()
2036
2037                 changed = False
2038
2039                 with open(path, "w", encoding='iso-8859-1') as o:
2040                     for line in lines:
2041                         if any(line.startswith(s) for s in ('key.store', 'key.alias')):
2042                             changed = True
2043                             continue
2044
2045                         o.write(line)
2046
2047                 if changed:
2048                     logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
2049
2050
2051 def set_FDroidPopen_env(build=None):
2052     '''
2053     set up the environment variables for the build environment
2054
2055     There is only a weak standard, the variables used by gradle, so also set
2056     up the most commonly used environment variables for SDK and NDK.  Also, if
2057     there is no locale set, this will set the locale (e.g. LANG) to en_US.UTF-8.
2058     '''
2059     global env, orig_path
2060
2061     if env is None:
2062         env = os.environ
2063         orig_path = env['PATH']
2064         for n in ['ANDROID_HOME', 'ANDROID_SDK']:
2065             env[n] = config['sdk_path']
2066         for k, v in config['java_paths'].items():
2067             env['JAVA%s_HOME' % k] = v
2068
2069     missinglocale = True
2070     for k, v in env.items():
2071         if k == 'LANG' and v != 'C':
2072             missinglocale = False
2073         elif k == 'LC_ALL':
2074             missinglocale = False
2075     if missinglocale:
2076         env['LANG'] = 'en_US.UTF-8'
2077
2078     if build is not None:
2079         path = build.ndk_path()
2080         paths = orig_path.split(os.pathsep)
2081         if path not in paths:
2082             paths = [path] + paths
2083             env['PATH'] = os.pathsep.join(paths)
2084         for n in ['ANDROID_NDK', 'NDK', 'ANDROID_NDK_HOME']:
2085             env[n] = build.ndk_path()
2086
2087
2088 def replace_build_vars(cmd, build):
2089     cmd = cmd.replace('$$COMMIT$$', build.commit)
2090     cmd = cmd.replace('$$VERSION$$', build.versionName)
2091     cmd = cmd.replace('$$VERCODE$$', build.versionCode)
2092     return cmd
2093
2094
2095 def replace_config_vars(cmd, build):
2096     cmd = cmd.replace('$$SDK$$', config['sdk_path'])
2097     cmd = cmd.replace('$$NDK$$', build.ndk_path())
2098     cmd = cmd.replace('$$MVN3$$', config['mvn3'])
2099     cmd = cmd.replace('$$QT$$', config['qt_sdk_path'] or '')
2100     if build is not None:
2101         cmd = replace_build_vars(cmd, build)
2102     return cmd
2103
2104
2105 def place_srclib(root_dir, number, libpath):
2106     if not number:
2107         return
2108     relpath = os.path.relpath(libpath, root_dir)
2109     proppath = os.path.join(root_dir, 'project.properties')
2110
2111     lines = []
2112     if os.path.isfile(proppath):
2113         with open(proppath, "r", encoding='iso-8859-1') as o:
2114             lines = o.readlines()
2115
2116     with open(proppath, "w", encoding='iso-8859-1') as o:
2117         placed = False
2118         for line in lines:
2119             if line.startswith('android.library.reference.%d=' % number):
2120                 o.write('android.library.reference.%d=%s\n' % (number, relpath))
2121                 placed = True
2122             else:
2123                 o.write(line)
2124         if not placed:
2125             o.write('android.library.reference.%d=%s\n' % (number, relpath))
2126
2127
2128 apk_sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA|DSA|EC)')
2129
2130
2131 def signer_fingerprint_short(sig):
2132     """Obtain shortened sha256 signing-key fingerprint for pkcs7 signature.
2133
2134     Extracts the first 7 hexadecimal digits of sha256 signing-key fingerprint
2135     for a given pkcs7 signature.
2136
2137     :param sig: Contents of an APK signing certificate.
2138     :returns: shortened signing-key fingerprint.
2139     """
2140     return signer_fingerprint(sig)[:7]
2141
2142
2143 def signer_fingerprint(sig):
2144     """Obtain sha256 signing-key fingerprint for pkcs7 signature.
2145
2146     Extracts hexadecimal sha256 signing-key fingerprint string
2147     for a given pkcs7 signature.
2148
2149     :param: Contents of an APK signature.
2150     :returns: shortened signature fingerprint.
2151     """
2152     cert_encoded = get_certificate(sig)
2153     return hashlib.sha256(cert_encoded).hexdigest()
2154
2155
2156 def apk_signer_fingerprint(apk_path):
2157     """Obtain sha256 signing-key fingerprint for APK.
2158
2159     Extracts hexadecimal sha256 signing-key fingerprint string
2160     for a given APK.
2161
2162     :param apkpath: path to APK
2163     :returns: signature fingerprint
2164     """
2165
2166     with zipfile.ZipFile(apk_path, 'r') as apk:
2167         certs = [n for n in apk.namelist() if CERT_PATH_REGEX.match(n)]
2168
2169         if len(certs) < 1:
2170             logging.error("Found no signing certificates on %s" % apk_path)
2171             return None
2172         if len(certs) > 1:
2173             logging.error("Found multiple signing certificates on %s" % apk_path)
2174             return None
2175
2176         cert = apk.read(certs[0])
2177         return signer_fingerprint(cert)
2178
2179
2180 def apk_signer_fingerprint_short(apk_path):
2181     """Obtain shortened sha256 signing-key fingerprint for APK.
2182
2183     Extracts the first 7 hexadecimal digits of sha256 signing-key fingerprint
2184     for a given pkcs7 APK.
2185
2186     :param apk_path: path to APK
2187     :returns: shortened signing-key fingerprint
2188     """
2189     return apk_signer_fingerprint(apk_path)[:7]
2190
2191
2192 def metadata_get_sigdir(appid, vercode=None):
2193     """Get signature directory for app"""
2194     if vercode:
2195         return os.path.join('metadata', appid, 'signatures', vercode)
2196     else:
2197         return os.path.join('metadata', appid, 'signatures')
2198
2199
2200 def metadata_find_developer_signature(appid, vercode=None):
2201     """Tires to find the developer signature for given appid.
2202
2203     This picks the first signature file found in metadata an returns its
2204     signature.
2205
2206     :returns: sha256 signing key fingerprint of the developer signing key.
2207         None in case no signature can not be found."""
2208
2209     # fetch list of dirs for all versions of signatures
2210     appversigdirs = []
2211     if vercode:
2212         appversigdirs.append(metadata_get_sigdir(appid, vercode))
2213     else:
2214         appsigdir = metadata_get_sigdir(appid)
2215         if os.path.isdir(appsigdir):
2216             numre = re.compile('[0-9]+')
2217             for ver in os.listdir(appsigdir):
2218                 if numre.match(ver):
2219                     appversigdir = os.path.join(appsigdir, ver)
2220                     appversigdirs.append(appversigdir)
2221
2222     for sigdir in appversigdirs:
2223         sigs = glob.glob(os.path.join(sigdir, '*.DSA')) + \
2224             glob.glob(os.path.join(sigdir, '*.EC')) + \
2225             glob.glob(os.path.join(sigdir, '*.RSA'))
2226         if len(sigs) > 1:
2227             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))
2228         for sig in sigs:
2229             with open(sig, 'rb') as f:
2230                 return signer_fingerprint(f.read())
2231     return None
2232
2233
2234 def metadata_find_signing_files(appid, vercode):
2235     """Gets a list of singed manifests and signatures.
2236
2237     :param appid: app id string
2238     :param vercode: app version code
2239     :returns: a list of triplets for each signing key with following paths:
2240         (signature_file, singed_file, manifest_file)
2241     """
2242     ret = []
2243     sigdir = metadata_get_sigdir(appid, vercode)
2244     sigs = glob.glob(os.path.join(sigdir, '*.DSA')) + \
2245         glob.glob(os.path.join(sigdir, '*.EC')) + \
2246         glob.glob(os.path.join(sigdir, '*.RSA'))
2247     extre = re.compile('(\.DSA|\.EC|\.RSA)$')
2248     for sig in sigs:
2249         sf = extre.sub('.SF', sig)
2250         if os.path.isfile(sf):
2251             mf = os.path.join(sigdir, 'MANIFEST.MF')
2252             if os.path.isfile(mf):
2253                 ret.append((sig, sf, mf))
2254     return ret
2255
2256
2257 def metadata_find_developer_signing_files(appid, vercode):
2258     """Get developer signature files for specified app from metadata.
2259
2260     :returns: A triplet of paths for signing files from metadata:
2261         (signature_file, singed_file, manifest_file)
2262     """
2263     allsigningfiles = metadata_find_signing_files(appid, vercode)
2264     if allsigningfiles and len(allsigningfiles) == 1:
2265         return allsigningfiles[0]
2266     else:
2267         return None
2268
2269
2270 def apk_strip_signatures(signed_apk, strip_manifest=False):
2271     """Removes signatures from APK.
2272
2273     :param signed_apk: path to apk file.
2274     :param strip_manifest: when set to True also the manifest file will
2275         be removed from the APK.
2276     """
2277     with tempfile.TemporaryDirectory() as tmpdir:
2278         tmp_apk = os.path.join(tmpdir, 'tmp.apk')
2279         os.rename(signed_apk, tmp_apk)
2280         with ZipFile(tmp_apk, 'r') as in_apk:
2281             with ZipFile(signed_apk, 'w') as out_apk:
2282                 for info in in_apk.infolist():
2283                     if not apk_sigfile.match(info.filename):
2284                         if strip_manifest:
2285                             if info.filename != 'META-INF/MANIFEST.MF':
2286                                 buf = in_apk.read(info.filename)
2287                                 out_apk.writestr(info, buf)
2288                         else:
2289                             buf = in_apk.read(info.filename)
2290                             out_apk.writestr(info, buf)
2291
2292
2293 def apk_implant_signatures(apkpath, signaturefile, signedfile, manifest):
2294     """Implats a signature from metadata into an APK.
2295
2296     Note: this changes there supplied APK in place. So copy it if you
2297     need the original to be preserved.
2298
2299     :param apkpath: location of the apk
2300     """
2301     # get list of available signature files in metadata
2302     with tempfile.TemporaryDirectory() as tmpdir:
2303         apkwithnewsig = os.path.join(tmpdir, 'newsig.apk')
2304         with ZipFile(apkpath, 'r') as in_apk:
2305             with ZipFile(apkwithnewsig, 'w') as out_apk:
2306                 for sig_file in [signaturefile, signedfile, manifest]:
2307                     with open(sig_file, 'rb') as fp:
2308                         buf = fp.read()
2309                     info = zipfile.ZipInfo('META-INF/' + os.path.basename(sig_file))
2310                     info.compress_type = zipfile.ZIP_DEFLATED
2311                     info.create_system = 0  # "Windows" aka "FAT", what Android SDK uses
2312                     out_apk.writestr(info, buf)
2313                 for info in in_apk.infolist():
2314                     if not apk_sigfile.match(info.filename):
2315                         if info.filename != 'META-INF/MANIFEST.MF':
2316                             buf = in_apk.read(info.filename)
2317                             out_apk.writestr(info, buf)
2318         os.remove(apkpath)
2319         p = SdkToolsPopen(['zipalign', '-v', '4', apkwithnewsig, apkpath])
2320         if p.returncode != 0:
2321             raise BuildException("Failed to align application")
2322
2323
2324 def apk_extract_signatures(apkpath, outdir, manifest=True):
2325     """Extracts a signature files from APK and puts them into target directory.
2326
2327     :param apkpath: location of the apk
2328     :param outdir: folder where the extracted signature files will be stored
2329     :param manifest: (optionally) disable extracting manifest file
2330     """
2331     with ZipFile(apkpath, 'r') as in_apk:
2332         for f in in_apk.infolist():
2333             if apk_sigfile.match(f.filename) or \
2334                     (manifest and f.filename == 'META-INF/MANIFEST.MF'):
2335                 newpath = os.path.join(outdir, os.path.basename(f.filename))
2336                 with open(newpath, 'wb') as out_file:
2337                     out_file.write(in_apk.read(f.filename))
2338
2339
2340 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
2341     """Verify that two apks are the same
2342
2343     One of the inputs is signed, the other is unsigned. The signature metadata
2344     is transferred from the signed to the unsigned apk, and then jarsigner is
2345     used to verify that the signature from the signed apk is also varlid for
2346     the unsigned one.  If the APK given as unsigned actually does have a
2347     signature, it will be stripped out and ignored.
2348
2349     There are two SHA1 git commit IDs that fdroidserver includes in the builds
2350     it makes: fdroidserverid and buildserverid.  Originally, these were inserted
2351     into AndroidManifest.xml, but that makes the build not reproducible. So
2352     instead they are included as separate files in the APK's META-INF/ folder.
2353     If those files exist in the signed APK, they will be part of the signature
2354     and need to also be included in the unsigned APK for it to validate.
2355
2356     :param signed_apk: Path to a signed apk file
2357     :param unsigned_apk: Path to an unsigned apk file expected to match it
2358     :param tmp_dir: Path to directory for temporary files
2359     :returns: None if the verification is successful, otherwise a string
2360               describing what went wrong.
2361     """
2362
2363     if not os.path.isfile(signed_apk):
2364         return 'can not verify: file does not exists: {}'.format(signed_apk)
2365
2366     if not os.path.isfile(unsigned_apk):
2367         return 'can not verify: file does not exists: {}'.format(unsigned_apk)
2368
2369     with ZipFile(signed_apk, 'r') as signed:
2370         meta_inf_files = ['META-INF/MANIFEST.MF']
2371         for f in signed.namelist():
2372             if apk_sigfile.match(f) \
2373                or f in ['META-INF/fdroidserverid', 'META-INF/buildserverid']:
2374                 meta_inf_files.append(f)
2375         if len(meta_inf_files) < 3:
2376             return "Signature files missing from {0}".format(signed_apk)
2377
2378         tmp_apk = os.path.join(tmp_dir, 'sigcp_' + os.path.basename(unsigned_apk))
2379         with ZipFile(unsigned_apk, 'r') as unsigned:
2380             # only read the signature from the signed APK, everything else from unsigned
2381             with ZipFile(tmp_apk, 'w') as tmp:
2382                 for filename in meta_inf_files:
2383                     tmp.writestr(signed.getinfo(filename), signed.read(filename))
2384                 for info in unsigned.infolist():
2385                     if info.filename in meta_inf_files:
2386                         logging.warning('Ignoring %s from %s',
2387                                         info.filename, unsigned_apk)
2388                         continue
2389                     if info.filename in tmp.namelist():
2390                         return "duplicate filename found: " + info.filename
2391                     tmp.writestr(info, unsigned.read(info.filename))
2392
2393     verified = verify_apk_signature(tmp_apk)
2394
2395     if not verified:
2396         logging.info("...NOT verified - {0}".format(tmp_apk))
2397         return compare_apks(signed_apk, tmp_apk, tmp_dir,
2398                             os.path.dirname(unsigned_apk))
2399
2400     logging.info("...successfully verified")
2401     return None
2402
2403
2404 def verify_jar_signature(jar):
2405     """Verifies the signature of a given JAR file.
2406
2407     jarsigner is very shitty: unsigned JARs pass as "verified"! So
2408     this has to turn on -strict then check for result 4, since this
2409     does not expect the signature to be from a CA-signed certificate.
2410
2411     :raises: VerificationException() if the JAR's signature could not be verified
2412
2413     """
2414
2415     if subprocess.call([config['jarsigner'], '-strict', '-verify', jar]) != 4:
2416         raise VerificationException(_("The repository's index could not be verified."))
2417
2418
2419 def verify_apk_signature(apk, min_sdk_version=None):
2420     """verify the signature on an APK
2421
2422     Try to use apksigner whenever possible since jarsigner is very
2423     shitty: unsigned APKs pass as "verified"!  Warning, this does
2424     not work on JARs with apksigner >= 0.7 (build-tools 26.0.1)
2425
2426     :returns: boolean whether the APK was verified
2427     """
2428     if set_command_in_config('apksigner'):
2429         args = [config['apksigner'], 'verify']
2430         if min_sdk_version:
2431             args += ['--min-sdk-version=' + min_sdk_version]
2432         return subprocess.call(args + [apk]) == 0
2433     else:
2434         logging.warning("Using Java's jarsigner, not recommended for verifying APKs! Use apksigner")
2435         try:
2436             verify_jar_signature(apk)
2437             return True
2438         except Exception:
2439             pass
2440     return False
2441
2442
2443 def verify_old_apk_signature(apk):
2444     """verify the signature on an archived APK, supporting deprecated algorithms
2445
2446     F-Droid aims to keep every single binary that it ever published.  Therefore,
2447     it needs to be able to verify APK signatures that include deprecated/removed
2448     algorithms.  For example, jarsigner treats an MD5 signature as unsigned.
2449
2450     jarsigner passes unsigned APKs as "verified"! So this has to turn
2451     on -strict then check for result 4.
2452
2453     :returns: boolean whether the APK was verified
2454     """
2455
2456     _java_security = os.path.join(os.getcwd(), '.java.security')
2457     with open(_java_security, 'w') as fp:
2458         fp.write('jdk.jar.disabledAlgorithms=MD2, RSA keySize < 1024')
2459
2460     return subprocess.call([config['jarsigner'], '-J-Djava.security.properties=' + _java_security,
2461                             '-strict', '-verify', apk]) == 4
2462
2463
2464 apk_badchars = re.compile('''[/ :;'"]''')
2465
2466
2467 def compare_apks(apk1, apk2, tmp_dir, log_dir=None):
2468     """Compare two apks
2469
2470     Returns None if the apk content is the same (apart from the signing key),
2471     otherwise a string describing what's different, or what went wrong when
2472     trying to do the comparison.
2473     """
2474
2475     if not log_dir:
2476         log_dir = tmp_dir
2477
2478     absapk1 = os.path.abspath(apk1)
2479     absapk2 = os.path.abspath(apk2)
2480
2481     if set_command_in_config('diffoscope'):
2482         logfilename = os.path.join(log_dir, os.path.basename(absapk1))
2483         htmlfile = logfilename + '.diffoscope.html'
2484         textfile = logfilename + '.diffoscope.txt'
2485         if subprocess.call([config['diffoscope'],
2486                             '--max-report-size', '12345678', '--max-diff-block-lines', '100',
2487                             '--html', htmlfile, '--text', textfile,
2488                             absapk1, absapk2]) != 0:
2489             return("Failed to unpack " + apk1)
2490
2491     apk1dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk1[0:-4]))  # trim .apk
2492     apk2dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk2[0:-4]))  # trim .apk
2493     for d in [apk1dir, apk2dir]:
2494         if os.path.exists(d):
2495             shutil.rmtree(d)
2496         os.mkdir(d)
2497         os.mkdir(os.path.join(d, 'jar-xf'))
2498
2499     if subprocess.call(['jar', 'xf',
2500                         os.path.abspath(apk1)],
2501                        cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
2502         return("Failed to unpack " + apk1)
2503     if subprocess.call(['jar', 'xf',
2504                         os.path.abspath(apk2)],
2505                        cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
2506         return("Failed to unpack " + apk2)
2507
2508     if set_command_in_config('apktool'):
2509         if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
2510                            cwd=apk1dir) != 0:
2511             return("Failed to unpack " + apk1)
2512         if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
2513                            cwd=apk2dir) != 0:
2514             return("Failed to unpack " + apk2)
2515
2516     p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
2517     lines = p.output.splitlines()
2518     if len(lines) != 1 or 'META-INF' not in lines[0]:
2519         if set_command_in_config('meld'):
2520             p = FDroidPopen([config['meld'], apk1dir, apk2dir], output=False)
2521         return("Unexpected diff output - " + p.output)
2522
2523     # since everything verifies, delete the comparison to keep cruft down
2524     shutil.rmtree(apk1dir)
2525     shutil.rmtree(apk2dir)
2526
2527     # If we get here, it seems like they're the same!
2528     return None
2529
2530
2531 def set_command_in_config(command):
2532     '''Try to find specified command in the path, if it hasn't been
2533     manually set in config.py.  If found, it is added to the config
2534     dict.  The return value says whether the command is available.
2535
2536     '''
2537     if command in config:
2538         return True
2539     else:
2540         tmp = find_command(command)
2541         if tmp is not None:
2542             config[command] = tmp
2543             return True
2544     return False
2545
2546
2547 def find_command(command):
2548     '''find the full path of a command, or None if it can't be found in the PATH'''
2549
2550     def is_exe(fpath):
2551         return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
2552
2553     fpath, fname = os.path.split(command)
2554     if fpath:
2555         if is_exe(command):
2556             return command
2557     else:
2558         for path in os.environ["PATH"].split(os.pathsep):
2559             path = path.strip('"')
2560             exe_file = os.path.join(path, command)
2561             if is_exe(exe_file):
2562                 return exe_file
2563
2564     return None
2565
2566
2567 def genpassword():
2568     '''generate a random password for when generating keys'''
2569     h = hashlib.sha256()
2570     h.update(os.urandom(16))  # salt
2571     h.update(socket.getfqdn().encode('utf-8'))
2572     passwd = base64.b64encode(h.digest()).strip()
2573     return passwd.decode('utf-8')
2574
2575
2576 def genkeystore(localconfig):
2577     """
2578     Generate a new key with password provided in :param localconfig and add it to new keystore
2579     :return: hexed public key, public key fingerprint
2580     """
2581     logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
2582     keystoredir = os.path.dirname(localconfig['keystore'])
2583     if keystoredir is None or keystoredir == '':
2584         keystoredir = os.path.join(os.getcwd(), keystoredir)
2585     if not os.path.exists(keystoredir):
2586         os.makedirs(keystoredir, mode=0o700)
2587
2588     env_vars = {
2589         'FDROID_KEY_STORE_PASS': localconfig['keystorepass'],
2590         'FDROID_KEY_PASS': localconfig['keypass'],
2591     }
2592     p = FDroidPopen([config['keytool'], '-genkey',
2593                      '-keystore', localconfig['keystore'],
2594                      '-alias', localconfig['repo_keyalias'],
2595                      '-keyalg', 'RSA', '-keysize', '4096',
2596                      '-sigalg', 'SHA256withRSA',
2597                      '-validity', '10000',
2598                      '-storepass:env', 'FDROID_KEY_STORE_PASS',
2599                      '-keypass:env', 'FDROID_KEY_PASS',
2600                      '-dname', localconfig['keydname']], envs=env_vars)
2601     if p.returncode != 0:
2602         raise BuildException("Failed to generate key", p.output)
2603     os.chmod(localconfig['keystore'], 0o0600)
2604     if not options.quiet:
2605         # now show the lovely key that was just generated
2606         p = FDroidPopen([config['keytool'], '-list', '-v',
2607                          '-keystore', localconfig['keystore'],
2608                          '-alias', localconfig['repo_keyalias'],
2609                          '-storepass:env', 'FDROID_KEY_STORE_PASS'], envs=env_vars)
2610         logging.info(p.output.strip() + '\n\n')
2611     # get the public key
2612     p = FDroidPopenBytes([config['keytool'], '-exportcert',
2613                           '-keystore', localconfig['keystore'],
2614                           '-alias', localconfig['repo_keyalias'],
2615                           '-storepass:env', 'FDROID_KEY_STORE_PASS']
2616                          + config['smartcardoptions'],
2617                          envs=env_vars, output=False, stderr_to_stdout=False)
2618     if p.returncode != 0 or len(p.output) < 20:
2619         raise BuildException("Failed to get public key", p.output)
2620     pubkey = p.output
2621     fingerprint = get_cert_fingerprint(pubkey)
2622     return hexlify(pubkey), fingerprint
2623
2624
2625 def get_cert_fingerprint(pubkey):
2626     """
2627     Generate a certificate fingerprint the same way keytool does it
2628     (but with slightly different formatting)
2629     """
2630     digest = hashlib.sha256(pubkey).digest()
2631     ret = [' '.join("%02X" % b for b in bytearray(digest))]
2632     return " ".join(ret)
2633
2634
2635 def get_certificate(certificate_file):
2636     """
2637     Extracts a certificate from the given file.
2638     :param certificate_file: file bytes (as string) representing the certificate
2639     :return: A binary representation of the certificate's public key, or None in case of error
2640     """
2641     content = decoder.decode(certificate_file, asn1Spec=rfc2315.ContentInfo())[0]
2642     if content.getComponentByName('contentType') != rfc2315.signedData:
2643         return None
2644     content = decoder.decode(content.getComponentByName('content'),
2645                              asn1Spec=rfc2315.SignedData())[0]
2646     try:
2647         certificates = content.getComponentByName('certificates')
2648         cert = certificates[0].getComponentByName('certificate')
2649     except PyAsn1Error:
2650         logging.error("Certificates not found.")
2651         return None
2652     return encoder.encode(cert)
2653
2654
2655 def load_stats_fdroid_signing_key_fingerprints():
2656     """Load list of signing-key fingerprints stored by fdroid publish from file.
2657
2658     :returns: list of dictionanryies containing the singing-key fingerprints.
2659     """
2660     jar_file = os.path.join('stats', 'publishsigkeys.jar')
2661     if not os.path.isfile(jar_file):
2662         return {}
2663     cmd = [config['jarsigner'], '-strict', '-verify', jar_file]
2664     p = FDroidPopen(cmd, output=False)
2665     if p.returncode != 4:
2666         raise FDroidException("Signature validation of '{}' failed! "
2667                               "Please run publish again to rebuild this file.".format(jar_file))
2668
2669     jar_sigkey = apk_signer_fingerprint(jar_file)
2670     repo_key_sig = config.get('repo_key_sha256')
2671     if repo_key_sig:
2672         if jar_sigkey != repo_key_sig:
2673             raise FDroidException("Signature key fingerprint of file '{}' does not match repo_key_sha256 in config.py (found fingerprint: '{}')".format(jar_file, jar_sigkey))
2674     else:
2675         logging.warning("repo_key_sha256 not in config.py, setting it to the signature key fingerprint of '{}'".format(jar_file))
2676         config['repo_key_sha256'] = jar_sigkey
2677         write_to_config(config, 'repo_key_sha256')
2678
2679     with zipfile.ZipFile(jar_file, 'r') as f:
2680         return json.loads(str(f.read('publishsigkeys.json'), 'utf-8'))
2681
2682
2683 def write_to_config(thisconfig, key, value=None, config_file=None):
2684     '''write a key/value to the local config.py
2685
2686     NOTE: only supports writing string variables.
2687
2688     :param thisconfig: config dictionary
2689     :param key: variable name in config.py to be overwritten/added
2690     :param value: optional value to be written, instead of fetched
2691         from 'thisconfig' dictionary.
2692     '''
2693     if value is None:
2694         origkey = key + '_orig'
2695         value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
2696     cfg = config_file if config_file else 'config.py'
2697
2698     # load config file, create one if it doesn't exist
2699     if not os.path.exists(cfg):
2700         open(cfg, 'a').close()
2701         logging.info("Creating empty " + cfg)
2702     with open(cfg, 'r', encoding="utf-8") as f:
2703         lines = f.readlines()
2704
2705     # make sure the file ends with a carraige return
2706     if len(lines) > 0:
2707         if not lines[-1].endswith('\n'):
2708             lines[-1] += '\n'
2709
2710     # regex for finding and replacing python string variable
2711     # definitions/initializations
2712     pattern = re.compile('^[\s#]*' + key + '\s*=\s*"[^"]*"')
2713     repl = key + ' = "' + value + '"'
2714     pattern2 = re.compile('^[\s#]*' + key + "\s*=\s*'[^']*'")
2715     repl2 = key + " = '" + value + "'"
2716
2717     # If we replaced this line once, we make sure won't be a
2718     # second instance of this line for this key in the document.
2719     didRepl = False
2720     # edit config file
2721     with open(cfg, 'w', encoding="utf-8") as f:
2722         for line in lines:
2723             if pattern.match(line) or pattern2.match(line):
2724                 if not didRepl:
2725                     line = pattern.sub(repl, line)
2726                     line = pattern2.sub(repl2, line)
2727                     f.write(line)
2728                     didRepl = True
2729             else:
2730                 f.write(line)
2731         if not didRepl:
2732             f.write('\n')
2733             f.write(repl)
2734             f.write('\n')
2735
2736
2737 def parse_xml(path):
2738     return XMLElementTree.parse(path).getroot()
2739
2740
2741 def string_is_integer(string):
2742     try:
2743         int(string)
2744         return True
2745     except ValueError:
2746         return False
2747
2748
2749 def local_rsync(options, fromdir, todir):
2750     '''Rsync method for local to local copying of things
2751
2752     This is an rsync wrapper with all the settings for safe use within
2753     the various fdroidserver use cases. This uses stricter rsync
2754     checking on all files since people using offline mode are already
2755     prioritizing security above ease and speed.
2756
2757     '''
2758     rsyncargs = ['rsync', '--recursive', '--safe-links', '--times', '--perms',
2759                  '--one-file-system', '--delete', '--chmod=Da+rx,Fa-x,a+r,u+w']
2760     if not options.no_checksum:
2761         rsyncargs.append('--checksum')
2762     if options.verbose:
2763         rsyncargs += ['--verbose']
2764     if options.quiet:
2765         rsyncargs += ['--quiet']
2766     logging.debug(' '.join(rsyncargs + [fromdir, todir]))
2767     if subprocess.call(rsyncargs + [fromdir, todir]) != 0:
2768         raise FDroidException()
2769
2770
2771 def get_per_app_repos():
2772     '''per-app repos are dirs named with the packageName of a single app'''
2773
2774     # Android packageNames are Java packages, they may contain uppercase or
2775     # lowercase letters ('A' through 'Z'), numbers, and underscores
2776     # ('_'). However, individual package name parts may only start with
2777     # letters. https://developer.android.com/guide/topics/manifest/manifest-element.html#package
2778     p = re.compile('^([a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*)?$')
2779
2780     repos = []
2781     for root, dirs, files in os.walk(os.getcwd()):
2782         for d in dirs:
2783             print('checking', root, 'for', d)
2784             if d in ('archive', 'metadata', 'repo', 'srclibs', 'tmp'):
2785                 # standard parts of an fdroid repo, so never packageNames
2786                 continue
2787             elif p.match(d) \
2788                     and os.path.exists(os.path.join(d, 'fdroid', 'repo', 'index.jar')):
2789                 repos.append(d)
2790         break
2791     return repos
2792
2793
2794 def is_repo_file(filename):
2795     '''Whether the file in a repo is a build product to be delivered to users'''
2796     if isinstance(filename, str):
2797         filename = filename.encode('utf-8', errors="surrogateescape")
2798     return os.path.isfile(filename) \
2799         and not filename.endswith(b'.asc') \
2800         and not filename.endswith(b'.sig') \
2801         and os.path.basename(filename) not in [
2802             b'index.jar',
2803             b'index_unsigned.jar',
2804             b'index.xml',
2805             b'index.html',
2806             b'index-v1.jar',
2807             b'index-v1.json',
2808             b'categories.txt',
2809         ]
2810
2811
2812 def get_examples_dir():
2813     '''Return the dir where the fdroidserver example files are available'''
2814     examplesdir = None
2815     tmp = os.path.dirname(sys.argv[0])
2816     if os.path.basename(tmp) == 'bin':
2817         egg_links = glob.glob(os.path.join(tmp, '..',
2818                                            'local/lib/python3.*/site-packages/fdroidserver.egg-link'))
2819         if egg_links:
2820             # installed from local git repo
2821             examplesdir = os.path.join(open(egg_links[0]).readline().rstrip(), 'examples')
2822         else:
2823             # try .egg layout
2824             examplesdir = os.path.dirname(os.path.dirname(__file__)) + '/share/doc/fdroidserver/examples'
2825             if not os.path.exists(examplesdir):  # use UNIX layout
2826                 examplesdir = os.path.dirname(tmp) + '/share/doc/fdroidserver/examples'
2827     else:
2828         # we're running straight out of the git repo
2829         prefix = os.path.normpath(os.path.join(os.path.dirname(__file__), '..'))
2830         examplesdir = prefix + '/examples'
2831
2832     return examplesdir