chiark / gitweb /
Replace getsig.java with a pure python implementation
[fdroidserver.git] / fdroidserver / update.py
index a1955e5b7ed765c219016590d1f4db40d8aaf44c..c618fc787ed5c693808e04e050defd24c9b2b2f5 100644 (file)
@@ -29,6 +29,11 @@ import pickle
 from xml.dom.minidom import Document
 from optparse import OptionParser
 import time
+from pyasn1.error import PyAsn1Error
+from pyasn1.codec.der import decoder, encoder
+from pyasn1_modules import rfc2315
+from hashlib import md5
+
 from PIL import Image
 import logging
 
@@ -62,7 +67,7 @@ def get_icon_dirs(repodir):
     yield os.path.join(repodir, "icons")
 
 
-def update_wiki(apps, apks):
+def update_wiki(apps, sortedids, apks):
     """Update the wiki
 
     :param apps: fully populated list of all applications
@@ -77,7 +82,10 @@ def update_wiki(apps, apks):
     site.login(config['wiki_user'], config['wiki_password'])
     generated_pages = {}
     generated_redirects = {}
-    for app in apps:
+
+    for appid in sortedids:
+        app = apps[appid]
+
         wikidata = ''
         if app['Disabled']:
             wikidata += '{{Disabled|' + app['Disabled'] + '}}\n'
@@ -85,7 +93,7 @@ def update_wiki(apps, apks):
             for af in app['AntiFeatures'].split(','):
                 wikidata += '{{AntiFeature|' + af + '}}\n'
         wikidata += '{{App|id=%s|name=%s|added=%s|lastupdated=%s|source=%s|tracker=%s|web=%s|donate=%s|flattr=%s|bitcoin=%s|litecoin=%s|dogecoin=%s|license=%s|root=%s}}\n' % (
-            app['id'],
+            appid,
             app['Name'],
             time.strftime('%Y-%m-%d', app['added']) if 'added' in app else '',
             time.strftime('%Y-%m-%d', app['lastupdated']) if 'lastupdated' in app else '',
@@ -104,7 +112,7 @@ def update_wiki(apps, apks):
             wikidata += "This app provides: %s" % ', '.join(app['Summary'].split(','))
 
         wikidata += app['Summary']
-        wikidata += " - [https://f-droid.org/repository/browse/?fdid=" + app['id'] + " view in repository]\n\n"
+        wikidata += " - [https://f-droid.org/repository/browse/?fdid=" + appid + " view in repository]\n\n"
 
         wikidata += "=Description=\n"
         wikidata += metadata.description_wiki(app['Description']) + "\n"
@@ -112,7 +120,7 @@ def update_wiki(apps, apks):
         wikidata += "=Maintainer Notes=\n"
         if 'Maintainer Notes' in app:
             wikidata += metadata.description_wiki(app['Maintainer Notes']) + "\n"
-        wikidata += "\nMetadata: [https://gitlab.com/fdroid/fdroiddata/blob/master/metadata/{0}.txt current] [https://gitlab.com/fdroid/fdroiddata/commits/master/metadata/{0}.txt history]\n".format(app['id'])
+        wikidata += "\nMetadata: [https://gitlab.com/fdroid/fdroiddata/blob/master/metadata/{0}.txt current] [https://gitlab.com/fdroid/fdroiddata/commits/master/metadata/{0}.txt history]\n".format(appid)
 
         # Get a list of all packages for this application...
         apklist = []
@@ -120,7 +128,7 @@ def update_wiki(apps, apks):
         cantupdate = False
         buildfails = False
         for apk in apks:
-            if apk['id'] == app['id']:
+            if apk['id'] == appid:
                 if str(apk['versioncode']) == app['Current Version Code']:
                     gotcurrentver = True
                 apklist.append(apk)
@@ -144,7 +152,7 @@ def update_wiki(apps, apks):
                     buildfails = True
                     apklist.append({'versioncode': int(thisbuild['vercode']),
                                     'version': thisbuild['version'],
-                                    'buildproblem': "The build for this version appears to have failed. Check the [[{0}/lastbuild|build log]].".format(app['id'])
+                                    'buildproblem': "The build for this version appears to have failed. Check the [[{0}/lastbuild_{1}|build log]].".format(appid, thisbuild['vercode'])
                                     })
         if app['Current Version Code'] == '0':
             cantupdate = True
@@ -200,7 +208,7 @@ def update_wiki(apps, apks):
 
         # We can't have underscores in the page name, even if they're in
         # the package ID, because MediaWiki messes with them...
-        pagename = app['id'].replace('_', ' ')
+        pagename = appid.replace('_', ' ')
 
         # Drop a trailing newline, because mediawiki is going to drop it anyway
         # and it we don't we'll think the page has changed when it hasn't...
@@ -270,14 +278,15 @@ def delete_disabled_builds(apps, apkcache, repodirs):
     :param apkcache: current apk cache information
     :param repodirs: the repo directories to process
     """
