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