3 # common.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>
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.
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.
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/>.
20 # common.py is imported by all modules, so do not import third-party
21 # libraries here as they will become a requirement for all commands.
39 import xml.etree.ElementTree as XMLElementTree
41 from binascii import hexlify
42 from datetime import datetime
43 from distutils.version import LooseVersion
44 from queue import Queue
45 from zipfile import ZipFile
47 from pyasn1.codec.der import decoder, encoder
48 from pyasn1_modules import rfc2315
49 from pyasn1.error import PyAsn1Error
51 import fdroidserver.metadata
52 from .asynchronousfilereader import AsynchronousFileReader
55 # A signature block file with a .DSA, .RSA, or .EC extension
56 CERT_PATH_REGEX = re.compile(r'^META-INF/.*\.(DSA|EC|RSA)$')
58 XMLElementTree.register_namespace('android', 'http://schemas.android.com/apk/res/android')
67 'sdk_path': "$ANDROID_HOME",
72 'r12b': "$ANDROID_NDK",
77 'build_tools': "25.0.2",
78 'force_build_tools': False,
83 'accepted_formats': ['txt', 'yml'],
84 'sync_from_local_copy_dir': False,
85 'per_app_repos': False,
86 'make_current_version_link': True,
87 'current_version_name_source': 'Name',
88 'update_stats': False,
92 'stats_to_carbon': False,
94 'build_server_always': False,
95 'keystore': 'keystore.jks',
96 'smartcardoptions': [],
102 'repo_url': "https://MyFirstFDroidRepo.org/fdroid/repo",
103 'repo_name': "My First FDroid Repo Demo",
104 'repo_icon': "fdroid-icon.png",
105 'repo_description': '''
106 This is a repository of apps to be used with FDroid. Applications in this
107 repository are either official binaries built by the original application
108 developers, or are binaries built from source by the admin of f-droid.org
109 using the tools on https://gitlab.com/u/fdroid.
115 def setup_global_opts(parser):
116 parser.add_argument("-v", "--verbose", action="store_true", default=False,
117 help="Spew out even more information than normal")
118 parser.add_argument("-q", "--quiet", action="store_true", default=False,
119 help="Restrict output to warnings and errors")
122 def fill_config_defaults(thisconfig):
123 for k, v in default_config.items():
124 if k not in thisconfig:
127 # Expand paths (~users and $vars)
128 def expand_path(path):
132 path = os.path.expanduser(path)
133 path = os.path.expandvars(path)
138 for k in ['sdk_path', 'ant', 'mvn3', 'gradle', 'keystore', 'repo_icon']:
143 thisconfig[k + '_orig'] = v
145 # find all installed JDKs for keytool, jarsigner, and JAVA[6-9]_HOME env vars
146 if thisconfig['java_paths'] is None:
147 thisconfig['java_paths'] = dict()
149 pathlist += glob.glob('/usr/lib/jvm/j*[6-9]*')
150 pathlist += glob.glob('/usr/java/jdk1.[6-9]*')
151 pathlist += glob.glob('/System/Library/Java/JavaVirtualMachines/1.[6-9].0.jdk')
152 pathlist += glob.glob('/Library/Java/JavaVirtualMachines/*jdk*[6-9]*')
153 if os.getenv('JAVA_HOME') is not None:
154 pathlist.append(os.getenv('JAVA_HOME'))
155 if os.getenv('PROGRAMFILES') is not None:
156 pathlist += glob.glob(os.path.join(os.getenv('PROGRAMFILES'), 'Java', 'jdk1.[6-9].*'))
157 for d in sorted(pathlist):
158 if os.path.islink(d):
160 j = os.path.basename(d)
161 # the last one found will be the canonical one, so order appropriately
163 r'^1\.([6-9])\.0\.jdk$', # OSX
164 r'^jdk1\.([6-9])\.0_[0-9]+.jdk$', # OSX and Oracle tarball
165 r'^jdk1\.([6-9])\.0_[0-9]+$', # Oracle Windows
166 r'^jdk([6-9])-openjdk$', # Arch
167 r'^java-([6-9])-openjdk$', # Arch
168 r'^java-([6-9])-jdk$', # Arch (oracle)
169 r'^java-1\.([6-9])\.0-.*$', # RedHat
170 r'^java-([6-9])-oracle$', # Debian WebUpd8
171 r'^jdk-([6-9])-oracle-.*$', # Debian make-jpkg
172 r'^java-([6-9])-openjdk-[^c][^o][^m].*$', # Debian
174 m = re.match(regex, j)
177 for p in [d, os.path.join(d, 'Contents', 'Home')]:
178 if os.path.exists(os.path.join(p, 'bin', 'javac')):
179 thisconfig['java_paths'][m.group(1)] = p
181 for java_version in ('7', '8', '9'):
182 if java_version not in thisconfig['java_paths']:
184 java_home = thisconfig['java_paths'][java_version]
185 jarsigner = os.path.join(java_home, 'bin', 'jarsigner')
186 if os.path.exists(jarsigner):
187 thisconfig['jarsigner'] = jarsigner
188 thisconfig['keytool'] = os.path.join(java_home, 'bin', 'keytool')
189 break # Java7 is preferred, so quit if found
191 for k in ['ndk_paths', 'java_paths']:
197 thisconfig[k][k2] = exp
198 thisconfig[k][k2 + '_orig'] = v
201 def regsub_file(pattern, repl, path):
202 with open(path, 'rb') as f:
204 text = re.sub(bytes(pattern, 'utf8'), bytes(repl, 'utf8'), text)
205 with open(path, 'wb') as f:
209 def read_config(opts, config_file='config.py'):
210 """Read the repository config
212 The config is read from config_file, which is in the current
213 directory when any of the repo management commands are used. If
214 there is a local metadata file in the git repo, then config.py is
215 not required, just use defaults.
218 global config, options
220 if config is not None:
227 if os.path.isfile(config_file):
228 logging.debug("Reading %s" % config_file)
229 with io.open(config_file, "rb") as f:
230 code = compile(f.read(), config_file, 'exec')
231 exec(code, None, config)
232 elif len(get_local_metadata_files()) == 0:
233 logging.critical("Missing config file - is this a repo directory?")
236 for k in ('mirrors', 'install_list', 'uninstall_list', 'serverwebroot', 'servergitroot'):
238 if not type(config[k]) in (str, list, tuple):
239 logging.warn('"' + k + '" will be in random order!'
240 + ' Use () or [] brackets if order is important!')
242 # smartcardoptions must be a list since its command line args for Popen
243 if 'smartcardoptions' in config:
244 config['smartcardoptions'] = config['smartcardoptions'].split(' ')
245 elif 'keystore' in config and config['keystore'] == 'NONE':
246 # keystore='NONE' means use smartcard, these are required defaults
247 config['smartcardoptions'] = ['-storetype', 'PKCS11', '-providerName',
248 'SunPKCS11-OpenSC', '-providerClass',
249 'sun.security.pkcs11.SunPKCS11',
250 '-providerArg', 'opensc-fdroid.cfg']
252 if any(k in config for k in ["keystore", "keystorepass", "keypass"]):
253 st = os.stat(config_file)
254 if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
255 logging.warn("unsafe permissions on {0} (should be 0600)!".format(config_file))
257 fill_config_defaults(config)
259 for k in ["keystorepass", "keypass"]:
261 write_password_file(k)
263 for k in ["repo_description", "archive_description"]:
265 config[k] = clean_description(config[k])
267 if 'serverwebroot' in config:
268 if isinstance(config['serverwebroot'], str):
269 roots = [config['serverwebroot']]
270 elif all(isinstance(item, str) for item in config['serverwebroot']):
271 roots = config['serverwebroot']
273 raise TypeError('only accepts strings, lists, and tuples')
275 for rootstr in roots:
276 # since this is used with rsync, where trailing slashes have
277 # meaning, ensure there is always a trailing slash
278 if rootstr[-1] != '/':
280 rootlist.append(rootstr.replace('//', '/'))
281 config['serverwebroot'] = rootlist
283 if 'servergitmirrors' in config:
284 if isinstance(config['servergitmirrors'], str):
285 roots = [config['servergitmirrors']]
286 elif all(isinstance(item, str) for item in config['servergitmirrors']):
287 roots = config['servergitmirrors']
289 raise TypeError('only accepts strings, lists, and tuples')
290 config['servergitmirrors'] = roots
295 def find_sdk_tools_cmd(cmd):
296 '''find a working path to a tool from the Android SDK'''
299 if config is not None and 'sdk_path' in config and os.path.exists(config['sdk_path']):
300 # try to find a working path to this command, in all the recent possible paths
301 if 'build_tools' in config:
302 build_tools = os.path.join(config['sdk_path'], 'build-tools')
303 # if 'build_tools' was manually set and exists, check only that one
304 configed_build_tools = os.path.join(build_tools, config['build_tools'])
305 if os.path.exists(configed_build_tools):
306 tooldirs.append(configed_build_tools)
308 # no configed version, so hunt known paths for it
309 for f in sorted(os.listdir(build_tools), reverse=True):
310 if os.path.isdir(os.path.join(build_tools, f)):
311 tooldirs.append(os.path.join(build_tools, f))
312 tooldirs.append(build_tools)
313 sdk_tools = os.path.join(config['sdk_path'], 'tools')
314 if os.path.exists(sdk_tools):
315 tooldirs.append(sdk_tools)
316 sdk_platform_tools = os.path.join(config['sdk_path'], 'platform-tools')
317 if os.path.exists(sdk_platform_tools):
318 tooldirs.append(sdk_platform_tools)
319 tooldirs.append('/usr/bin')
321 path = os.path.join(d, cmd)
322 if os.path.isfile(path):
324 test_aapt_version(path)
326 # did not find the command, exit with error message
327 ensure_build_tools_exists(config)
330 def test_aapt_version(aapt):
331 '''Check whether the version of aapt is new enough'''
332 output = subprocess.check_output([aapt, 'version'], universal_newlines=True)
333 if output is None or output == '':
334 logging.error(aapt + ' failed to execute!')
336 m = re.match(r'.*v([0-9]+)\.([0-9]+)[.-]?([0-9.-]*)', output)
341 # the Debian package has the version string like "v0.2-23.0.2"
342 if '.' not in bugfix and LooseVersion('.'.join((major, minor, bugfix))) < LooseVersion('0.2.2166767'):
343 logging.warning(aapt + ' is too old, fdroid requires build-tools-23.0.0 or newer!')
345 logging.warning('Unknown version of aapt, might cause problems: ' + output)
348 def test_sdk_exists(thisconfig):
349 if 'sdk_path' not in thisconfig:
350 if 'aapt' in thisconfig and os.path.isfile(thisconfig['aapt']):
351 test_aapt_version(thisconfig['aapt'])
354 logging.error("'sdk_path' not set in config.py!")
356 if thisconfig['sdk_path'] == default_config['sdk_path']:
357 logging.error('No Android SDK found!')
358 logging.error('You can use ANDROID_HOME to set the path to your SDK, i.e.:')
359 logging.error('\texport ANDROID_HOME=/opt/android-sdk')
361 if not os.path.exists(thisconfig['sdk_path']):
362 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" does not exist!')
364 if not os.path.isdir(thisconfig['sdk_path']):
365 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" is not a directory!')
367 for d in ['build-tools', 'platform-tools', 'tools']:
368 if not os.path.isdir(os.path.join(thisconfig['sdk_path'], d)):
369 logging.critical('Android SDK path "%s" does not contain "%s/"!' % (
370 thisconfig['sdk_path'], d))
375 def ensure_build_tools_exists(thisconfig):
376 if not test_sdk_exists(thisconfig):
378 build_tools = os.path.join(thisconfig['sdk_path'], 'build-tools')
379 versioned_build_tools = os.path.join(build_tools, thisconfig['build_tools'])
380 if not os.path.isdir(versioned_build_tools):
381 logging.critical('Android Build Tools path "'
382 + versioned_build_tools + '" does not exist!')
386 def write_password_file(pwtype, password=None):
388 writes out passwords to a protected file instead of passing passwords as
389 command line argments
391 filename = '.fdroid.' + pwtype + '.txt'
392 fd = os.open(filename, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0o600)
394 os.write(fd, config[pwtype].encode('utf-8'))
396 os.write(fd, password.encode('utf-8'))
398 config[pwtype + 'file'] = filename
401 def get_local_metadata_files():
402 '''get any metadata files local to an app's source repo
404 This tries to ignore anything that does not count as app metdata,
405 including emacs cruft ending in ~ and the .fdroid.key*pass.txt files.
408 return glob.glob('.fdroid.[a-jl-z]*[a-rt-z]')
411 def read_pkg_args(args, allow_vercodes=False):
413 Given the arguments in the form of multiple appid:[vc] strings, this returns
414 a dictionary with the set of vercodes specified for each package.
422 if allow_vercodes and ':' in p:
423 package, vercode = p.split(':')
425 package, vercode = p, None
426 if package not in vercodes:
427 vercodes[package] = [vercode] if vercode else []
429 elif vercode and vercode not in vercodes[package]:
430 vercodes[package] += [vercode] if vercode else []
435 def read_app_args(args, allapps, allow_vercodes=False):
437 On top of what read_pkg_args does, this returns the whole app metadata, but
438 limiting the builds list to the builds matching the vercodes specified.
441 vercodes = read_pkg_args(args, allow_vercodes)
447 for appid, app in allapps.items():
448 if appid in vercodes:
451 if len(apps) != len(vercodes):
454 logging.critical("No such package: %s" % p)
455 raise FDroidException("Found invalid app ids in arguments")
457 raise FDroidException("No packages specified")
460 for appid, app in apps.items():
464 app.builds = [b for b in app.builds if b.versionCode in vc]
465 if len(app.builds) != len(vercodes[appid]):
467 allvcs = [b.versionCode for b in app.builds]
468 for v in vercodes[appid]:
470 logging.critical("No such vercode %s for app %s" % (v, appid))
473 raise FDroidException("Found invalid vercodes for some apps")
478 def get_extension(filename):
479 base, ext = os.path.splitext(filename)
482 return base, ext.lower()[1:]
485 def has_extension(filename, ext):
486 _, f_ext = get_extension(filename)
490 publish_name_regex = re.compile(r"^(.+)_([0-9]+)\.(apk|zip)$")
493 def clean_description(description):
494 'Remove unneeded newlines and spaces from a block of description text'
496 # this is split up by paragraph to make removing the newlines easier
497 for paragraph in re.split(r'\n\n', description):
498 paragraph = re.sub('\r', '', paragraph)
499 paragraph = re.sub('\n', ' ', paragraph)
500 paragraph = re.sub(' {2,}', ' ', paragraph)
501 paragraph = re.sub('^\s*(\w)', r'\1', paragraph)
502 returnstring += paragraph + '\n\n'
503 return returnstring.rstrip('\n')
506 def publishednameinfo(filename):
507 filename = os.path.basename(filename)
508 m = publish_name_regex.match(filename)
510 result = (m.group(1), m.group(2))
511 except AttributeError:
512 raise FDroidException("Invalid name for published file: %s" % filename)
516 def get_release_filename(app, build):
518 return "%s_%s.%s" % (app.id, build.versionCode, get_file_extension(build.output))
520 return "%s_%s.apk" % (app.id, build.versionCode)
523 def get_toolsversion_logname(app, build):
524 return "%s_%s_toolsversion.log" % (app.id, build.versionCode)
527 def getsrcname(app, build):
528 return "%s_%s_src.tar.gz" % (app.id, build.versionCode)
540 return '%s (%s)' % (app.CurrentVersion, app.CurrentVersionCode)
543 def get_build_dir(app):
544 '''get the dir that this app will be built in'''
546 if app.RepoType == 'srclib':
547 return os.path.join('build', 'srclib', app.Repo)
549 return os.path.join('build', app.id)
553 '''checkout code from VCS and return instance of vcs and the build dir'''
554 build_dir = get_build_dir(app)
556 # Set up vcs interface and make sure we have the latest code...
557 logging.debug("Getting {0} vcs interface for {1}"
558 .format(app.RepoType, app.Repo))
559 if app.RepoType == 'git' and os.path.exists('.fdroid.yml'):
563 vcs = getvcs(app.RepoType, remote, build_dir)
565 return vcs, build_dir
568 def getvcs(vcstype, remote, local):
570 return vcs_git(remote, local)
571 if vcstype == 'git-svn':
572 return vcs_gitsvn(remote, local)
574 return vcs_hg(remote, local)
576 return vcs_bzr(remote, local)
577 if vcstype == 'srclib':
578 if local != os.path.join('build', 'srclib', remote):
579 raise VCSException("Error: srclib paths are hard-coded!")
580 return getsrclib(remote, os.path.join('build', 'srclib'), raw=True)
582 raise VCSException("Deprecated vcs type 'svn' - please use 'git-svn' instead")
583 raise VCSException("Invalid vcs type " + vcstype)
586 def getsrclibvcs(name):
587 if name not in fdroidserver.metadata.srclibs:
588 raise VCSException("Missing srclib " + name)
589 return fdroidserver.metadata.srclibs[name]['Repo Type']
594 def __init__(self, remote, local):
596 # svn, git-svn and bzr may require auth
598 if self.repotype() in ('git-svn', 'bzr'):
600 if self.repotype == 'git-svn':
601 raise VCSException("Authentication is not supported for git-svn")
602 self.username, remote = remote.split('@')
603 if ':' not in self.username:
604 raise VCSException("Password required with username")
605 self.username, self.password = self.username.split(':')
609 self.clone_failed = False
610 self.refreshed = False
616 # Take the local repository to a clean version of the given revision, which
617 # is specificed in the VCS's native format. Beforehand, the repository can
618 # be dirty, or even non-existent. If the repository does already exist
619 # locally, it will be updated from the origin, but only once in the
620 # lifetime of the vcs object.
621 # None is acceptable for 'rev' if you know you are cloning a clean copy of
622 # the repo - otherwise it must specify a valid revision.
623 def gotorevision(self, rev, refresh=True):
625 if self.clone_failed:
626 raise VCSException("Downloading the repository already failed once, not trying again.")
628 # The .fdroidvcs-id file for a repo tells us what VCS type
629 # and remote that directory was created from, allowing us to drop it
630 # automatically if either of those things changes.
631 fdpath = os.path.join(self.local, '..',
632 '.fdroidvcs-' + os.path.basename(self.local))
633 fdpath = os.path.normpath(fdpath)
634 cdata = self.repotype() + ' ' + self.remote
637 if os.path.exists(self.local):
638 if os.path.exists(fdpath):
639 with open(fdpath, 'r') as f:
640 fsdata = f.read().strip()
645 logging.info("Repository details for %s changed - deleting" % (
649 logging.info("Repository details for %s missing - deleting" % (
652 shutil.rmtree(self.local)
656 self.refreshed = True
659 self.gotorevisionx(rev)
660 except FDroidException as e:
663 # If necessary, write the .fdroidvcs file.
664 if writeback and not self.clone_failed:
665 os.makedirs(os.path.dirname(fdpath), exist_ok=True)
666 with open(fdpath, 'w+') as f:
672 # Derived classes need to implement this. It's called once basic checking
673 # has been performend.
674 def gotorevisionx(self, rev):
675 raise VCSException("This VCS type doesn't define gotorevisionx")
677 # Initialise and update submodules
678 def initsubmodules(self):
679 raise VCSException('Submodules not supported for this vcs type')
681 # Get a list of all known tags
683 if not self._gettags:
684 raise VCSException('gettags not supported for this vcs type')
686 for tag in self._gettags():
687 if re.match('[-A-Za-z0-9_. /]+$', tag):
691 # Get a list of all the known tags, sorted from newest to oldest
692 def latesttags(self):
693 raise VCSException('latesttags not supported for this vcs type')
695 # Get current commit reference (hash, revision, etc)
697 raise VCSException('getref not supported for this vcs type')
699 # Returns the srclib (name, path) used in setting up the current
710 # If the local directory exists, but is somehow not a git repository, git
711 # will traverse up the directory tree until it finds one that is (i.e.
712 # fdroidserver) and then we'll proceed to destroy it! This is called as
715 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
716 result = p.output.rstrip()
717 if not result.endswith(self.local):
718 raise VCSException('Repository mismatch')
720 def gotorevisionx(self, rev):
721 if not os.path.exists(self.local):
723 p = FDroidPopen(['git', 'clone', self.remote, self.local])
724 if p.returncode != 0:
725 self.clone_failed = True
726 raise VCSException("Git clone failed", p.output)
730 # Discard any working tree changes
731 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
732 'git', 'reset', '--hard'], cwd=self.local, output=False)
733 if p.returncode != 0:
734 raise VCSException("Git reset failed", p.output)
735 # Remove untracked files now, in case they're tracked in the target
736 # revision (it happens!)
737 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
738 'git', 'clean', '-dffx'], cwd=self.local, output=False)
739 if p.returncode != 0:
740 raise VCSException("Git clean failed", p.output)
741 if not self.refreshed:
742 # Get latest commits and tags from remote
743 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
744 if p.returncode != 0:
745 raise VCSException("Git fetch failed", p.output)
746 p = FDroidPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local, output=False)
747 if p.returncode != 0:
748 raise VCSException("Git fetch failed", p.output)
749 # Recreate origin/HEAD as git clone would do it, in case it disappeared
750 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
751 if p.returncode != 0:
752 lines = p.output.splitlines()
753 if 'Multiple remote HEAD branches' not in lines[0]:
754 raise VCSException("Git remote set-head failed", p.output)
755 branch = lines[1].split(' ')[-1]
756 p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local, output=False)
757 if p2.returncode != 0:
758 raise VCSException("Git remote set-head failed", p.output + '\n' + p2.output)
759 self.refreshed = True
760 # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
761 # a github repo. Most of the time this is the same as origin/master.
762 rev = rev or 'origin/HEAD'
763 p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
764 if p.returncode != 0:
765 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
766 # Get rid of any uncontrolled files left behind
767 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
768 if p.returncode != 0:
769 raise VCSException("Git clean failed", p.output)
771 def initsubmodules(self):
773 submfile = os.path.join(self.local, '.gitmodules')
774 if not os.path.isfile(submfile):
775 raise VCSException("No git submodules available")
777 # fix submodules not accessible without an account and public key auth
778 with open(submfile, 'r') as f:
779 lines = f.readlines()
780 with open(submfile, 'w') as f:
782 if 'git@github.com' in line:
783 line = line.replace('git@github.com:', 'https://github.com/')
784 if 'git@gitlab.com' in line:
785 line = line.replace('git@gitlab.com:', 'https://gitlab.com/')
788 p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
789 if p.returncode != 0:
790 raise VCSException("Git submodule sync failed", p.output)
791 p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
792 if p.returncode != 0:
793 raise VCSException("Git submodule update failed", p.output)
797 p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
798 return p.output.splitlines()
800 tag_format = re.compile(r'tag: ([^),]*)')
802 def latesttags(self):
804 p = FDroidPopen(['git', 'log', '--tags',
805 '--simplify-by-decoration', '--pretty=format:%d'],
806 cwd=self.local, output=False)
808 for line in p.output.splitlines():
809 for tag in self.tag_format.findall(line):
814 class vcs_gitsvn(vcs):
819 # If the local directory exists, but is somehow not a git repository, git
820 # will traverse up the directory tree until it finds one that is (i.e.
821 # fdroidserver) and then we'll proceed to destory it! This is called as
824 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
825 result = p.output.rstrip()
826 if not result.endswith(self.local):
827 raise VCSException('Repository mismatch')
829 def gotorevisionx(self, rev):
830 if not os.path.exists(self.local):
832 gitsvn_args = ['git', 'svn', 'clone']
833 if ';' in self.remote:
834 remote_split = self.remote.split(';')
835 for i in remote_split[1:]:
836 if i.startswith('trunk='):
837 gitsvn_args.extend(['-T', i[6:]])
838 elif i.startswith('tags='):
839 gitsvn_args.extend(['-t', i[5:]])
840 elif i.startswith('branches='):
841 gitsvn_args.extend(['-b', i[9:]])
842 gitsvn_args.extend([remote_split[0], self.local])
843 p = FDroidPopen(gitsvn_args, output=False)
844 if p.returncode != 0:
845 self.clone_failed = True
846 raise VCSException("Git svn clone failed", p.output)
848 gitsvn_args.extend([self.remote, self.local])
849 p = FDroidPopen(gitsvn_args, output=False)
850 if p.returncode != 0:
851 self.clone_failed = True
852 raise VCSException("Git svn clone failed", p.output)
856 # Discard any working tree changes
857 p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
858 if p.returncode != 0:
859 raise VCSException("Git reset failed", p.output)
860 # Remove untracked files now, in case they're tracked in the target
861 # revision (it happens!)
862 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
863 if p.returncode != 0:
864 raise VCSException("Git clean failed", p.output)
865 if not self.refreshed:
866 # Get new commits, branches and tags from repo
867 p = FDroidPopen(['git', 'svn', 'fetch'], cwd=self.local, output=False)
868 if p.returncode != 0:
869 raise VCSException("Git svn fetch failed")
870 p = FDroidPopen(['git', 'svn', 'rebase'], cwd=self.local, output=False)
871 if p.returncode != 0:
872 raise VCSException("Git svn rebase failed", p.output)
873 self.refreshed = True
875 rev = rev or 'master'
877 nospaces_rev = rev.replace(' ', '%20')
878 # Try finding a svn tag
879 for treeish in ['origin/', '']:
880 p = FDroidPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
881 if p.returncode == 0:
883 if p.returncode != 0:
884 # No tag found, normal svn rev translation
885 # Translate svn rev into git format
886 rev_split = rev.split('/')
889 for treeish in ['origin/', '']:
890 if len(rev_split) > 1:
891 treeish += rev_split[0]
892 svn_rev = rev_split[1]
895 # if no branch is specified, then assume trunk (i.e. 'master' branch):
899 svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
901 p = FDroidPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
902 git_rev = p.output.rstrip()
904 if p.returncode == 0 and git_rev:
907 if p.returncode != 0 or not git_rev:
908 # Try a plain git checkout as a last resort
909 p = FDroidPopen(['git', 'checkout', rev], cwd=self.local, output=False)
910 if p.returncode != 0:
911 raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
913 # Check out the git rev equivalent to the svn rev
914 p = FDroidPopen(['git', 'checkout', git_rev], cwd=self.local, output=False)
915 if p.returncode != 0:
916 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
918 # Get rid of any uncontrolled files left behind
919 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
920 if p.returncode != 0:
921 raise VCSException("Git clean failed", p.output)
925 for treeish in ['origin/', '']:
926 d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
932 p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
933 if p.returncode != 0:
935 return p.output.strip()
943 def gotorevisionx(self, rev):
944 if not os.path.exists(self.local):
945 p = FDroidPopen(['hg', 'clone', self.remote, self.local], output=False)
946 if p.returncode != 0:
947 self.clone_failed = True
948 raise VCSException("Hg clone failed", p.output)
950 p = FDroidPopen(['hg', 'status', '-uS'], cwd=self.local, output=False)
951 if p.returncode != 0:
952 raise VCSException("Hg status failed", p.output)
953 for line in p.output.splitlines():
954 if not line.startswith('? '):
955 raise VCSException("Unexpected output from hg status -uS: " + line)
956 FDroidPopen(['rm', '-rf', line[2:]], cwd=self.local, output=False)
957 if not self.refreshed:
958 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
959 if p.returncode != 0:
960 raise VCSException("Hg pull failed", p.output)
961 self.refreshed = True
963 rev = rev or 'default'
966 p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
967 if p.returncode != 0:
968 raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
969 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
970 # Also delete untracked files, we have to enable purge extension for that:
971 if "'purge' is provided by the following extension" in p.output:
972 with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
973 myfile.write("\n[extensions]\nhgext.purge=\n")
974 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
975 if p.returncode != 0:
976 raise VCSException("HG purge failed", p.output)
977 elif p.returncode != 0:
978 raise VCSException("HG purge failed", p.output)
981 p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
982 return p.output.splitlines()[1:]
990 def gotorevisionx(self, rev):
991 if not os.path.exists(self.local):
992 p = FDroidPopen(['bzr', 'branch', self.remote, self.local], output=False)
993 if p.returncode != 0:
994 self.clone_failed = True
995 raise VCSException("Bzr branch failed", p.output)
997 p = FDroidPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
998 if p.returncode != 0:
999 raise VCSException("Bzr revert failed", p.output)
1000 if not self.refreshed:
1001 p = FDroidPopen(['bzr', 'pull'], cwd=self.local, output=False)
1002 if p.returncode != 0:
1003 raise VCSException("Bzr update failed", p.output)
1004 self.refreshed = True
1006 revargs = list(['-r', rev] if rev else [])
1007 p = FDroidPopen(['bzr', 'revert'] + revargs, cwd=self.local, output=False)
1008 if p.returncode != 0:
1009 raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
1012 p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
1013 return [tag.split(' ')[0].strip() for tag in
1014 p.output.splitlines()]
1017 def unescape_string(string):
1020 if string[0] == '"' and string[-1] == '"':
1023 return string.replace("\\'", "'")
1026 def retrieve_string(app_dir, string, xmlfiles=None):
1028 if not string.startswith('@string/'):
1029 return unescape_string(string)
1031 if xmlfiles is None:
1034 os.path.join(app_dir, 'res'),
1035 os.path.join(app_dir, 'src', 'main', 'res'),
1037 for r, d, f in os.walk(res_dir):
1038 if os.path.basename(r) == 'values':
1039 xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
1041 name = string[len('@string/'):]
1043 def element_content(element):
1044 if element.text is None:
1046 s = XMLElementTree.tostring(element, encoding='utf-8', method='text')
1047 return s.decode('utf-8').strip()
1049 for path in xmlfiles:
1050 if not os.path.isfile(path):
1052 xml = parse_xml(path)
1053 element = xml.find('string[@name="' + name + '"]')
1054 if element is not None:
1055 content = element_content(element)
1056 return retrieve_string(app_dir, content, xmlfiles)
1061 def retrieve_string_singleline(app_dir, string, xmlfiles=None):
1062 return retrieve_string(app_dir, string, xmlfiles).replace('\n', ' ').strip()
1065 def manifest_paths(app_dir, flavours):
1066 '''Return list of existing files that will be used to find the highest vercode'''
1068 possible_manifests = \
1069 [os.path.join(app_dir, 'AndroidManifest.xml'),
1070 os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
1071 os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
1072 os.path.join(app_dir, 'build.gradle')]
1074 for flavour in flavours:
1075 if flavour == 'yes':
1077 possible_manifests.append(
1078 os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
1080 return [path for path in possible_manifests if os.path.isfile(path)]
1083 def fetch_real_name(app_dir, flavours):
1084 '''Retrieve the package name. Returns the name, or None if not found.'''
1085 for path in manifest_paths(app_dir, flavours):
1086 if not has_extension(path, 'xml') or not os.path.isfile(path):
1088 logging.debug("fetch_real_name: Checking manifest at " + path)
1089 xml = parse_xml(path)
1090 app = xml.find('application')
1093 if "{http://schemas.android.com/apk/res/android}label" not in app.attrib:
1095 label = app.attrib["{http://schemas.android.com/apk/res/android}label"]
1096 result = retrieve_string_singleline(app_dir, label)
1098 result = result.strip()
1103 def get_library_references(root_dir):
1105 proppath = os.path.join(root_dir, 'project.properties')
1106 if not os.path.isfile(proppath):
1108 with open(proppath, 'r', encoding='iso-8859-1') as f:
1110 if not line.startswith('android.library.reference.'):
1112 path = line.split('=')[1].strip()
1113 relpath = os.path.join(root_dir, path)
1114 if not os.path.isdir(relpath):
1116 logging.debug("Found subproject at %s" % path)
1117 libraries.append(path)
1121 def ant_subprojects(root_dir):
1122 subprojects = get_library_references(root_dir)
1123 for subpath in subprojects:
1124 subrelpath = os.path.join(root_dir, subpath)
1125 for p in get_library_references(subrelpath):
1126 relp = os.path.normpath(os.path.join(subpath, p))
1127 if relp not in subprojects:
1128 subprojects.insert(0, relp)
1132 def remove_debuggable_flags(root_dir):
1133 # Remove forced debuggable flags
1134 logging.debug("Removing debuggable flags from %s" % root_dir)
1135 for root, dirs, files in os.walk(root_dir):
1136 if 'AndroidManifest.xml' in files:
1137 regsub_file(r'android:debuggable="[^"]*"',
1139 os.path.join(root, 'AndroidManifest.xml'))
1142 vcsearch_g = re.compile(r'''.*[Vv]ersionCode[ =]+["']*([0-9]+)["']*''').search
1143 vnsearch_g = re.compile(r'.*[Vv]ersionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
1144 psearch_g = re.compile(r'.*(packageName|applicationId) *=* *["\']([^"]+)["\'].*').search
1147 def app_matches_packagename(app, package):
1150 appid = app.UpdateCheckName or app.id
1151 if appid is None or appid == "Ignore":
1153 return appid == package
1156 def parse_androidmanifests(paths, app):
1158 Extract some information from the AndroidManifest.xml at the given path.
1159 Returns (version, vercode, package), any or all of which might be None.
1160 All values returned are strings.
1163 ignoreversions = app.UpdateCheckIgnore
1164 ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
1167 return (None, None, None)
1175 if not os.path.isfile(path):
1178 logging.debug("Parsing manifest at {0}".format(path))
1183 if has_extension(path, 'gradle'):
1184 with open(path, 'r') as f:
1186 if gradle_comment.match(line):
1188 # Grab first occurence of each to avoid running into
1189 # alternative flavours and builds.
1191 matches = psearch_g(line)
1193 s = matches.group(2)
1194 if app_matches_packagename(app, s):
1197 matches = vnsearch_g(line)
1199 version = matches.group(2)
1201 matches = vcsearch_g(line)
1203 vercode = matches.group(1)
1206 xml = parse_xml(path)
1207 if "package" in xml.attrib:
1208 s = xml.attrib["package"]
1209 if app_matches_packagename(app, s):
1211 if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1212 version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"]
1213 base_dir = os.path.dirname(path)
1214 version = retrieve_string_singleline(base_dir, version)
1215 if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1216 a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"]
1217 if string_is_integer(a):
1220 logging.warning("Problem with xml at {0}".format(path))
1222 # Remember package name, may be defined separately from version+vercode
1224 package = max_package
1226 logging.debug("..got package={0}, version={1}, vercode={2}"
1227 .format(package, version, vercode))
1229 # Always grab the package name and version name in case they are not
1230 # together with the highest version code
1231 if max_package is None and package is not None:
1232 max_package = package
1233 if max_version is None and version is not None:
1234 max_version = version
1236 if vercode is not None \
1237 and (max_vercode is None or vercode > max_vercode):
1238 if not ignoresearch or not ignoresearch(version):
1239 if version is not None:
1240 max_version = version
1241 if vercode is not None:
1242 max_vercode = vercode
1243 if package is not None:
1244 max_package = package
1246 max_version = "Ignore"
1248 if max_version is None:
1249 max_version = "Unknown"
1251 if max_package and not is_valid_package_name(max_package):
1252 raise FDroidException("Invalid package name {0}".format(max_package))
1254 return (max_version, max_vercode, max_package)
1257 def is_valid_package_name(name):
1258 return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1261 class FDroidException(Exception):
1263 def __init__(self, value, detail=None):
1265 self.detail = detail
1267 def shortened_detail(self):
1268 if len(self.detail) < 16000:
1270 return '[...]\n' + self.detail[-16000:]
1272 def get_wikitext(self):
1273 ret = repr(self.value) + "\n"
1276 ret += "<pre>\n" + self.shortened_detail() + "</pre>\n"
1282 ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1286 class VCSException(FDroidException):
1290 class BuildException(FDroidException):
1294 # Get the specified source library.
1295 # Returns the path to it. Normally this is the path to be used when referencing
1296 # it, which may be a subdirectory of the actual project. If you want the base
1297 # directory of the project, pass 'basepath=True'.
1298 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1299 raw=False, prepare=True, preponly=False, refresh=True,
1308 name, ref = spec.split('@')
1310 number, name = name.split(':', 1)
1312 name, subdir = name.split('/', 1)
1314 if name not in fdroidserver.metadata.srclibs:
1315 raise VCSException('srclib ' + name + ' not found.')
1317 srclib = fdroidserver.metadata.srclibs[name]
1319 sdir = os.path.join(srclib_dir, name)
1322 vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1323 vcs.srclib = (name, number, sdir)
1325 vcs.gotorevision(ref, refresh)
1332 libdir = os.path.join(sdir, subdir)
1333 elif srclib["Subdir"]:
1334 for subdir in srclib["Subdir"]:
1335 libdir_candidate = os.path.join(sdir, subdir)
1336 if os.path.exists(libdir_candidate):
1337 libdir = libdir_candidate
1343 remove_signing_keys(sdir)
1344 remove_debuggable_flags(sdir)
1348 if srclib["Prepare"]:
1349 cmd = replace_config_vars(srclib["Prepare"], build)
1351 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1352 if p.returncode != 0:
1353 raise BuildException("Error running prepare command for srclib %s"
1359 return (name, number, libdir)
1362 gradle_version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1365 # Prepare the source code for a particular build
1366 # 'vcs' - the appropriate vcs object for the application
1367 # 'app' - the application details from the metadata
1368 # 'build' - the build details from the metadata
1369 # 'build_dir' - the path to the build directory, usually
1371 # 'srclib_dir' - the path to the source libraries directory, usually
1373 # 'extlib_dir' - the path to the external libraries directory, usually
1375 # Returns the (root, srclibpaths) where:
1376 # 'root' is the root directory, which may be the same as 'build_dir' or may
1377 # be a subdirectory of it.
1378 # 'srclibpaths' is information on the srclibs being used
1379 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False, refresh=True):
1381 # Optionally, the actual app source can be in a subdirectory
1383 root_dir = os.path.join(build_dir, build.subdir)
1385 root_dir = build_dir
1387 # Get a working copy of the right revision
1388 logging.info("Getting source for revision " + build.commit)
1389 vcs.gotorevision(build.commit, refresh)
1391 # Initialise submodules if required
1392 if build.submodules:
1393 logging.info("Initialising submodules")
1394 vcs.initsubmodules()
1396 # Check that a subdir (if we're using one) exists. This has to happen
1397 # after the checkout, since it might not exist elsewhere
1398 if not os.path.exists(root_dir):
1399 raise BuildException('Missing subdir ' + root_dir)
1401 # Run an init command if one is required
1403 cmd = replace_config_vars(build.init, build)
1404 logging.info("Running 'init' commands in %s" % root_dir)
1406 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1407 if p.returncode != 0:
1408 raise BuildException("Error running init command for %s:%s" %
1409 (app.id, build.versionName), p.output)
1411 # Apply patches if any
1413 logging.info("Applying patches")
1414 for patch in build.patch:
1415 patch = patch.strip()
1416 logging.info("Applying " + patch)
1417 patch_path = os.path.join('metadata', app.id, patch)
1418 p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1419 if p.returncode != 0:
1420 raise BuildException("Failed to apply patch %s" % patch_path)
1422 # Get required source libraries
1425 logging.info("Collecting source libraries")
1426 for lib in build.srclibs:
1427 srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver,
1428 refresh=refresh, build=build))
1430 for name, number, libpath in srclibpaths:
1431 place_srclib(root_dir, int(number) if number else None, libpath)
1433 basesrclib = vcs.getsrclib()
1434 # If one was used for the main source, add that too.
1436 srclibpaths.append(basesrclib)
1438 # Update the local.properties file
1439 localprops = [os.path.join(build_dir, 'local.properties')]
1441 parts = build.subdir.split(os.sep)
1444 cur = os.path.join(cur, d)
1445 localprops += [os.path.join(cur, 'local.properties')]
1446 for path in localprops:
1448 if os.path.isfile(path):
1449 logging.info("Updating local.properties file at %s" % path)
1450 with open(path, 'r', encoding='iso-8859-1') as f:
1454 logging.info("Creating local.properties file at %s" % path)
1455 # Fix old-fashioned 'sdk-location' by copying
1456 # from sdk.dir, if necessary
1458 sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1459 re.S | re.M).group(1)
1460 props += "sdk-location=%s\n" % sdkloc
1462 props += "sdk.dir=%s\n" % config['sdk_path']
1463 props += "sdk-location=%s\n" % config['sdk_path']
1464 ndk_path = build.ndk_path()
1465 # if for any reason the path isn't valid or the directory
1466 # doesn't exist, some versions of Gradle will error with a
1467 # cryptic message (even if the NDK is not even necessary).
1468 # https://gitlab.com/fdroid/fdroidserver/issues/171
1469 if ndk_path and os.path.exists(ndk_path):
1471 props += "ndk.dir=%s\n" % ndk_path
1472 props += "ndk-location=%s\n" % ndk_path
1473 # Add java.encoding if necessary
1475 props += "java.encoding=%s\n" % build.encoding
1476 with open(path, 'w', encoding='iso-8859-1') as f:
1480 if build.build_method() == 'gradle':
1481 flavours = build.gradle
1484 n = build.target.split('-')[1]
1485 regsub_file(r'compileSdkVersion[ =]+[0-9]+',
1486 r'compileSdkVersion %s' % n,
1487 os.path.join(root_dir, 'build.gradle'))
1489 # Remove forced debuggable flags
1490 remove_debuggable_flags(root_dir)
1492 # Insert version code and number into the manifest if necessary
1493 if build.forceversion:
1494 logging.info("Changing the version name")
1495 for path in manifest_paths(root_dir, flavours):
1496 if not os.path.isfile(path):
1498 if has_extension(path, 'xml'):
1499 regsub_file(r'android:versionName="[^"]*"',
1500 r'android:versionName="%s"' % build.versionName,
1502 elif has_extension(path, 'gradle'):
1503 regsub_file(r"""(\s*)versionName[\s'"=]+.*""",
1504 r"""\1versionName '%s'""" % build.versionName,
1507 if build.forcevercode:
1508 logging.info("Changing the version code")
1509 for path in manifest_paths(root_dir, flavours):
1510 if not os.path.isfile(path):
1512 if has_extension(path, 'xml'):
1513 regsub_file(r'android:versionCode="[^"]*"',
1514 r'android:versionCode="%s"' % build.versionCode,
1516 elif has_extension(path, 'gradle'):
1517 regsub_file(r'versionCode[ =]+[0-9]+',
1518 r'versionCode %s' % build.versionCode,
1521 # Delete unwanted files
1523 logging.info("Removing specified files")
1524 for part in getpaths(build_dir, build.rm):
1525 dest = os.path.join(build_dir, part)
1526 logging.info("Removing {0}".format(part))
1527 if os.path.lexists(dest):
1528 if os.path.islink(dest):
1529 FDroidPopen(['unlink', dest], output=False)
1531 FDroidPopen(['rm', '-rf', dest], output=False)
1533 logging.info("...but it didn't exist")
1535 remove_signing_keys(build_dir)
1537 # Add required external libraries
1539 logging.info("Collecting prebuilt libraries")
1540 libsdir = os.path.join(root_dir, 'libs')
1541 if not os.path.exists(libsdir):
1543 for lib in build.extlibs:
1545 logging.info("...installing extlib {0}".format(lib))
1546 libf = os.path.basename(lib)
1547 libsrc = os.path.join(extlib_dir, lib)
1548 if not os.path.exists(libsrc):
1549 raise BuildException("Missing extlib file {0}".format(libsrc))
1550 shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1552 # Run a pre-build command if one is required
1554 logging.info("Running 'prebuild' commands in %s" % root_dir)
1556 cmd = replace_config_vars(build.prebuild, build)
1558 # Substitute source library paths into prebuild commands
1559 for name, number, libpath in srclibpaths:
1560 libpath = os.path.relpath(libpath, root_dir)
1561 cmd = cmd.replace('$$' + name + '$$', libpath)
1563 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1564 if p.returncode != 0:
1565 raise BuildException("Error running prebuild command for %s:%s" %
1566 (app.id, build.versionName), p.output)
1568 # Generate (or update) the ant build file, build.xml...
1569 if build.build_method() == 'ant' and build.androidupdate != ['no']:
1570 parms = ['android', 'update', 'lib-project']
1571 lparms = ['android', 'update', 'project']
1574 parms += ['-t', build.target]
1575 lparms += ['-t', build.target]
1576 if build.androidupdate:
1577 update_dirs = build.androidupdate
1579 update_dirs = ant_subprojects(root_dir) + ['.']
1581 for d in update_dirs:
1582 subdir = os.path.join(root_dir, d)
1584 logging.debug("Updating main project")
1585 cmd = parms + ['-p', d]
1587 logging.debug("Updating subproject %s" % d)
1588 cmd = lparms + ['-p', d]
1589 p = SdkToolsPopen(cmd, cwd=root_dir)
1590 # Check to see whether an error was returned without a proper exit
1591 # code (this is the case for the 'no target set or target invalid'
1593 if p.returncode != 0 or p.output.startswith("Error: "):
1594 raise BuildException("Failed to update project at %s" % d, p.output)
1595 # Clean update dirs via ant
1597 logging.info("Cleaning subproject %s" % d)
1598 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1600 return (root_dir, srclibpaths)
1603 # Extend via globbing the paths from a field and return them as a map from
1604 # original path to resulting paths
1605 def getpaths_map(build_dir, globpaths):
1609 full_path = os.path.join(build_dir, p)
1610 full_path = os.path.normpath(full_path)
1611 paths[p] = [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1613 raise FDroidException("glob path '%s' did not match any files/dirs" % p)
1617 # Extend via globbing the paths from a field and return them as a set
1618 def getpaths(build_dir, globpaths):
1619 paths_map = getpaths_map(build_dir, globpaths)
1621 for k, v in paths_map.items():
1628 return [int(sp) if sp.isdigit() else sp for sp in re.split(r'(\d+)', s)]
1634 self.path = os.path.join('stats', 'known_apks.txt')
1636 if os.path.isfile(self.path):
1637 with open(self.path, 'r', encoding='utf8') as f:
1639 t = line.rstrip().split(' ')
1641 self.apks[t[0]] = (t[1], None)
1643 self.apks[t[0]] = (t[1], datetime.strptime(t[2], '%Y-%m-%d'))
1644 self.changed = False
1646 def writeifchanged(self):
1647 if not self.changed:
1650 if not os.path.exists('stats'):
1654 for apk, app in self.apks.items():
1656 line = apk + ' ' + appid
1658 line += ' ' + added.strftime('%Y-%m-%d')
1661 with open(self.path, 'w', encoding='utf8') as f:
1662 for line in sorted(lst, key=natural_key):
1663 f.write(line + '\n')
1665 def recordapk(self, apk, app, default_date=None):
1667 Record an apk (if it's new, otherwise does nothing)
1668 Returns the date it was added as a datetime instance
1670 if apk not in self.apks:
1671 if default_date is None:
1672 default_date = datetime.utcnow()
1673 self.apks[apk] = (app, default_date)
1675 _, added = self.apks[apk]
1678 # Look up information - given the 'apkname', returns (app id, date added/None).
1679 # Or returns None for an unknown apk.
1680 def getapp(self, apkname):
1681 if apkname in self.apks:
1682 return self.apks[apkname]
1685 # Get the most recent 'num' apps added to the repo, as a list of package ids
1686 # with the most recent first.
1687 def getlatest(self, num):
1689 for apk, app in self.apks.items():
1693 if apps[appid] > added:
1697 sortedapps = sorted(apps.items(), key=operator.itemgetter(1))[-num:]
1698 lst = [app for app, _ in sortedapps]
1703 def get_file_extension(filename):
1704 """get the normalized file extension, can be blank string but never None"""
1705 if isinstance(filename, bytes):
1706 filename = filename.decode('utf-8')
1707 return os.path.splitext(filename)[1].lower()[1:]
1710 def isApkAndDebuggable(apkfile, config):
1711 """Returns True if the given file is an APK and is debuggable
1713 :param apkfile: full path to the apk to check"""
1715 if get_file_extension(apkfile) != 'apk':
1718 p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1720 if p.returncode != 0:
1721 logging.critical("Failed to get apk manifest information")
1723 for line in p.output.splitlines():
1724 if 'android:debuggable' in line and not line.endswith('0x0'):
1731 self.returncode = None
1735 def SdkToolsPopen(commands, cwd=None, output=True):
1737 if cmd not in config:
1738 config[cmd] = find_sdk_tools_cmd(commands[0])
1739 abscmd = config[cmd]
1741 logging.critical("Could not find '%s' on your system" % cmd)
1744 test_aapt_version(config['aapt'])
1745 return FDroidPopen([abscmd] + commands[1:],
1746 cwd=cwd, output=output)
1749 def FDroidPopenBytes(commands, cwd=None, output=True, stderr_to_stdout=True):
1751 Run a command and capture the possibly huge output as bytes.
1753 :param commands: command and argument list like in subprocess.Popen
1754 :param cwd: optionally specifies a working directory
1755 :returns: A PopenResult.
1760 set_FDroidPopen_env()
1763 cwd = os.path.normpath(cwd)
1764 logging.debug("Directory: %s" % cwd)
1765 logging.debug("> %s" % ' '.join(commands))
1767 stderr_param = subprocess.STDOUT if stderr_to_stdout else subprocess.PIPE
1768 result = PopenResult()
1771 p = subprocess.Popen(commands, cwd=cwd, shell=False, env=env,
1772 stdout=subprocess.PIPE, stderr=stderr_param)
1773 except OSError as e:
1774 raise BuildException("OSError while trying to execute " +
1775 ' '.join(commands) + ': ' + str(e))
1777 if not stderr_to_stdout and options.verbose:
1778 stderr_queue = Queue()
1779 stderr_reader = AsynchronousFileReader(p.stderr, stderr_queue)
1781 while not stderr_reader.eof():
1782 while not stderr_queue.empty():
1783 line = stderr_queue.get()
1784 sys.stderr.buffer.write(line)
1789 stdout_queue = Queue()
1790 stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1793 # Check the queue for output (until there is no more to get)
1794 while not stdout_reader.eof():
1795 while not stdout_queue.empty():
1796 line = stdout_queue.get()
1797 if output and options.verbose:
1798 # Output directly to console
1799 sys.stderr.buffer.write(line)
1805 result.returncode = p.wait()
1806 result.output = buf.getvalue()
1811 def FDroidPopen(commands, cwd=None, output=True, stderr_to_stdout=True):
1813 Run a command and capture the possibly huge output as a str.
1815 :param commands: command and argument list like in subprocess.Popen
1816 :param cwd: optionally specifies a working directory
1817 :returns: A PopenResult.
1819 result = FDroidPopenBytes(commands, cwd, output, stderr_to_stdout)
1820 result.output = result.output.decode('utf-8', 'ignore')
1824 gradle_comment = re.compile(r'[ ]*//')
1825 gradle_signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1826 gradle_line_matches = [
1827 re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1828 re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1829 re.compile(r'.*\.readLine\(.*'),
1833 def remove_signing_keys(build_dir):
1834 for root, dirs, files in os.walk(build_dir):
1835 if 'build.gradle' in files:
1836 path = os.path.join(root, 'build.gradle')
1838 with open(path, "r", encoding='utf8') as o:
1839 lines = o.readlines()
1845 with open(path, "w", encoding='utf8') as o:
1846 while i < len(lines):
1849 while line.endswith('\\\n'):
1850 line = line.rstrip('\\\n') + lines[i]
1853 if gradle_comment.match(line):
1858 opened += line.count('{')
1859 opened -= line.count('}')
1862 if gradle_signing_configs.match(line):
1867 if any(s.match(line) for s in gradle_line_matches):
1875 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1878 'project.properties',
1880 'default.properties',
1881 'ant.properties', ]:
1882 if propfile in files:
1883 path = os.path.join(root, propfile)
1885 with open(path, "r", encoding='iso-8859-1') as o:
1886 lines = o.readlines()
1890 with open(path, "w", encoding='iso-8859-1') as o:
1892 if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1899 logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1902 def set_FDroidPopen_env(build=None):
1904 set up the environment variables for the build environment
1906 There is only a weak standard, the variables used by gradle, so also set
1907 up the most commonly used environment variables for SDK and NDK. Also, if
1908 there is no locale set, this will set the locale (e.g. LANG) to en_US.UTF-8.
1910 global env, orig_path
1914 orig_path = env['PATH']
1915 for n in ['ANDROID_HOME', 'ANDROID_SDK']:
1916 env[n] = config['sdk_path']
1917 for k, v in config['java_paths'].items():
1918 env['JAVA%s_HOME' % k] = v
1920 missinglocale = True
1921 for k, v in env.items():
1922 if k == 'LANG' and v != 'C':
1923 missinglocale = False
1925 missinglocale = False
1927 env['LANG'] = 'en_US.UTF-8'
1929 if build is not None:
1930 path = build.ndk_path()
1931 paths = orig_path.split(os.pathsep)
1932 if path not in paths:
1933 paths = [path] + paths
1934 env['PATH'] = os.pathsep.join(paths)
1935 for n in ['ANDROID_NDK', 'NDK', 'ANDROID_NDK_HOME']:
1936 env[n] = build.ndk_path()
1939 def replace_build_vars(cmd, build):
1940 cmd = cmd.replace('$$COMMIT$$', build.commit)
1941 cmd = cmd.replace('$$VERSION$$', build.versionName)
1942 cmd = cmd.replace('$$VERCODE$$', build.versionCode)
1946 def replace_config_vars(cmd, build):
1947 cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1948 cmd = cmd.replace('$$NDK$$', build.ndk_path())
1949 cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1950 cmd = cmd.replace('$$QT$$', config['qt_sdk_path'] or '')
1951 if build is not None:
1952 cmd = replace_build_vars(cmd, build)
1956 def place_srclib(root_dir, number, libpath):
1959 relpath = os.path.relpath(libpath, root_dir)
1960 proppath = os.path.join(root_dir, 'project.properties')
1963 if os.path.isfile(proppath):
1964 with open(proppath, "r", encoding='iso-8859-1') as o:
1965 lines = o.readlines()
1967 with open(proppath, "w", encoding='iso-8859-1') as o:
1970 if line.startswith('android.library.reference.%d=' % number):
1971 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1976 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1979 apk_sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA|DSA|EC)')
1982 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
1983 """Verify that two apks are the same
1985 One of the inputs is signed, the other is unsigned. The signature metadata
1986 is transferred from the signed to the unsigned apk, and then jarsigner is
1987 used to verify that the signature from the signed apk is also varlid for
1988 the unsigned one. If the APK given as unsigned actually does have a
1989 signature, it will be stripped out and ignored.
1991 There are two SHA1 git commit IDs that fdroidserver includes in the builds
1992 it makes: fdroidserverid and buildserverid. Originally, these were inserted
1993 into AndroidManifest.xml, but that makes the build not reproducible. So
1994 instead they are included as separate files in the APK's META-INF/ folder.
1995 If those files exist in the signed APK, they will be part of the signature
1996 and need to also be included in the unsigned APK for it to validate.
1998 :param signed_apk: Path to a signed apk file
1999 :param unsigned_apk: Path to an unsigned apk file expected to match it
2000 :param tmp_dir: Path to directory for temporary files
2001 :returns: None if the verification is successful, otherwise a string
2002 describing what went wrong.
2005 signed = ZipFile(signed_apk, 'r')
2006 meta_inf_files = ['META-INF/MANIFEST.MF']
2007 for f in signed.namelist():
2008 if apk_sigfile.match(f) \
2009 or f in ['META-INF/fdroidserverid', 'META-INF/buildserverid']:
2010 meta_inf_files.append(f)
2011 if len(meta_inf_files) < 3:
2012 return "Signature files missing from {0}".format(signed_apk)
2014 tmp_apk = os.path.join(tmp_dir, 'sigcp_' + os.path.basename(unsigned_apk))
2015 unsigned = ZipFile(unsigned_apk, 'r')
2016 # only read the signature from the signed APK, everything else from unsigned
2017 with ZipFile(tmp_apk, 'w') as tmp:
2018 for filename in meta_inf_files:
2019 tmp.writestr(signed.getinfo(filename), signed.read(filename))
2020 for info in unsigned.infolist():
2021 if info.filename in meta_inf_files:
2022 logging.warning('Ignoring ' + info.filename + ' from ' + unsigned_apk)
2024 if info.filename in tmp.namelist():
2025 return "duplicate filename found: " + info.filename
2026 tmp.writestr(info, unsigned.read(info.filename))
2030 verified = verify_apk_signature(tmp_apk)
2033 logging.info("...NOT verified - {0}".format(tmp_apk))
2034 return compare_apks(signed_apk, tmp_apk, tmp_dir, os.path.dirname(unsigned_apk))
2036 logging.info("...successfully verified")
2040 def verify_apk_signature(apk, jar=False):
2041 """verify the signature on an APK
2043 Try to use apksigner whenever possible since jarsigner is very
2044 shitty: unsigned APKs pass as "verified"! So this has to turn on
2045 -strict then check for result 4.
2047 You can set :param: jar to True if you want to use this method
2048 to verify jar signatures.
2050 if set_command_in_config('apksigner'):
2051 args = [config['apksigner'], 'verify']
2053 args += ['--min-sdk-version=1']
2054 return subprocess.call(args + [apk]) == 0
2056 logging.warning("Using Java's jarsigner, not recommended for verifying APKs! Use apksigner")
2057 return subprocess.call([config['jarsigner'], '-strict', '-verify', apk]) == 4
2060 apk_badchars = re.compile('''[/ :;'"]''')
2063 def compare_apks(apk1, apk2, tmp_dir, log_dir=None):
2066 Returns None if the apk content is the same (apart from the signing key),
2067 otherwise a string describing what's different, or what went wrong when
2068 trying to do the comparison.
2074 absapk1 = os.path.abspath(apk1)
2075 absapk2 = os.path.abspath(apk2)
2077 if set_command_in_config('diffoscope'):
2078 logfilename = os.path.join(log_dir, os.path.basename(absapk1))
2079 htmlfile = logfilename + '.diffoscope.html'
2080 textfile = logfilename + '.diffoscope.txt'
2081 if subprocess.call([config['diffoscope'],
2082 '--max-report-size', '12345678', '--max-diff-block-lines', '100',
2083 '--html', htmlfile, '--text', textfile,
2084 absapk1, absapk2]) != 0:
2085 return("Failed to unpack " + apk1)
2087 apk1dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk1[0:-4])) # trim .apk
2088 apk2dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk2[0:-4])) # trim .apk
2089 for d in [apk1dir, apk2dir]:
2090 if os.path.exists(d):
2093 os.mkdir(os.path.join(d, 'jar-xf'))
2095 if subprocess.call(['jar', 'xf',
2096 os.path.abspath(apk1)],
2097 cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
2098 return("Failed to unpack " + apk1)
2099 if subprocess.call(['jar', 'xf',
2100 os.path.abspath(apk2)],
2101 cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
2102 return("Failed to unpack " + apk2)
2104 if set_command_in_config('apktool'):
2105 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
2107 return("Failed to unpack " + apk1)
2108 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
2110 return("Failed to unpack " + apk2)
2112 p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
2113 lines = p.output.splitlines()
2114 if len(lines) != 1 or 'META-INF' not in lines[0]:
2115 meld = find_command('meld')
2116 if meld is not None:
2117 p = FDroidPopen(['meld', apk1dir, apk2dir], output=False)
2118 return("Unexpected diff output - " + p.output)
2120 # since everything verifies, delete the comparison to keep cruft down
2121 shutil.rmtree(apk1dir)
2122 shutil.rmtree(apk2dir)
2124 # If we get here, it seems like they're the same!
2128 def set_command_in_config(command):
2129 '''Try to find specified command in the path, if it hasn't been
2130 manually set in config.py. If found, it is added to the config
2131 dict. The return value says whether the command is available.
2134 if command in config:
2137 tmp = find_command(command)
2139 config[command] = tmp
2144 def find_command(command):
2145 '''find the full path of a command, or None if it can't be found in the PATH'''
2148 return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
2150 fpath, fname = os.path.split(command)
2155 for path in os.environ["PATH"].split(os.pathsep):
2156 path = path.strip('"')
2157 exe_file = os.path.join(path, command)
2158 if is_exe(exe_file):
2165 '''generate a random password for when generating keys'''
2166 h = hashlib.sha256()
2167 h.update(os.urandom(16)) # salt
2168 h.update(socket.getfqdn().encode('utf-8'))
2169 passwd = base64.b64encode(h.digest()).strip()
2170 return passwd.decode('utf-8')
2173 def genkeystore(localconfig):
2175 Generate a new key with password provided in :param localconfig and add it to new keystore
2176 :return: hexed public key, public key fingerprint
2178 logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
2179 keystoredir = os.path.dirname(localconfig['keystore'])
2180 if keystoredir is None or keystoredir == '':
2181 keystoredir = os.path.join(os.getcwd(), keystoredir)
2182 if not os.path.exists(keystoredir):
2183 os.makedirs(keystoredir, mode=0o700)
2185 write_password_file("keystorepass", localconfig['keystorepass'])
2186 write_password_file("keypass", localconfig['keypass'])
2187 p = FDroidPopen([config['keytool'], '-genkey',
2188 '-keystore', localconfig['keystore'],
2189 '-alias', localconfig['repo_keyalias'],
2190 '-keyalg', 'RSA', '-keysize', '4096',
2191 '-sigalg', 'SHA256withRSA',
2192 '-validity', '10000',
2193 '-storepass:file', config['keystorepassfile'],
2194 '-keypass:file', config['keypassfile'],
2195 '-dname', localconfig['keydname']])
2196 # TODO keypass should be sent via stdin
2197 if p.returncode != 0:
2198 raise BuildException("Failed to generate key", p.output)
2199 os.chmod(localconfig['keystore'], 0o0600)
2200 if not options.quiet:
2201 # now show the lovely key that was just generated
2202 p = FDroidPopen([config['keytool'], '-list', '-v',
2203 '-keystore', localconfig['keystore'],
2204 '-alias', localconfig['repo_keyalias'],
2205 '-storepass:file', config['keystorepassfile']])
2206 logging.info(p.output.strip() + '\n\n')
2207 # get the public key
2208 p = FDroidPopenBytes([config['keytool'], '-exportcert',
2209 '-keystore', localconfig['keystore'],
2210 '-alias', localconfig['repo_keyalias'],
2211 '-storepass:file', config['keystorepassfile']]
2212 + config['smartcardoptions'],
2213 output=False, stderr_to_stdout=False)
2214 if p.returncode != 0 or len(p.output) < 20:
2215 raise BuildException("Failed to get public key", p.output)
2217 fingerprint = get_cert_fingerprint(pubkey)
2218 return hexlify(pubkey), fingerprint
2221 def get_cert_fingerprint(pubkey):
2223 Generate a certificate fingerprint the same way keytool does it
2224 (but with slightly different formatting)
2226 digest = hashlib.sha256(pubkey).digest()
2227 ret = [' '.join("%02X" % b for b in bytearray(digest))]
2228 return " ".join(ret)
2231 def get_certificate(certificate_file):
2233 Extracts a certificate from the given file.
2234 :param certificate_file: file bytes (as string) representing the certificate
2235 :return: A binary representation of the certificate's public key, or None in case of error
2237 content = decoder.decode(certificate_file, asn1Spec=rfc2315.ContentInfo())[0]
2238 if content.getComponentByName('contentType') != rfc2315.signedData:
2240 content = decoder.decode(content.getComponentByName('content'),
2241 asn1Spec=rfc2315.SignedData())[0]
2243 certificates = content.getComponentByName('certificates')
2244 cert = certificates[0].getComponentByName('certificate')
2246 logging.error("Certificates not found.")
2248 return encoder.encode(cert)
2251 def write_to_config(thisconfig, key, value=None, config_file=None):
2252 '''write a key/value to the local config.py
2254 NOTE: only supports writing string variables.
2256 :param thisconfig: config dictionary
2257 :param key: variable name in config.py to be overwritten/added
2258 :param value: optional value to be written, instead of fetched
2259 from 'thisconfig' dictionary.
2262 origkey = key + '_orig'
2263 value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
2264 cfg = config_file if config_file else 'config.py'
2267 with open(cfg, 'r', encoding="utf-8") as f:
2268 lines = f.readlines()
2270 # make sure the file ends with a carraige return
2272 if not lines[-1].endswith('\n'):
2275 # regex for finding and replacing python string variable
2276 # definitions/initializations
2277 pattern = re.compile('^[\s#]*' + key + '\s*=\s*"[^"]*"')
2278 repl = key + ' = "' + value + '"'
2279 pattern2 = re.compile('^[\s#]*' + key + "\s*=\s*'[^']*'")
2280 repl2 = key + " = '" + value + "'"
2282 # If we replaced this line once, we make sure won't be a
2283 # second instance of this line for this key in the document.
2286 with open(cfg, 'w', encoding="utf-8") as f:
2288 if pattern.match(line) or pattern2.match(line):
2290 line = pattern.sub(repl, line)
2291 line = pattern2.sub(repl2, line)
2302 def parse_xml(path):
2303 return XMLElementTree.parse(path).getroot()
2306 def string_is_integer(string):
2314 def get_per_app_repos():
2315 '''per-app repos are dirs named with the packageName of a single app'''
2317 # Android packageNames are Java packages, they may contain uppercase or
2318 # lowercase letters ('A' through 'Z'), numbers, and underscores
2319 # ('_'). However, individual package name parts may only start with
2320 # letters. https://developer.android.com/guide/topics/manifest/manifest-element.html#package
2321 p = re.compile('^([a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*)?$')
2324 for root, dirs, files in os.walk(os.getcwd()):
2326 print('checking', root, 'for', d)
2327 if d in ('archive', 'metadata', 'repo', 'srclibs', 'tmp'):
2328 # standard parts of an fdroid repo, so never packageNames
2331 and os.path.exists(os.path.join(d, 'fdroid', 'repo', 'index.jar')):
2337 def is_repo_file(filename):
2338 '''Whether the file in a repo is a build product to be delivered to users'''
2339 if isinstance(filename, str):
2340 filename = filename.encode('utf-8', errors="surrogateescape")
2341 return os.path.isfile(filename) \
2342 and not filename.endswith(b'.asc') \
2343 and not filename.endswith(b'.sig') \
2344 and os.path.basename(filename) not in [
2346 b'index_unsigned.jar',
2355 def make_binary_transparency_log(repodirs, btrepo='binary_transparency',
2357 commit_title='fdroid update'):
2358 '''Log the indexes in a standalone git repo to serve as a "binary
2361 see: https://www.eff.org/deeplinks/2014/02/open-letter-to-tech-companies
2366 import xml.dom.minidom
2368 if os.path.exists(os.path.join(btrepo, '.git')):
2369 gitrepo = git.Repo(btrepo)
2371 if not os.path.exists(btrepo):
2373 gitrepo = git.Repo.init(btrepo)
2376 url = config['repo_url'].rstrip('/')
2377 with open(os.path.join(btrepo, 'README.md'), 'w') as fp:
2379 # Binary Transparency Log for %s
2381 This is a log of the signed app index metadata. This is stored in a
2382 git repo, which serves as an imperfect append-only storage mechanism.
2383 People can then check that any file that they received from that
2384 F-Droid repository was a publicly released file.
2386 For more info on this idea:
2387 * https://wiki.mozilla.org/Security/Binary_Transparency
2388 """ % url[:url.rindex('/')]) # strip '/repo'
2389 gitrepo.index.add(['README.md', ])
2390 gitrepo.index.commit('add README')
2392 for repodir in repodirs:
2393 cpdir = os.path.join(btrepo, repodir)
2394 if not os.path.exists(cpdir):
2396 for f in ('index.xml', 'index-v1.json'):
2397 repof = os.path.join(repodir, f)
2398 if not os.path.exists(repof):
2400 dest = os.path.join(cpdir, f)
2401 if f.endswith('.xml'):
2402 doc = xml.dom.minidom.parse(repof)
2403 output = doc.toprettyxml(encoding='utf-8')
2404 with open(dest, 'wb') as f:
2406 elif f.endswith('.json'):
2407 with open(repof) as fp:
2408 output = json.load(fp, object_pairs_hook=collections.OrderedDict)
2409 with open(dest, 'w') as fp:
2410 json.dump(output, fp, indent=2)
2411 gitrepo.index.add([repof, ])
2412 for f in ('index.jar', 'index-v1.jar'):
2413 repof = os.path.join(repodir, f)
2414 if not os.path.exists(repof):
2416 dest = os.path.join(cpdir, f)
2417 jarin = ZipFile(repof, 'r')
2418 jarout = ZipFile(dest, 'w')
2419 for info in jarin.infolist():
2420 if info.filename.startswith('META-INF/'):
2421 jarout.writestr(info, jarin.read(info.filename))
2424 gitrepo.index.add([repof, ])
2427 for root, dirs, filenames in os.walk(repodir):
2429 files.append(os.path.relpath(os.path.join(root, f), repodir))
2430 output = collections.OrderedDict()
2431 for f in sorted(files):
2432 repofile = os.path.join(repodir, f)
2433 stat = os.stat(repofile)
2442 fslogfile = os.path.join(cpdir, 'filesystemlog.json')
2443 with open(fslogfile, 'w') as fp:
2444 json.dump(output, fp, indent=2)
2445 gitrepo.index.add([os.path.join(repodir, 'filesystemlog.json'), ])
2447 for f in glob.glob(os.path.join(cpdir, '*.HTTP-headers.json')):
2448 gitrepo.index.add([os.path.join(repodir, os.path.basename(f)), ])
2450 gitrepo.index.commit(commit_title)