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