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