chiark / gitweb /
run all SDK tools commands using SdkToolsPopen
[fdroidserver.git] / fdroidserver / publish.py
1 #!/usr/bin/env python2
2 # -*- coding: utf-8 -*-
3 #
4 # publish.py - part of the FDroid server tools
5 # Copyright (C) 2010-13, Ciaran Gultnieks, ciaran@ciarang.com
6 # Copyright (C) 2013-2014 Daniel Martí <mvdan@mvdan.cc>
7 #
8 # This program is free software: you can redistribute it and/or modify
9 # it under the terms of the GNU Affero General Public License as published by
10 # the Free Software Foundation, either version 3 of the License, or
11 # (at your option) any later version.
12 #
13 # This program is distributed in the hope that it will be useful,
14 # but WITHOUT ANY WARRANTY; without even the implied warranty of
15 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
16 # GNU Affero General Public License for more details.
17 #
18 # You should have received a copy of the GNU Affero General Public License
19 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
20
21 import sys
22 import os
23 import shutil
24 import md5
25 import glob
26 from optparse import OptionParser
27 import logging
28
29 import common
30 import metadata
31 from common import FDroidPopen, SdkToolsPopen, BuildException
32
33 config = None
34 options = None
35
36
37 def main():
38
39     global config, options
40
41     # Parse command line...
42     parser = OptionParser(usage="Usage: %prog [options] "
43                           "[APPID[:VERCODE] [APPID[:VERCODE] ...]]")
44     parser.add_option("-v", "--verbose", action="store_true", default=False,
45                       help="Spew out even more information than normal")
46     parser.add_option("-q", "--quiet", action="store_true", default=False,
47                       help="Restrict output to warnings and errors")
48     (options, args) = parser.parse_args()
49
50     config = common.read_config(options)
51
52     log_dir = 'logs'
53     if not os.path.isdir(log_dir):
54         logging.info("Creating log directory")
55         os.makedirs(log_dir)
56
57     tmp_dir = 'tmp'
58     if not os.path.isdir(tmp_dir):
59         logging.info("Creating temporary directory")
60         os.makedirs(tmp_dir)
61
62     output_dir = 'repo'
63     if not os.path.isdir(output_dir):
64         logging.info("Creating output directory")
65         os.makedirs(output_dir)
66
67     unsigned_dir = 'unsigned'
68     if not os.path.isdir(unsigned_dir):
69         logging.warning("No unsigned directory - nothing to do")
70         sys.exit(1)
71
72     for f in [config['keystorepassfile'],
73               config['keystore'],
74               config['keypassfile']]:
75         if not os.path.exists(f):
76             logging.error("Config error - missing '{0}'".format(f))
77             sys.exit(1)
78
79     # It was suggested at
80     #    https://dev.guardianproject.info/projects/bazaar/wiki/FDroid_Audit
81     # that a package could be crafted, such that it would use the same signing
82     # key as an existing app. While it may be theoretically possible for such a
83     # colliding package ID to be generated, it seems virtually impossible that
84     # the colliding ID would be something that would be a) a valid package ID,
85     # and b) a sane-looking ID that would make its way into the repo.
86     # Nonetheless, to be sure, before publishing we check that there are no
87     # collisions, and refuse to do any publishing if that's the case...
88     allapps = metadata.read_metadata()
89     vercodes = common.read_pkg_args(args, True)
90     allaliases = []
91     for appid in allapps:
92         m = md5.new()
93         m.update(appid)
94         keyalias = m.hexdigest()[:8]
95         if keyalias in allaliases:
96             logging.error("There is a keyalias collision - publishing halted")
97             sys.exit(1)
98         allaliases.append(keyalias)
99     logging.info("{0} apps, {0} key aliases".format(len(allapps),
100                                                     len(allaliases)))
101
102     # Process any apks that are waiting to be signed...
103     for apkfile in sorted(glob.glob(os.path.join(unsigned_dir, '*.apk'))):
104
105         appid, vercode = common.apknameinfo(apkfile)
106         apkfilename = os.path.basename(apkfile)
107         if vercodes and appid not in vercodes:
108             continue
109         if appid in vercodes and vercodes[appid]:
110             if vercode not in vercodes[appid]:
111                 continue
112         logging.info("Processing " + apkfile)
113
114         # There ought to be valid metadata for this app, otherwise why are we
115         # trying to publish it?
116         if appid not in allapps:
117             logging.error("Unexpected {0} found in unsigned directory"
118                           .format(apkfilename))
119             sys.exit(1)
120         app = allapps[appid]
121
122         if app.get('Binaries', None):
123
124             # It's an app where we build from source, and verify the apk
125             # contents against a developer's binary, and then publish their
126             # version if everything checks out.
127
128             # Need the version name for the version code...
129             versionname = None
130             for build in app['builds']:
131                 if build['vercode'] == vercode:
132                     versionname = build['version']
133                     break
134             if not versionname:
135                 logging.error("...no defined build for version code {0}"
136                               .format(vercode))
137                 continue
138
139             # Figure out where the developer's binary is supposed to come from...
140             url = app['Binaries']
141             url = url.replace('%v', versionname)
142             url = url.replace('%c', str(vercode))
143
144             # Grab the binary from where the developer publishes it...
145             logging.info("...retrieving " + url)
146             srcapk = os.path.join(tmp_dir, url.split('/')[-1])
147             p = FDroidPopen(['wget', '-nv', url], cwd=tmp_dir)
148             if p.returncode != 0 or not os.path.exists(srcapk):
149                 logging.error("...failed to retrieve " + url +
150                               " - publish skipped")
151                 continue
152
153             # Compare our unsigned one with the downloaded one...
154             compare_result = common.compare_apks(srcapk, apkfile, tmp_dir)
155             if compare_result:
156                 logging.error("...verfication failed - publish skipped : "
157                               + compare_result)
158                 continue
159
160             # Success! So move the downloaded file to the repo...
161             shutil.move(srcapk, os.path.join(output_dir, apkfilename))
162
163         else:
164
165             # It's a 'normal' app, i.e. we sign and publish it...
166
167             # Figure out the key alias name we'll use. Only the first 8
168             # characters are significant, so we'll use the first 8 from
169             # the MD5 of the app's ID and hope there are no collisions.
170             # If a collision does occur later, we're going to have to
171             # come up with a new alogrithm, AND rename all existing keys
172             # in the keystore!
173             if appid in config['keyaliases']:
174                 # For this particular app, the key alias is overridden...
175                 keyalias = config['keyaliases'][appid]
176                 if keyalias.startswith('@'):
177                     m = md5.new()
178                     m.update(keyalias[1:])
179                     keyalias = m.hexdigest()[:8]
180             else:
181                 m = md5.new()
182                 m.update(appid)
183                 keyalias = m.hexdigest()[:8]
184             logging.info("Key alias: " + keyalias)
185
186             # See if we already have a key for this application, and
187             # if not generate one...
188             p = FDroidPopen(['keytool', '-list',
189                              '-alias', keyalias, '-keystore', config['keystore'],
190                              '-storepass:file', config['keystorepassfile']])
191             if p.returncode != 0:
192                 logging.info("Key does not exist - generating...")
193                 p = FDroidPopen(['keytool', '-genkey',
194                                  '-keystore', config['keystore'],
195                                  '-alias', keyalias,
196                                  '-keyalg', 'RSA', '-keysize', '2048',
197                                  '-validity', '10000',
198                                  '-storepass:file', config['keystorepassfile'],
199                                  '-keypass:file', config['keypassfile'],
200                                  '-dname', config['keydname']])
201                 # TODO keypass should be sent via stdin
202                 if p.returncode != 0:
203                     raise BuildException("Failed to generate key")
204
205             # Sign the application...
206             p = FDroidPopen(['jarsigner', '-keystore', config['keystore'],
207                              '-storepass:file', config['keystorepassfile'],
208                              '-keypass:file', config['keypassfile'], '-sigalg',
209                              'MD5withRSA', '-digestalg', 'SHA1',
210                              apkfile, keyalias])
211             # TODO keypass should be sent via stdin
212             if p.returncode != 0:
213                 raise BuildException("Failed to sign application")
214
215             # Zipalign it...
216             p = SdkToolsPopen(['zipalign', '-v', '4', apkfile,
217                                os.path.join(output_dir, apkfilename)])
218             if p.returncode != 0:
219                 raise BuildException("Failed to align application")
220             os.remove(apkfile)
221
222         # Move the source tarball into the output directory...
223         tarfilename = apkfilename[:-4] + '_src.tar.gz'
224         tarfile = os.path.join(unsigned_dir, tarfilename)
225         if os.path.exists(tarfile):
226             shutil.move(tarfile, os.path.join(output_dir, tarfilename))
227
228         logging.info('Published ' + apkfilename)
229
230
231 if __name__ == "__main__":
232     main()