chiark / gitweb /
Merge branch 'plural' into 'master'
[fdroidserver.git] / fdroidserver / publish.py
index c0791d27087380eaaa8b4a98de08b5844acfc889..b15c12a8553e93e031a9bdc12ac84dcd269dc8fd 100644 (file)
@@ -1,8 +1,8 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
+#!/usr/bin/env python3
 #
 # publish.py - part of the FDroid server tools
-# Copyright (C) 2010-12, Ciaran Gultnieks, ciaran@ciarang.com
+# Copyright (C) 2010-13, Ciaran Gultnieks, ciaran@ciarang.com
+# Copyright (C) 2013-2014 Daniel Martí <mvdan@mvdan.cc>
 #
 # This program is free software: you can redistribute it and/or modify
 # it under the terms of the GNU Affero General Public License as published by
 
 import sys
 import os
-import shutil
-import subprocess
 import re
-import zipfile
-import tarfile
-import md5
+import shutil
 import glob
-from optparse import OptionParser
+import hashlib
+from argparse import ArgumentParser
+import logging
+from gettext import ngettext
+
+from . import _
+from . import common
+from . import metadata
+from .common import FDroidPopen, SdkToolsPopen
+from .exception import BuildException
+
+config = None
+options = None
 
-import common
-from common import BuildException
 
 def main():
 
-    #Read configuration...
-    execfile('config.py', globals())
+    global config, options
 
     # Parse command line...
-    parser = OptionParser()
-    parser.add_option("-v", "--verbose", action="store_true", default=False,
-                      help="Spew out even more information than normal")
-    parser.add_option("-p", "--package", default=None,
-                      help="Publish only the specified package")
-    (options, args) = parser.parse_args()
+    parser = ArgumentParser(usage="%(prog)s [options] "
+                            "[APPID[:VERCODE] [APPID[:VERCODE] ...]]")
+    common.setup_global_opts(parser)
+    parser.add_argument("appid", nargs='*', help=_("applicationId with optional versionCode in the form APPID[:VERCODE]"))
+    metadata.add_metadata_arguments(parser)
+    options = parser.parse_args()
+    metadata.warnings_action = options.W
+
+    config = common.read_config(options)
+
+    if not ('jarsigner' in config and 'keytool' in config):
+        logging.critical(_('Java JDK not found! Install in standard location or set java_paths!'))
+        sys.exit(1)
 
     log_dir = 'logs'
     if not os.path.isdir(log_dir):
-        print "Creating log directory"
+        logging.info(_("Creating log directory"))
         os.makedirs(log_dir)
 
     tmp_dir = 'tmp'
     if not os.path.isdir(tmp_dir):
-        print "Creating temporary directory"
+        logging.info(_("Creating temporary directory"))
         os.makedirs(tmp_dir)
 
     output_dir = 'repo'
     if not os.path.isdir(output_dir):
-        print "Creating output directory"
+        logging.info(_("Creating output directory"))
         os.makedirs(output_dir)
 
     unsigned_dir = 'unsigned'
     if not os.path.isdir(unsigned_dir):
