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