chiark / gitweb /
build: stop git from waiting forever at username/password prompts
[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         envs.update({'GIT_TERMINAL_PROMPT': '0'})  # supported in git >= 2.3
806         return FDroidPopen(['git', ] + git_config + gitargs,
807                            envs=envs, cwd=cwd, output=output)
808
809     def checkrepo(self):
810         """If the local directory exists, but is somehow not a git repository,
811         git will traverse up the directory tree until it finds one
812         that is (i.e.  fdroidserver) and then we'll proceed to destroy
813         it!  This is called as a safety check.
814
815         """
816
817         p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
818         result = p.output.rstrip()
819         if not result.endswith(self.local):
820             raise VCSException('Repository mismatch')
821
822     def gotorevisionx(self, rev):
823         if not os.path.exists(self.local):
824             # Brand new checkout
825             p = FDroidPopen(['git', 'clone', self.remote, self.local], cwd=None)
826             if p.returncode != 0:
827                 self.clone_failed = True
828                 raise VCSException("Git clone failed", p.output)
829             self.checkrepo()
830         else:
831             self.checkrepo()
832             # Discard any working tree changes
833             p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
834                              'git', 'reset', '--hard'], cwd=self.local, output=False)
835             if p.returncode != 0:
836                 raise VCSException(_("Git reset failed"), p.output)
837             # Remove untracked files now, in case they're tracked in the target
838             # revision (it happens!)
839             p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
840                              'git', 'clean', '-dffx'], cwd=self.local, output=False)
841             if p.returncode != 0:
842                 raise VCSException(_("Git clean failed"), p.output)
843             if not self.refreshed:
844                 # Get latest commits and tags from remote
845                 p = self.GitFetchFDroidPopen(['fetch', 'origin'])
846                 if p.returncode != 0:
847                     raise VCSException(_("Git fetch failed"), p.output)
848                 p = self.GitFetchFDroidPopen(['fetch', '--prune', '--tags', 'origin'], output=False)
849                 if p.returncode != 0:
850                     raise VCSException(_("Git fetch failed"), p.output)
851                 # Recreate origin/HEAD as git clone would do it, in case it disappeared
852                 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
853                 if p.returncode != 0:
854                     lines = p.output.splitlines()
855                     if 'Multiple remote HEAD branches' not in lines[0]:
856                         raise VCSException(_("Git remote set-head failed"), p.output)
857                     branch = lines[1].split(' ')[-1]
858                     p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local, output=False)
859                     if p2.returncode != 0:
860                         raise VCSException(_("Git remote set-head failed"), p.output + '\n' + p2.output)
861                 self.refreshed = True
862         # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
863         # a github repo. Most of the time this is the same as origin/master.
864         rev = rev or 'origin/HEAD'
865         p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
866         if p.returncode != 0:
867             raise VCSException(_("Git checkout of '%s' failed") % rev, p.output)
868         # Get rid of any uncontrolled files left behind
869         p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
870         if p.returncode != 0:
871             raise VCSException(_("Git clean failed"), p.output)
872
873     def initsubmodules(self):
874         self.checkrepo()
875         submfile = os.path.join(self.local, '.gitmodules')
876         if not os.path.isfile(submfile):
877             raise VCSException(_("No git submodules available"))
878
879         # fix submodules not accessible without an account and public key auth
880         with open(submfile, 'r') as f:
881             lines = f.readlines()
882         with open(submfile, 'w') as f:
883             for line in lines:
884                 for domain in ('bitbucket.org', 'github.com', 'gitlab.com'):
885                     line = re.sub('git@' + domain + ':', 'https://u:p@' + domain + '/', line)
886                 f.write(line)
887
888         p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
889         if p.returncode != 0:
890             raise VCSException(_("Git submodule sync failed"), p.output)
891         p = self.GitFetchFDroidPopen(['submodule', 'update', '--init', '--force', '--recursive'])
892         if p.returncode != 0:
893             raise VCSException(_("Git submodule update failed"), p.output)
894
895     def _gettags(self):
896         self.checkrepo()
897         p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
898         return p.output.splitlines()
899
900     tag_format = re.compile(r'tag: ([^),]*)')
901
902     def latesttags(self):
903         self.checkrepo()
904         p = FDroidPopen(['git', 'log', '--tags',
905                          '--simplify-by-decoration', '--pretty=format:%d'],
906                         cwd=self.local, output=False)
907         tags = []
908         for line in p.output.splitlines():
909             for tag in self.tag_format.findall(line):
910                 tags.append(tag)
911         return tags
912
913
914 class vcs_gitsvn(vcs):
915
916     def repotype(self):
917         return 'git-svn'
918
919     def clientversioncmd(self):
920         return ['git', 'svn', '--version']
921
922     def checkrepo(self):
923         """If the local directory exists, but is somehow not a git repository,
924         git will traverse up the directory tree until it finds one that
925         is (i.e.  fdroidserver) and then we'll proceed to destory it!
926         This is called as a safety check.
927
928         """
929         p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
930         result = p.output.rstrip()
931         if not result.endswith(self.local):
932             raise VCSException('Repository mismatch')
933
934     def gotorevisionx(self, rev):
935         if not os.path.exists(self.local):
936             # Brand new checkout
937             gitsvn_args = ['git', 'svn', 'clone']
938             if ';' in self.remote:
939                 remote_split = self.remote.split(';')
940                 for i in remote_split[1:]:
941                     if i.startswith('trunk='):
942                         gitsvn_args.extend(['-T', i[6:]])
943                     elif i.startswith('tags='):
944                         gitsvn_args.extend(['-t', i[5:]])
945                     elif i.startswith('branches='):
946                         gitsvn_args.extend(['-b', i[9:]])
947                 gitsvn_args.extend([remote_split[0], self.local])
948                 p = FDroidPopen(gitsvn_args, output=False)
949                 if p.returncode != 0:
950                     self.clone_failed = True
951                     raise VCSException("Git svn clone failed", p.output)
952             else:
953                 gitsvn_args.extend([self.remote, self.local])
954                 p = FDroidPopen(gitsvn_args, output=False)
955                 if p.returncode != 0:
956                     self.clone_failed = True
957                     raise VCSException("Git svn clone failed", p.output)
958             self.checkrepo()
959         else:
960             self.checkrepo()
961             # Discard any working tree changes
962             p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
963             if p.returncode != 0:
964                 raise VCSException("Git reset failed", p.output)
965             # Remove untracked files now, in case they're tracked in the target
966             # revision (it happens!)
967             p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
968             if p.returncode != 0:
969                 raise VCSException("Git clean failed", p.output)
970             if not self.refreshed:
971                 # Get new commits, branches and tags from repo
972                 p = FDroidPopen(['git', 'svn', 'fetch'], cwd=self.local, output=False)
973                 if p.returncode != 0:
974                     raise VCSException("Git svn fetch failed")
975                 p = FDroidPopen(['git', 'svn', 'rebase'], cwd=self.local, output=False)
976                 if p.returncode != 0:
977                     raise VCSException("Git svn rebase failed", p.output)
978                 self.refreshed = True
979
980         rev = rev or 'master'
981         if rev:
982             nospaces_rev = rev.replace(' ', '%20')
983             # Try finding a svn tag
984             for treeish in ['origin/', '']:
985                 p = FDroidPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
986                 if p.returncode == 0:
987                     break
988             if p.returncode != 0:
989                 # No tag found, normal svn rev translation
990                 # Translate svn rev into git format
991                 rev_split = rev.split('/')
992
993                 p = None
994                 for treeish in ['origin/', '']:
995                     if len(rev_split) > 1:
996                         treeish += rev_split[0]
997                         svn_rev = rev_split[1]
998
999                     else:
1000                         # if no branch is specified, then assume trunk (i.e. 'master' branch):
1001                         treeish += 'master'
1002                         svn_rev = rev
1003
1004                     svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
1005
1006                     p = FDroidPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
1007                     git_rev = p.output.rstrip()
1008
1009                     if p.returncode == 0 and git_rev:
1010                         break
1011
1012                 if p.returncode != 0 or not git_rev:
1013                     # Try a plain git checkout as a last resort
1014                     p = FDroidPopen(['git', 'checkout', rev], cwd=self.local, output=False)
1015                     if p.returncode != 0:
1016                         raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
1017                 else:
1018                     # Check out the git rev equivalent to the svn rev
1019                     p = FDroidPopen(['git', 'checkout', git_rev], cwd=self.local, output=False)
1020                     if p.returncode != 0:
1021                         raise VCSException(_("Git checkout of '%s' failed") % rev, p.output)
1022
1023         # Get rid of any uncontrolled files left behind
1024         p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
1025         if p.returncode != 0:
1026             raise VCSException(_("Git clean failed"), p.output)
1027
1028     def _gettags(self):
1029         self.checkrepo()
1030         for treeish in ['origin/', '']:
1031             d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
1032             if os.path.isdir(d):
1033                 return os.listdir(d)
1034
1035     def getref(self):
1036         self.checkrepo()
1037         p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
1038         if p.returncode != 0:
1039             return None
1040         return p.output.strip()
1041
1042
1043 class vcs_hg(vcs):
1044
1045     def repotype(self):
1046         return 'hg'
1047
1048     def clientversioncmd(self):
1049         return ['hg', '--version']
1050
1051     def gotorevisionx(self, rev):
1052         if not os.path.exists(self.local):
1053             p = FDroidPopen(['hg', 'clone', self.remote, self.local], output=False)
1054             if p.returncode != 0:
1055                 self.clone_failed = True
1056                 raise VCSException("Hg clone failed", p.output)
1057         else:
1058             p = FDroidPopen(['hg', 'status', '-uS'], cwd=self.local, output=False)
1059             if p.returncode != 0:
1060                 raise VCSException("Hg status failed", p.output)
1061             for line in p.output.splitlines():
1062                 if not line.startswith('? '):
1063                     raise VCSException("Unexpected output from hg status -uS: " + line)
1064                 FDroidPopen(['rm', '-rf', line[2:]], cwd=self.local, output=False)
1065             if not self.refreshed:
1066                 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
1067                 if p.returncode != 0:
1068                     raise VCSException("Hg pull failed", p.output)
1069                 self.refreshed = True
1070
1071         rev = rev or 'default'
1072         if not rev:
1073             return
1074         p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
1075         if p.returncode != 0:
1076             raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
1077         p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
1078         # Also delete untracked files, we have to enable purge extension for that:
1079         if "'purge' is provided by the following extension" in p.output:
1080             with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
1081                 myfile.write("\n[extensions]\nhgext.purge=\n")
1082             p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
1083             if p.returncode != 0:
1084                 raise VCSException("HG purge failed", p.output)
1085         elif p.returncode != 0:
1086             raise VCSException("HG purge failed", p.output)
1087
1088     def _gettags(self):
1089         p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
1090         return p.output.splitlines()[1:]
1091
1092
1093 class vcs_bzr(vcs):
1094
1095     def repotype(self):
1096         return 'bzr'
1097
1098     def clientversioncmd(self):
1099         return ['bzr', '--version']
1100
1101     def gotorevisionx(self, rev):
1102         if not os.path.exists(self.local):
1103             p = FDroidPopen(['bzr', 'branch', self.remote, self.local], output=False)
1104             if p.returncode != 0:
1105                 self.clone_failed = True
1106                 raise VCSException("Bzr branch failed", p.output)
1107         else:
1108             p = FDroidPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
1109             if p.returncode != 0:
1110                 raise VCSException("Bzr revert failed", p.output)
1111             if not self.refreshed:
1112                 p = FDroidPopen(['bzr', 'pull'], cwd=self.local, output=False)
1113                 if p.returncode != 0:
1114                     raise VCSException("Bzr update failed", p.output)
1115                 self.refreshed = True
1116
1117         revargs = list(['-r', rev] if rev else [])
1118         p = FDroidPopen(['bzr', 'revert'] + revargs, cwd=self.local, output=False)
1119         if p.returncode != 0:
1120             raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
1121
1122     def _gettags(self):
1123         p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
1124         return [tag.split('   ')[0].strip() for tag in
1125                 p.output.splitlines()]
1126
1127
1128 def unescape_string(string):
1129     if len(string) < 2:
1130         return string
1131     if string[0] == '"' and string[-1] == '"':
1132         return string[1:-1]
1133
1134     return string.replace("\\'", "'")
1135
1136
1137 def retrieve_string(app_dir, string, xmlfiles=None):
1138
1139     if not string.startswith('@string/'):
1140         return unescape_string(string)
1141
1142     if xmlfiles is None:
1143         xmlfiles = []
1144         for res_dir in [
1145             os.path.join(app_dir, 'res'),
1146             os.path.join(app_dir, 'src', 'main', 'res'),
1147         ]:
1148             for root, dirs, files in os.walk(res_dir):
1149                 if os.path.basename(root) == 'values':
1150                     xmlfiles += [os.path.join(root, x) for x in files if x.endswith('.xml')]
1151
1152     name = string[len('@string/'):]
1153
1154     def element_content(element):
1155         if element.text is None:
1156             return ""
1157         s = XMLElementTree.tostring(element, encoding='utf-8', method='text')
1158         return s.decode('utf-8').strip()
1159
1160     for path in xmlfiles:
1161         if not os.path.isfile(path):
1162             continue
1163         xml = parse_xml(path)
1164         element = xml.find('string[@name="' + name + '"]')
1165         if element is not None:
1166             content = element_content(element)
1167             return retrieve_string(app_dir, content, xmlfiles)
1168
1169     return ''
1170
1171
1172 def retrieve_string_singleline(app_dir, string, xmlfiles=None):
1173     return retrieve_string(app_dir, string, xmlfiles).replace('\n', ' ').strip()
1174
1175
1176 def manifest_paths(app_dir, flavours):
1177     '''Return list of existing files that will be used to find the highest vercode'''
1178
1179     possible_manifests = \
1180         [os.path.join(app_dir, 'AndroidManifest.xml'),
1181          os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
1182          os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
1183          os.path.join(app_dir, 'build.gradle')]
1184
1185     for flavour in flavours:
1186         if flavour == 'yes':
1187             continue
1188         possible_manifests.append(
1189             os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
1190
1191     return [path for path in possible_manifests if os.path.isfile(path)]
1192
1193
1194 def fetch_real_name(app_dir, flavours):
1195     '''Retrieve the package name. Returns the name, or None if not found.'''
1196     for path in manifest_paths(app_dir, flavours):
1197         if not has_extension(path, 'xml') or not os.path.isfile(path):
1198             continue
1199         logging.debug("fetch_real_name: Checking manifest at " + path)
1200         xml = parse_xml(path)
1201         app = xml.find('application')
1202         if app is None:
1203             continue
1204         if "{http://schemas.android.com/apk/res/android}label" not in app.attrib:
1205             continue
1206         label = app.attrib["{http://schemas.android.com/apk/res/android}label"]
1207         result = retrieve_string_singleline(app_dir, label)
1208         if result:
1209             result = result.strip()
1210         return result
1211     return None
1212
1213
1214 def get_library_references(root_dir):
1215     libraries = []
1216     proppath = os.path.join(root_dir, 'project.properties')
1217     if not os.path.isfile(proppath):
1218         return libraries
1219     with open(proppath, 'r', encoding='iso-8859-1') as f:
1220         for line in f:
1221             if not line.startswith('android.library.reference.'):
1222                 continue
1223             path = line.split('=')[1].strip()
1224             relpath = os.path.join(root_dir, path)
1225             if not os.path.isdir(relpath):
1226                 continue
1227             logging.debug("Found subproject at %s" % path)
1228             libraries.append(path)
1229     return libraries
1230
1231
1232 def ant_subprojects(root_dir):
1233     subprojects = get_library_references(root_dir)
1234     for subpath in subprojects:
1235         subrelpath = os.path.join(root_dir, subpath)
1236         for p in get_library_references(subrelpath):
1237             relp = os.path.normpath(os.path.join(subpath, p))
1238             if relp not in subprojects:
1239                 subprojects.insert(0, relp)
1240     return subprojects
1241
1242
1243 def remove_debuggable_flags(root_dir):
1244     # Remove forced debuggable flags
1245     logging.debug("Removing debuggable flags from %s" % root_dir)
1246     for root, dirs, files in os.walk(root_dir):
1247         if 'AndroidManifest.xml' in files and os.path.isfile(os.path.join(root, 'AndroidManifest.xml')):
1248             regsub_file(r'android:debuggable="[^"]*"',
1249                         '',
1250                         os.path.join(root, 'AndroidManifest.xml'))
1251
1252
1253 vcsearch_g = re.compile(r'''.*[Vv]ersionCode[ =]+["']*([0-9]+)["']*''').search
1254 vnsearch_g = re.compile(r'.*[Vv]ersionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
1255 psearch_g = re.compile(r'.*(packageName|applicationId) *=* *["\']([^"]+)["\'].*').search
1256
1257
1258 def app_matches_packagename(app, package):
1259     if not package:
1260         return False
1261     appid = app.UpdateCheckName or app.id
1262     if appid is None or appid == "Ignore":
1263         return True
1264     return appid == package
1265
1266
1267 def parse_androidmanifests(paths, app):
1268     """
1269     Extract some information from the AndroidManifest.xml at the given path.
1270     Returns (version, vercode, package), any or all of which might be None.
1271     All values returned are strings.
1272     """
1273
1274     ignoreversions = app.UpdateCheckIgnore
1275     ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
1276
1277     if not paths:
1278         return (None, None, None)
1279
1280     max_version = None
1281     max_vercode = None
1282     max_package = None
1283
1284     for path in paths:
1285
1286         if not os.path.isfile(path):
1287             continue
1288
1289         logging.debug(_("Parsing manifest at '{path}'").format(path=path))
1290         version = None
1291         vercode = None
1292         package = None
1293
1294         if has_extension(path, 'gradle'):
1295             with open(path, 'r') as f:
1296                 for line in f:
1297                     if gradle_comment.match(line):
1298                         continue
1299                     # Grab first occurence of each to avoid running into
1300                     # alternative flavours and builds.
1301                     if not package:
1302                         matches = psearch_g(line)
1303                         if matches:
1304                             s = matches.group(2)
1305                             if app_matches_packagename(app, s):
1306                                 package = s
1307                     if not version:
1308                         matches = vnsearch_g(line)
1309                         if matches:
1310                             version = matches.group(2)
1311                     if not vercode:
1312                         matches = vcsearch_g(line)
1313                         if matches:
1314                             vercode = matches.group(1)
1315         else:
1316             try:
1317                 xml = parse_xml(path)
1318                 if "package" in xml.attrib:
1319                     s = xml.attrib["package"]
1320                     if app_matches_packagename(app, s):
1321                         package = s
1322                 if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1323                     version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"]
1324                     base_dir = os.path.dirname(path)
1325                     version = retrieve_string_singleline(base_dir, version)
1326                 if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1327                     a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"]
1328                     if string_is_integer(a):
1329                         vercode = a
1330             except Exception:
1331                 logging.warning(_("Problem with xml at '{path}'").format(path=path))
1332
1333         # Remember package name, may be defined separately from version+vercode
1334         if package is None:
1335             package = max_package
1336
1337         logging.debug("..got package={0}, version={1}, vercode={2}"
1338                       .format(package, version, vercode))
1339
1340         # Always grab the package name and version name in case they are not
1341         # together with the highest version code
1342         if max_package is None and package is not None:
1343             max_package = package
1344         if max_version is None and version is not None:
1345             max_version = version
1346
1347         if vercode is not None \
1348            and (max_vercode is None or vercode > max_vercode):
1349             if not ignoresearch or not ignoresearch(version):
1350                 if version is not None:
1351                     max_version = version
1352                 if vercode is not None:
1353                     max_vercode = vercode
1354                 if package is not None:
1355                     max_package = package
1356             else:
1357                 max_version = "Ignore"
1358
1359     if max_version is None:
1360         max_version = "Unknown"
1361
1362     if max_package and not is_valid_package_name(max_package):
1363         raise FDroidException(_("Invalid package name {0}").format(max_package))
1364
1365     return (max_version, max_vercode, max_package)
1366
1367
1368 def is_valid_package_name(name):
1369     return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1370
1371
1372 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1373               raw=False, prepare=True, preponly=False, refresh=True,
1374               build=None):
1375     """Get the specified source library.
1376
1377     Returns the path to it. Normally this is the path to be used when
1378     referencing it, which may be a subdirectory of the actual project. If
1379     you want the base directory of the project, pass 'basepath=True'.
1380
1381     """
1382     number = None
1383     subdir = None
1384     if raw:
1385         name = spec
1386         ref = None
1387     else:
1388         name, ref = spec.split('@')
1389         if ':' in name:
1390             number, name = name.split(':', 1)
1391         if '/' in name:
1392             name, subdir = name.split('/', 1)
1393
1394     if name not in fdroidserver.metadata.srclibs:
1395         raise VCSException('srclib ' + name + ' not found.')
1396
1397     srclib = fdroidserver.metadata.srclibs[name]
1398
1399     sdir = os.path.join(srclib_dir, name)
1400
1401     if not preponly:
1402         vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1403         vcs.srclib = (name, number, sdir)
1404         if ref:
1405             vcs.gotorevision(ref, refresh)
1406
1407         if raw:
1408             return vcs
1409
1410     libdir = None
1411     if subdir:
1412         libdir = os.path.join(sdir, subdir)
1413     elif srclib["Subdir"]:
1414         for subdir in srclib["Subdir"]:
1415             libdir_candidate = os.path.join(sdir, subdir)
1416             if os.path.exists(libdir_candidate):
1417                 libdir = libdir_candidate
1418                 break
1419
1420     if libdir is None:
1421         libdir = sdir
1422
1423     remove_signing_keys(sdir)
1424     remove_debuggable_flags(sdir)
1425
1426     if prepare:
1427
1428         if srclib["Prepare"]:
1429             cmd = replace_config_vars(srclib["Prepare"], build)
1430
1431             p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1432             if p.returncode != 0:
1433                 raise BuildException("Error running prepare command for srclib %s"
1434                                      % name, p.output)
1435
1436     if basepath:
1437         libdir = sdir
1438
1439     return (name, number, libdir)
1440
1441
1442 gradle_version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1443
1444
1445 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False, refresh=True):
1446     """ Prepare the source code for a particular build
1447
1448     :param vcs: the appropriate vcs object for the application
1449     :param app: the application details from the metadata
1450     :param build: the build details from the metadata
1451     :param build_dir: the path to the build directory, usually 'build/app.id'
1452     :param srclib_dir: the path to the source libraries directory, usually 'build/srclib'
1453     :param extlib_dir: the path to the external libraries directory, usually 'build/extlib'
1454
1455     Returns the (root, srclibpaths) where:
1456     :param root: is the root directory, which may be the same as 'build_dir' or may
1457                  be a subdirectory of it.
1458     :param srclibpaths: is information on the srclibs being used
1459     """
1460
1461     # Optionally, the actual app source can be in a subdirectory
1462     if build.subdir:
1463         root_dir = os.path.join(build_dir, build.subdir)
1464     else:
1465         root_dir = build_dir
1466
1467     # Get a working copy of the right revision
1468     logging.info("Getting source for revision " + build.commit)
1469     vcs.gotorevision(build.commit, refresh)
1470
1471     # Initialise submodules if required
1472     if build.submodules:
1473         logging.info(_("Initialising submodules"))
1474         vcs.initsubmodules()
1475
1476     # Check that a subdir (if we're using one) exists. This has to happen
1477     # after the checkout, since it might not exist elsewhere
1478     if not os.path.exists(root_dir):
1479         raise BuildException('Missing subdir ' + root_dir)
1480
1481     # Run an init command if one is required
1482     if build.init:
1483         cmd = replace_config_vars(build.init, build)
1484         logging.info("Running 'init' commands in %s" % root_dir)
1485
1486         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1487         if p.returncode != 0:
1488             raise BuildException("Error running init command for %s:%s" %
1489                                  (app.id, build.versionName), p.output)
1490
1491     # Apply patches if any
1492     if build.patch:
1493         logging.info("Applying patches")
1494         for patch in build.patch:
1495             patch = patch.strip()
1496             logging.info("Applying " + patch)
1497             patch_path = os.path.join('metadata', app.id, patch)
1498             p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1499             if p.returncode != 0:
1500                 raise BuildException("Failed to apply patch %s" % patch_path)
1501
1502     # Get required source libraries
1503     srclibpaths = []
1504     if build.srclibs:
1505         logging.info("Collecting source libraries")
1506         for lib in build.srclibs:
1507             srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver,
1508                                          refresh=refresh, build=build))
1509
1510     for name, number, libpath in srclibpaths:
1511         place_srclib(root_dir, int(number) if number else None, libpath)
1512
1513     basesrclib = vcs.getsrclib()
1514     # If one was used for the main source, add that too.
1515     if basesrclib:
1516         srclibpaths.append(basesrclib)
1517
1518     # Update the local.properties file
1519     localprops = [os.path.join(build_dir, 'local.properties')]
1520     if build.subdir:
1521         parts = build.subdir.split(os.sep)
1522         cur = build_dir
1523         for d in parts:
1524             cur = os.path.join(cur, d)
1525             localprops += [os.path.join(cur, 'local.properties')]
1526     for path in localprops:
1527         props = ""
1528         if os.path.isfile(path):
1529             logging.info("Updating local.properties file at %s" % path)
1530             with open(path, 'r', encoding='iso-8859-1') as f:
1531                 props += f.read()
1532             props += '\n'
1533         else:
1534             logging.info("Creating local.properties file at %s" % path)
1535         # Fix old-fashioned 'sdk-location' by copying
1536         # from sdk.dir, if necessary
1537         if build.oldsdkloc:
1538             sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1539                               re.S | re.M).group(1)
1540             props += "sdk-location=%s\n" % sdkloc
1541         else:
1542             props += "sdk.dir=%s\n" % config['sdk_path']
1543             props += "sdk-location=%s\n" % config['sdk_path']
1544         ndk_path = build.ndk_path()
1545         # if for any reason the path isn't valid or the directory
1546         # doesn't exist, some versions of Gradle will error with a
1547         # cryptic message (even if the NDK is not even necessary).
1548         # https://gitlab.com/fdroid/fdroidserver/issues/171
1549         if ndk_path and os.path.exists(ndk_path):
1550             # Add ndk location
1551             props += "ndk.dir=%s\n" % ndk_path
1552             props += "ndk-location=%s\n" % ndk_path
1553         # Add java.encoding if necessary
1554         if build.encoding:
1555             props += "java.encoding=%s\n" % build.encoding
1556         with open(path, 'w', encoding='iso-8859-1') as f:
1557             f.write(props)
1558
1559     flavours = []
1560     if build.build_method() == 'gradle':
1561         flavours = build.gradle
1562
1563         if build.target:
1564             n = build.target.split('-')[1]
1565             regsub_file(r'compileSdkVersion[ =]+[0-9]+',
1566                         r'compileSdkVersion %s' % n,
1567                         os.path.join(root_dir, 'build.gradle'))
1568
1569     # Remove forced debuggable flags
1570     remove_debuggable_flags(root_dir)
1571
1572     # Insert version code and number into the manifest if necessary
1573     if build.forceversion:
1574         logging.info("Changing the version name")
1575         for path in manifest_paths(root_dir, flavours):
1576             if not os.path.isfile(path):
1577                 continue
1578             if has_extension(path, 'xml'):
1579                 regsub_file(r'android:versionName="[^"]*"',
1580                             r'android:versionName="%s"' % build.versionName,
1581                             path)
1582             elif has_extension(path, 'gradle'):
1583                 regsub_file(r"""(\s*)versionName[\s'"=]+.*""",
1584                             r"""\1versionName '%s'""" % build.versionName,
1585                             path)
1586
1587     if build.forcevercode:
1588         logging.info("Changing the version code")
1589         for path in manifest_paths(root_dir, flavours):
1590             if not os.path.isfile(path):
1591                 continue
1592             if has_extension(path, 'xml'):
1593                 regsub_file(r'android:versionCode="[^"]*"',
1594                             r'android:versionCode="%s"' % build.versionCode,
1595                             path)
1596             elif has_extension(path, 'gradle'):
1597                 regsub_file(r'versionCode[ =]+[0-9]+',
1598                             r'versionCode %s' % build.versionCode,
1599                             path)
1600
1601     # Delete unwanted files
1602     if build.rm:
1603         logging.info(_("Removing specified files"))
1604         for part in getpaths(build_dir, build.rm):
1605             dest = os.path.join(build_dir, part)
1606             logging.info("Removing {0}".format(part))
1607             if os.path.lexists(dest):
1608                 if os.path.islink(dest):
1609                     FDroidPopen(['unlink', dest], output=False)
1610                 else:
1611                     FDroidPopen(['rm', '-rf', dest], output=False)
1612             else:
1613                 logging.info("...but it didn't exist")
1614
1615     remove_signing_keys(build_dir)
1616
1617     # Add required external libraries
1618     if build.extlibs:
1619         logging.info("Collecting prebuilt libraries")
1620         libsdir = os.path.join(root_dir, 'libs')
1621         if not os.path.exists(libsdir):
1622             os.mkdir(libsdir)
1623         for lib in build.extlibs:
1624             lib = lib.strip()
1625             logging.info("...installing extlib {0}".format(lib))
1626             libf = os.path.basename(lib)
1627             libsrc = os.path.join(extlib_dir, lib)
1628             if not os.path.exists(libsrc):
1629                 raise BuildException("Missing extlib file {0}".format(libsrc))
1630             shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1631
1632     # Run a pre-build command if one is required
1633     if build.prebuild:
1634         logging.info("Running 'prebuild' commands in %s" % root_dir)
1635
1636         cmd = replace_config_vars(build.prebuild, build)
1637
1638         # Substitute source library paths into prebuild commands
1639         for name, number, libpath in srclibpaths:
1640             libpath = os.path.relpath(libpath, root_dir)
1641             cmd = cmd.replace('$$' + name + '$$', libpath)
1642
1643         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1644         if p.returncode != 0:
1645             raise BuildException("Error running prebuild command for %s:%s" %
1646                                  (app.id, build.versionName), p.output)
1647
1648     # Generate (or update) the ant build file, build.xml...
1649     if build.build_method() == 'ant' and build.androidupdate != ['no']:
1650         parms = ['android', 'update', 'lib-project']
1651         lparms = ['android', 'update', 'project']
1652
1653         if build.target:
1654             parms += ['-t', build.target]
1655             lparms += ['-t', build.target]
1656         if build.androidupdate:
1657             update_dirs = build.androidupdate
1658         else:
1659             update_dirs = ant_subprojects(root_dir) + ['.']
1660
1661         for d in update_dirs:
1662             subdir = os.path.join(root_dir, d)
1663             if d == '.':
1664                 logging.debug("Updating main project")
1665                 cmd = parms + ['-p', d]
1666             else:
1667                 logging.debug("Updating subproject %s" % d)
1668                 cmd = lparms + ['-p', d]
1669             p = SdkToolsPopen(cmd, cwd=root_dir)
1670             # Check to see whether an error was returned without a proper exit
1671             # code (this is the case for the 'no target set or target invalid'
1672             # error)
1673             if p.returncode != 0 or p.output.startswith("Error: "):
1674                 raise BuildException("Failed to update project at %s" % d, p.output)
1675             # Clean update dirs via ant
1676             if d != '.':
1677                 logging.info("Cleaning subproject %s" % d)
1678                 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1679
1680     return (root_dir, srclibpaths)
1681
1682
1683 def getpaths_map(build_dir, globpaths):
1684     """Extend via globbing the paths from a field and return them as a map from original path to resulting paths"""
1685     paths = dict()
1686     for p in globpaths:
1687         p = p.strip()
1688         full_path = os.path.join(build_dir, p)
1689         full_path = os.path.normpath(full_path)
1690         paths[p] = [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1691         if not paths[p]:
1692             raise FDroidException("glob path '%s' did not match any files/dirs" % p)
1693     return paths
1694
1695
1696 def getpaths(build_dir, globpaths):
1697     """Extend via globbing the paths from a field and return them as a set"""
1698     paths_map = getpaths_map(build_dir, globpaths)
1699     paths = set()
1700     for k, v in paths_map.items():
1701         for p in v:
1702             paths.add(p)
1703     return paths
1704
1705
1706 def natural_key(s):
1707     return [int(sp) if sp.isdigit() else sp for sp in re.split(r'(\d+)', s)]
1708
1709
1710 class KnownApks:
1711     """permanent store of existing APKs with the date they were added
1712
1713     This is currently the only way to permanently store the "updated"
1714     date of APKs.
1715     """
1716
1717     def __init__(self):
1718         '''Load filename/date info about previously seen APKs
1719
1720         Since the appid and date strings both will never have spaces,
1721         this is parsed as a list from the end to allow the filename to
1722         have any combo of spaces.
1723         '''
1724
1725         self.path = os.path.join('stats', 'known_apks.txt')
1726         self.apks = {}
1727         if os.path.isfile(self.path):
1728             with open(self.path, 'r', encoding='utf8') as f:
1729                 for line in f:
1730                     t = line.rstrip().split(' ')
1731                     if len(t) == 2:
1732                         self.apks[t[0]] = (t[1], None)
1733                     else:
1734                         appid = t[-2]
1735                         date = datetime.strptime(t[-1], '%Y-%m-%d')
1736                         filename = line[0:line.rfind(appid) - 1]
1737                         self.apks[filename] = (appid, date)
1738         self.changed = False
1739
1740     def writeifchanged(self):
1741         if not self.changed:
1742             return
1743
1744         if not os.path.exists('stats'):
1745             os.mkdir('stats')
1746
1747         lst = []
1748         for apk, app in self.apks.items():
1749             appid, added = app
1750             line = apk + ' ' + appid
1751             if added:
1752                 line += ' ' + added.strftime('%Y-%m-%d')
1753             lst.append(line)
1754
1755         with open(self.path, 'w', encoding='utf8') as f:
1756             for line in sorted(lst, key=natural_key):
1757                 f.write(line + '\n')
1758
1759     def recordapk(self, apkName, app, default_date=None):
1760         '''
1761         Record an apk (if it's new, otherwise does nothing)
1762         Returns the date it was added as a datetime instance
1763         '''
1764         if apkName not in self.apks:
1765             if default_date is None:
1766                 default_date = datetime.utcnow()
1767             self.apks[apkName] = (app, default_date)
1768             self.changed = True
1769         _ignored, added = self.apks[apkName]
1770         return added
1771
1772     def getapp(self, apkname):
1773         """Look up information - given the 'apkname', returns (app id, date added/None).
1774
1775         Or returns None for an unknown apk.
1776         """
1777         if apkname in self.apks:
1778             return self.apks[apkname]
1779         return None
1780
1781     def getlatest(self, num):
1782         """Get the most recent 'num' apps added to the repo, as a list of package ids with the most recent first"""
1783         apps = {}
1784         for apk, app in self.apks.items():
1785             appid, added = app
1786             if added:
1787                 if appid in apps:
1788                     if apps[appid] > added:
1789                         apps[appid] = added
1790                 else:
1791                     apps[appid] = added
1792         sortedapps = sorted(apps.items(), key=operator.itemgetter(1))[-num:]
1793         lst = [app for app, _ignored in sortedapps]
1794         lst.reverse()
1795         return lst
1796
1797
1798 def get_file_extension(filename):
1799     """get the normalized file extension, can be blank string but never None"""
1800     if isinstance(filename, bytes):
1801         filename = filename.decode('utf-8')
1802     return os.path.splitext(filename)[1].lower()[1:]
1803
1804
1805 def get_apk_debuggable_aapt(apkfile):
1806     p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1807                       output=False)
1808     if p.returncode != 0:
1809         raise FDroidException(_("Failed to get APK manifest information"))
1810     for line in p.output.splitlines():
1811         if 'android:debuggable' in line and not line.endswith('0x0'):
1812             return True
1813     return False
1814
1815
1816 def get_apk_debuggable_androguard(apkfile):
1817     try:
1818         from androguard.core.bytecodes.apk import APK
1819     except ImportError:
1820         raise FDroidException("androguard library is not installed and aapt not present")
1821
1822     apkobject = APK(apkfile)
1823     if apkobject.is_valid_APK():
1824         debuggable = apkobject.get_element("application", "debuggable")
1825         if debuggable is not None:
1826             return bool(strtobool(debuggable))
1827     return False
1828
1829
1830 def isApkAndDebuggable(apkfile):
1831     """Returns True if the given file is an APK and is debuggable
1832
1833     :param apkfile: full path to the apk to check"""
1834
1835     if get_file_extension(apkfile) != 'apk':
1836         return False
1837
1838     if SdkToolsPopen(['aapt', 'version'], output=False):
1839         return get_apk_debuggable_aapt(apkfile)
1840     else:
1841         return get_apk_debuggable_androguard(apkfile)
1842
1843
1844 def get_apk_id_aapt(apkfile):
1845     """Extrat identification information from APK using aapt.
1846
1847     :param apkfile: path to an APK file.
1848     :returns: triplet (appid, version code, version name)
1849     """
1850     r = re.compile("package: name='(?P<appid>.*)' versionCode='(?P<vercode>.*)' versionName='(?P<vername>.*)' platformBuildVersionName='.*'")
1851     p = SdkToolsPopen(['aapt', 'dump', 'badging', apkfile], output=False)
1852     for line in p.output.splitlines():
1853         m = r.match(line)
1854         if m:
1855             return m.group('appid'), m.group('vercode'), m.group('vername')
1856     raise FDroidException(_("Reading packageName/versionCode/versionName failed, APK invalid: '{apkfilename}'")
1857                           .format(apkfilename=apkfile))
1858
1859
1860 class PopenResult:
1861     def __init__(self):
1862         self.returncode = None
1863         self.output = None
1864
1865
1866 def SdkToolsPopen(commands, cwd=None, output=True):
1867     cmd = commands[0]
1868     if cmd not in config:
1869         config[cmd] = find_sdk_tools_cmd(commands[0])
1870     abscmd = config[cmd]
1871     if abscmd is None:
1872         raise FDroidException(_("Could not find '{command}' on your system").format(command=cmd))
1873     if cmd == 'aapt':
1874         test_aapt_version(config['aapt'])
1875     return FDroidPopen([abscmd] + commands[1:],
1876                        cwd=cwd, output=output)
1877
1878
1879 def FDroidPopenBytes(commands, cwd=None, envs=None, output=True, stderr_to_stdout=True):
1880     """
1881     Run a command and capture the possibly huge output as bytes.
1882
1883     :param commands: command and argument list like in subprocess.Popen
1884     :param cwd: optionally specifies a working directory
1885     :param envs: a optional dictionary of environment variables and their values
1886     :returns: A PopenResult.
1887     """
1888
1889     global env
1890     if env is None:
1891         set_FDroidPopen_env()
1892
1893     process_env = env.copy()
1894     if envs is not None and len(envs) > 0:
1895         process_env.update(envs)
1896
1897     if cwd:
1898         cwd = os.path.normpath(cwd)
1899         logging.debug("Directory: %s" % cwd)
1900     logging.debug("> %s" % ' '.join(commands))
1901
1902     stderr_param = subprocess.STDOUT if stderr_to_stdout else subprocess.PIPE
1903     result = PopenResult()
1904     p = None
1905     try:
1906         p = subprocess.Popen(commands, cwd=cwd, shell=False, env=process_env,
1907                              stdout=subprocess.PIPE, stderr=stderr_param)
1908     except OSError as e:
1909         raise BuildException("OSError while trying to execute " +
1910                              ' '.join(commands) + ': ' + str(e))
1911
1912     if not stderr_to_stdout and options.verbose:
1913         stderr_queue = Queue()
1914         stderr_reader = AsynchronousFileReader(p.stderr, stderr_queue)
1915
1916         while not stderr_reader.eof():
1917             while not stderr_queue.empty():
1918                 line = stderr_queue.get()
1919                 sys.stderr.buffer.write(line)
1920                 sys.stderr.flush()
1921
1922             time.sleep(0.1)
1923
1924     stdout_queue = Queue()
1925     stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1926     buf = io.BytesIO()
1927
1928     # Check the queue for output (until there is no more to get)
1929     while not stdout_reader.eof():
1930         while not stdout_queue.empty():
1931             line = stdout_queue.get()
1932             if output and options.verbose:
1933                 # Output directly to console
1934                 sys.stderr.buffer.write(line)
1935                 sys.stderr.flush()
1936             buf.write(line)
1937
1938         time.sleep(0.1)
1939
1940     result.returncode = p.wait()
1941     result.output = buf.getvalue()
1942     buf.close()
1943     # make sure all filestreams of the subprocess are closed
1944     for streamvar in ['stdin', 'stdout', 'stderr']:
1945         if hasattr(p, streamvar):
1946             stream = getattr(p, streamvar)
1947             if stream:
1948                 stream.close()
1949     return result
1950
1951
1952 def FDroidPopen(commands, cwd=None, envs=None, output=True, stderr_to_stdout=True):
1953     """
1954     Run a command and capture the possibly huge output as a str.
1955
1956     :param commands: command and argument list like in subprocess.Popen
1957     :param cwd: optionally specifies a working directory
1958     :param envs: a optional dictionary of environment variables and their values
1959     :returns: A PopenResult.
1960     """
1961     result = FDroidPopenBytes(commands, cwd, envs, output, stderr_to_stdout)
1962     result.output = result.output.decode('utf-8', 'ignore')
1963     return result
1964
1965
1966 gradle_comment = re.compile(r'[ ]*//')
1967 gradle_signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1968 gradle_line_matches = [
1969     re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1970     re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1971     re.compile(r'.*\.readLine\(.*'),
1972 ]
1973
1974
1975 def remove_signing_keys(build_dir):
1976     for root, dirs, files in os.walk(build_dir):
1977         if 'build.gradle' in files:
1978             path = os.path.join(root, 'build.gradle')
1979
1980             with open(path, "r", encoding='utf8') as o:
1981                 lines = o.readlines()
1982
1983             changed = False
1984
1985             opened = 0
1986             i = 0
1987             with open(path, "w", encoding='utf8') as o:
1988                 while i < len(lines):
1989                     line = lines[i]
1990                     i += 1
1991                     while line.endswith('\\\n'):
1992                         line = line.rstrip('\\\n') + lines[i]
1993                         i += 1
1994
1995                     if gradle_comment.match(line):
1996                         o.write(line)
1997                         continue
1998
1999                     if opened > 0:
2000                         opened += line.count('{')
2001                         opened -= line.count('}')
2002                         continue
2003
2004                     if gradle_signing_configs.match(line):
2005                         changed = True
2006                         opened += 1
2007                         continue
2008
2009                     if any(s.match(line) for s in gradle_line_matches):
2010                         changed = True
2011                         continue
2012
2013                     if opened == 0:
2014                         o.write(line)
2015
2016             if changed:
2017                 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
2018
2019         for propfile in [
2020                 'project.properties',
2021                 'build.properties',
2022                 'default.properties',
2023                 'ant.properties', ]:
2024             if propfile in files:
2025                 path = os.path.join(root, propfile)
2026
2027                 with open(path, "r", encoding='iso-8859-1') as o:
2028                     lines = o.readlines()
2029
2030                 changed = False
2031
2032                 with open(path, "w", encoding='iso-8859-1') as o:
2033                     for line in lines:
2034                         if any(line.startswith(s) for s in ('key.store', 'key.alias')):
2035                             changed = True
2036                             continue
2037
2038                         o.write(line)
2039
2040                 if changed:
2041                     logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
2042
2043
2044 def set_FDroidPopen_env(build=None):
2045     '''
2046     set up the environment variables for the build environment
2047
2048     There is only a weak standard, the variables used by gradle, so also set
2049     up the most commonly used environment variables for SDK and NDK.  Also, if
2050     there is no locale set, this will set the locale (e.g. LANG) to en_US.UTF-8.
2051     '''
2052     global env, orig_path
2053
2054     if env is None:
2055         env = os.environ
2056         orig_path = env['PATH']
2057         for n in ['ANDROID_HOME', 'ANDROID_SDK']:
2058             env[n] = config['sdk_path']
2059         for k, v in config['java_paths'].items():
2060             env['JAVA%s_HOME' % k] = v
2061
2062     missinglocale = True
2063     for k, v in env.items():
2064         if k == 'LANG' and v != 'C':
2065             missinglocale = False
2066         elif k == 'LC_ALL':
2067             missinglocale = False
2068     if missinglocale:
2069         env['LANG'] = 'en_US.UTF-8'
2070
2071     if build is not None:
2072         path = build.ndk_path()
2073         paths = orig_path.split(os.pathsep)
2074         if path not in paths:
2075             paths = [path] + paths
2076             env['PATH'] = os.pathsep.join(paths)
2077         for n in ['ANDROID_NDK', 'NDK', 'ANDROID_NDK_HOME']:
2078             env[n] = build.ndk_path()
2079
2080
2081 def replace_build_vars(cmd, build):
2082     cmd = cmd.replace('$$COMMIT$$', build.commit)
2083     cmd = cmd.replace('$$VERSION$$', build.versionName)
2084     cmd = cmd.replace('$$VERCODE$$', build.versionCode)
2085     return cmd
2086
2087
2088 def replace_config_vars(cmd, build):
2089     cmd = cmd.replace('$$SDK$$', config['sdk_path'])
2090     cmd = cmd.replace('$$NDK$$', build.ndk_path())
2091     cmd = cmd.replace('$$MVN3$$', config['mvn3'])
2092     cmd = cmd.replace('$$QT$$', config['qt_sdk_path'] or '')
2093     if build is not None:
2094         cmd = replace_build_vars(cmd, build)
2095     return cmd
2096
2097
2098 def place_srclib(root_dir, number, libpath):
2099     if not number:
2100         return
2101     relpath = os.path.relpath(libpath, root_dir)
2102     proppath = os.path.join(root_dir, 'project.properties')
2103
2104     lines = []
2105     if os.path.isfile(proppath):
2106         with open(proppath, "r", encoding='iso-8859-1') as o:
2107             lines = o.readlines()
2108
2109     with open(proppath, "w", encoding='iso-8859-1') as o:
2110         placed = False
2111         for line in lines:
2112             if line.startswith('android.library.reference.%d=' % number):
2113                 o.write('android.library.reference.%d=%s\n' % (number, relpath))
2114                 placed = True
2115             else:
2116                 o.write(line)
2117         if not placed:
2118             o.write('android.library.reference.%d=%s\n' % (number, relpath))
2119
2120
2121 apk_sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA|DSA|EC)')
2122
2123
2124 def signer_fingerprint_short(sig):
2125     """Obtain shortened sha256 signing-key fingerprint for pkcs7 signature.
2126
2127     Extracts the first 7 hexadecimal digits of sha256 signing-key fingerprint
2128     for a given pkcs7 signature.
2129
2130     :param sig: Contents of an APK signing certificate.
2131     :returns: shortened signing-key fingerprint.
2132     """
2133     return signer_fingerprint(sig)[:7]
2134
2135
2136 def signer_fingerprint(sig):
2137     """Obtain sha256 signing-key fingerprint for pkcs7 signature.
2138
2139     Extracts hexadecimal sha256 signing-key fingerprint string
2140     for a given pkcs7 signature.
2141
2142     :param: Contents of an APK signature.
2143     :returns: shortened signature fingerprint.
2144     """
2145     cert_encoded = get_certificate(sig)
2146     return hashlib.sha256(cert_encoded).hexdigest()
2147
2148
2149 def apk_signer_fingerprint(apk_path):
2150     """Obtain sha256 signing-key fingerprint for APK.
2151
2152     Extracts hexadecimal sha256 signing-key fingerprint string
2153     for a given APK.
2154
2155     :param apkpath: path to APK
2156     :returns: signature fingerprint
2157     """
2158
2159     with zipfile.ZipFile(apk_path, 'r') as apk:
2160         certs = [n for n in apk.namelist() if CERT_PATH_REGEX.match(n)]
2161
2162         if len(certs) < 1:
2163             logging.error("Found no signing certificates on %s" % apk_path)
2164             return None
2165         if len(certs) > 1:
2166             logging.error("Found multiple signing certificates on %s" % apk_path)
2167             return None
2168
2169         cert = apk.read(certs[0])
2170         return signer_fingerprint(cert)
2171
2172
2173 def apk_signer_fingerprint_short(apk_path):
2174     """Obtain shortened sha256 signing-key fingerprint for APK.
2175
2176     Extracts the first 7 hexadecimal digits of sha256 signing-key fingerprint
2177     for a given pkcs7 APK.
2178
2179     :param apk_path: path to APK
2180     :returns: shortened signing-key fingerprint
2181     """
2182     return apk_signer_fingerprint(apk_path)[:7]
2183
2184
2185 def metadata_get_sigdir(appid, vercode=None):
2186     """Get signature directory for app"""
2187     if vercode:
2188         return os.path.join('metadata', appid, 'signatures', vercode)
2189     else:
2190         return os.path.join('metadata', appid, 'signatures')
2191
2192
2193 def metadata_find_developer_signature(appid, vercode=None):
2194     """Tires to find the developer signature for given appid.
2195
2196     This picks the first signature file found in metadata an returns its
2197     signature.
2198
2199     :returns: sha256 signing key fingerprint of the developer signing key.
2200         None in case no signature can not be found."""
2201
2202     # fetch list of dirs for all versions of signatures
2203     appversigdirs = []
2204     if vercode:
2205         appversigdirs.append(metadata_get_sigdir(appid, vercode))
2206     else:
2207         appsigdir = metadata_get_sigdir(appid)
2208         if os.path.isdir(appsigdir):
2209             numre = re.compile('[0-9]+')
2210             for ver in os.listdir(appsigdir):
2211                 if numre.match(ver):
2212                     appversigdir = os.path.join(appsigdir, ver)
2213                     appversigdirs.append(appversigdir)
2214
2215     for sigdir in appversigdirs:
2216         sigs = glob.glob(os.path.join(sigdir, '*.DSA')) + \
2217             glob.glob(os.path.join(sigdir, '*.EC')) + \
2218             glob.glob(os.path.join(sigdir, '*.RSA'))
2219         if len(sigs) > 1:
2220             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))
2221         for sig in sigs:
2222             with open(sig, 'rb') as f:
2223                 return signer_fingerprint(f.read())
2224     return None
2225
2226
2227 def metadata_find_signing_files(appid, vercode):
2228     """Gets a list of singed manifests and signatures.
2229
2230     :param appid: app id string
2231     :param vercode: app version code
2232     :returns: a list of triplets for each signing key with following paths:
2233         (signature_file, singed_file, manifest_file)
2234     """
2235     ret = []
2236     sigdir = metadata_get_sigdir(appid, vercode)
2237     sigs = glob.glob(os.path.join(sigdir, '*.DSA')) + \
2238         glob.glob(os.path.join(sigdir, '*.EC')) + \
2239         glob.glob(os.path.join(sigdir, '*.RSA'))
2240     extre = re.compile('(\.DSA|\.EC|\.RSA)$')
2241     for sig in sigs:
2242         sf = extre.sub('.SF', sig)
2243         if os.path.isfile(sf):
2244             mf = os.path.join(sigdir, 'MANIFEST.MF')
2245             if os.path.isfile(mf):
2246                 ret.append((sig, sf, mf))
2247     return ret
2248
2249
2250 def metadata_find_developer_signing_files(appid, vercode):
2251     """Get developer signature files for specified app from metadata.
2252
2253     :returns: A triplet of paths for signing files from metadata:
2254         (signature_file, singed_file, manifest_file)
2255     """
2256     allsigningfiles = metadata_find_signing_files(appid, vercode)
2257     if allsigningfiles and len(allsigningfiles) == 1:
2258         return allsigningfiles[0]
2259     else:
2260         return None
2261
2262
2263 def apk_strip_signatures(signed_apk, strip_manifest=False):
2264     """Removes signatures from APK.
2265
2266     :param signed_apk: path to apk file.
2267     :param strip_manifest: when set to True also the manifest file will
2268         be removed from the APK.
2269     """
2270     with tempfile.TemporaryDirectory() as tmpdir:
2271         tmp_apk = os.path.join(tmpdir, 'tmp.apk')
2272         os.rename(signed_apk, tmp_apk)
2273         with ZipFile(tmp_apk, 'r') as in_apk:
2274             with ZipFile(signed_apk, 'w') as out_apk:
2275                 for info in in_apk.infolist():
2276                     if not apk_sigfile.match(info.filename):
2277                         if strip_manifest:
2278                             if info.filename != 'META-INF/MANIFEST.MF':
2279                                 buf = in_apk.read(info.filename)
2280                                 out_apk.writestr(info, buf)
2281                         else:
2282                             buf = in_apk.read(info.filename)
2283                             out_apk.writestr(info, buf)
2284
2285
2286 def apk_implant_signatures(apkpath, signaturefile, signedfile, manifest):
2287     """Implats a signature from metadata into an APK.
2288
2289     Note: this changes there supplied APK in place. So copy it if you
2290     need the original to be preserved.
2291
2292     :param apkpath: location of the apk
2293     """
2294     # get list of available signature files in metadata
2295     with tempfile.TemporaryDirectory() as tmpdir:
2296         apkwithnewsig = os.path.join(tmpdir, 'newsig.apk')
2297         with ZipFile(apkpath, 'r') as in_apk:
2298             with ZipFile(apkwithnewsig, 'w') as out_apk:
2299                 for sig_file in [signaturefile, signedfile, manifest]:
2300                     with open(sig_file, 'rb') as fp:
2301                         buf = fp.read()
2302                     info = zipfile.ZipInfo('META-INF/' + os.path.basename(sig_file))
2303                     info.compress_type = zipfile.ZIP_DEFLATED
2304                     info.create_system = 0  # "Windows" aka "FAT", what Android SDK uses
2305                     out_apk.writestr(info, buf)
2306                 for info in in_apk.infolist():
2307                     if not apk_sigfile.match(info.filename):
2308                         if info.filename != 'META-INF/MANIFEST.MF':
2309                             buf = in_apk.read(info.filename)
2310                             out_apk.writestr(info, buf)
2311         os.remove(apkpath)
2312         p = SdkToolsPopen(['zipalign', '-v', '4', apkwithnewsig, apkpath])
2313         if p.returncode != 0:
2314             raise BuildException("Failed to align application")
2315
2316
2317 def apk_extract_signatures(apkpath, outdir, manifest=True):
2318     """Extracts a signature files from APK and puts them into target directory.
2319
2320     :param apkpath: location of the apk
2321     :param outdir: folder where the extracted signature files will be stored
2322     :param manifest: (optionally) disable extracting manifest file
2323     """
2324     with ZipFile(apkpath, 'r') as in_apk:
2325         for f in in_apk.infolist():
2326             if apk_sigfile.match(f.filename) or \
2327                     (manifest and f.filename == 'META-INF/MANIFEST.MF'):
2328                 newpath = os.path.join(outdir, os.path.basename(f.filename))
2329                 with open(newpath, 'wb') as out_file:
2330                     out_file.write(in_apk.read(f.filename))
2331
2332
2333 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
2334     """Verify that two apks are the same
2335
2336     One of the inputs is signed, the other is unsigned. The signature metadata
2337     is transferred from the signed to the unsigned apk, and then jarsigner is
2338     used to verify that the signature from the signed apk is also varlid for
2339     the unsigned one.  If the APK given as unsigned actually does have a
2340     signature, it will be stripped out and ignored.
2341
2342     There are two SHA1 git commit IDs that fdroidserver includes in the builds
2343     it makes: fdroidserverid and buildserverid.  Originally, these were inserted
2344     into AndroidManifest.xml, but that makes the build not reproducible. So
2345     instead they are included as separate files in the APK's META-INF/ folder.
2346     If those files exist in the signed APK, they will be part of the signature
2347     and need to also be included in the unsigned APK for it to validate.
2348
2349     :param signed_apk: Path to a signed apk file
2350     :param unsigned_apk: Path to an unsigned apk file expected to match it
2351     :param tmp_dir: Path to directory for temporary files
2352     :returns: None if the verification is successful, otherwise a string
2353               describing what went wrong.
2354     """
2355
2356     if not os.path.isfile(signed_apk):
2357         return 'can not verify: file does not exists: {}'.format(signed_apk)
2358
2359     if not os.path.isfile(unsigned_apk):
2360         return 'can not verify: file does not exists: {}'.format(unsigned_apk)
2361
2362     with ZipFile(signed_apk, 'r') as signed:
2363         meta_inf_files = ['META-INF/MANIFEST.MF']
2364         for f in signed.namelist():
2365             if apk_sigfile.match(f) \
2366                or f in ['META-INF/fdroidserverid', 'META-INF/buildserverid']:
2367                 meta_inf_files.append(f)
2368         if len(meta_inf_files) < 3:
2369             return "Signature files missing from {0}".format(signed_apk)
2370
2371         tmp_apk = os.path.join(tmp_dir, 'sigcp_' + os.path.basename(unsigned_apk))
2372         with ZipFile(unsigned_apk, 'r') as unsigned:
2373             # only read the signature from the signed APK, everything else from unsigned
2374             with ZipFile(tmp_apk, 'w') as tmp:
2375                 for filename in meta_inf_files:
2376                     tmp.writestr(signed.getinfo(filename), signed.read(filename))
2377                 for info in unsigned.infolist():
2378                     if info.filename in meta_inf_files:
2379                         logging.warning('Ignoring %s from %s',
2380                                         info.filename, unsigned_apk)
2381                         continue
2382                     if info.filename in tmp.namelist():
2383                         return "duplicate filename found: " + info.filename
2384                     tmp.writestr(info, unsigned.read(info.filename))
2385
2386     verified = verify_apk_signature(tmp_apk)
2387
2388     if not verified:
2389         logging.info("...NOT verified - {0}".format(tmp_apk))
2390         return compare_apks(signed_apk, tmp_apk, tmp_dir,
2391                             os.path.dirname(unsigned_apk))
2392
2393     logging.info("...successfully verified")
2394     return None
2395
2396
2397 def verify_jar_signature(jar):
2398     """Verifies the signature of a given JAR file.
2399
2400     jarsigner is very shitty: unsigned JARs pass as "verified"! So
2401     this has to turn on -strict then check for result 4, since this
2402     does not expect the signature to be from a CA-signed certificate.
2403
2404     :raises: VerificationException() if the JAR's signature could not be verified
2405
2406     """
2407
2408     if subprocess.call([config['jarsigner'], '-strict', '-verify', jar]) != 4:
2409         raise VerificationException(_("The repository's index could not be verified."))
2410
2411
2412 def verify_apk_signature(apk, min_sdk_version=None):
2413     """verify the signature on an APK
2414
2415     Try to use apksigner whenever possible since jarsigner is very
2416     shitty: unsigned APKs pass as "verified"!  Warning, this does
2417     not work on JARs with apksigner >= 0.7 (build-tools 26.0.1)
2418
2419     :returns: boolean whether the APK was verified
2420     """
2421     if set_command_in_config('apksigner'):
2422         args = [config['apksigner'], 'verify']
2423         if min_sdk_version:
2424             args += ['--min-sdk-version=' + min_sdk_version]
2425         return subprocess.call(args + [apk]) == 0
2426     else:
2427         logging.warning("Using Java's jarsigner, not recommended for verifying APKs! Use apksigner")
2428         try:
2429             verify_jar_signature(apk)
2430             return True
2431         except Exception:
2432             pass
2433     return False
2434
2435
2436 def verify_old_apk_signature(apk):
2437     """verify the signature on an archived APK, supporting deprecated algorithms
2438
2439     F-Droid aims to keep every single binary that it ever published.  Therefore,
2440     it needs to be able to verify APK signatures that include deprecated/removed
2441     algorithms.  For example, jarsigner treats an MD5 signature as unsigned.
2442
2443     jarsigner passes unsigned APKs as "verified"! So this has to turn
2444     on -strict then check for result 4.
2445
2446     :returns: boolean whether the APK was verified
2447     """
2448
2449     _java_security = os.path.join(os.getcwd(), '.java.security')
2450     with open(_java_security, 'w') as fp:
2451         fp.write('jdk.jar.disabledAlgorithms=MD2, RSA keySize < 1024')
2452
2453     return subprocess.call([config['jarsigner'], '-J-Djava.security.properties=' + _java_security,
2454                             '-strict', '-verify', apk]) == 4
2455
2456
2457 apk_badchars = re.compile('''[/ :;'"]''')
2458
2459
2460 def compare_apks(apk1, apk2, tmp_dir, log_dir=None):
2461     """Compare two apks
2462
2463     Returns None if the apk content is the same (apart from the signing key),
2464     otherwise a string describing what's different, or what went wrong when
2465     trying to do the comparison.
2466     """
2467
2468     if not log_dir:
2469         log_dir = tmp_dir
2470
2471     absapk1 = os.path.abspath(apk1)
2472     absapk2 = os.path.abspath(apk2)
2473
2474     if set_command_in_config('diffoscope'):
2475         logfilename = os.path.join(log_dir, os.path.basename(absapk1))
2476         htmlfile = logfilename + '.diffoscope.html'
2477         textfile = logfilename + '.diffoscope.txt'
2478         if subprocess.call([config['diffoscope'],
2479                             '--max-report-size', '12345678', '--max-diff-block-lines', '100',
2480                             '--html', htmlfile, '--text', textfile,
2481                             absapk1, absapk2]) != 0:
2482             return("Failed to unpack " + apk1)
2483
2484     apk1dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk1[0:-4]))  # trim .apk
2485     apk2dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk2[0:-4]))  # trim .apk
2486     for d in [apk1dir, apk2dir]:
2487         if os.path.exists(d):
2488             shutil.rmtree(d)
2489         os.mkdir(d)
2490         os.mkdir(os.path.join(d, 'jar-xf'))
2491
2492     if subprocess.call(['jar', 'xf',
2493                         os.path.abspath(apk1)],
2494                        cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
2495         return("Failed to unpack " + apk1)
2496     if subprocess.call(['jar', 'xf',
2497                         os.path.abspath(apk2)],
2498                        cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
2499         return("Failed to unpack " + apk2)
2500
2501     if set_command_in_config('apktool'):
2502         if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
2503                            cwd=apk1dir) != 0:
2504             return("Failed to unpack " + apk1)
2505         if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
2506                            cwd=apk2dir) != 0:
2507             return("Failed to unpack " + apk2)
2508
2509     p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
2510     lines = p.output.splitlines()
2511     if len(lines) != 1 or 'META-INF' not in lines[0]:
2512         if set_command_in_config('meld'):
2513             p = FDroidPopen([config['meld'], apk1dir, apk2dir], output=False)
2514         return("Unexpected diff output - " + p.output)
2515
2516     # since everything verifies, delete the comparison to keep cruft down
2517     shutil.rmtree(apk1dir)
2518     shutil.rmtree(apk2dir)
2519
2520     # If we get here, it seems like they're the same!
2521     return None
2522
2523
2524 def set_command_in_config(command):
2525     '''Try to find specified command in the path, if it hasn't been
2526     manually set in config.py.  If found, it is added to the config
2527     dict.  The return value says whether the command is available.
2528
2529     '''
2530     if command in config:
2531         return True
2532     else:
2533         tmp = find_command(command)
2534         if tmp is not None:
2535             config[command] = tmp
2536             return True
2537     return False
2538
2539
2540 def find_command(command):
2541     '''find the full path of a command, or None if it can't be found in the PATH'''
2542
2543     def is_exe(fpath):
2544         return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
2545
2546     fpath, fname = os.path.split(command)
2547     if fpath:
2548         if is_exe(command):
2549             return command
2550     else:
2551         for path in os.environ["PATH"].split(os.pathsep):
2552             path = path.strip('"')
2553             exe_file = os.path.join(path, command)
2554             if is_exe(exe_file):
2555                 return exe_file
2556
2557     return None
2558
2559
2560 def genpassword():
2561     '''generate a random password for when generating keys'''
2562     h = hashlib.sha256()
2563     h.update(os.urandom(16))  # salt
2564     h.update(socket.getfqdn().encode('utf-8'))
2565     passwd = base64.b64encode(h.digest()).strip()
2566     return passwd.decode('utf-8')
2567
2568
2569 def genkeystore(localconfig):
2570     """
2571     Generate a new key with password provided in :param localconfig and add it to new keystore
2572     :return: hexed public key, public key fingerprint
2573     """
2574     logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
2575     keystoredir = os.path.dirname(localconfig['keystore'])
2576     if keystoredir is None or keystoredir == '':
2577         keystoredir = os.path.join(os.getcwd(), keystoredir)
2578     if not os.path.exists(keystoredir):
2579         os.makedirs(keystoredir, mode=0o700)
2580
2581     env_vars = {
2582         'FDROID_KEY_STORE_PASS': localconfig['keystorepass'],
2583         'FDROID_KEY_PASS': localconfig['keypass'],
2584     }
2585     p = FDroidPopen([config['keytool'], '-genkey',
2586                      '-keystore', localconfig['keystore'],
2587                      '-alias', localconfig['repo_keyalias'],
2588                      '-keyalg', 'RSA', '-keysize', '4096',
2589                      '-sigalg', 'SHA256withRSA',
2590                      '-validity', '10000',
2591                      '-storepass:env', 'FDROID_KEY_STORE_PASS',
2592                      '-keypass:env', 'FDROID_KEY_PASS',
2593                      '-dname', localconfig['keydname']], envs=env_vars)
2594     if p.returncode != 0:
2595         raise BuildException("Failed to generate key", p.output)
2596     os.chmod(localconfig['keystore'], 0o0600)
2597     if not options.quiet:
2598         # now show the lovely key that was just generated
2599         p = FDroidPopen([config['keytool'], '-list', '-v',
2600                          '-keystore', localconfig['keystore'],
2601                          '-alias', localconfig['repo_keyalias'],
2602                          '-storepass:env', 'FDROID_KEY_STORE_PASS'], envs=env_vars)
2603         logging.info(p.output.strip() + '\n\n')
2604     # get the public key
2605     p = FDroidPopenBytes([config['keytool'], '-exportcert',
2606                           '-keystore', localconfig['keystore'],
2607                           '-alias', localconfig['repo_keyalias'],
2608                           '-storepass:env', 'FDROID_KEY_STORE_PASS']
2609                          + config['smartcardoptions'],
2610                          envs=env_vars, output=False, stderr_to_stdout=False)
2611     if p.returncode != 0 or len(p.output) < 20:
2612         raise BuildException("Failed to get public key", p.output)
2613     pubkey = p.output
2614     fingerprint = get_cert_fingerprint(pubkey)
2615     return hexlify(pubkey), fingerprint
2616
2617
2618 def get_cert_fingerprint(pubkey):
2619     """
2620     Generate a certificate fingerprint the same way keytool does it
2621     (but with slightly different formatting)
2622     """
2623     digest = hashlib.sha256(pubkey).digest()
2624     ret = [' '.join("%02X" % b for b in bytearray(digest))]
2625     return " ".join(ret)
2626
2627
2628 def get_certificate(certificate_file):
2629     """
2630     Extracts a certificate from the given file.
2631     :param certificate_file: file bytes (as string) representing the certificate
2632     :return: A binary representation of the certificate's public key, or None in case of error
2633     """
2634     content = decoder.decode(certificate_file, asn1Spec=rfc2315.ContentInfo())[0]
2635     if content.getComponentByName('contentType') != rfc2315.signedData:
2636         return None
2637     content = decoder.decode(content.getComponentByName('content'),
2638                              asn1Spec=rfc2315.SignedData())[0]
2639     try:
2640         certificates = content.getComponentByName('certificates')
2641         cert = certificates[0].getComponentByName('certificate')
2642     except PyAsn1Error:
2643         logging.error("Certificates not found.")
2644         return None
2645     return encoder.encode(cert)
2646
2647
2648 def load_stats_fdroid_signing_key_fingerprints():
2649     """Load list of signing-key fingerprints stored by fdroid publish from file.
2650
2651     :returns: list of dictionanryies containing the singing-key fingerprints.
2652     """
2653     jar_file = os.path.join('stats', 'publishsigkeys.jar')
2654     if not os.path.isfile(jar_file):
2655         return {}
2656     cmd = [config['jarsigner'], '-strict', '-verify', jar_file]
2657     p = FDroidPopen(cmd, output=False)
2658     if p.returncode != 4:
2659         raise FDroidException("Signature validation of '{}' failed! "
2660                               "Please run publish again to rebuild this file.".format(jar_file))
2661
2662     jar_sigkey = apk_signer_fingerprint(jar_file)
2663     repo_key_sig = config.get('repo_key_sha256')
2664     if repo_key_sig:
2665         if jar_sigkey != repo_key_sig:
2666             raise FDroidException("Signature key fingerprint of file '{}' does not match repo_key_sha256 in config.py (found fingerprint: '{}')".format(jar_file, jar_sigkey))
2667     else:
2668         logging.warning("repo_key_sha256 not in config.py, setting it to the signature key fingerprint of '{}'".format(jar_file))
2669         config['repo_key_sha256'] = jar_sigkey
2670         write_to_config(config, 'repo_key_sha256')
2671
2672     with zipfile.ZipFile(jar_file, 'r') as f:
2673         return json.loads(str(f.read('publishsigkeys.json'), 'utf-8'))
2674
2675
2676 def write_to_config(thisconfig, key, value=None, config_file=None):
2677     '''write a key/value to the local config.py
2678
2679     NOTE: only supports writing string variables.
2680
2681     :param thisconfig: config dictionary
2682     :param key: variable name in config.py to be overwritten/added
2683     :param value: optional value to be written, instead of fetched
2684         from 'thisconfig' dictionary.
2685     '''
2686     if value is None:
2687         origkey = key + '_orig'
2688         value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
2689     cfg = config_file if config_file else 'config.py'
2690
2691     # load config file, create one if it doesn't exist
2692     if not os.path.exists(cfg):
2693         open(cfg, 'a').close()
2694         logging.info("Creating empty " + cfg)
2695     with open(cfg, 'r', encoding="utf-8") as f:
2696         lines = f.readlines()
2697
2698     # make sure the file ends with a carraige return
2699     if len(lines) > 0:
2700         if not lines[-1].endswith('\n'):
2701             lines[-1] += '\n'
2702
2703     # regex for finding and replacing python string variable
2704     # definitions/initializations
2705     pattern = re.compile('^[\s#]*' + key + '\s*=\s*"[^"]*"')
2706     repl = key + ' = "' + value + '"'
2707     pattern2 = re.compile('^[\s#]*' + key + "\s*=\s*'[^']*'")
2708     repl2 = key + " = '" + value + "'"
2709
2710     # If we replaced this line once, we make sure won't be a
2711     # second instance of this line for this key in the document.
2712     didRepl = False
2713     # edit config file
2714     with open(cfg, 'w', encoding="utf-8") as f:
2715         for line in lines:
2716             if pattern.match(line) or pattern2.match(line):
2717                 if not didRepl:
2718                     line = pattern.sub(repl, line)
2719                     line = pattern2.sub(repl2, line)
2720                     f.write(line)
2721                     didRepl = True
2722             else:
2723                 f.write(line)
2724         if not didRepl:
2725             f.write('\n')
2726             f.write(repl)
2727             f.write('\n')
2728
2729
2730 def parse_xml(path):
2731     return XMLElementTree.parse(path).getroot()
2732
2733
2734 def string_is_integer(string):
2735     try:
2736         int(string)
2737         return True
2738     except ValueError:
2739         return False
2740
2741
2742 def local_rsync(options, fromdir, todir):
2743     '''Rsync method for local to local copying of things
2744
2745     This is an rsync wrapper with all the settings for safe use within
2746     the various fdroidserver use cases. This uses stricter rsync
2747     checking on all files since people using offline mode are already
2748     prioritizing security above ease and speed.
2749
2750     '''
2751     rsyncargs = ['rsync', '--recursive', '--safe-links', '--times', '--perms',
2752                  '--one-file-system', '--delete', '--chmod=Da+rx,Fa-x,a+r,u+w']
2753     if not options.no_checksum:
2754         rsyncargs.append('--checksum')
2755     if options.verbose:
2756         rsyncargs += ['--verbose']
2757     if options.quiet:
2758         rsyncargs += ['--quiet']
2759     logging.debug(' '.join(rsyncargs + [fromdir, todir]))
2760     if subprocess.call(rsyncargs + [fromdir, todir]) != 0:
2761         raise FDroidException()
2762
2763
2764 def get_per_app_repos():
2765     '''per-app repos are dirs named with the packageName of a single app'''
2766
2767     # Android packageNames are Java packages, they may contain uppercase or
2768     # lowercase letters ('A' through 'Z'), numbers, and underscores
2769     # ('_'). However, individual package name parts may only start with
2770     # letters. https://developer.android.com/guide/topics/manifest/manifest-element.html#package
2771     p = re.compile('^([a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*)?$')
2772
2773     repos = []
2774     for root, dirs, files in os.walk(os.getcwd()):
2775         for d in dirs:
2776             print('checking', root, 'for', d)
2777             if d in ('archive', 'metadata', 'repo', 'srclibs', 'tmp'):
2778                 # standard parts of an fdroid repo, so never packageNames
2779                 continue
2780             elif p.match(d) \
2781                     and os.path.exists(os.path.join(d, 'fdroid', 'repo', 'index.jar')):
2782                 repos.append(d)
2783         break
2784     return repos
2785
2786
2787 def is_repo_file(filename):
2788     '''Whether the file in a repo is a build product to be delivered to users'''
2789     if isinstance(filename, str):
2790         filename = filename.encode('utf-8', errors="surrogateescape")
2791     return os.path.isfile(filename) \
2792         and not filename.endswith(b'.asc') \
2793         and not filename.endswith(b'.sig') \
2794         and os.path.basename(filename) not in [
2795             b'index.jar',
2796             b'index_unsigned.jar',
2797             b'index.xml',
2798             b'index.html',
2799             b'index-v1.jar',
2800             b'index-v1.json',
2801             b'categories.txt',
2802         ]
2803
2804
2805 def get_examples_dir():
2806     '''Return the dir where the fdroidserver example files are available'''
2807     examplesdir = None
2808     tmp = os.path.dirname(sys.argv[0])
2809     if os.path.basename(tmp) == 'bin':
2810         egg_links = glob.glob(os.path.join(tmp, '..',
2811                                            'local/lib/python3.*/site-packages/fdroidserver.egg-link'))
2812         if egg_links:
2813             # installed from local git repo
2814             examplesdir = os.path.join(open(egg_links[0]).readline().rstrip(), 'examples')
2815         else:
2816             # try .egg layout
2817             examplesdir = os.path.dirname(os.path.dirname(__file__)) + '/share/doc/fdroidserver/examples'
2818             if not os.path.exists(examplesdir):  # use UNIX layout
2819                 examplesdir = os.path.dirname(tmp) + '/share/doc/fdroidserver/examples'
2820     else:
2821         # we're running straight out of the git repo
2822         prefix = os.path.normpath(os.path.join(os.path.dirname(__file__), '..'))
2823         examplesdir = prefix + '/examples'
2824
2825     return examplesdir