chiark / gitweb /
handle jarsigner/apksigner output cleanly for rational logging
[fdroidserver.git] / fdroidserver / index.py
1 #!/usr/bin/env python3
2 #
3 # update.py - part of the FDroid server tools
4 # Copyright (C) 2017, Torsten Grote <t at grobox dot de>
5 # Copyright (C) 2016, Blue Jay Wireless
6 # Copyright (C) 2014-2016, Hans-Christoph Steiner <hans@eds.org>
7 # Copyright (C) 2010-2015, Ciaran Gultnieks <ciaran@ciarang.com>
8 # Copyright (C) 2013-2014, Daniel Martí <mvdan@mvdan.cc>
9 #
10 # This program is free software: you can redistribute it and/or modify
11 # it under the terms of the GNU Affero General Public License as published by
12 # the Free Software Foundation, either version 3 of the License, or
13 # (at your option) any later version.
14 #
15 # This program is distributed in the hope that it will be useful,
16 # but WITHOUT ANY WARRANTY; without even the implied warranty of
17 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
18 # GNU Affero General Public License for more details.
19 #
20 # You should have received a copy of the GNU Affero General Public License
21 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
22
23 import collections
24 import copy
25 import json
26 import logging
27 import os
28 import re
29 import shutil
30 import tempfile
31 import urllib.parse
32 import zipfile
33 import calendar
34 from binascii import hexlify, unhexlify
35 from datetime import datetime
36 from xml.dom.minidom import Document
37
38 from . import _
39 from . import common
40 from . import metadata
41 from . import net
42 from . import signindex
43 from fdroidserver.common import FDroidPopen, FDroidPopenBytes, load_stats_fdroid_signing_key_fingerprints
44 from fdroidserver.exception import FDroidException, VerificationException, MetaDataException
45
46
47 def make(apps, sortedids, apks, repodir, archive):
48     """Generate the repo index files.
49
50     This requires properly initialized options and config objects.
51
52     :param apps: fully populated apps list
53     :param sortedids: app package IDs, sorted
54     :param apks: full populated apks list
55     :param repodir: the repo directory
56     :param archive: True if this is the archive repo, False if it's the
57                     main one.
58     """
59     from fdroidserver.update import METADATA_VERSION
60
61     def _resolve_description_link(appid):
62         if appid in apps:
63             return "fdroid.app:" + appid, apps[appid].Name
64         raise MetaDataException("Cannot resolve app id " + appid)
65
66     if not common.options.nosign:
67         common.assert_config_keystore(common.config)
68
69     repodict = collections.OrderedDict()
70     repodict['timestamp'] = datetime.utcnow()
71     repodict['version'] = METADATA_VERSION
72
73     if common.config['repo_maxage'] != 0:
74         repodict['maxage'] = common.config['repo_maxage']
75
76     if archive:
77         repodict['name'] = common.config['archive_name']
78         repodict['icon'] = os.path.basename(common.config['archive_icon'])
79         repodict['address'] = common.config['archive_url']
80         repodict['description'] = common.config['archive_description']
81         urlbasepath = os.path.basename(urllib.parse.urlparse(common.config['archive_url']).path)
82     else:
83         repodict['name'] = common.config['repo_name']
84         repodict['icon'] = os.path.basename(common.config['repo_icon'])
85         repodict['address'] = common.config['repo_url']
86         repodict['description'] = common.config['repo_description']
87         urlbasepath = os.path.basename(urllib.parse.urlparse(common.config['repo_url']).path)
88
89     mirrorcheckfailed = False
90     mirrors = []
91     for mirror in sorted(common.config.get('mirrors', [])):
92         base = os.path.basename(urllib.parse.urlparse(mirror).path.rstrip('/'))
93         if common.config.get('nonstandardwebroot') is not True and base != 'fdroid':
94             logging.error(_("mirror '%s' does not end with 'fdroid'!") % mirror)
95             mirrorcheckfailed = True
96         # must end with / or urljoin strips a whole path segment
97         if mirror.endswith('/'):
98             mirrors.append(urllib.parse.urljoin(mirror, urlbasepath))
99         else:
100             mirrors.append(urllib.parse.urljoin(mirror + '/', urlbasepath))
101     for mirror in common.config.get('servergitmirrors', []):
102         for url in get_mirror_service_urls(mirror):
103             mirrors.append(url + '/' + repodir)
104     if mirrorcheckfailed:
105         raise FDroidException(_("Malformed repository mirrors."))
106     if mirrors:
107         repodict['mirrors'] = mirrors
108
109     appsWithPackages = collections.OrderedDict()
110     for packageName in sortedids:
111         app = apps[packageName]
112         if app['Disabled']:
113             continue
114
115         # only include apps with packages
116         for apk in apks:
117             if apk['packageName'] == packageName:
118                 newapp = copy.copy(app)  # update wiki needs unmodified description
119                 newapp['Description'] = metadata.description_html(app['Description'],
120                                                                   _resolve_description_link)
121                 appsWithPackages[packageName] = newapp
122                 break
123
124     requestsdict = collections.OrderedDict()
125     for command in ('install', 'uninstall'):
126         packageNames = []
127         key = command + '_list'
128         if key in common.config:
129             if isinstance(common.config[key], str):
130                 packageNames = [common.config[key]]
131             elif all(isinstance(item, str) for item in common.config[key]):
132                 packageNames = common.config[key]
133             else:
134                 raise TypeError(_('only accepts strings, lists, and tuples'))
135         requestsdict[command] = packageNames
136
137     fdroid_signing_key_fingerprints = load_stats_fdroid_signing_key_fingerprints()
138
139     make_v0(appsWithPackages, apks, repodir, repodict, requestsdict,
140             fdroid_signing_key_fingerprints)
141     make_v1(appsWithPackages, apks, repodir, repodict, requestsdict,
142             fdroid_signing_key_fingerprints)
143
144
145 def make_v1(apps, packages, repodir, repodict, requestsdict, fdroid_signing_key_fingerprints):
146
147     def _index_encoder_default(obj):
148         if isinstance(obj, set):
149             return sorted(list(obj))
150         if isinstance(obj, datetime):
151             # Java prefers milliseconds
152             # we also need to accound for time zone/daylight saving time
153             return int(calendar.timegm(obj.timetuple()) * 1000)
154         if isinstance(obj, dict):
155             d = collections.OrderedDict()
156             for key in sorted(obj.keys()):
157                 d[key] = obj[key]
158             return d
159         raise TypeError(repr(obj) + " is not JSON serializable")
160
161     output = collections.OrderedDict()
162     output['repo'] = repodict
163     output['requests'] = requestsdict
164
165     # establish sort order of the index
166     v1_sort_packages(packages, repodir, fdroid_signing_key_fingerprints)
167
168     appslist = []
169     output['apps'] = appslist
170     for packageName, appdict in apps.items():
171         d = collections.OrderedDict()
172         appslist.append(d)
173         for k, v in sorted(appdict.items()):
174             if not v:
175                 continue
176             if k in ('builds', 'comments', 'metadatapath',
177                      'ArchivePolicy', 'AutoUpdateMode', 'MaintainerNotes',
178                      'Provides', 'Repo', 'RepoType', 'RequiresRoot',
179                      'UpdateCheckData', 'UpdateCheckIgnore', 'UpdateCheckMode',
180                      'UpdateCheckName', 'NoSourceSince', 'VercodeOperation'):
181                 continue
182
183             # name things after the App class fields in fdroidclient
184             if k == 'id':
185                 k = 'packageName'
186             elif k == 'CurrentVersionCode':  # TODO make SuggestedVersionCode the canonical name
187                 k = 'suggestedVersionCode'
188             elif k == 'CurrentVersion':  # TODO make SuggestedVersionName the canonical name
189                 k = 'suggestedVersionName'
190             elif k == 'AutoName':
191                 if 'Name' not in apps[packageName]:
192                     d['name'] = v
193                 continue
194             else:
195                 k = k[:1].lower() + k[1:]
196             d[k] = v
197
198     # establish sort order in localized dicts
199     for app in output['apps']:
200         localized = app.get('localized')
201         if localized:
202             lordered = collections.OrderedDict()
203             for lkey, lvalue in sorted(localized.items()):
204                 lordered[lkey] = collections.OrderedDict()
205                 for ikey, iname in sorted(lvalue.items()):
206                     lordered[lkey][ikey] = iname
207             app['localized'] = lordered
208
209     output_packages = collections.OrderedDict()
210     output['packages'] = output_packages
211     for package in packages:
212         packageName = package['packageName']
213         if packageName not in apps:
214             logging.info(_('Ignoring package without metadata: ') + package['apkName'])
215             continue
216         if packageName in output_packages:
217             packagelist = output_packages[packageName]
218         else:
219             packagelist = []
220             output_packages[packageName] = packagelist
221         d = collections.OrderedDict()
222         packagelist.append(d)
223         for k, v in sorted(package.items()):
224             if not v:
225                 continue
226             if k in ('icon', 'icons', 'icons_src', 'name', ):
227                 continue
228             d[k] = v
229
230     json_name = 'index-v1.json'
231     index_file = os.path.join(repodir, json_name)
232     with open(index_file, 'w') as fp:
233         if common.options.pretty:
234             json.dump(output, fp, default=_index_encoder_default, indent=2)
235         else:
236             json.dump(output, fp, default=_index_encoder_default)
237
238     if common.options.nosign:
239         logging.debug(_('index-v1 must have a signature, use `fdroid signindex` to create it!'))
240     else:
241         signindex.config = common.config
242         signindex.sign_index_v1(repodir, json_name)
243
244
245 def v1_sort_packages(packages, repodir, fdroid_signing_key_fingerprints):
246     """Sorts the supplied list to ensure a deterministic sort order for
247     package entries in the index file. This sort-order also expresses
248     installation preference to the clients.
249     (First in this list = first to install)
250
251     :param packages: list of packages which need to be sorted before but into index file.
252     """
253
254     GROUP_DEV_SIGNED = 1
255     GROUP_FDROID_SIGNED = 2
256     GROUP_OTHER_SIGNED = 3
257
258     def v1_sort_keys(package):
259         packageName = package.get('packageName', None)
260
261         sig = package.get('signer', None)
262
263         dev_sig = common.metadata_find_developer_signature(packageName)
264         group = GROUP_OTHER_SIGNED
265         if dev_sig and dev_sig == sig:
266             group = GROUP_DEV_SIGNED
267         else:
268             fdroidsig = fdroid_signing_key_fingerprints.get(packageName, {}).get('signer')
269             if fdroidsig and fdroidsig == sig:
270                 group = GROUP_FDROID_SIGNED
271
272         versionCode = None
273         if package.get('versionCode', None):
274             versionCode = -int(package['versionCode'])
275
276         return(packageName, group, sig, versionCode)
277
278     packages.sort(key=v1_sort_keys)
279
280
281 def make_v0(apps, apks, repodir, repodict, requestsdict, fdroid_signing_key_fingerprints):
282     """
283     aka index.jar aka index.xml
284     """
285
286     doc = Document()
287
288     def addElement(name, value, doc, parent):
289         el = doc.createElement(name)
290         el.appendChild(doc.createTextNode(value))
291         parent.appendChild(el)
292
293     def addElementNonEmpty(name, value, doc, parent):
294         if not value:
295             return
296         addElement(name, value, doc, parent)
297
298     def addElementIfInApk(name, apk, key, doc, parent):
299         if key not in apk:
300             return
301         value = str(apk[key])
302         addElement(name, value, doc, parent)
303
304     def addElementCDATA(name, value, doc, parent):
305         el = doc.createElement(name)
306         el.appendChild(doc.createCDATASection(value))
307         parent.appendChild(el)
308
309     def addElementCheckLocalized(name, app, key, doc, parent, default=''):
310         '''Fill in field from metadata or localized block
311
312         For name/summary/description, they can come only from the app source,
313         or from a dir in fdroiddata.  They can be entirely missing from the
314         metadata file if there is localized versions.  This will fetch those
315         from the localized version if its not available in the metadata file.
316         '''
317
318         el = doc.createElement(name)
319         value = app.get(key)
320         lkey = key[:1].lower() + key[1:]
321         localized = app.get('localized')
322         if not value and localized:
323             for lang in ['en-US'] + [x for x in localized.keys()]:
324                 if not lang.startswith('en'):
325                     continue
326                 if lang in localized:
327                     value = localized[lang].get(lkey)
328                     if value:
329                         break
330         if not value and localized and len(localized) > 1:
331             lang = list(localized.keys())[0]
332             value = localized[lang].get(lkey)
333         if not value:
334             value = default
335         el.appendChild(doc.createTextNode(value))
336         parent.appendChild(el)
337
338     root = doc.createElement("fdroid")
339     doc.appendChild(root)
340
341     repoel = doc.createElement("repo")
342
343     repoel.setAttribute("name", repodict['name'])
344     if 'maxage' in repodict:
345         repoel.setAttribute("maxage", str(repodict['maxage']))
346     repoel.setAttribute("icon", os.path.basename(repodict['icon']))
347     repoel.setAttribute("url", repodict['address'])
348     addElement('description', repodict['description'], doc, repoel)
349     for mirror in repodict.get('mirrors', []):
350         addElement('mirror', mirror, doc, repoel)
351
352     repoel.setAttribute("version", str(repodict['version']))
353     repoel.setAttribute("timestamp", '%d' % repodict['timestamp'].timestamp())
354
355     pubkey, repo_pubkey_fingerprint = extract_pubkey()
356     repoel.setAttribute("pubkey", pubkey.decode('utf-8'))
357     root.appendChild(repoel)
358
359     for command in ('install', 'uninstall'):
360         for packageName in requestsdict[command]:
361             element = doc.createElement(command)
362             root.appendChild(element)
363             element.setAttribute('packageName', packageName)
364
365     for appid, appdict in apps.items():
366         app = metadata.App(appdict)
367
368         if app.Disabled is not None:
369             continue
370
371         # Get a list of the apks for this app...
372         apklist = []
373         apksbyversion = collections.defaultdict(lambda: [])
374         for apk in apks:
375             if apk.get('versionCode') and apk.get('packageName') == appid:
376                 apksbyversion[apk['versionCode']].append(apk)
377         for versionCode, apksforver in apksbyversion.items():
378             fdroidsig = fdroid_signing_key_fingerprints.get(appid, {}).get('signer')
379             fdroid_signed_apk = None
380             name_match_apk = None
381             for x in apksforver:
382                 if fdroidsig and x.get('signer', None) == fdroidsig:
383                     fdroid_signed_apk = x
384                 if common.apk_release_filename.match(x.get('apkName', '')):
385                     name_match_apk = x
386             # choose which of the available versions is most
387             # suiteable for index v0
388             if fdroid_signed_apk:
389                 apklist.append(fdroid_signed_apk)
390             elif name_match_apk:
391                 apklist.append(name_match_apk)
392             else:
393                 apklist.append(apksforver[0])
394
395         if len(apklist) == 0:
396             continue
397
398         apel = doc.createElement("application")
399         apel.setAttribute("id", app.id)
400         root.appendChild(apel)
401
402         addElement('id', app.id, doc, apel)
403         if app.added:
404             addElement('added', app.added.strftime('%Y-%m-%d'), doc, apel)
405         if app.lastUpdated:
406             addElement('lastupdated', app.lastUpdated.strftime('%Y-%m-%d'), doc, apel)
407
408         addElementCheckLocalized('name', app, 'Name', doc, apel)
409         addElementCheckLocalized('summary', app, 'Summary', doc, apel)
410
411         if app.icon:
412             addElement('icon', app.icon, doc, apel)
413
414         addElementCheckLocalized('desc', app, 'Description', doc, apel,
415                                  '<p>No description available</p>')
416
417         addElement('license', app.License, doc, apel)
418         if app.Categories:
419             addElement('categories', ','.join(app.Categories), doc, apel)
420             # We put the first (primary) category in LAST, which will have
421             # the desired effect of making clients that only understand one
422             # category see that one.
423             addElement('category', app.Categories[0], doc, apel)
424         addElement('web', app.WebSite, doc, apel)
425         addElement('source', app.SourceCode, doc, apel)
426         addElement('tracker', app.IssueTracker, doc, apel)
427         addElementNonEmpty('changelog', app.Changelog, doc, apel)
428         addElementNonEmpty('author', app.AuthorName, doc, apel)
429         addElementNonEmpty('email', app.AuthorEmail, doc, apel)
430         addElementNonEmpty('donate', app.Donate, doc, apel)
431         addElementNonEmpty('bitcoin', app.Bitcoin, doc, apel)
432         addElementNonEmpty('litecoin', app.Litecoin, doc, apel)
433         addElementNonEmpty('flattr', app.FlattrID, doc, apel)
434
435         # These elements actually refer to the current version (i.e. which
436         # one is recommended. They are historically mis-named, and need
437         # changing, but stay like this for now to support existing clients.
438         addElement('marketversion', app.CurrentVersion, doc, apel)
439         addElement('marketvercode', app.CurrentVersionCode, doc, apel)
440
441         if app.Provides:
442             pv = app.Provides.split(',')
443             addElementNonEmpty('provides', ','.join(pv), doc, apel)
444         if app.RequiresRoot:
445             addElement('requirements', 'root', doc, apel)
446
447         # Sort the apk list into version order, just so the web site
448         # doesn't have to do any work by default...
449         apklist = sorted(apklist, key=lambda apk: apk['versionCode'], reverse=True)
450
451         if 'antiFeatures' in apklist[0]:
452             app.AntiFeatures.extend(apklist[0]['antiFeatures'])
453         if app.AntiFeatures:
454             addElementNonEmpty('antifeatures', ','.join(app.AntiFeatures), doc, apel)
455
456         # Check for duplicates - they will make the client unhappy...
457         for i in range(len(apklist) - 1):
458             first = apklist[i]
459             second = apklist[i + 1]
460             if first['versionCode'] == second['versionCode'] \
461                and first['sig'] == second['sig']:
462                 if first['hash'] == second['hash']:
463                     raise FDroidException('"{0}/{1}" and "{0}/{2}" are exact duplicates!'.format(
464                         repodir, first['apkName'], second['apkName']))
465                 else:
466                     raise FDroidException('duplicates: "{0}/{1}" - "{0}/{2}"'.format(
467                         repodir, first['apkName'], second['apkName']))
468
469         current_version_code = 0
470         current_version_file = None
471         for apk in apklist:
472             file_extension = common.get_file_extension(apk['apkName'])
473             # find the APK for the "Current Version"
474             if current_version_code < apk['versionCode']:
475                 current_version_code = apk['versionCode']
476             if current_version_code < int(app.CurrentVersionCode):
477                 current_version_file = apk['apkName']
478
479             apkel = doc.createElement("package")
480             apel.appendChild(apkel)
481             addElement('version', apk['versionName'], doc, apkel)
482             addElement('versioncode', str(apk['versionCode']), doc, apkel)
483             addElement('apkname', apk['apkName'], doc, apkel)
484             addElementIfInApk('srcname', apk, 'srcname', doc, apkel)
485
486             hashel = doc.createElement("hash")
487             hashel.setAttribute('type', 'sha256')
488             hashel.appendChild(doc.createTextNode(apk['hash']))
489             apkel.appendChild(hashel)
490
491             addElement('size', str(apk['size']), doc, apkel)
492             addElementIfInApk('sdkver', apk,
493                               'minSdkVersion', doc, apkel)
494             addElementIfInApk('targetSdkVersion', apk,
495                               'targetSdkVersion', doc, apkel)
496             addElementIfInApk('maxsdkver', apk,
497                               'maxSdkVersion', doc, apkel)
498             addElementIfInApk('obbMainFile', apk,
499                               'obbMainFile', doc, apkel)
500             addElementIfInApk('obbMainFileSha256', apk,
501                               'obbMainFileSha256', doc, apkel)
502             addElementIfInApk('obbPatchFile', apk,
503                               'obbPatchFile', doc, apkel)
504             addElementIfInApk('obbPatchFileSha256', apk,
505                               'obbPatchFileSha256', doc, apkel)
506             if 'added' in apk:
507                 addElement('added', apk['added'].strftime('%Y-%m-%d'), doc, apkel)
508
509             if file_extension == 'apk':  # sig is required for APKs, but only APKs
510                 addElement('sig', apk['sig'], doc, apkel)
511
512                 old_permissions = set()
513                 sorted_permissions = sorted(apk['uses-permission'])
514                 for perm in sorted_permissions:
515                     perm_name = perm.name
516                     if perm_name.startswith("android.permission."):
517                         perm_name = perm_name[19:]
518                     old_permissions.add(perm_name)
519                 addElementNonEmpty('permissions', ','.join(sorted(old_permissions)), doc, apkel)
520
521                 for permission in sorted_permissions:
522                     permel = doc.createElement('uses-permission')
523                     permel.setAttribute('name', permission.name)
524                     if permission.maxSdkVersion is not None:
525                         permel.setAttribute('maxSdkVersion', '%d' % permission.maxSdkVersion)
526                         apkel.appendChild(permel)
527                 for permission_sdk_23 in sorted(apk['uses-permission-sdk-23']):
528                     permel = doc.createElement('uses-permission-sdk-23')
529                     permel.setAttribute('name', permission_sdk_23.name)
530                     if permission_sdk_23.maxSdkVersion is not None:
531                         permel.setAttribute('maxSdkVersion', '%d' % permission_sdk_23.maxSdkVersion)
532                         apkel.appendChild(permel)
533                 if 'nativecode' in apk:
534                     addElement('nativecode', ','.join(sorted(apk['nativecode'])), doc, apkel)
535                 addElementNonEmpty('features', ','.join(sorted(apk['features'])), doc, apkel)
536
537         if current_version_file is not None \
538                 and common.config['make_current_version_link'] \
539                 and repodir == 'repo':  # only create these
540             namefield = common.config['current_version_name_source']
541             sanitized_name = re.sub(b'''[ '"&%?+=/]''', b'', app.get(namefield).encode('utf-8'))
542             apklinkname = sanitized_name + os.path.splitext(current_version_file)[1].encode('utf-8')
543             current_version_path = os.path.join(repodir, current_version_file).encode('utf-8', 'surrogateescape')
544             if os.path.islink(apklinkname):
545                 os.remove(apklinkname)
546             os.symlink(current_version_path, apklinkname)
547             # also symlink gpg signature, if it exists
548             for extension in (b'.asc', b'.sig'):
549                 sigfile_path = current_version_path + extension
550                 if os.path.exists(sigfile_path):
551                     siglinkname = apklinkname + extension
552                     if os.path.islink(siglinkname):
553                         os.remove(siglinkname)
554                     os.symlink(sigfile_path, siglinkname)
555
556     if common.options.pretty:
557         output = doc.toprettyxml(encoding='utf-8')
558     else:
559         output = doc.toxml(encoding='utf-8')
560
561     with open(os.path.join(repodir, 'index.xml'), 'wb') as f:
562         f.write(output)
563
564     if 'repo_keyalias' in common.config:
565
566         if common.options.nosign:
567             logging.info(_("Creating unsigned index in preparation for signing"))
568         else:
569             logging.info(_("Creating signed index with this key (SHA256):"))
570             logging.info("%s" % repo_pubkey_fingerprint)
571
572         # Create a jar of the index...
573         jar_output = 'index_unsigned.jar' if common.options.nosign else 'index.jar'
574         p = FDroidPopen(['jar', 'cf', jar_output, 'index.xml'], cwd=repodir)
575         if p.returncode != 0:
576             raise FDroidException("Failed to create {0}".format(jar_output))
577
578         # Sign the index...
579         signed = os.path.join(repodir, 'index.jar')
580         if common.options.nosign:
581             # Remove old signed index if not signing
582             if os.path.exists(signed):
583                 os.remove(signed)
584         else:
585             signindex.config = common.config
586             signindex.sign_jar(signed)
587
588     # Copy the repo icon into the repo directory...
589     icon_dir = os.path.join(repodir, 'icons')
590     iconfilename = os.path.join(icon_dir, os.path.basename(common.config['repo_icon']))
591     shutil.copyfile(common.config['repo_icon'], iconfilename)
592
593
594 def extract_pubkey():
595     """
596     Extracts and returns the repository's public key from the keystore.
597     :return: public key in hex, repository fingerprint
598     """
599     if 'repo_pubkey' in common.config:
600         pubkey = unhexlify(common.config['repo_pubkey'])
601     else:
602         env_vars = {'FDROID_KEY_STORE_PASS': common.config['keystorepass']}
603         p = FDroidPopenBytes([common.config['keytool'], '-exportcert',
604                               '-alias', common.config['repo_keyalias'],
605                               '-keystore', common.config['keystore'],
606                               '-storepass:env', 'FDROID_KEY_STORE_PASS']
607                              + common.config['smartcardoptions'],
608                              envs=env_vars, output=False, stderr_to_stdout=False)
609         if p.returncode != 0 or len(p.output) < 20:
610             msg = "Failed to get repo pubkey!"
611             if common.config['keystore'] == 'NONE':
612                 msg += ' Is your crypto smartcard plugged in?'
613             raise FDroidException(msg)
614         pubkey = p.output
615     repo_pubkey_fingerprint = common.get_cert_fingerprint(pubkey)
616     return hexlify(pubkey), repo_pubkey_fingerprint
617
618
619 def get_mirror_service_urls(url):
620     '''Get direct URLs from git service for use by fdroidclient
621
622     Via 'servergitmirrors', fdroidserver can create and push a mirror
623     to certain well known git services like gitlab or github.  This
624     will always use the 'master' branch since that is the default
625     branch in git. The files are then accessible via alternate URLs,
626     where they are served in their raw format via a CDN rather than
627     from git.
628     '''
629
630     if url.startswith('git@'):
631         url = re.sub(r'^git@(.*):(.*)', r'https://\1/\2', url)
632
633     segments = url.split("/")
634
635     if segments[4].endswith('.git'):
636         segments[4] = segments[4][:-4]
637
638     hostname = segments[2]
639     user = segments[3]
640     repo = segments[4]
641     branch = "master"
642     folder = "fdroid"
643
644     urls = []
645     if hostname == "github.com":
646         # Github-like RAW segments "https://raw.githubusercontent.com/user/repo/branch/folder"
647         segments[2] = "raw.githubusercontent.com"
648         segments.extend([branch, folder])
649         urls.append('/'.join(segments))
650     elif hostname == "gitlab.com":
651         # Gitlab Raw "https://gitlab.com/user/repo/raw/branch/folder"
652         gitlab_raw = segments + ['raw', branch, folder]
653         urls.append('/'.join(gitlab_raw))
654         # Gitlab-like Pages segments "https://user.gitlab.io/repo/folder"
655         gitlab_pages = ["https:", "", user + ".gitlab.io", repo, folder]
656         urls.append('/'.join(gitlab_pages))
657         return urls
658
659     return urls
660
661
662 def download_repo_index(url_str, etag=None, verify_fingerprint=True):
663     """
664     Downloads the repository index from the given :param url_str
665     and verifies the repository's fingerprint if :param verify_fingerprint is not False.
666
667     :raises: VerificationException() if the repository could not be verified
668
669     :return: A tuple consisting of:
670         - The index in JSON format or None if the index did not change
671         - The new eTag as returned by the HTTP request
672     """
673     url = urllib.parse.urlsplit(url_str)
674
675     fingerprint = None
676     if verify_fingerprint:
677         query = urllib.parse.parse_qs(url.query)
678         if 'fingerprint' not in query:
679             raise VerificationException(_("No fingerprint in URL."))
680         fingerprint = query['fingerprint'][0]
681
682     url = urllib.parse.SplitResult(url.scheme, url.netloc, url.path + '/index-v1.jar', '', '')
683     download, new_etag = net.http_get(url.geturl(), etag)
684
685     if download is None:
686         return None, new_etag
687
688     with tempfile.NamedTemporaryFile() as fp:
689         # write and open JAR file
690         fp.write(download)
691         jar = zipfile.ZipFile(fp)
692
693         # verify that the JAR signature is valid
694         logging.debug(_('Verifying index signature:'))
695         common.verify_jar_signature(fp.name)
696
697         # get public key and its fingerprint from JAR
698         public_key, public_key_fingerprint = get_public_key_from_jar(jar)
699
700         # compare the fingerprint if verify_fingerprint is True
701         if verify_fingerprint and fingerprint.upper() != public_key_fingerprint:
702             raise VerificationException(_("The repository's fingerprint does not match."))
703
704         # load repository index from JSON
705         index = json.loads(jar.read('index-v1.json').decode("utf-8"))
706         index["repo"]["pubkey"] = hexlify(public_key).decode("utf-8")
707         index["repo"]["fingerprint"] = public_key_fingerprint
708
709         # turn the apps into App objects
710         index["apps"] = [metadata.App(app) for app in index["apps"]]
711
712         return index, new_etag
713
714
715 def get_public_key_from_jar(jar):
716     """
717     Get the public key and its fingerprint from a JAR file.
718
719     :raises: VerificationException() if the JAR was not signed exactly once
720
721     :param jar: a zipfile.ZipFile object
722     :return: the public key from the jar and its fingerprint
723     """
724     # extract certificate from jar
725     certs = [n for n in jar.namelist() if common.CERT_PATH_REGEX.match(n)]
726     if len(certs) < 1:
727         raise VerificationException(_("Found no signing certificates for repository."))
728     if len(certs) > 1:
729         raise VerificationException(_("Found multiple signing certificates for repository."))
730
731     # extract public key from certificate
732     public_key = common.get_certificate(jar.read(certs[0]))
733     public_key_fingerprint = common.get_cert_fingerprint(public_key).replace(' ', '')
734
735     return public_key, public_key_fingerprint