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