-    for app in apps:
+    for appid, app in apps.iteritems():
         for build in app['builds']:
             if build['disable']:
-                apkfilename = app['id'] + '_' + str(build['vercode']) + '.apk'
+                apkfilename = appid + '_' + str(build['vercode']) + '.apk'
                 for repodir in repodirs:
                     apkpath = os.path.join(repodir, apkfilename)
+                    ascpath = apkpath + ".asc"
                     srcpath = os.path.join(repodir, apkfilename[:-4] + "_src.tar.gz")
-                    for name in [apkpath, srcpath]:
+                    for name in [apkpath, srcpath, ascpath]:
                         if os.path.exists(name):
                             logging.warn("Deleting disabled build output " + apkfilename)
                             os.remove(name)
@@ -318,6 +327,52 @@ def resize_all_icons(repodirs):
                 resize_icon(iconpath, density)
 
 
+cert_path_regex = re.compile(r'^META-INF/.*\.RSA$')
+
+
+def getsig(apkpath):
+    """ Get the signing certificate of an apk. To get the same md5 has that
+    Android gets, we encode the .RSA certificate in a specific format and pass
+    it hex-encoded to the md5 digest algorithm.
+
+    :param apkpath: path to the apk
+    :returns: A string containing the md5 of the signature of the apk or None
+              if an error occurred.
+    """
+
+    cert = None
+
+    with zipfile.ZipFile(apkpath, 'r') as apk:
+
+        certs = [n for n in apk.namelist() if cert_path_regex.match(n)]
+
+        if len(certs) < 1:
+            logging.error("Found no signing certificates on %s" % apkpath)
+            return None
+        if len(certs) > 1:
+            logging.error("Found multiple signing certificates on %s" % apkpath)
+            return None
+
+        cert = apk.read(certs[0])
+
+    content = decoder.decode(cert, asn1Spec=rfc2315.ContentInfo())[0]
+    if content.getComponentByName('contentType') != rfc2315.signedData:
+        logging.error("Unexpected format.")
+        return None
+
+    content = decoder.decode(content.getComponentByName('content'),
+                             asn1Spec=rfc2315.SignedData())[0]
+    try:
+        certificates = content.getComponentByName('certificates')
+    except PyAsn1Error:
+        logging.error("Certificates not found.")
+        return None
+
+    cert_encoded = encoder.encode(certificates)[4:]
+
+    return md5(cert_encoded.encode('hex')).hexdigest()
+
+
 def scan_apks(apps, apkcache, repodir, knownapks):
     """Scan the apks in the given repo directory.
 
@@ -370,8 +425,8 @@ def scan_apks(apps, apkcache, repodir, knownapks):
             if os.path.exists(os.path.join(repodir, srcfilename)):
                 thisinfo['srcname'] = srcfilename
             thisinfo['size'] = os.path.getsize(apkfile)
-            thisinfo['permissions'] = []
-            thisinfo['features'] = []
+            thisinfo['permissions'] = set()
+            thisinfo['features'] = set()
             thisinfo['icons_src'] = {}
             thisinfo['icons'] = {}
             p = SilentPopen([config['aapt'], 'dump', 'badging', apkfile])
@@ -432,7 +487,7 @@ def scan_apks(apps, apkcache, repodir, knownapks):
                     perm = re.match(string_pat, line).group(1)
                     if perm.startswith("android.permission."):
                         perm = perm[19:]
-                    thisinfo['permissions'].append(perm)
+                    thisinfo['permissions'].add(perm)
                 elif line.startswith("uses-feature:"):
                     perm = re.match(string_pat, line).group(1)
                     # Filter out this, it's only added with the latest SDK tools and
@@ -441,15 +496,15 @@ def scan_apks(apps, apkcache, repodir, knownapks):
                             and perm != "android.hardware.screen.landscape":
                         if perm.startswith("android.feature."):
                             perm = perm[16:]
-                        thisinfo['features'].append(perm)
+                        thisinfo['features'].add(perm)
 
             if 'sdkversion' not in thisinfo:
-                logging.warn("no SDK version information found")
+                logging.warn("No SDK version information found in {0}".format(apkfile))
                 thisinfo['sdkversion'] = 0
 
             # Check for debuggable apks...
             if common.isApkDebuggable(apkfile, config):
-                logging.warn('{0} is set to android:debuggable="true"!'.format(apkfile))
+                logging.warn('{0} is set to android:debuggable="true"'.format(apkfile))
 
             # Calculate the sha256...
             sha = hashlib.sha256()
@@ -461,17 +516,21 @@ def scan_apks(apps, apkcache, repodir, knownapks):
                     sha.update(t)
                 thisinfo['sha256'] = sha.hexdigest()
 
-            # Get the signature (or md5 of, to be precise)...
-            getsig_dir = os.path.join(os.path.dirname(__file__), 'getsig')
-            if not os.path.exists(getsig_dir + "/getsig.class"):
-                logging.critical("getsig.class not found. To fix: cd '%s' && ./make.sh" % getsig_dir)
+            # verify the jar signature is correct
+            args = ['jarsigner', '-verify']
+            if options.verbose:
+                args += ['-verbose', '-certs']
+            args += apkfile
+            p = FDroidPopen(args)
+            if p.returncode != 0:
+                logging.critical(apkfile + " has a bad signature!")
                 sys.exit(1)
-            p = FDroidPopen(['java', '-cp', os.path.join(os.path.dirname(__file__), 'getsig'),
-                             'getsig', os.path.join(os.getcwd(), apkfile)])
-            if p.returncode != 0 or not p.output.startswith('Result:'):
+
+            # Get the signature (or md5 of, to be precise)...
+            thisinfo['sig'] = getsig(os.path.join(os.getcwd(), apkfile))
+            if not thisinfo['sig']:
                 logging.critical("Failed to get apk signature")
                 sys.exit(1)
-            thisinfo['sig'] = p.output[7:].strip()
 
             apk = zipfile.ZipFile(apkfile, 'r')
 
@@ -600,7 +659,7 @@ def scan_apks(apps, apkcache, repodir, knownapks):
 repo_pubkey_fingerprint = None
 
 
-def make_index(apps, apks, repodir, archive, categories):
+def make_index(apps, sortedids, apks, repodir, archive, categories):
     """Make a repo index.
 
     :param apps: fully populated apps list
