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