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