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