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