chiark / gitweb /
move keystore gen logic to common function
[fdroidserver.git] / fdroidserver / common.py
1 # -*- coding: utf-8 -*-
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 import os
21 import sys
22 import re
23 import shutil
24 import glob
25 import stat
26 import subprocess
27 import time
28 import operator
29 import Queue
30 import threading
31 import magic
32 import logging
33 import hashlib
34 import socket
35
36 from distutils.version import LooseVersion
37 from zipfile import ZipFile
38
39 import metadata
40
41 config = None
42 options = None
43 env = None
44 orig_path = None
45
46
47 default_config = {
48     'sdk_path': "$ANDROID_HOME",
49     'ndk_paths': {
50         'r9b': None,
51         'r10d': "$ANDROID_NDK"
52     },
53     'build_tools': "22.0.0",
54     'ant': "ant",
55     'mvn3': "mvn",
56     'gradle': 'gradle',
57     'sync_from_local_copy_dir': False,
58     'make_current_version_link': True,
59     'current_version_name_source': 'Name',
60     'update_stats': False,
61     'stats_ignore': [],
62     'stats_server': None,
63     'stats_user': None,
64     'stats_to_carbon': False,
65     'repo_maxage': 0,
66     'build_server_always': False,
67     'keystore': 'keystore.jks',
68     'smartcardoptions': [],
69     'char_limits': {
70         'Summary': 50,
71         'Description': 1500
72     },
73     'keyaliases': {},
74     'repo_url': "https://MyFirstFDroidRepo.org/fdroid/repo",
75     'repo_name': "My First FDroid Repo Demo",
76     'repo_icon': "fdroid-icon.png",
77     'repo_description': '''
78         This is a repository of apps to be used with FDroid. Applications in this
79         repository are either official binaries built by the original application
80         developers, or are binaries built from source by the admin of f-droid.org
81         using the tools on https://gitlab.com/u/fdroid.
82         ''',
83     'archive_older': 0,
84 }
85
86
87 def fill_config_defaults(thisconfig):
88     for k, v in default_config.items():
89         if k not in thisconfig:
90             thisconfig[k] = v
91
92     # Expand paths (~users and $vars)
93     def expand_path(path):
94         if path is None:
95             return None
96         orig = path
97         path = os.path.expanduser(path)
98         path = os.path.expandvars(path)
99         if orig == path:
100             return None
101         return path
102
103     for k in ['sdk_path', 'ant', 'mvn3', 'gradle', 'keystore', 'repo_icon']:
104         v = thisconfig[k]
105         exp = expand_path(v)
106         if exp is not None:
107             thisconfig[k] = exp
108             thisconfig[k + '_orig'] = v
109
110     for k in ['ndk_paths']:
111         d = thisconfig[k]
112         for k2 in d.copy():
113             v = d[k2]
114             exp = expand_path(v)
115             if exp is not None:
116                 thisconfig[k][k2] = exp
117                 thisconfig[k][k2 + '_orig'] = v
118
119
120 def read_config(opts, config_file='config.py'):
121     """Read the repository config
122
123     The config is read from config_file, which is in the current directory when
124     any of the repo management commands are used.
125     """
126     global config, options, env, orig_path
127
128     if config is not None:
129         return config
130     if not os.path.isfile(config_file):
131         logging.critical("Missing config file - is this a repo directory?")
132         sys.exit(2)
133
134     options = opts
135
136     config = {}
137
138     logging.debug("Reading %s" % config_file)
139     execfile(config_file, config)
140
141     # smartcardoptions must be a list since its command line args for Popen
142     if 'smartcardoptions' in config:
143         config['smartcardoptions'] = config['smartcardoptions'].split(' ')
144     elif 'keystore' in config and config['keystore'] == 'NONE':
145         # keystore='NONE' means use smartcard, these are required defaults
146         config['smartcardoptions'] = ['-storetype', 'PKCS11', '-providerName',
147                                       'SunPKCS11-OpenSC', '-providerClass',
148                                       'sun.security.pkcs11.SunPKCS11',
149                                       '-providerArg', 'opensc-fdroid.cfg']
150
151     if any(k in config for k in ["keystore", "keystorepass", "keypass"]):
152         st = os.stat(config_file)
153         if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
154             logging.warn("unsafe permissions on {0} (should be 0600)!".format(config_file))
155
156     fill_config_defaults(config)
157
158     # There is no standard, so just set up the most common environment
159     # variables
160     env = os.environ
161     orig_path = env['PATH']
162     for n in ['ANDROID_HOME', 'ANDROID_SDK']:
163         env[n] = config['sdk_path']
164
165     for k in ["keystorepass", "keypass"]:
166         if k in config:
167             write_password_file(k)
168
169     for k in ["repo_description", "archive_description"]:
170         if k in config:
171             config[k] = clean_description(config[k])
172
173     if 'serverwebroot' in config:
174         if isinstance(config['serverwebroot'], basestring):
175             roots = [config['serverwebroot']]
176         elif all(isinstance(item, basestring) for item in config['serverwebroot']):
177             roots = config['serverwebroot']
178         else:
179             raise TypeError('only accepts strings, lists, and tuples')
180         rootlist = []
181         for rootstr in roots:
182             # since this is used with rsync, where trailing slashes have
183             # meaning, ensure there is always a trailing slash
184             if rootstr[-1] != '/':
185                 rootstr += '/'
186             rootlist.append(rootstr.replace('//', '/'))
187         config['serverwebroot'] = rootlist
188
189     return config
190
191
192 def get_ndk_path(version):
193     if version is None:
194         version = 'r10d'  # latest
195     paths = config['ndk_paths']
196     if version not in paths:
197         return ''
198     return paths[version] or ''
199
200
201 def find_sdk_tools_cmd(cmd):
202     '''find a working path to a tool from the Android SDK'''
203
204     tooldirs = []
205     if config is not None and 'sdk_path' in config and os.path.exists(config['sdk_path']):
206         # try to find a working path to this command, in all the recent possible paths
207         if 'build_tools' in config:
208             build_tools = os.path.join(config['sdk_path'], 'build-tools')
209             # if 'build_tools' was manually set and exists, check only that one
210             configed_build_tools = os.path.join(build_tools, config['build_tools'])
211             if os.path.exists(configed_build_tools):
212                 tooldirs.append(configed_build_tools)
213             else:
214                 # no configed version, so hunt known paths for it
215                 for f in sorted(os.listdir(build_tools), reverse=True):
216                     if os.path.isdir(os.path.join(build_tools, f)):
217                         tooldirs.append(os.path.join(build_tools, f))
218                 tooldirs.append(build_tools)
219         sdk_tools = os.path.join(config['sdk_path'], 'tools')
220         if os.path.exists(sdk_tools):
221             tooldirs.append(sdk_tools)
222         sdk_platform_tools = os.path.join(config['sdk_path'], 'platform-tools')
223         if os.path.exists(sdk_platform_tools):
224             tooldirs.append(sdk_platform_tools)
225     tooldirs.append('/usr/bin')
226     for d in tooldirs:
227         if os.path.isfile(os.path.join(d, cmd)):
228             return os.path.join(d, cmd)
229     # did not find the command, exit with error message
230     ensure_build_tools_exists(config)
231
232
233 def test_sdk_exists(thisconfig):
234     if 'sdk_path' not in thisconfig:
235         if 'aapt' in thisconfig and os.path.isfile(thisconfig['aapt']):
236             return True
237         else:
238             logging.error("'sdk_path' not set in config.py!")
239             return False
240     if thisconfig['sdk_path'] == default_config['sdk_path']:
241         logging.error('No Android SDK found!')
242         logging.error('You can use ANDROID_HOME to set the path to your SDK, i.e.:')
243         logging.error('\texport ANDROID_HOME=/opt/android-sdk')
244         return False
245     if not os.path.exists(thisconfig['sdk_path']):
246         logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" does not exist!')
247         return False
248     if not os.path.isdir(thisconfig['sdk_path']):
249         logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" is not a directory!')
250         return False
251     for d in ['build-tools', 'platform-tools', 'tools']:
252         if not os.path.isdir(os.path.join(thisconfig['sdk_path'], d)):
253             logging.critical('Android SDK path "%s" does not contain "%s/"!' % (
254                 thisconfig['sdk_path'], d))
255             return False
256     return True
257
258
259 def ensure_build_tools_exists(thisconfig):
260     if not test_sdk_exists(thisconfig):
261         sys.exit(3)
262     build_tools = os.path.join(thisconfig['sdk_path'], 'build-tools')
263     versioned_build_tools = os.path.join(build_tools, thisconfig['build_tools'])
264     if not os.path.isdir(versioned_build_tools):
265         logging.critical('Android Build Tools path "'
266                          + versioned_build_tools + '" does not exist!')
267         sys.exit(3)
268
269
270 def write_password_file(pwtype, password=None):
271     '''
272     writes out passwords to a protected file instead of passing passwords as
273     command line argments
274     '''
275     filename = '.fdroid.' + pwtype + '.txt'
276     fd = os.open(filename, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0600)
277     if password is None:
278         os.write(fd, config[pwtype])
279     else:
280         os.write(fd, password)
281     os.close(fd)
282     config[pwtype + 'file'] = filename
283
284
285 # Given the arguments in the form of multiple appid:[vc] strings, this returns
286 # a dictionary with the set of vercodes specified for each package.
287 def read_pkg_args(args, allow_vercodes=False):
288
289     vercodes = {}
290     if not args:
291         return vercodes
292
293     for p in args:
294         if allow_vercodes and ':' in p:
295             package, vercode = p.split(':')
296         else:
297             package, vercode = p, None
298         if package not in vercodes:
299             vercodes[package] = [vercode] if vercode else []
300             continue
301         elif vercode and vercode not in vercodes[package]:
302             vercodes[package] += [vercode] if vercode else []
303
304     return vercodes
305
306
307 # On top of what read_pkg_args does, this returns the whole app metadata, but
308 # limiting the builds list to the builds matching the vercodes specified.
309 def read_app_args(args, allapps, allow_vercodes=False):
310
311     vercodes = read_pkg_args(args, allow_vercodes)
312
313     if not vercodes:
314         return allapps
315
316     apps = {}
317     for appid, app in allapps.iteritems():
318         if appid in vercodes:
319             apps[appid] = app
320
321     if len(apps) != len(vercodes):
322         for p in vercodes:
323             if p not in allapps:
324                 logging.critical("No such package: %s" % p)
325         raise FDroidException("Found invalid app ids in arguments")
326     if not apps:
327         raise FDroidException("No packages specified")
328
329     error = False
330     for appid, app in apps.iteritems():
331         vc = vercodes[appid]
332         if not vc:
333             continue
334         app['builds'] = [b for b in app['builds'] if b['vercode'] in vc]
335         if len(app['builds']) != len(vercodes[appid]):
336             error = True
337             allvcs = [b['vercode'] for b in app['builds']]
338             for v in vercodes[appid]:
339                 if v not in allvcs:
340                     logging.critical("No such vercode %s for app %s" % (v, appid))
341
342     if error:
343         raise FDroidException("Found invalid vercodes for some apps")
344
345     return apps
346
347
348 def has_extension(filename, extension):
349     name, ext = os.path.splitext(filename)
350     ext = ext.lower()[1:]
351     return ext == extension
352
353 apk_regex = None
354
355
356 def clean_description(description):
357     'Remove unneeded newlines and spaces from a block of description text'
358     returnstring = ''
359     # this is split up by paragraph to make removing the newlines easier
360     for paragraph in re.split(r'\n\n', description):
361         paragraph = re.sub('\r', '', paragraph)
362         paragraph = re.sub('\n', ' ', paragraph)
363         paragraph = re.sub(' {2,}', ' ', paragraph)
364         paragraph = re.sub('^\s*(\w)', r'\1', paragraph)
365         returnstring += paragraph + '\n\n'
366     return returnstring.rstrip('\n')
367
368
369 def apknameinfo(filename):
370     global apk_regex
371     filename = os.path.basename(filename)
372     if apk_regex is None:
373         apk_regex = re.compile(r"^(.+)_([0-9]+)\.apk$")
374     m = apk_regex.match(filename)
375     try:
376         result = (m.group(1), m.group(2))
377     except AttributeError:
378         raise FDroidException("Invalid apk name: %s" % filename)
379     return result
380
381
382 def getapkname(app, build):
383     return "%s_%s.apk" % (app['id'], build['vercode'])
384
385
386 def getsrcname(app, build):
387     return "%s_%s_src.tar.gz" % (app['id'], build['vercode'])
388
389
390 def getappname(app):
391     if app['Name']:
392         return app['Name']
393     if app['Auto Name']:
394         return app['Auto Name']
395     return app['id']
396
397
398 def getcvname(app):
399     return '%s (%s)' % (app['Current Version'], app['Current Version Code'])
400
401
402 def getvcs(vcstype, remote, local):
403     if vcstype == 'git':
404         return vcs_git(remote, local)
405     if vcstype == 'git-svn':
406         return vcs_gitsvn(remote, local)
407     if vcstype == 'hg':
408         return vcs_hg(remote, local)
409     if vcstype == 'bzr':
410         return vcs_bzr(remote, local)
411     if vcstype == 'srclib':
412         if local != os.path.join('build', 'srclib', remote):
413             raise VCSException("Error: srclib paths are hard-coded!")
414         return getsrclib(remote, os.path.join('build', 'srclib'), raw=True)
415     if vcstype == 'svn':
416         raise VCSException("Deprecated vcs type 'svn' - please use 'git-svn' instead")
417     raise VCSException("Invalid vcs type " + vcstype)
418
419
420 def getsrclibvcs(name):
421     if name not in metadata.srclibs:
422         raise VCSException("Missing srclib " + name)
423     return metadata.srclibs[name]['Repo Type']
424
425
426 class vcs:
427
428     def __init__(self, remote, local):
429
430         # svn, git-svn and bzr may require auth
431         self.username = None
432         if self.repotype() in ('git-svn', 'bzr'):
433             if '@' in remote:
434                 if self.repotype == 'git-svn':
435                     raise VCSException("Authentication is not supported for git-svn")
436                 self.username, remote = remote.split('@')
437                 if ':' not in self.username:
438                     raise VCSException("Password required with username")
439                 self.username, self.password = self.username.split(':')
440
441         self.remote = remote
442         self.local = local
443         self.clone_failed = False
444         self.refreshed = False
445         self.srclib = None
446
447     def repotype(self):
448         return None
449
450     # Take the local repository to a clean version of the given revision, which
451     # is specificed in the VCS's native format. Beforehand, the repository can
452     # be dirty, or even non-existent. If the repository does already exist
453     # locally, it will be updated from the origin, but only once in the
454     # lifetime of the vcs object.
455     # None is acceptable for 'rev' if you know you are cloning a clean copy of
456     # the repo - otherwise it must specify a valid revision.
457     def gotorevision(self, rev):
458
459         if self.clone_failed:
460             raise VCSException("Downloading the repository already failed once, not trying again.")
461
462         # The .fdroidvcs-id file for a repo tells us what VCS type
463         # and remote that directory was created from, allowing us to drop it
464         # automatically if either of those things changes.
465         fdpath = os.path.join(self.local, '..',
466                               '.fdroidvcs-' + os.path.basename(self.local))
467         cdata = self.repotype() + ' ' + self.remote
468         writeback = True
469         deleterepo = False
470         if os.path.exists(self.local):
471             if os.path.exists(fdpath):
472                 with open(fdpath, 'r') as f:
473                     fsdata = f.read().strip()
474                 if fsdata == cdata:
475                     writeback = False
476                 else:
477                     deleterepo = True
478                     logging.info("Repository details for %s changed - deleting" % (
479                         self.local))
480             else:
481                 deleterepo = True
482                 logging.info("Repository details for %s missing - deleting" % (
483                     self.local))
484         if deleterepo:
485             shutil.rmtree(self.local)
486
487         exc = None
488
489         try:
490             self.gotorevisionx(rev)
491         except FDroidException, e:
492             exc = e
493
494         # If necessary, write the .fdroidvcs file.
495         if writeback and not self.clone_failed:
496             with open(fdpath, 'w') as f:
497                 f.write(cdata)
498
499         if exc is not None:
500             raise exc
501
502     # Derived classes need to implement this. It's called once basic checking
503     # has been performend.
504     def gotorevisionx(self, rev):
505         raise VCSException("This VCS type doesn't define gotorevisionx")
506
507     # Initialise and update submodules
508     def initsubmodules(self):
509         raise VCSException('Submodules not supported for this vcs type')
510
511     # Get a list of all known tags
512     def gettags(self):
513         if not self._gettags:
514             raise VCSException('gettags not supported for this vcs type')
515         rtags = []
516         for tag in self._gettags():
517             if re.match('[-A-Za-z0-9_. ]+$', tag):
518                 rtags.append(tag)
519         return rtags
520
521     def latesttags(self, tags, number):
522         """Get the most recent tags in a given list.
523
524         :param tags: a list of tags
525         :param number: the number to return
526         :returns: A list containing the most recent tags in the provided
527                   list, up to the maximum number given.
528         """
529         raise VCSException('latesttags not supported for this vcs type')
530
531     # Get current commit reference (hash, revision, etc)
532     def getref(self):
533         raise VCSException('getref not supported for this vcs type')
534
535     # Returns the srclib (name, path) used in setting up the current
536     # revision, or None.
537     def getsrclib(self):
538         return self.srclib
539
540
541 class vcs_git(vcs):
542
543     def repotype(self):
544         return 'git'
545
546     # If the local directory exists, but is somehow not a git repository, git
547     # will traverse up the directory tree until it finds one that is (i.e.
548     # fdroidserver) and then we'll proceed to destroy it! This is called as
549     # a safety check.
550     def checkrepo(self):
551         p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
552         result = p.output.rstrip()
553         if not result.endswith(self.local):
554             raise VCSException('Repository mismatch')
555
556     def gotorevisionx(self, rev):
557         if not os.path.exists(self.local):
558             # Brand new checkout
559             p = FDroidPopen(['git', 'clone', self.remote, self.local])
560             if p.returncode != 0:
561                 self.clone_failed = True
562                 raise VCSException("Git clone failed", p.output)
563             self.checkrepo()
564         else:
565             self.checkrepo()
566             # Discard any working tree changes
567             p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
568             if p.returncode != 0:
569                 raise VCSException("Git reset failed", p.output)
570             # Remove untracked files now, in case they're tracked in the target
571             # revision (it happens!)
572             p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
573             if p.returncode != 0:
574                 raise VCSException("Git clean failed", p.output)
575             if not self.refreshed:
576                 # Get latest commits and tags from remote
577                 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
578                 if p.returncode != 0:
579                     raise VCSException("Git fetch failed", p.output)
580                 p = FDroidPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local, output=False)
581                 if p.returncode != 0:
582                     raise VCSException("Git fetch failed", p.output)
583                 # Recreate origin/HEAD as git clone would do it, in case it disappeared
584                 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
585                 if p.returncode != 0:
586                     lines = p.output.splitlines()
587                     if 'Multiple remote HEAD branches' not in lines[0]:
588                         raise VCSException("Git remote set-head failed", p.output)
589                     branch = lines[1].split(' ')[-1]
590                     p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local, output=False)
591                     if p2.returncode != 0:
592                         raise VCSException("Git remote set-head failed", p.output + '\n' + p2.output)
593                 self.refreshed = True
594         # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
595         # a github repo. Most of the time this is the same as origin/master.
596         rev = rev or 'origin/HEAD'
597         p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
598         if p.returncode != 0:
599             raise VCSException("Git checkout of '%s' failed" % rev, p.output)
600         # Get rid of any uncontrolled files left behind
601         p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
602         if p.returncode != 0:
603             raise VCSException("Git clean failed", p.output)
604
605     def initsubmodules(self):
606         self.checkrepo()
607         submfile = os.path.join(self.local, '.gitmodules')
608         if not os.path.isfile(submfile):
609             raise VCSException("No git submodules available")
610
611         # fix submodules not accessible without an account and public key auth
612         with open(submfile, 'r') as f:
613             lines = f.readlines()
614         with open(submfile, 'w') as f:
615             for line in lines:
616                 if 'git@github.com' in line:
617                     line = line.replace('git@github.com:', 'https://github.com/')
618                 f.write(line)
619
620         for cmd in [
621                 ['git', 'reset', '--hard'],
622                 ['git', 'clean', '-dffx'],
623                 ]:
624             p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive'] + cmd, cwd=self.local, output=False)
625             if p.returncode != 0:
626                 raise VCSException("Git submodule reset failed", p.output)
627         p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
628         if p.returncode != 0:
629             raise VCSException("Git submodule sync failed", p.output)
630         p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
631         if p.returncode != 0:
632             raise VCSException("Git submodule update failed", p.output)
633
634     def _gettags(self):
635         self.checkrepo()
636         p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
637         return p.output.splitlines()
638
639     def latesttags(self, tags, number):
640         self.checkrepo()
641         tl = []
642         for tag in tags:
643             p = FDroidPopen(
644                 ['git', 'show', '--format=format:%ct', '-s', tag],
645                 cwd=self.local, output=False)
646             # Timestamp is on the last line. For a normal tag, it's the only
647             # line, but for annotated tags, the rest of the info precedes it.
648             ts = int(p.output.splitlines()[-1])
649             tl.append((ts, tag))
650         latest = []
651         for _, t in sorted(tl)[-number:]:
652             latest.append(t)
653         return latest
654
655
656 class vcs_gitsvn(vcs):
657
658     def repotype(self):
659         return 'git-svn'
660
661     # If the local directory exists, but is somehow not a git repository, git
662     # will traverse up the directory tree until it finds one that is (i.e.
663     # fdroidserver) and then we'll proceed to destory it! This is called as
664     # a safety check.
665     def checkrepo(self):
666         p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
667         result = p.output.rstrip()
668         if not result.endswith(self.local):
669             raise VCSException('Repository mismatch')
670
671     def gotorevisionx(self, rev):
672         if not os.path.exists(self.local):
673             # Brand new checkout
674             gitsvn_args = ['git', 'svn', 'clone']
675             if ';' in self.remote:
676                 remote_split = self.remote.split(';')
677                 for i in remote_split[1:]:
678                     if i.startswith('trunk='):
679                         gitsvn_args.extend(['-T', i[6:]])
680                     elif i.startswith('tags='):
681                         gitsvn_args.extend(['-t', i[5:]])
682                     elif i.startswith('branches='):
683                         gitsvn_args.extend(['-b', i[9:]])
684                 gitsvn_args.extend([remote_split[0], self.local])
685                 p = FDroidPopen(gitsvn_args, output=False)
686                 if p.returncode != 0:
687                     self.clone_failed = True
688                     raise VCSException("Git svn clone failed", p.output)
689             else:
690                 gitsvn_args.extend([self.remote, self.local])
691                 p = FDroidPopen(gitsvn_args, output=False)
692                 if p.returncode != 0:
693                     self.clone_failed = True
694                     raise VCSException("Git svn clone failed", p.output)
695             self.checkrepo()
696         else:
697             self.checkrepo()
698             # Discard any working tree changes
699             p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
700             if p.returncode != 0:
701                 raise VCSException("Git reset failed", p.output)
702             # Remove untracked files now, in case they're tracked in the target
703             # revision (it happens!)
704             p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
705             if p.returncode != 0:
706                 raise VCSException("Git clean failed", p.output)
707             if not self.refreshed:
708                 # Get new commits, branches and tags from repo
709                 p = FDroidPopen(['git', 'svn', 'fetch'], cwd=self.local, output=False)
710                 if p.returncode != 0:
711                     raise VCSException("Git svn fetch failed")
712                 p = FDroidPopen(['git', 'svn', 'rebase'], cwd=self.local, output=False)
713                 if p.returncode != 0:
714                     raise VCSException("Git svn rebase failed", p.output)
715                 self.refreshed = True
716
717         rev = rev or 'master'
718         if rev:
719             nospaces_rev = rev.replace(' ', '%20')
720             # Try finding a svn tag
721             for treeish in ['origin/', '']:
722                 p = FDroidPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
723                 if p.returncode == 0:
724                     break
725             if p.returncode != 0:
726                 # No tag found, normal svn rev translation
727                 # Translate svn rev into git format
728                 rev_split = rev.split('/')
729
730                 p = None
731                 for treeish in ['origin/', '']:
732                     if len(rev_split) > 1:
733                         treeish += rev_split[0]
734                         svn_rev = rev_split[1]
735
736                     else:
737                         # if no branch is specified, then assume trunk (i.e. 'master' branch):
738                         treeish += 'master'
739                         svn_rev = rev
740
741                     svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
742
743                     p = FDroidPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
744                     git_rev = p.output.rstrip()
745
746                     if p.returncode == 0 and git_rev:
747                         break
748
749                 if p.returncode != 0 or not git_rev:
750                     # Try a plain git checkout as a last resort
751                     p = FDroidPopen(['git', 'checkout', rev], cwd=self.local, output=False)
752                     if p.returncode != 0:
753                         raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
754                 else:
755                     # Check out the git rev equivalent to the svn rev
756                     p = FDroidPopen(['git', 'checkout', git_rev], cwd=self.local, output=False)
757                     if p.returncode != 0:
758                         raise VCSException("Git checkout of '%s' failed" % rev, p.output)
759
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 _gettags(self):
766         self.checkrepo()
767         for treeish in ['origin/', '']:
768             d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
769             if os.path.isdir(d):
770                 return os.listdir(d)
771
772     def getref(self):
773         self.checkrepo()
774         p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
775         if p.returncode != 0:
776             return None
777         return p.output.strip()
778
779
780 class vcs_hg(vcs):
781
782     def repotype(self):
783         return 'hg'
784
785     def gotorevisionx(self, rev):
786         if not os.path.exists(self.local):
787             p = FDroidPopen(['hg', 'clone', self.remote, self.local], output=False)
788             if p.returncode != 0:
789                 self.clone_failed = True
790                 raise VCSException("Hg clone failed", p.output)
791         else:
792             p = FDroidPopen(['hg', 'status', '-uS'], cwd=self.local, output=False)
793             if p.returncode != 0:
794                 raise VCSException("Hg status failed", p.output)
795             for line in p.output.splitlines():
796                 if not line.startswith('? '):
797                     raise VCSException("Unexpected output from hg status -uS: " + line)
798                 FDroidPopen(['rm', '-rf', line[2:]], cwd=self.local, output=False)
799             if not self.refreshed:
800                 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
801                 if p.returncode != 0:
802                     raise VCSException("Hg pull failed", p.output)
803                 self.refreshed = True
804
805         rev = rev or 'default'
806         if not rev:
807             return
808         p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
809         if p.returncode != 0:
810             raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
811         p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
812         # Also delete untracked files, we have to enable purge extension for that:
813         if "'purge' is provided by the following extension" in p.output:
814             with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
815                 myfile.write("\n[extensions]\nhgext.purge=\n")
816             p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
817             if p.returncode != 0:
818                 raise VCSException("HG purge failed", p.output)
819         elif p.returncode != 0:
820             raise VCSException("HG purge failed", p.output)
821
822     def _gettags(self):
823         p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
824         return p.output.splitlines()[1:]
825
826
827 class vcs_bzr(vcs):
828
829     def repotype(self):
830         return 'bzr'
831
832     def gotorevisionx(self, rev):
833         if not os.path.exists(self.local):
834             p = FDroidPopen(['bzr', 'branch', self.remote, self.local], output=False)
835             if p.returncode != 0:
836                 self.clone_failed = True
837                 raise VCSException("Bzr branch failed", p.output)
838         else:
839             p = FDroidPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
840             if p.returncode != 0:
841                 raise VCSException("Bzr revert failed", p.output)
842             if not self.refreshed:
843                 p = FDroidPopen(['bzr', 'pull'], cwd=self.local, output=False)
844                 if p.returncode != 0:
845                     raise VCSException("Bzr update failed", p.output)
846                 self.refreshed = True
847
848         revargs = list(['-r', rev] if rev else [])
849         p = FDroidPopen(['bzr', 'revert'] + revargs, cwd=self.local, output=False)
850         if p.returncode != 0:
851             raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
852
853     def _gettags(self):
854         p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
855         return [tag.split('   ')[0].strip() for tag in
856                 p.output.splitlines()]
857
858
859 def retrieve_string(app_dir, string, xmlfiles=None):
860
861     res_dirs = [
862         os.path.join(app_dir, 'res'),
863         os.path.join(app_dir, 'src', 'main'),
864         ]
865
866     if xmlfiles is None:
867         xmlfiles = []
868         for res_dir in res_dirs:
869             for r, d, f in os.walk(res_dir):
870                 if os.path.basename(r) == 'values':
871                     xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
872
873     string_search = None
874     if string.startswith('@string/'):
875         string_search = re.compile(r'.*name="' + string[8:] + '".*?>"?([^<]+?)"?<.*').search
876     elif string.startswith('&') and string.endswith(';'):
877         string_search = re.compile(r'.*<!ENTITY.*' + string[1:-1] + '.*?"([^"]+?)".*>').search
878
879     if string_search is not None:
880         for xmlfile in xmlfiles:
881             if not os.path.isfile(xmlfile):
882                 continue
883             for line in file(xmlfile):
884                 matches = string_search(line)
885                 if matches:
886                     return retrieve_string(app_dir, matches.group(1), xmlfiles)
887         return None
888
889     return string.replace("\\'", "'")
890
891
892 # Return list of existing files that will be used to find the highest vercode
893 def manifest_paths(app_dir, flavours):
894
895     possible_manifests = \
896         [os.path.join(app_dir, 'AndroidManifest.xml'),
897          os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
898          os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
899          os.path.join(app_dir, 'build.gradle')]
900
901     for flavour in flavours:
902         if flavour == 'yes':
903             continue
904         possible_manifests.append(
905             os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
906
907     return [path for path in possible_manifests if os.path.isfile(path)]
908
909
910 # Retrieve the package name. Returns the name, or None if not found.
911 def fetch_real_name(app_dir, flavours):
912     app_search = re.compile(r'.*<application.*').search
913     name_search = re.compile(r'.*android:label="([^"]+)".*').search
914     app_found = False
915     for f in manifest_paths(app_dir, flavours):
916         if not has_extension(f, 'xml') or not os.path.isfile(f):
917             continue
918         logging.debug("fetch_real_name: Checking manifest at " + f)
919         for line in file(f):
920             if not app_found:
921                 if app_search(line):
922                     app_found = True
923             if app_found:
924                 matches = name_search(line)
925                 if matches:
926                     stringname = matches.group(1)
927                     logging.debug("fetch_real_name: using string " + stringname)
928                     result = retrieve_string(app_dir, stringname)
929                     if result:
930                         result = result.strip()
931                     return result
932     return None
933
934
935 # Retrieve the version name
936 def version_name(original, app_dir, flavours):
937     for f in manifest_paths(app_dir, flavours):
938         if not has_extension(f, 'xml'):
939             continue
940         string = retrieve_string(app_dir, original)
941         if string:
942             return string
943     return original
944
945
946 def get_library_references(root_dir):
947     libraries = []
948     proppath = os.path.join(root_dir, 'project.properties')
949     if not os.path.isfile(proppath):
950         return libraries
951     for line in file(proppath):
952         if not line.startswith('android.library.reference.'):
953             continue
954         path = line.split('=')[1].strip()
955         relpath = os.path.join(root_dir, path)
956         if not os.path.isdir(relpath):
957             continue
958         logging.debug("Found subproject at %s" % path)
959         libraries.append(path)
960     return libraries
961
962
963 def ant_subprojects(root_dir):
964     subprojects = get_library_references(root_dir)
965     for subpath in subprojects:
966         subrelpath = os.path.join(root_dir, subpath)
967         for p in get_library_references(subrelpath):
968             relp = os.path.normpath(os.path.join(subpath, p))
969             if relp not in subprojects:
970                 subprojects.insert(0, relp)
971     return subprojects
972
973
974 def remove_debuggable_flags(root_dir):
975     # Remove forced debuggable flags
976     logging.debug("Removing debuggable flags from %s" % root_dir)
977     for root, dirs, files in os.walk(root_dir):
978         if 'AndroidManifest.xml' in files:
979             path = os.path.join(root, 'AndroidManifest.xml')
980             p = FDroidPopen(['sed', '-i', 's/android:debuggable="[^"]*"//g', path], output=False)
981             if p.returncode != 0:
982                 raise BuildException("Failed to remove debuggable flags of %s" % path)
983
984
985 # Extract some information from the AndroidManifest.xml at the given path.
986 # Returns (version, vercode, package), any or all of which might be None.
987 # All values returned are strings.
988 def parse_androidmanifests(paths, ignoreversions=None):
989
990     if not paths:
991         return (None, None, None)
992
993     vcsearch = re.compile(r'.*:versionCode="([0-9]+?)".*').search
994     vnsearch = re.compile(r'.*:versionName="([^"]+?)".*').search
995     psearch = re.compile(r'.*package="([^"]+)".*').search
996
997     vcsearch_g = re.compile(r'.*versionCode *=* *["\']*([0-9]+)["\']*').search
998     vnsearch_g = re.compile(r'.*versionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
999     psearch_g = re.compile(r'.*packageName *=* *["\']([^"]+)["\'].*').search
1000
1001     ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
1002
1003     max_version = None
1004     max_vercode = None
1005     max_package = None
1006
1007     for path in paths:
1008
1009         if not os.path.isfile(path):
1010             continue
1011
1012         logging.debug("Parsing manifest at {0}".format(path))
1013         gradle = has_extension(path, 'gradle')
1014         version = None
1015         vercode = None
1016         # Remember package name, may be defined separately from version+vercode
1017         package = max_package
1018
1019         for line in file(path):
1020             if not package:
1021                 if gradle:
1022                     matches = psearch_g(line)
1023                 else:
1024                     matches = psearch(line)
1025                 if matches:
1026                     package = matches.group(1)
1027             if not version:
1028                 if gradle:
1029                     matches = vnsearch_g(line)
1030                 else:
1031                     matches = vnsearch(line)
1032                 if matches:
1033                     version = matches.group(2 if gradle else 1)
1034             if not vercode:
1035                 if gradle:
1036                     matches = vcsearch_g(line)
1037                 else:
1038                     matches = vcsearch(line)
1039                 if matches:
1040                     vercode = matches.group(1)
1041
1042         logging.debug("..got package={0}, version={1}, vercode={2}"
1043                       .format(package, version, vercode))
1044
1045         # Always grab the package name and version name in case they are not
1046         # together with the highest version code
1047         if max_package is None and package is not None:
1048             max_package = package
1049         if max_version is None and version is not None:
1050             max_version = version
1051
1052         if max_vercode is None or (vercode is not None and vercode > max_vercode):
1053             if not ignoresearch or not ignoresearch(version):
1054                 if version is not None:
1055                     max_version = version
1056                 if vercode is not None:
1057                     max_vercode = vercode
1058                 if package is not None:
1059                     max_package = package
1060             else:
1061                 max_version = "Ignore"
1062
1063     if max_version is None:
1064         max_version = "Unknown"
1065
1066     if max_package and not is_valid_package_name(max_package):
1067         raise FDroidException("Invalid package name {0}".format(max_package))
1068
1069     return (max_version, max_vercode, max_package)
1070
1071
1072 def is_valid_package_name(name):
1073     return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1074
1075
1076 class FDroidException(Exception):
1077
1078     def __init__(self, value, detail=None):
1079         self.value = value
1080         self.detail = detail
1081
1082     def get_wikitext(self):
1083         ret = repr(self.value) + "\n"
1084         if self.detail:
1085             ret += "=detail=\n"
1086             ret += "<pre>\n"
1087             txt = self.detail[-8192:] if len(self.detail) > 8192 else self.detail
1088             ret += str(txt)
1089             ret += "</pre>\n"
1090         return ret
1091
1092     def __str__(self):
1093         ret = self.value
1094         if self.detail:
1095             ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1096         return ret
1097
1098
1099 class VCSException(FDroidException):
1100     pass
1101
1102
1103 class BuildException(FDroidException):
1104     pass
1105
1106
1107 # Get the specified source library.
1108 # Returns the path to it. Normally this is the path to be used when referencing
1109 # it, which may be a subdirectory of the actual project. If you want the base
1110 # directory of the project, pass 'basepath=True'.
1111 def getsrclib(spec, srclib_dir, srclibpaths=[], subdir=None,
1112               basepath=False, raw=False, prepare=True, preponly=False):
1113
1114     number = None
1115     subdir = None
1116     if raw:
1117         name = spec
1118         ref = None
1119     else:
1120         name, ref = spec.split('@')
1121         if ':' in name:
1122             number, name = name.split(':', 1)
1123         if '/' in name:
1124             name, subdir = name.split('/', 1)
1125
1126     if name not in metadata.srclibs:
1127         raise VCSException('srclib ' + name + ' not found.')
1128
1129     srclib = metadata.srclibs[name]
1130
1131     sdir = os.path.join(srclib_dir, name)
1132
1133     if not preponly:
1134         vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1135         vcs.srclib = (name, number, sdir)
1136         if ref:
1137             vcs.gotorevision(ref)
1138
1139         if raw:
1140             return vcs
1141
1142     libdir = None
1143     if subdir:
1144         libdir = os.path.join(sdir, subdir)
1145     elif srclib["Subdir"]:
1146         for subdir in srclib["Subdir"]:
1147             libdir_candidate = os.path.join(sdir, subdir)
1148             if os.path.exists(libdir_candidate):
1149                 libdir = libdir_candidate
1150                 break
1151
1152     if libdir is None:
1153         libdir = sdir
1154
1155     if srclib["Srclibs"]:
1156         n = 1
1157         for lib in srclib["Srclibs"].replace(';', ',').split(','):
1158             s_tuple = None
1159             for t in srclibpaths:
1160                 if t[0] == lib:
1161                     s_tuple = t
1162                     break
1163             if s_tuple is None:
1164                 raise VCSException('Missing recursive srclib %s for %s' % (
1165                     lib, name))
1166             place_srclib(libdir, n, s_tuple[2])
1167             n += 1
1168
1169     remove_signing_keys(sdir)
1170     remove_debuggable_flags(sdir)
1171
1172     if prepare:
1173
1174         if srclib["Prepare"]:
1175             cmd = replace_config_vars(srclib["Prepare"])
1176
1177             p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1178             if p.returncode != 0:
1179                 raise BuildException("Error running prepare command for srclib %s"
1180                                      % name, p.output)
1181
1182     if basepath:
1183         libdir = sdir
1184
1185     return (name, number, libdir)
1186
1187
1188 # Prepare the source code for a particular build
1189 #  'vcs'         - the appropriate vcs object for the application
1190 #  'app'         - the application details from the metadata
1191 #  'build'       - the build details from the metadata
1192 #  'build_dir'   - the path to the build directory, usually
1193 #                   'build/app.id'
1194 #  'srclib_dir'  - the path to the source libraries directory, usually
1195 #                   'build/srclib'
1196 #  'extlib_dir'  - the path to the external libraries directory, usually
1197 #                   'build/extlib'
1198 # Returns the (root, srclibpaths) where:
1199 #   'root' is the root directory, which may be the same as 'build_dir' or may
1200 #          be a subdirectory of it.
1201 #   'srclibpaths' is information on the srclibs being used
1202 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False):
1203
1204     # Optionally, the actual app source can be in a subdirectory
1205     if build['subdir']:
1206         root_dir = os.path.join(build_dir, build['subdir'])
1207     else:
1208         root_dir = build_dir
1209
1210     # Get a working copy of the right revision
1211     logging.info("Getting source for revision " + build['commit'])
1212     vcs.gotorevision(build['commit'])
1213
1214     # Initialise submodules if requred
1215     if build['submodules']:
1216         logging.info("Initialising submodules")
1217         vcs.initsubmodules()
1218
1219     # Check that a subdir (if we're using one) exists. This has to happen
1220     # after the checkout, since it might not exist elsewhere
1221     if not os.path.exists(root_dir):
1222         raise BuildException('Missing subdir ' + root_dir)
1223
1224     # Run an init command if one is required
1225     if build['init']:
1226         cmd = replace_config_vars(build['init'])
1227         logging.info("Running 'init' commands in %s" % root_dir)
1228
1229         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1230         if p.returncode != 0:
1231             raise BuildException("Error running init command for %s:%s" %
1232                                  (app['id'], build['version']), p.output)
1233
1234     # Apply patches if any
1235     if build['patch']:
1236         logging.info("Applying patches")
1237         for patch in build['patch']:
1238             patch = patch.strip()
1239             logging.info("Applying " + patch)
1240             patch_path = os.path.join('metadata', app['id'], patch)
1241             p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1242             if p.returncode != 0:
1243                 raise BuildException("Failed to apply patch %s" % patch_path)
1244
1245     # Get required source libraries
1246     srclibpaths = []
1247     if build['srclibs']:
1248         logging.info("Collecting source libraries")
1249         for lib in build['srclibs']:
1250             srclibpaths.append(getsrclib(lib, srclib_dir, build, srclibpaths,
1251                                          preponly=onserver))
1252
1253     for name, number, libpath in srclibpaths:
1254         place_srclib(root_dir, int(number) if number else None, libpath)
1255
1256     basesrclib = vcs.getsrclib()
1257     # If one was used for the main source, add that too.
1258     if basesrclib:
1259         srclibpaths.append(basesrclib)
1260
1261     # Update the local.properties file
1262     localprops = [os.path.join(build_dir, 'local.properties')]
1263     if build['subdir']:
1264         localprops += [os.path.join(root_dir, 'local.properties')]
1265     for path in localprops:
1266         props = ""
1267         if os.path.isfile(path):
1268             logging.info("Updating local.properties file at %s" % path)
1269             f = open(path, 'r')
1270             props += f.read()
1271             f.close()
1272             props += '\n'
1273         else:
1274             logging.info("Creating local.properties file at %s" % path)
1275         # Fix old-fashioned 'sdk-location' by copying
1276         # from sdk.dir, if necessary
1277         if build['oldsdkloc']:
1278             sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1279                               re.S | re.M).group(1)
1280             props += "sdk-location=%s\n" % sdkloc
1281         else:
1282             props += "sdk.dir=%s\n" % config['sdk_path']
1283             props += "sdk-location=%s\n" % config['sdk_path']
1284         if build['ndk_path']:
1285             # Add ndk location
1286             props += "ndk.dir=%s\n" % build['ndk_path']
1287             props += "ndk-location=%s\n" % build['ndk_path']
1288         # Add java.encoding if necessary
1289         if build['encoding']:
1290             props += "java.encoding=%s\n" % build['encoding']
1291         f = open(path, 'w')
1292         f.write(props)
1293         f.close()
1294
1295     flavours = []
1296     if build['type'] == 'gradle':
1297         flavours = build['gradle']
1298
1299         version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1300         gradlepluginver = None
1301
1302         gradle_dirs = [root_dir]
1303
1304         # Parent dir build.gradle
1305         parent_dir = os.path.normpath(os.path.join(root_dir, '..'))
1306         if parent_dir.startswith(build_dir):
1307             gradle_dirs.append(parent_dir)
1308
1309         for dir_path in gradle_dirs:
1310             if gradlepluginver:
1311                 break
1312             if not os.path.isdir(dir_path):
1313                 continue
1314             for filename in os.listdir(dir_path):
1315                 if not filename.endswith('.gradle'):
1316                     continue
1317                 path = os.path.join(dir_path, filename)
1318                 if not os.path.isfile(path):
1319                     continue
1320                 for line in file(path):
1321                     match = version_regex.match(line)
1322                     if match:
1323                         gradlepluginver = match.group(1)
1324                         break
1325
1326         if gradlepluginver:
1327             build['gradlepluginver'] = LooseVersion(gradlepluginver)
1328         else:
1329             logging.warn("Could not fetch the gradle plugin version, defaulting to 0.11")
1330             build['gradlepluginver'] = LooseVersion('0.11')
1331
1332         if build['target']:
1333             n = build["target"].split('-')[1]
1334             FDroidPopen(['sed', '-i',
1335                          's@compileSdkVersion *[0-9]*@compileSdkVersion ' + n + '@g',
1336                          'build.gradle'], cwd=root_dir, output=False)
1337
1338     # Remove forced debuggable flags
1339     remove_debuggable_flags(root_dir)
1340
1341     # Insert version code and number into the manifest if necessary
1342     if build['forceversion']:
1343         logging.info("Changing the version name")
1344         for path in manifest_paths(root_dir, flavours):
1345             if not os.path.isfile(path):
1346                 continue
1347             if has_extension(path, 'xml'):
1348                 p = FDroidPopen(['sed', '-i',
1349                                  's/android:versionName="[^"]*"/android:versionName="' + build['version'] + '"/g',
1350                                  path], output=False)
1351                 if p.returncode != 0:
1352                     raise BuildException("Failed to amend manifest")
1353             elif has_extension(path, 'gradle'):
1354                 p = FDroidPopen(['sed', '-i',
1355                                  's/versionName *=* *.*/versionName = "' + build['version'] + '"/g',
1356                                  path], output=False)
1357                 if p.returncode != 0:
1358                     raise BuildException("Failed to amend build.gradle")
1359     if build['forcevercode']:
1360         logging.info("Changing the version code")
1361         for path in manifest_paths(root_dir, flavours):
1362             if not os.path.isfile(path):
1363                 continue
1364             if has_extension(path, 'xml'):
1365                 p = FDroidPopen(['sed', '-i',
1366                                  's/android:versionCode="[^"]*"/android:versionCode="' + build['vercode'] + '"/g',
1367                                  path], output=False)
1368                 if p.returncode != 0:
1369                     raise BuildException("Failed to amend manifest")
1370             elif has_extension(path, 'gradle'):
1371                 p = FDroidPopen(['sed', '-i',
1372                                  's/versionCode *=* *[0-9]*/versionCode = ' + build['vercode'] + '/g',
1373                                  path], output=False)
1374                 if p.returncode != 0:
1375                     raise BuildException("Failed to amend build.gradle")
1376
1377     # Delete unwanted files
1378     if build['rm']:
1379         logging.info("Removing specified files")
1380         for part in getpaths(build_dir, build, 'rm'):
1381             dest = os.path.join(build_dir, part)
1382             logging.info("Removing {0}".format(part))
1383             if os.path.lexists(dest):
1384                 if os.path.islink(dest):
1385                     FDroidPopen(['unlink', dest], output=False)
1386                 else:
1387                     FDroidPopen(['rm', '-rf', dest], output=False)
1388             else:
1389                 logging.info("...but it didn't exist")
1390
1391     remove_signing_keys(build_dir)
1392
1393     # Add required external libraries
1394     if build['extlibs']:
1395         logging.info("Collecting prebuilt libraries")
1396         libsdir = os.path.join(root_dir, 'libs')
1397         if not os.path.exists(libsdir):
1398             os.mkdir(libsdir)
1399         for lib in build['extlibs']:
1400             lib = lib.strip()
1401             logging.info("...installing extlib {0}".format(lib))
1402             libf = os.path.basename(lib)
1403             libsrc = os.path.join(extlib_dir, lib)
1404             if not os.path.exists(libsrc):
1405                 raise BuildException("Missing extlib file {0}".format(libsrc))
1406             shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1407
1408     # Run a pre-build command if one is required
1409     if build['prebuild']:
1410         logging.info("Running 'prebuild' commands in %s" % root_dir)
1411
1412         cmd = replace_config_vars(build['prebuild'])
1413
1414         # Substitute source library paths into prebuild commands
1415         for name, number, libpath in srclibpaths:
1416             libpath = os.path.relpath(libpath, root_dir)
1417             cmd = cmd.replace('$$' + name + '$$', libpath)
1418
1419         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1420         if p.returncode != 0:
1421             raise BuildException("Error running prebuild command for %s:%s" %
1422                                  (app['id'], build['version']), p.output)
1423
1424     # Generate (or update) the ant build file, build.xml...
1425     if build['update'] and build['update'] != ['no'] and build['type'] == 'ant':
1426         parms = ['android', 'update', 'lib-project']
1427         lparms = ['android', 'update', 'project']
1428
1429         if build['target']:
1430             parms += ['-t', build['target']]
1431             lparms += ['-t', build['target']]
1432         if build['update'] == ['auto']:
1433             update_dirs = ant_subprojects(root_dir) + ['.']
1434         else:
1435             update_dirs = build['update']
1436
1437         for d in update_dirs:
1438             subdir = os.path.join(root_dir, d)
1439             if d == '.':
1440                 logging.debug("Updating main project")
1441                 cmd = parms + ['-p', d]
1442             else:
1443                 logging.debug("Updating subproject %s" % d)
1444                 cmd = lparms + ['-p', d]
1445             p = SdkToolsPopen(cmd, cwd=root_dir)
1446             # Check to see whether an error was returned without a proper exit
1447             # code (this is the case for the 'no target set or target invalid'
1448             # error)
1449             if p.returncode != 0 or p.output.startswith("Error: "):
1450                 raise BuildException("Failed to update project at %s" % d, p.output)
1451             # Clean update dirs via ant
1452             if d != '.':
1453                 logging.info("Cleaning subproject %s" % d)
1454                 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1455
1456     return (root_dir, srclibpaths)
1457
1458
1459 # Split and extend via globbing the paths from a field
1460 def getpaths(build_dir, build, field):
1461     paths = []
1462     for p in build[field]:
1463         p = p.strip()
1464         full_path = os.path.join(build_dir, p)
1465         full_path = os.path.normpath(full_path)
1466         paths += [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1467     return paths
1468
1469
1470 # Scan the source code in the given directory (and all subdirectories)
1471 # and return the number of fatal problems encountered
1472 def scan_source(build_dir, root_dir, thisbuild):
1473
1474     count = 0
1475
1476     # Common known non-free blobs (always lower case):
1477     usual_suspects = [
1478         re.compile(r'flurryagent', re.IGNORECASE),
1479         re.compile(r'paypal.*mpl', re.IGNORECASE),
1480         re.compile(r'google.*analytics', re.IGNORECASE),
1481         re.compile(r'admob.*sdk.*android', re.IGNORECASE),
1482         re.compile(r'google.*ad.*view', re.IGNORECASE),
1483         re.compile(r'google.*admob', re.IGNORECASE),
1484         re.compile(r'google.*play.*services', re.IGNORECASE),
1485         re.compile(r'crittercism', re.IGNORECASE),
1486         re.compile(r'heyzap', re.IGNORECASE),
1487         re.compile(r'jpct.*ae', re.IGNORECASE),
1488         re.compile(r'youtube.*android.*player.*api', re.IGNORECASE),
1489         re.compile(r'bugsense', re.IGNORECASE),
1490         re.compile(r'crashlytics', re.IGNORECASE),
1491         re.compile(r'ouya.*sdk', re.IGNORECASE),
1492         re.compile(r'libspen23', re.IGNORECASE),
1493     ]
1494
1495     scanignore = getpaths(build_dir, thisbuild, 'scanignore')
1496     scandelete = getpaths(build_dir, thisbuild, 'scandelete')
1497
1498     scanignore_worked = set()
1499     scandelete_worked = set()
1500
1501     try:
1502         ms = magic.open(magic.MIME_TYPE)
1503         ms.load()
1504     except AttributeError:
1505         ms = None
1506
1507     def toignore(fd):
1508         for p in scanignore:
1509             if fd.startswith(p):
1510                 scanignore_worked.add(p)
1511                 return True
1512         return False
1513
1514     def todelete(fd):
1515         for p in scandelete:
1516             if fd.startswith(p):
1517                 scandelete_worked.add(p)
1518                 return True
1519         return False
1520
1521     def ignoreproblem(what, fd, fp):
1522         logging.info('Ignoring %s at %s' % (what, fd))
1523         return 0
1524
1525     def removeproblem(what, fd, fp):
1526         logging.info('Removing %s at %s' % (what, fd))
1527         os.remove(fp)
1528         return 0
1529
1530     def warnproblem(what, fd):
1531         logging.warn('Found %s at %s' % (what, fd))
1532
1533     def handleproblem(what, fd, fp):
1534         if toignore(fd):
1535             return ignoreproblem(what, fd, fp)
1536         if todelete(fd):
1537             return removeproblem(what, fd, fp)
1538         logging.error('Found %s at %s' % (what, fd))
1539         return 1
1540
1541     # Iterate through all files in the source code
1542     for r, d, f in os.walk(build_dir, topdown=True):
1543
1544         # It's topdown, so checking the basename is enough
1545         for ignoredir in ('.hg', '.git', '.svn', '.bzr'):
1546             if ignoredir in d:
1547                 d.remove(ignoredir)
1548
1549         for curfile in f:
1550
1551             # Path (relative) to the file
1552             fp = os.path.join(r, curfile)
1553             fd = fp[len(build_dir) + 1:]
1554
1555             try:
1556                 mime = magic.from_file(fp, mime=True) if ms is None else ms.file(fp)
1557             except UnicodeError:
1558                 warnproblem('malformed magic number', fd)
1559
1560             if mime == 'application/x-sharedlib':
1561                 count += handleproblem('shared library', fd, fp)
1562
1563             elif mime == 'application/x-archive':
1564                 count += handleproblem('static library', fd, fp)
1565
1566             elif mime == 'application/x-executable':
1567                 count += handleproblem('binary executable', fd, fp)
1568
1569             elif mime == 'application/x-java-applet':
1570                 count += handleproblem('Java compiled class', fd, fp)
1571
1572             elif mime in (
1573                     'application/jar',
1574                     'application/zip',
1575                     'application/java-archive',
1576                     'application/octet-stream',
1577                     'binary', ):
1578
1579                 if has_extension(fp, 'apk'):
1580                     removeproblem('APK file', fd, fp)
1581
1582                 elif has_extension(fp, 'jar'):
1583
1584                     if any(suspect.match(curfile) for suspect in usual_suspects):
1585                         count += handleproblem('usual supect', fd, fp)
1586                     else:
1587                         warnproblem('JAR file', fd)
1588
1589                 elif has_extension(fp, 'zip'):
1590                     warnproblem('ZIP file', fd)
1591
1592                 else:
1593                     warnproblem('unknown compressed or binary file', fd)
1594
1595             elif has_extension(fp, 'java') and os.path.isfile(fp):
1596                 if not os.path.isfile(fp):
1597                     continue
1598                 for line in file(fp):
1599                     if 'DexClassLoader' in line:
1600                         count += handleproblem('DexClassLoader', fd, fp)
1601                         break
1602     if ms is not None:
1603         ms.close()
1604
1605     for p in scanignore:
1606         if p not in scanignore_worked:
1607             logging.error('Unused scanignore path: %s' % p)
1608             count += 1
1609
1610     for p in scandelete:
1611         if p not in scandelete_worked:
1612             logging.error('Unused scandelete path: %s' % p)
1613             count += 1
1614
1615     # Presence of a jni directory without buildjni=yes might
1616     # indicate a problem (if it's not a problem, explicitly use
1617     # buildjni=no to bypass this check)
1618     if (os.path.exists(os.path.join(root_dir, 'jni')) and
1619             not thisbuild['buildjni']):
1620         logging.error('Found jni directory, but buildjni is not enabled. Set it to \'no\' to ignore.')
1621         count += 1
1622
1623     return count
1624
1625
1626 class KnownApks:
1627
1628     def __init__(self):
1629         self.path = os.path.join('stats', 'known_apks.txt')
1630         self.apks = {}
1631         if os.path.isfile(self.path):
1632             for line in file(self.path):
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], time.strptime(t[2], '%Y-%m-%d'))
1638         self.changed = False
1639
1640     def writeifchanged(self):
1641         if self.changed:
1642             if not os.path.exists('stats'):
1643                 os.mkdir('stats')
1644             f = open(self.path, 'w')
1645             lst = []
1646             for apk, app in self.apks.iteritems():
1647                 appid, added = app
1648                 line = apk + ' ' + appid
1649                 if added:
1650                     line += ' ' + time.strftime('%Y-%m-%d', added)
1651                 lst.append(line)
1652             for line in sorted(lst):
1653                 f.write(line + '\n')
1654             f.close()
1655
1656     # Record an apk (if it's new, otherwise does nothing)
1657     # Returns the date it was added.
1658     def recordapk(self, apk, app):
1659         if apk not in self.apks:
1660             self.apks[apk] = (app, time.gmtime(time.time()))
1661             self.changed = True
1662         _, added = self.apks[apk]
1663         return added
1664
1665     # Look up information - given the 'apkname', returns (app id, date added/None).
1666     # Or returns None for an unknown apk.
1667     def getapp(self, apkname):
1668         if apkname in self.apks:
1669             return self.apks[apkname]
1670         return None
1671
1672     # Get the most recent 'num' apps added to the repo, as a list of package ids
1673     # with the most recent first.
1674     def getlatest(self, num):
1675         apps = {}
1676         for apk, app in self.apks.iteritems():
1677             appid, added = app
1678             if added:
1679                 if appid in apps:
1680                     if apps[appid] > added:
1681                         apps[appid] = added
1682                 else:
1683                     apps[appid] = added
1684         sortedapps = sorted(apps.iteritems(), key=operator.itemgetter(1))[-num:]
1685         lst = [app for app, _ in sortedapps]
1686         lst.reverse()
1687         return lst
1688
1689
1690 def isApkDebuggable(apkfile, config):
1691     """Returns True if the given apk file is debuggable
1692
1693     :param apkfile: full path to the apk to check"""
1694
1695     p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1696                       output=False)
1697     if p.returncode != 0:
1698         logging.critical("Failed to get apk manifest information")
1699         sys.exit(1)
1700     for line in p.output.splitlines():
1701         if 'android:debuggable' in line and not line.endswith('0x0'):
1702             return True
1703     return False
1704
1705
1706 class AsynchronousFileReader(threading.Thread):
1707
1708     '''
1709     Helper class to implement asynchronous reading of a file
1710     in a separate thread. Pushes read lines on a queue to
1711     be consumed in another thread.
1712     '''
1713
1714     def __init__(self, fd, queue):
1715         assert isinstance(queue, Queue.Queue)
1716         assert callable(fd.readline)
1717         threading.Thread.__init__(self)
1718         self._fd = fd
1719         self._queue = queue
1720
1721     def run(self):
1722         '''The body of the tread: read lines and put them on the queue.'''
1723         for line in iter(self._fd.readline, ''):
1724             self._queue.put(line)
1725
1726     def eof(self):
1727         '''Check whether there is no more content to expect.'''
1728         return not self.is_alive() and self._queue.empty()
1729
1730
1731 class PopenResult:
1732     returncode = None
1733     output = ''
1734
1735
1736 def SdkToolsPopen(commands, cwd=None, output=True):
1737     cmd = commands[0]
1738     if cmd not in config:
1739         config[cmd] = find_sdk_tools_cmd(commands[0])
1740     return FDroidPopen([config[cmd]] + commands[1:],
1741                        cwd=cwd, output=output)
1742
1743
1744 def FDroidPopen(commands, cwd=None, output=True):
1745     """
1746     Run a command and capture the possibly huge output.
1747
1748     :param commands: command and argument list like in subprocess.Popen
1749     :param cwd: optionally specifies a working directory
1750     :returns: A PopenResult.
1751     """
1752
1753     global 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     result = PopenResult()
1761     p = None
1762     try:
1763         p = subprocess.Popen(commands, cwd=cwd, shell=False, env=env,
1764                              stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
1765     except OSError, e:
1766         raise BuildException("OSError while trying to execute " +
1767                              ' '.join(commands) + ': ' + str(e))
1768
1769     stdout_queue = Queue.Queue()
1770     stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1771     stdout_reader.start()
1772
1773     # Check the queue for output (until there is no more to get)
1774     while not stdout_reader.eof():
1775         while not stdout_queue.empty():
1776             line = stdout_queue.get()
1777             if output and options.verbose:
1778                 # Output directly to console
1779                 sys.stderr.write(line)
1780                 sys.stderr.flush()
1781             result.output += line
1782
1783         time.sleep(0.1)
1784
1785     result.returncode = p.wait()
1786     return result
1787
1788
1789 def remove_signing_keys(build_dir):
1790     comment = re.compile(r'[ ]*//')
1791     signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1792     line_matches = [
1793         re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1794         re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1795         re.compile(r'.*variant\.outputFile = .*'),
1796         re.compile(r'.*output\.outputFile = .*'),
1797         re.compile(r'.*\.readLine\(.*'),
1798     ]
1799     for root, dirs, files in os.walk(build_dir):
1800         if 'build.gradle' in files:
1801             path = os.path.join(root, 'build.gradle')
1802
1803             with open(path, "r") as o:
1804                 lines = o.readlines()
1805
1806             changed = False
1807
1808             opened = 0
1809             i = 0
1810             with open(path, "w") as o:
1811                 while i < len(lines):
1812                     line = lines[i]
1813                     i += 1
1814                     while line.endswith('\\\n'):
1815                         line = line.rstrip('\\\n') + lines[i]
1816                         i += 1
1817
1818                     if comment.match(line):
1819                         continue
1820
1821                     if opened > 0:
1822                         opened += line.count('{')
1823                         opened -= line.count('}')
1824                         continue
1825
1826                     if signing_configs.match(line):
1827                         changed = True
1828                         opened += 1
1829                         continue
1830
1831                     if any(s.match(line) for s in line_matches):
1832                         changed = True
1833                         continue
1834
1835                     if opened == 0:
1836                         o.write(line)
1837
1838             if changed:
1839                 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1840
1841         for propfile in [
1842                 'project.properties',
1843                 'build.properties',
1844                 'default.properties',
1845                 'ant.properties', ]:
1846             if propfile in files:
1847                 path = os.path.join(root, propfile)
1848
1849                 with open(path, "r") as o:
1850                     lines = o.readlines()
1851
1852                 changed = False
1853
1854                 with open(path, "w") as o:
1855                     for line in lines:
1856                         if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1857                             changed = True
1858                             continue
1859
1860                         o.write(line)
1861
1862                 if changed:
1863                     logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1864
1865
1866 def reset_env_path():
1867     global env, orig_path
1868     env['PATH'] = orig_path
1869
1870
1871 def add_to_env_path(path):
1872     global env
1873     paths = env['PATH'].split(os.pathsep)
1874     if path in paths:
1875         return
1876     paths.append(path)
1877     env['PATH'] = os.pathsep.join(paths)
1878
1879
1880 def replace_config_vars(cmd):
1881     global env
1882     cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1883     # env['ANDROID_NDK'] is set in build_local right before prepare_source
1884     cmd = cmd.replace('$$NDK$$', env['ANDROID_NDK'])
1885     cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1886     return cmd
1887
1888
1889 def place_srclib(root_dir, number, libpath):
1890     if not number:
1891         return
1892     relpath = os.path.relpath(libpath, root_dir)
1893     proppath = os.path.join(root_dir, 'project.properties')
1894
1895     lines = []
1896     if os.path.isfile(proppath):
1897         with open(proppath, "r") as o:
1898             lines = o.readlines()
1899
1900     with open(proppath, "w") as o:
1901         placed = False
1902         for line in lines:
1903             if line.startswith('android.library.reference.%d=' % number):
1904                 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1905                 placed = True
1906             else:
1907                 o.write(line)
1908         if not placed:
1909             o.write('android.library.reference.%d=%s\n' % (number, relpath))
1910
1911
1912 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
1913     """Verify that two apks are the same
1914
1915     One of the inputs is signed, the other is unsigned. The signature metadata
1916     is transferred from the signed to the unsigned apk, and then jarsigner is
1917     used to verify that the signature from the signed apk is also varlid for
1918     the unsigned one.
1919     :param signed_apk: Path to a signed apk file
1920     :param unsigned_apk: Path to an unsigned apk file expected to match it
1921     :param tmp_dir: Path to directory for temporary files
1922     :returns: None if the verification is successful, otherwise a string
1923               describing what went wrong.
1924     """
1925     sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA)')
1926     with ZipFile(signed_apk) as signed_apk_as_zip:
1927         meta_inf_files = ['META-INF/MANIFEST.MF']
1928         for f in signed_apk_as_zip.namelist():
1929             if sigfile.match(f):
1930                 meta_inf_files.append(f)
1931         if len(meta_inf_files) < 3:
1932             return "Signature files missing from {0}".format(signed_apk)
1933         signed_apk_as_zip.extractall(tmp_dir, meta_inf_files)
1934     with ZipFile(unsigned_apk, mode='a') as unsigned_apk_as_zip:
1935         for meta_inf_file in meta_inf_files:
1936             unsigned_apk_as_zip.write(os.path.join(tmp_dir, meta_inf_file), arcname=meta_inf_file)
1937
1938     if subprocess.call(['jarsigner', '-verify', unsigned_apk]) != 0:
1939         logging.info("...NOT verified - {0}".format(signed_apk))
1940         return compare_apks(signed_apk, unsigned_apk, tmp_dir)
1941     logging.info("...successfully verified")
1942     return None
1943
1944
1945 def compare_apks(apk1, apk2, tmp_dir):
1946     """Compare two apks
1947
1948     Returns None if the apk content is the same (apart from the signing key),
1949     otherwise a string describing what's different, or what went wrong when
1950     trying to do the comparison.
1951     """
1952
1953     badchars = re.compile('''[/ :;'"]''')
1954     apk1dir = os.path.join(tmp_dir, badchars.sub('_', apk1[0:-4]))  # trim .apk
1955     apk2dir = os.path.join(tmp_dir, badchars.sub('_', apk2[0:-4]))  # trim .apk
1956     for d in [apk1dir, apk2dir]:
1957         if os.path.exists(d):
1958             shutil.rmtree(d)
1959         os.mkdir(d)
1960         os.mkdir(os.path.join(d, 'jar-xf'))
1961
1962     if subprocess.call(['jar', 'xf',
1963                         os.path.abspath(apk1)],
1964                        cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
1965         return("Failed to unpack " + apk1)
1966     if subprocess.call(['jar', 'xf',
1967                         os.path.abspath(apk2)],
1968                        cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
1969         return("Failed to unpack " + apk2)
1970
1971     # try to find apktool in the path, if it hasn't been manually configed
1972     if 'apktool' not in config:
1973         tmp = find_command('apktool')
1974         if tmp is not None:
1975             config['apktool'] = tmp
1976     if 'apktool' in config:
1977         if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
1978                            cwd=apk1dir) != 0:
1979             return("Failed to unpack " + apk1)
1980         if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
1981                            cwd=apk2dir) != 0:
1982             return("Failed to unpack " + apk2)
1983
1984     p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
1985     lines = p.output.splitlines()
1986     if len(lines) != 1 or 'META-INF' not in lines[0]:
1987         meld = find_command('meld')
1988         if meld is not None:
1989             p = FDroidPopen(['meld', apk1dir, apk2dir], output=False)
1990         return("Unexpected diff output - " + p.output)
1991
1992     # since everything verifies, delete the comparison to keep cruft down
1993     shutil.rmtree(apk1dir)
1994     shutil.rmtree(apk2dir)
1995
1996     # If we get here, it seems like they're the same!
1997     return None
1998
1999
2000 def find_command(command):
2001     '''find the full path of a command, or None if it can't be found in the PATH'''
2002
2003     def is_exe(fpath):
2004         return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
2005
2006     fpath, fname = os.path.split(command)
2007     if fpath:
2008         if is_exe(command):
2009             return command
2010     else:
2011         for path in os.environ["PATH"].split(os.pathsep):
2012             path = path.strip('"')
2013             exe_file = os.path.join(path, command)
2014             if is_exe(exe_file):
2015                 return exe_file
2016
2017     return None
2018
2019
2020 def genpassword():
2021     '''generate a random password for when generating keys'''
2022     h = hashlib.sha256()
2023     h.update(os.urandom(16))  # salt
2024     h.update(bytes(socket.getfqdn()))
2025     return h.digest().encode('base64').strip()
2026
2027
2028 def genkeystore(localconfig):
2029     '''Generate a new key with random passwords and add it to new keystore'''
2030     logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
2031     keystoredir = os.path.dirname(localconfig['keystore'])
2032     if keystoredir is None or keystoredir == '':
2033         keystoredir = os.path.join(os.getcwd(), keystoredir)
2034     if not os.path.exists(keystoredir):
2035         os.makedirs(keystoredir, mode=0o700)
2036
2037     write_password_file("keystorepass", localconfig['keystorepass'])
2038     write_password_file("keypass", localconfig['keypass'])
2039     p = FDroidPopen(['keytool', '-genkey',
2040                      '-keystore', localconfig['keystore'],
2041                      '-alias', localconfig['repo_keyalias'],
2042                      '-keyalg', 'RSA', '-keysize', '4096',
2043                      '-sigalg', 'SHA256withRSA',
2044                      '-validity', '10000',
2045                      '-storepass:file', config['keystorepassfile'],
2046                      '-keypass:file', config['keypassfile'],
2047                      '-dname', localconfig['keydname']])
2048     # TODO keypass should be sent via stdin
2049     os.chmod(localconfig['keystore'], 0o0600)
2050     if p.returncode != 0:
2051         raise BuildException("Failed to generate key", p.output)
2052     # now show the lovely key that was just generated
2053     p = FDroidPopen(['keytool', '-list', '-v',
2054                      '-keystore', localconfig['keystore'],
2055                      '-alias', localconfig['repo_keyalias'],
2056                      '-storepass:file', config['keystorepassfile']])
2057     logging.info(p.output.strip() + '\n\n')
2058
2059
2060 def write_to_config(thisconfig, key, value=None):
2061     '''write a key/value to the local config.py'''
2062     if value is None:
2063         origkey = key + '_orig'
2064         value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
2065     with open('config.py', 'r') as f:
2066         data = f.read()
2067     pattern = '\n[\s#]*' + key + '\s*=\s*"[^"]*"'
2068     repl = '\n' + key + ' = "' + value + '"'
2069     data = re.sub(pattern, repl, data)
2070     with open('config.py', 'w') as f:
2071         f.writelines(data)