-        print "No unsigned directory - nothing to do"
-        sys.exit(0)
+        logging.warning(_("No unsigned directory - nothing to do"))
+        sys.exit(1)
+
+    if not os.path.exists(config['keystore']):
+        logging.error("Config error - missing '{0}'".format(config['keystore']))
+        sys.exit(1)
+
+    # It was suggested at
+    #    https://dev.guardianproject.info/projects/bazaar/wiki/FDroid_Audit
+    # that a package could be crafted, such that it would use the same signing
+    # key as an existing app. While it may be theoretically possible for such a
+    # colliding package ID to be generated, it seems virtually impossible that
+    # the colliding ID would be something that would be a) a valid package ID,
+    # and b) a sane-looking ID that would make its way into the repo.
+    # Nonetheless, to be sure, before publishing we check that there are no
+    # collisions, and refuse to do any publishing if that's the case...
+    allapps = metadata.read_metadata()
+    vercodes = common.read_pkg_args(options.appid, True)
+    allaliases = []
+    for appid in allapps:
+        m = hashlib.md5()
+        m.update(appid.encode('utf-8'))
+        keyalias = m.hexdigest()[:8]
+        if keyalias in allaliases:
+            logging.error(_("There is a keyalias collision - publishing halted"))
+            sys.exit(1)
+        allaliases.append(keyalias)
+    logging.info(ngettext('{0} app, {1} key aliases',
+                          '{0} apps, {1} key aliases', len(allapps)).format(len(allapps), len(allaliases)))
+
+    # Process any APKs or ZIPs that are waiting to be signed...
+    for apkfile in sorted(glob.glob(os.path.join(unsigned_dir, '*.apk'))
+                          + glob.glob(os.path.join(unsigned_dir, '*.zip'))):
+
+        appid, vercode = common.publishednameinfo(apkfile)
+        apkfilename = os.path.basename(apkfile)
+        if vercodes and appid not in vercodes:
+            continue
+        if appid in vercodes and vercodes[appid]:
+            if vercode not in vercodes[appid]:
+                continue
+        logging.info("Processing " + apkfile)
+
+        # There ought to be valid metadata for this app, otherwise why are we
+        # trying to publish it?
+        if appid not in allapps:
+            logging.error("Unexpected {0} found in unsigned directory"
+                          .format(apkfilename))
+            sys.exit(1)
+        app = allapps[appid]
+
+        if app.Binaries:
+
+            # It's an app where we build from source, and verify the apk
+            # contents against a developer's binary, and then publish their
+            # version if everything checks out.
+            # The binary should already have been retrieved during the build
+            # process.
+            srcapk = re.sub(r'.apk$', '.binary.apk', apkfile)
+
+            # Compare our unsigned one with the downloaded one...
+            compare_result = common.verify_apks(srcapk, apkfile, tmp_dir)
+            if compare_result:
+                logging.error("...verification failed - publish skipped : "
+                              + compare_result)
+                continue
+
+            # Success! So move the downloaded file to the repo, and remove
+            # our built version.
+            shutil.move(srcapk, os.path.join(output_dir, apkfilename))
+            os.remove(apkfile)
 
-    for apkfile in sorted(glob.glob(os.path.join(unsigned_dir, '*.apk'))):
+        elif apkfile.endswith('.zip'):
 
-        apkfilename = os.path.basename(apkfile)
-        i = apkfilename.rfind('_')
-        if i == -1:
-            raise BuildException("Invalid apk name")
-        appid = apkfilename[:i]
-        print "Processing " + appid
+            # OTA ZIPs built by fdroid do not need to be signed by jarsigner,
+            # just to be moved into place in the repo
+            shutil.move(apkfile, os.path.join(output_dir, apkfilename))
+
+        else:
 
-        if not options.package or options.package == appid:
+            # It's a 'normal' app, i.e. we sign and publish it...
 
             # Figure out the key alias name we'll use. Only the first 8
             # characters are significant, so we'll use the first 8 from
@@ -81,63 +161,72 @@ def main():
             # If a collision does occur later, we're going to have to
             # come up with a new alogrithm, AND rename all existing keys
             # in the keystore!
-            if appid in keyaliases:
+            if appid in config['keyaliases']:
                 # For this particular app, the key alias is overridden...
-                keyalias = keyaliases[appid]
+                keyalias = config['keyaliases'][appid]
+                if keyalias.startswith('@'):
+                    m = hashlib.md5()
+                    m.update(keyalias[1:].encode('utf-8'))
+                    keyalias = m.hexdigest()[:8]
             else:
-                m = md5.new()
-                m.update(appid)
+                m = hashlib.md5()
+                m.update(appid.encode('utf-8'))
                 keyalias = m.hexdigest()[:8]
-            print "Key alias: " + keyalias
+            logging.info("Key alias: " + keyalias)
 
             # See if we already have a key for this application, and
             # if not generate one...