@@ -677,7 +736,8 @@ def make_index(apps, apks, repodir, archive, categories):
 
     root.appendChild(repoel)
 
-    for app in apps:
+    for appid in sortedids:
+        app = apps[appid]
 
         if app['Disabled'] is not None:
             continue
@@ -685,7 +745,7 @@ def make_index(apps, apks, repodir, archive, categories):
         # Get a list of the apks for this app...
         apklist = []
         for apk in apks:
-            if apk['id'] == app['id']:
+            if apk['id'] == appid:
                 apklist.append(apk)
 
         if len(apklist) == 0:
@@ -705,11 +765,11 @@ def make_index(apps, apks, repodir, archive, categories):
         if app['icon']:
             addElement('icon', app['icon'], doc, apel)
 
-        def linkres(link):
-            for app in apps:
-                if app['id'] == link:
-                    return ("fdroid.app:" + link, app['Name'])
-            raise MetaDataException("Cannot resolve app id " + link)
+        def linkres(appid):
+            if appid in apps:
+                return ("fdroid.app:" + appid, apps[appid]['Name'])
+            raise MetaDataException("Cannot resolve app id " + appid)
+
         addElement('desc',
                    metadata.description_html(app['Description'], linkres),
                    doc, apel)
@@ -791,7 +851,7 @@ def make_index(apps, apks, repodir, archive, categories):
                 addElement('added', time.strftime('%Y-%m-%d', apk['added']), doc, apkel)
             if app['Requires Root']:
                 if 'ACCESS_SUPERUSER' not in apk['permissions']:
-                    apk['permissions'].append('ACCESS_SUPERUSER')
+                    apk['permissions'].add('ACCESS_SUPERUSER')
 
             if len(apk['permissions']) > 0:
                 addElement('permissions', ','.join(apk['permissions']), doc, apkel)
@@ -850,12 +910,12 @@ def make_index(apps, apks, repodir, archive, categories):
 
 def archive_old_apks(apps, apks, archapks, repodir, archivedir, defaultkeepversions):
 
