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