-            p = subprocess.Popen(['keytool', '-list',
-                '-alias', keyalias, '-keystore', keystore,
-                '-storepass', keystorepass], stdout=subprocess.PIPE)
-            output = p.communicate()[0]
-            if p.returncode !=0:
-                print "Key does not exist - generating..."
-                p = subprocess.Popen(['keytool', '-genkey',
-                    '-keystore', keystore, '-alias', keyalias,
-                    '-keyalg', 'RSA', '-keysize', '2048',
-                    '-validity', '10000',
-                    '-storepass', keystorepass, '-keypass', keypass,
-                    '-dname', keydname], stdout=subprocess.PIPE)
-                output = p.communicate()[0]
-                print output
+            env_vars = {
+                'FDROID_KEY_STORE_PASS': config['keystorepass'],
+                'FDROID_KEY_PASS': config['keypass'],
+            }
+            p = FDroidPopen([config['keytool'], '-list',
+                             '-alias', keyalias, '-keystore', config['keystore'],
+                             '-storepass:env', 'FDROID_KEY_STORE_PASS'], envs=env_vars)
+            if p.returncode != 0:
+                logging.info("Key does not exist - generating...")
+                p = FDroidPopen([config['keytool'], '-genkey',
+                                 '-keystore', config['keystore'],
+                                 '-alias', keyalias,
+                                 '-keyalg', 'RSA', '-keysize', '2048',
+                                 '-validity', '10000',
+                                 '-storepass:env', 'FDROID_KEY_STORE_PASS',
+                                 '-keypass:env', 'FDROID_KEY_PASS',
+                                 '-dname', config['keydname']], envs=env_vars)
                 if p.returncode != 0:
                     raise BuildException("Failed to generate key")
 
+            signed_apk_path = os.path.join(output_dir, apkfilename)
+            if os.path.exists(signed_apk_path):
+                raise BuildException("Refusing to sign '{0}' file exists in both "
+                                     "{1} and {2} folder.".format(apkfilename,
+                                                                  unsigned_dir,
+                                                                  output_dir))
+
             # Sign the application...
-            p = subprocess.Popen(['jarsigner', '-keystore', keystore,
-                '-storepass', keystorepass, '-keypass', keypass, '-sigalg',
-                'MD5withRSA', '-digestalg', 'SHA1',
-                    apkfile, keyalias], stdout=subprocess.PIPE)
-            output = p.communicate()[0]
-            print output
+            p = FDroidPopen([config['jarsigner'], '-keystore', config['keystore'],
+                             '-storepass:env', 'FDROID_KEY_STORE_PASS',
+                             '-keypass:env', 'FDROID_KEY_PASS', '-sigalg',
+                             'SHA1withRSA', '-digestalg', 'SHA1',
+                             apkfile, keyalias], envs=env_vars)
             if p.returncode != 0:
-                raise BuildException("Failed to sign application")
+                raise BuildException(_("Failed to sign application"))
 
             # Zipalign it...
-            p = subprocess.Popen([os.path.join(sdk_path,'tools','zipalign'),
-                                '-v', '4', apkfile,
-                                os.path.join(output_dir, apkfilename)],
-                                stdout=subprocess.PIPE)
-            output = p.communicate()[0]
-            print output
+            p = SdkToolsPopen(['zipalign', '-v', '4', apkfile,
+                               os.path.join(output_dir, apkfilename)])
             if p.returncode != 0:
-                raise BuildException("Failed to align application")
+                raise BuildException(_("Failed to align application"))
             os.remove(apkfile)
 
-            # Move the source tarball into the output directory...
-            tarfilename = apkfilename[:-4] + '_src.tar.gz'
-            shutil.move(os.path.join(unsigned_dir, tarfilename),
-                    os.path.join(output_dir, tarfilename))
+        # Move the source tarball into the output directory...
+        tarfilename = apkfilename[:-4] + '_src.tar.gz'
+        tarfile = os.path.join(unsigned_dir, tarfilename)
+        if os.path.exists(tarfile):
+            shutil.move(tarfile, os.path.join(output_dir, tarfilename))
 
-            print 'Published ' + apkfilename
+        logging.info('Published ' + apkfilename)
 
 
 if __name__ == "__main__":
     main()
-