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