-    for app in apps:
+    for appid, app in apps.iteritems():
 
         # Get a list of the apks for this app...
         apklist = []
         for apk in apks:
-            if apk['id'] == app['id']:
+            if apk['id'] == appid:
                 apklist.append(apk)
 
         # Sort the apk list into version order...
@@ -943,7 +1003,7 @@ def main():
 
     # Generate a list of categories...
     categories = set()
-    for app in apps:
+    for app in apps.itervalues():
         categories.update(app['Categories'])
 
     # Read known apks data (will be updated and written back when we've finished)
@@ -970,12 +1030,7 @@ def main():
     # metadata files, if requested on the command line)
     newmetadata = False
     for apk in apks:
-        found = False
-        for app in apps:
-            if app['id'] == apk['id']:
-                found = True
-                break
-        if not found:
+        if apk['id'] not in apps:
             if options.create_metadata:
                 if 'name' not in apk:
                     logging.error(apk['id'] + ' does not have a name! Skipping...')
@@ -1020,12 +1075,12 @@ def main():
     # level. When doing this, we use the info from the most recent version's apk.
     # We deal with figuring out when the app was added and last updated at the
     # same time.
-    for app in apps:
+    for appid, app in apps.iteritems():
         bestver = 0
         added = None
         lastupdated = None
         for apk in apks + archapks:
-            if apk['id'] == app['id']:
+            if apk['id'] == appid:
                 if apk['versioncode'] > bestver:
                     bestver = apk['versioncode']
                     bestapk = apk
@@ -1039,17 +1094,17 @@ def main():
         if added:
             app['added'] = added
         else:
-            logging.warn("Don't know when " + app['id'] + " was added")
+            logging.warn("Don't know when " + appid + " was added")
         if lastupdated:
             app['lastupdated'] = lastupdated
         else:
-            logging.warn("Don't know when " + app['id'] + " was last updated")
+            logging.warn("Don't know when " + appid + " was last updated")
 
         if bestver == 0:
             if app['Name'] is None:
-                app['Name'] = app['id']
+                app['Name'] = app['Auto Name'] or appid
             app['icon'] = None
-            logging.warn("Application " + app['id'] + " has no packages")
+            logging.warn("Application " + appid + " has no packages")
         else:
             if app['Name'] is None:
                 app['Name'] = bestapk['name']
@@ -1058,18 +1113,18 @@ def main():
     # Sort the app list by name, then the web site doesn't have to by default.
     # (we had to wait until we'd scanned the apks to do this, because mostly the
     # name comes from there!)
-    apps = sorted(apps, key=lambda app: app['Name'].upper())
+    sortedids = sorted(apps.iterkeys(), key=lambda appid: apps[appid]['Name'].upper())
 
     if len(repodirs) > 1:
         archive_old_apks(apps, apks, archapks, repodirs[0], repodirs[1], config['archive_older'])
 
     # Make the index for the main repo...
-    make_index(apps, apks, repodirs[0], False, categories)
+    make_index(apps, sortedids, apks, repodirs[0], False, categories)
 
     # If there's an archive repo,  make the index for it. We already scanned it
     # earlier on.
     if len(repodirs) > 1:
-        make_index(apps, archapks, repodirs[1], True, categories)
+        make_index(apps, sortedids, archapks, repodirs[1], True, categories)
 
     if config['update_stats']:
 
@@ -1082,13 +1137,11 @@ def main():
             for line in file(os.path.join('stats', 'latestapps.txt')):
                 appid = line.rstrip()
                 data += appid + "\t"
-                for app in apps:
-                    if app['id'] == appid:
-                        data += app['Name'] + "\t"
-                        if app['icon'] is not None:
-                            data += app['icon'] + "\t"
-                        data += app['License'] + "\n"
-                        break
+                app = apps[appid]
+                data += app['Name'] + "\t"
+                if app['icon'] is not None:
+                    data += app['icon'] + "\t"
+                data += app['License'] + "\n"
             f = open(os.path.join(repodirs[0], 'latestapps.dat'), 'w')
             f.write(data)
             f.close()
@@ -1099,7 +1152,7 @@ def main():
 
     # Update the wiki...
     if options.wiki:
-        update_wiki(apps, apks + archapks)
+        update_wiki(apps, sortedids, apks + archapks)
 
     logging.info("Finished.")