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