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