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.
37 import xml.etree.ElementTree as XMLElementTree
39 from binascii import hexlify
40 from datetime import datetime
41 from distutils.version import LooseVersion
42 from queue import Queue
43 from zipfile import ZipFile
45 from pyasn1.codec.der import decoder, encoder
46 from pyasn1_modules import rfc2315
47 from pyasn1.error import PyAsn1Error
49 from distutils.util import strtobool
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': [],
106 'repo_url': "https://MyFirstFDroidRepo.org/fdroid/repo",
107 'repo_name': "My First FDroid Repo Demo",
108 'repo_icon': "fdroid-icon.png",
109 'repo_description': '''
110 This is a repository of apps to be used with FDroid. Applications in this
111 repository are either official binaries built by the original application
112 developers, or are binaries built from source by the admin of f-droid.org
113 using the tools on https://gitlab.com/u/fdroid.
119 def setup_global_opts(parser):
120 parser.add_argument("-v", "--verbose", action="store_true", default=False,
121 help="Spew out even more information than normal")
122 parser.add_argument("-q", "--quiet", action="store_true", default=False,
123 help="Restrict output to warnings and errors")
126 def fill_config_defaults(thisconfig):
127 for k, v in default_config.items():
128 if k not in thisconfig:
131 # Expand paths (~users and $vars)
132 def expand_path(path):
136 path = os.path.expanduser(path)
137 path = os.path.expandvars(path)
142 for k in ['sdk_path', 'ant', 'mvn3', 'gradle', 'keystore', 'repo_icon']:
147 thisconfig[k + '_orig'] = v
149 # find all installed JDKs for keytool, jarsigner, and JAVA[6-9]_HOME env vars
150 if thisconfig['java_paths'] is None:
151 thisconfig['java_paths'] = dict()
153 pathlist += glob.glob('/usr/lib/jvm/j*[6-9]*')
154 pathlist += glob.glob('/usr/java/jdk1.[6-9]*')
155 pathlist += glob.glob('/System/Library/Java/JavaVirtualMachines/1.[6-9].0.jdk')
156 pathlist += glob.glob('/Library/Java/JavaVirtualMachines/*jdk*[6-9]*')
157 if os.getenv('JAVA_HOME') is not None:
158 pathlist.append(os.getenv('JAVA_HOME'))
159 if os.getenv('PROGRAMFILES') is not None:
160 pathlist += glob.glob(os.path.join(os.getenv('PROGRAMFILES'), 'Java', 'jdk1.[6-9].*'))
161 for d in sorted(pathlist):
162 if os.path.islink(d):
164 j = os.path.basename(d)
165 # the last one found will be the canonical one, so order appropriately
167 r'^1\.([6-9])\.0\.jdk$', # OSX
168 r'^jdk1\.([6-9])\.0_[0-9]+.jdk$', # OSX and Oracle tarball
169 r'^jdk1\.([6-9])\.0_[0-9]+$', # Oracle Windows
170 r'^jdk([6-9])-openjdk$', # Arch
171 r'^java-([6-9])-openjdk$', # Arch
172 r'^java-([6-9])-jdk$', # Arch (oracle)
173 r'^java-1\.([6-9])\.0-.*$', # RedHat
174 r'^java-([6-9])-oracle$', # Debian WebUpd8
175 r'^jdk-([6-9])-oracle-.*$', # Debian make-jpkg
176 r'^java-([6-9])-openjdk-[^c][^o][^m].*$', # Debian
178 m = re.match(regex, j)
181 for p in [d, os.path.join(d, 'Contents', 'Home')]:
182 if os.path.exists(os.path.join(p, 'bin', 'javac')):
183 thisconfig['java_paths'][m.group(1)] = p
185 for java_version in ('7', '8', '9'):
186 if java_version not in thisconfig['java_paths']:
188 java_home = thisconfig['java_paths'][java_version]
189 jarsigner = os.path.join(java_home, 'bin', 'jarsigner')
190 if os.path.exists(jarsigner):
191 thisconfig['jarsigner'] = jarsigner
192 thisconfig['keytool'] = os.path.join(java_home, 'bin', 'keytool')
193 break # Java7 is preferred, so quit if found
195 for k in ['ndk_paths', 'java_paths']:
201 thisconfig[k][k2] = exp
202 thisconfig[k][k2 + '_orig'] = v
205 def regsub_file(pattern, repl, path):
206 with open(path, 'rb') as f:
208 text = re.sub(bytes(pattern, 'utf8'), bytes(repl, 'utf8'), text)
209 with open(path, 'wb') as f:
213 def read_config(opts, config_file='config.py'):
214 """Read the repository config
216 The config is read from config_file, which is in the current
217 directory when any of the repo management commands are used. If
218 there is a local metadata file in the git repo, then config.py is
219 not required, just use defaults.
222 global config, options
224 if config is not None:
231 if os.path.isfile(config_file):
232 logging.debug("Reading %s" % config_file)
233 with io.open(config_file, "rb") as f:
234 code = compile(f.read(), config_file, 'exec')
235 exec(code, None, config)
236 elif len(get_local_metadata_files()) == 0:
237 logging.critical("Missing config file - is this a repo directory?")
240 for k in ('mirrors', 'install_list', 'uninstall_list', 'serverwebroot', 'servergitroot'):
242 if not type(config[k]) in (str, list, tuple):
243 logging.warn('"' + k + '" will be in random order!'
244 + ' Use () or [] brackets if order is important!')
246 # smartcardoptions must be a list since its command line args for Popen
247 if 'smartcardoptions' in config:
248 config['smartcardoptions'] = config['smartcardoptions'].split(' ')
249 elif 'keystore' in config and config['keystore'] == 'NONE':
250 # keystore='NONE' means use smartcard, these are required defaults
251 config['smartcardoptions'] = ['-storetype', 'PKCS11', '-providerName',
252 'SunPKCS11-OpenSC', '-providerClass',
253 'sun.security.pkcs11.SunPKCS11',
254 '-providerArg', 'opensc-fdroid.cfg']
256 if any(k in config for k in ["keystore", "keystorepass", "keypass"]):
257 st = os.stat(config_file)
258 if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
259 logging.warn("unsafe permissions on {0} (should be 0600)!".format(config_file))
261 fill_config_defaults(config)
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 get_local_metadata_files():
387 '''get any metadata files local to an app's source repo
389 This tries to ignore anything that does not count as app metdata,
390 including emacs cruft ending in ~ and the .fdroid.key*pass.txt files.
393 return glob.glob('.fdroid.[a-jl-z]*[a-rt-z]')
396 def read_pkg_args(args, allow_vercodes=False):
398 Given the arguments in the form of multiple appid:[vc] strings, this returns
399 a dictionary with the set of vercodes specified for each package.
407 if allow_vercodes and ':' in p:
408 package, vercode = p.split(':')
410 package, vercode = p, None
411 if package not in vercodes:
412 vercodes[package] = [vercode] if vercode else []
414 elif vercode and vercode not in vercodes[package]:
415 vercodes[package] += [vercode] if vercode else []
420 def read_app_args(args, allapps, allow_vercodes=False):
422 On top of what read_pkg_args does, this returns the whole app metadata, but
423 limiting the builds list to the builds matching the vercodes specified.
426 vercodes = read_pkg_args(args, allow_vercodes)
432 for appid, app in allapps.items():
433 if appid in vercodes:
436 if len(apps) != len(vercodes):
439 logging.critical("No such package: %s" % p)
440 raise FDroidException("Found invalid app ids in arguments")
442 raise FDroidException("No packages specified")
445 for appid, app in apps.items():
449 app.builds = [b for b in app.builds if b.versionCode in vc]
450 if len(app.builds) != len(vercodes[appid]):
452 allvcs = [b.versionCode for b in app.builds]
453 for v in vercodes[appid]:
455 logging.critical("No such vercode %s for app %s" % (v, appid))
458 raise FDroidException("Found invalid vercodes for some apps")
463 def get_extension(filename):
464 base, ext = os.path.splitext(filename)
467 return base, ext.lower()[1:]
470 def has_extension(filename, ext):
471 _, f_ext = get_extension(filename)
475 publish_name_regex = re.compile(r"^(.+)_([0-9]+)\.(apk|zip)$")
478 def clean_description(description):
479 'Remove unneeded newlines and spaces from a block of description text'
481 # this is split up by paragraph to make removing the newlines easier
482 for paragraph in re.split(r'\n\n', description):
483 paragraph = re.sub('\r', '', paragraph)
484 paragraph = re.sub('\n', ' ', paragraph)
485 paragraph = re.sub(' {2,}', ' ', paragraph)
486 paragraph = re.sub('^\s*(\w)', r'\1', paragraph)
487 returnstring += paragraph + '\n\n'
488 return returnstring.rstrip('\n')
491 def publishednameinfo(filename):
492 filename = os.path.basename(filename)
493 m = publish_name_regex.match(filename)
495 result = (m.group(1), m.group(2))
496 except AttributeError:
497 raise FDroidException("Invalid name for published file: %s" % filename)
501 def get_release_filename(app, build):
503 return "%s_%s.%s" % (app.id, build.versionCode, get_file_extension(build.output))
505 return "%s_%s.apk" % (app.id, build.versionCode)
508 def get_toolsversion_logname(app, build):
509 return "%s_%s_toolsversion.log" % (app.id, build.versionCode)
512 def getsrcname(app, build):
513 return "%s_%s_src.tar.gz" % (app.id, build.versionCode)
525 return '%s (%s)' % (app.CurrentVersion, app.CurrentVersionCode)
528 def get_build_dir(app):
529 '''get the dir that this app will be built in'''
531 if app.RepoType == 'srclib':
532 return os.path.join('build', 'srclib', app.Repo)
534 return os.path.join('build', app.id)
538 '''checkout code from VCS and return instance of vcs and the build dir'''
539 build_dir = get_build_dir(app)
541 # Set up vcs interface and make sure we have the latest code...
542 logging.debug("Getting {0} vcs interface for {1}"
543 .format(app.RepoType, app.Repo))
544 if app.RepoType == 'git' and os.path.exists('.fdroid.yml'):
548 vcs = getvcs(app.RepoType, remote, build_dir)
550 return vcs, build_dir
553 def getvcs(vcstype, remote, local):
555 return vcs_git(remote, local)
556 if vcstype == 'git-svn':
557 return vcs_gitsvn(remote, local)
559 return vcs_hg(remote, local)
561 return vcs_bzr(remote, local)
562 if vcstype == 'srclib':
563 if local != os.path.join('build', 'srclib', remote):
564 raise VCSException("Error: srclib paths are hard-coded!")
565 return getsrclib(remote, os.path.join('build', 'srclib'), raw=True)
567 raise VCSException("Deprecated vcs type 'svn' - please use 'git-svn' instead")
568 raise VCSException("Invalid vcs type " + vcstype)
571 def getsrclibvcs(name):
572 if name not in fdroidserver.metadata.srclibs:
573 raise VCSException("Missing srclib " + name)
574 return fdroidserver.metadata.srclibs[name]['Repo Type']
579 def __init__(self, remote, local):
581 # svn, git-svn and bzr may require auth
583 if self.repotype() in ('git-svn', 'bzr'):
585 if self.repotype == 'git-svn':
586 raise VCSException("Authentication is not supported for git-svn")
587 self.username, remote = remote.split('@')
588 if ':' not in self.username:
589 raise VCSException("Password required with username")
590 self.username, self.password = self.username.split(':')
594 self.clone_failed = False
595 self.refreshed = False
601 # Take the local repository to a clean version of the given revision, which
602 # is specificed in the VCS's native format. Beforehand, the repository can
603 # be dirty, or even non-existent. If the repository does already exist
604 # locally, it will be updated from the origin, but only once in the
605 # lifetime of the vcs object.
606 # None is acceptable for 'rev' if you know you are cloning a clean copy of
607 # the repo - otherwise it must specify a valid revision.
608 def gotorevision(self, rev, refresh=True):
610 if self.clone_failed:
611 raise VCSException("Downloading the repository already failed once, not trying again.")
613 # The .fdroidvcs-id file for a repo tells us what VCS type
614 # and remote that directory was created from, allowing us to drop it
615 # automatically if either of those things changes.
616 fdpath = os.path.join(self.local, '..',
617 '.fdroidvcs-' + os.path.basename(self.local))
618 fdpath = os.path.normpath(fdpath)
619 cdata = self.repotype() + ' ' + self.remote
622 if os.path.exists(self.local):
623 if os.path.exists(fdpath):
624 with open(fdpath, 'r') as f:
625 fsdata = f.read().strip()
630 logging.info("Repository details for %s changed - deleting" % (
634 logging.info("Repository details for %s missing - deleting" % (
637 shutil.rmtree(self.local)
641 self.refreshed = True
644 self.gotorevisionx(rev)
645 except FDroidException as e:
648 # If necessary, write the .fdroidvcs file.
649 if writeback and not self.clone_failed:
650 os.makedirs(os.path.dirname(fdpath), exist_ok=True)
651 with open(fdpath, 'w+') as f:
657 # Derived classes need to implement this. It's called once basic checking
658 # has been performend.
659 def gotorevisionx(self, rev):
660 raise VCSException("This VCS type doesn't define gotorevisionx")
662 # Initialise and update submodules
663 def initsubmodules(self):
664 raise VCSException('Submodules not supported for this vcs type')
666 # Get a list of all known tags
668 if not self._gettags:
669 raise VCSException('gettags not supported for this vcs type')
671 for tag in self._gettags():
672 if re.match('[-A-Za-z0-9_. /]+$', tag):
676 # Get a list of all the known tags, sorted from newest to oldest
677 def latesttags(self):
678 raise VCSException('latesttags not supported for this vcs type')
680 # Get current commit reference (hash, revision, etc)
682 raise VCSException('getref not supported for this vcs type')
684 # Returns the srclib (name, path) used in setting up the current
695 # If the local directory exists, but is somehow not a git repository, git
696 # will traverse up the directory tree until it finds one that is (i.e.
697 # fdroidserver) and then we'll proceed to destroy it! This is called as
700 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
701 result = p.output.rstrip()
702 if not result.endswith(self.local):
703 raise VCSException('Repository mismatch')
705 def gotorevisionx(self, rev):
706 if not os.path.exists(self.local):
708 p = FDroidPopen(['git', 'clone', self.remote, self.local])
709 if p.returncode != 0:
710 self.clone_failed = True
711 raise VCSException("Git clone failed", p.output)
715 # Discard any working tree changes
716 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
717 'git', 'reset', '--hard'], cwd=self.local, output=False)
718 if p.returncode != 0:
719 raise VCSException("Git reset failed", p.output)
720 # Remove untracked files now, in case they're tracked in the target
721 # revision (it happens!)
722 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
723 'git', 'clean', '-dffx'], cwd=self.local, output=False)
724 if p.returncode != 0:
725 raise VCSException("Git clean failed", p.output)
726 if not self.refreshed:
727 # Get latest commits and tags from remote
728 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
729 if p.returncode != 0:
730 raise VCSException("Git fetch failed", p.output)
731 p = FDroidPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local, output=False)
732 if p.returncode != 0:
733 raise VCSException("Git fetch failed", p.output)
734 # Recreate origin/HEAD as git clone would do it, in case it disappeared
735 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
736 if p.returncode != 0:
737 lines = p.output.splitlines()
738 if 'Multiple remote HEAD branches' not in lines[0]:
739 raise VCSException("Git remote set-head failed", p.output)
740 branch = lines[1].split(' ')[-1]
741 p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local, output=False)
742 if p2.returncode != 0:
743 raise VCSException("Git remote set-head failed", p.output + '\n' + p2.output)
744 self.refreshed = True
745 # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
746 # a github repo. Most of the time this is the same as origin/master.
747 rev = rev or 'origin/HEAD'
748 p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
749 if p.returncode != 0:
750 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
751 # Get rid of any uncontrolled files left behind
752 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
753 if p.returncode != 0:
754 raise VCSException("Git clean failed", p.output)
756 def initsubmodules(self):
758 submfile = os.path.join(self.local, '.gitmodules')
759 if not os.path.isfile(submfile):
760 raise VCSException("No git submodules available")
762 # fix submodules not accessible without an account and public key auth
763 with open(submfile, 'r') as f:
764 lines = f.readlines()
765 with open(submfile, 'w') as f:
767 if 'git@github.com' in line:
768 line = line.replace('git@github.com:', 'https://github.com/')
769 if 'git@gitlab.com' in line:
770 line = line.replace('git@gitlab.com:', 'https://gitlab.com/')
773 p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
774 if p.returncode != 0:
775 raise VCSException("Git submodule sync failed", p.output)
776 p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
777 if p.returncode != 0:
778 raise VCSException("Git submodule update failed", p.output)
782 p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
783 return p.output.splitlines()
785 tag_format = re.compile(r'tag: ([^),]*)')
787 def latesttags(self):
789 p = FDroidPopen(['git', 'log', '--tags',
790 '--simplify-by-decoration', '--pretty=format:%d'],
791 cwd=self.local, output=False)
793 for line in p.output.splitlines():
794 for tag in self.tag_format.findall(line):
799 class vcs_gitsvn(vcs):
804 # If the local directory exists, but is somehow not a git repository, git
805 # will traverse up the directory tree until it finds one that is (i.e.
806 # fdroidserver) and then we'll proceed to destory it! This is called as
809 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
810 result = p.output.rstrip()
811 if not result.endswith(self.local):
812 raise VCSException('Repository mismatch')
814 def gotorevisionx(self, rev):
815 if not os.path.exists(self.local):
817 gitsvn_args = ['git', 'svn', 'clone']
818 if ';' in self.remote:
819 remote_split = self.remote.split(';')
820 for i in remote_split[1:]:
821 if i.startswith('trunk='):
822 gitsvn_args.extend(['-T', i[6:]])
823 elif i.startswith('tags='):
824 gitsvn_args.extend(['-t', i[5:]])
825 elif i.startswith('branches='):
826 gitsvn_args.extend(['-b', i[9:]])
827 gitsvn_args.extend([remote_split[0], self.local])
828 p = FDroidPopen(gitsvn_args, output=False)
829 if p.returncode != 0:
830 self.clone_failed = True
831 raise VCSException("Git svn clone failed", p.output)
833 gitsvn_args.extend([self.remote, self.local])
834 p = FDroidPopen(gitsvn_args, output=False)
835 if p.returncode != 0:
836 self.clone_failed = True
837 raise VCSException("Git svn clone failed", p.output)
841 # Discard any working tree changes
842 p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
843 if p.returncode != 0:
844 raise VCSException("Git reset failed", p.output)
845 # Remove untracked files now, in case they're tracked in the target
846 # revision (it happens!)
847 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
848 if p.returncode != 0:
849 raise VCSException("Git clean failed", p.output)
850 if not self.refreshed:
851 # Get new commits, branches and tags from repo
852 p = FDroidPopen(['git', 'svn', 'fetch'], cwd=self.local, output=False)
853 if p.returncode != 0:
854 raise VCSException("Git svn fetch failed")
855 p = FDroidPopen(['git', 'svn', 'rebase'], cwd=self.local, output=False)
856 if p.returncode != 0:
857 raise VCSException("Git svn rebase failed", p.output)
858 self.refreshed = True
860 rev = rev or 'master'
862 nospaces_rev = rev.replace(' ', '%20')
863 # Try finding a svn tag
864 for treeish in ['origin/', '']:
865 p = FDroidPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
866 if p.returncode == 0:
868 if p.returncode != 0:
869 # No tag found, normal svn rev translation
870 # Translate svn rev into git format
871 rev_split = rev.split('/')
874 for treeish in ['origin/', '']:
875 if len(rev_split) > 1:
876 treeish += rev_split[0]
877 svn_rev = rev_split[1]
880 # if no branch is specified, then assume trunk (i.e. 'master' branch):
884 svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
886 p = FDroidPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
887 git_rev = p.output.rstrip()
889 if p.returncode == 0 and git_rev:
892 if p.returncode != 0 or not git_rev:
893 # Try a plain git checkout as a last resort
894 p = FDroidPopen(['git', 'checkout', rev], cwd=self.local, output=False)
895 if p.returncode != 0:
896 raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
898 # Check out the git rev equivalent to the svn rev
899 p = FDroidPopen(['git', 'checkout', git_rev], cwd=self.local, output=False)
900 if p.returncode != 0:
901 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
903 # Get rid of any uncontrolled files left behind
904 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
905 if p.returncode != 0:
906 raise VCSException("Git clean failed", p.output)
910 for treeish in ['origin/', '']:
911 d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
917 p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
918 if p.returncode != 0:
920 return p.output.strip()
928 def gotorevisionx(self, rev):
929 if not os.path.exists(self.local):
930 p = FDroidPopen(['hg', 'clone', self.remote, self.local], output=False)
931 if p.returncode != 0:
932 self.clone_failed = True
933 raise VCSException("Hg clone failed", p.output)
935 p = FDroidPopen(['hg', 'status', '-uS'], cwd=self.local, output=False)
936 if p.returncode != 0:
937 raise VCSException("Hg status failed", p.output)
938 for line in p.output.splitlines():
939 if not line.startswith('? '):
940 raise VCSException("Unexpected output from hg status -uS: " + line)
941 FDroidPopen(['rm', '-rf', line[2:]], cwd=self.local, output=False)
942 if not self.refreshed:
943 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
944 if p.returncode != 0:
945 raise VCSException("Hg pull failed", p.output)
946 self.refreshed = True
948 rev = rev or 'default'
951 p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
952 if p.returncode != 0:
953 raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
954 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
955 # Also delete untracked files, we have to enable purge extension for that:
956 if "'purge' is provided by the following extension" in p.output:
957 with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
958 myfile.write("\n[extensions]\nhgext.purge=\n")
959 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
960 if p.returncode != 0:
961 raise VCSException("HG purge failed", p.output)
962 elif p.returncode != 0:
963 raise VCSException("HG purge failed", p.output)
966 p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
967 return p.output.splitlines()[1:]
975 def gotorevisionx(self, rev):
976 if not os.path.exists(self.local):
977 p = FDroidPopen(['bzr', 'branch', self.remote, self.local], output=False)
978 if p.returncode != 0:
979 self.clone_failed = True
980 raise VCSException("Bzr branch failed", p.output)
982 p = FDroidPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
983 if p.returncode != 0:
984 raise VCSException("Bzr revert failed", p.output)
985 if not self.refreshed:
986 p = FDroidPopen(['bzr', 'pull'], cwd=self.local, output=False)
987 if p.returncode != 0:
988 raise VCSException("Bzr update failed", p.output)
989 self.refreshed = True
991 revargs = list(['-r', rev] if rev else [])
992 p = FDroidPopen(['bzr', 'revert'] + revargs, cwd=self.local, output=False)
993 if p.returncode != 0:
994 raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
997 p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
998 return [tag.split(' ')[0].strip() for tag in
999 p.output.splitlines()]
1002 def unescape_string(string):
1005 if string[0] == '"' and string[-1] == '"':
1008 return string.replace("\\'", "'")
1011 def retrieve_string(app_dir, string, xmlfiles=None):
1013 if not string.startswith('@string/'):
1014 return unescape_string(string)
1016 if xmlfiles is None:
1019 os.path.join(app_dir, 'res'),
1020 os.path.join(app_dir, 'src', 'main', 'res'),
1022 for r, d, f in os.walk(res_dir):
1023 if os.path.basename(r) == 'values':
1024 xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
1026 name = string[len('@string/'):]
1028 def element_content(element):
1029 if element.text is None:
1031 s = XMLElementTree.tostring(element, encoding='utf-8', method='text')
1032 return s.decode('utf-8').strip()
1034 for path in xmlfiles:
1035 if not os.path.isfile(path):
1037 xml = parse_xml(path)
1038 element = xml.find('string[@name="' + name + '"]')
1039 if element is not None:
1040 content = element_content(element)
1041 return retrieve_string(app_dir, content, xmlfiles)
1046 def retrieve_string_singleline(app_dir, string, xmlfiles=None):
1047 return retrieve_string(app_dir, string, xmlfiles).replace('\n', ' ').strip()
1050 def manifest_paths(app_dir, flavours):
1051 '''Return list of existing files that will be used to find the highest vercode'''
1053 possible_manifests = \
1054 [os.path.join(app_dir, 'AndroidManifest.xml'),
1055 os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
1056 os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
1057 os.path.join(app_dir, 'build.gradle')]
1059 for flavour in flavours:
1060 if flavour == 'yes':
1062 possible_manifests.append(
1063 os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
1065 return [path for path in possible_manifests if os.path.isfile(path)]
1068 def fetch_real_name(app_dir, flavours):
1069 '''Retrieve the package name. Returns the name, or None if not found.'''
1070 for path in manifest_paths(app_dir, flavours):
1071 if not has_extension(path, 'xml') or not os.path.isfile(path):
1073 logging.debug("fetch_real_name: Checking manifest at " + path)
1074 xml = parse_xml(path)
1075 app = xml.find('application')
1078 if "{http://schemas.android.com/apk/res/android}label" not in app.attrib:
1080 label = app.attrib["{http://schemas.android.com/apk/res/android}label"]
1081 result = retrieve_string_singleline(app_dir, label)
1083 result = result.strip()
1088 def get_library_references(root_dir):
1090 proppath = os.path.join(root_dir, 'project.properties')
1091 if not os.path.isfile(proppath):
1093 with open(proppath, 'r', encoding='iso-8859-1') as f:
1095 if not line.startswith('android.library.reference.'):
1097 path = line.split('=')[1].strip()
1098 relpath = os.path.join(root_dir, path)
1099 if not os.path.isdir(relpath):
1101 logging.debug("Found subproject at %s" % path)
1102 libraries.append(path)
1106 def ant_subprojects(root_dir):
1107 subprojects = get_library_references(root_dir)
1108 for subpath in subprojects:
1109 subrelpath = os.path.join(root_dir, subpath)
1110 for p in get_library_references(subrelpath):
1111 relp = os.path.normpath(os.path.join(subpath, p))
1112 if relp not in subprojects:
1113 subprojects.insert(0, relp)
1117 def remove_debuggable_flags(root_dir):
1118 # Remove forced debuggable flags
1119 logging.debug("Removing debuggable flags from %s" % root_dir)
1120 for root, dirs, files in os.walk(root_dir):
1121 if 'AndroidManifest.xml' in files:
1122 regsub_file(r'android:debuggable="[^"]*"',
1124 os.path.join(root, 'AndroidManifest.xml'))
1127 vcsearch_g = re.compile(r'''.*[Vv]ersionCode[ =]+["']*([0-9]+)["']*''').search
1128 vnsearch_g = re.compile(r'.*[Vv]ersionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
1129 psearch_g = re.compile(r'.*(packageName|applicationId) *=* *["\']([^"]+)["\'].*').search
1132 def app_matches_packagename(app, package):
1135 appid = app.UpdateCheckName or app.id
1136 if appid is None or appid == "Ignore":
1138 return appid == package
1141 def parse_androidmanifests(paths, app):
1143 Extract some information from the AndroidManifest.xml at the given path.
1144 Returns (version, vercode, package), any or all of which might be None.
1145 All values returned are strings.
1148 ignoreversions = app.UpdateCheckIgnore
1149 ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
1152 return (None, None, None)
1160 if not os.path.isfile(path):
1163 logging.debug("Parsing manifest at {0}".format(path))
1168 if has_extension(path, 'gradle'):
1169 with open(path, 'r') as f:
1171 if gradle_comment.match(line):
1173 # Grab first occurence of each to avoid running into
1174 # alternative flavours and builds.
1176 matches = psearch_g(line)
1178 s = matches.group(2)
1179 if app_matches_packagename(app, s):
1182 matches = vnsearch_g(line)
1184 version = matches.group(2)
1186 matches = vcsearch_g(line)
1188 vercode = matches.group(1)
1191 xml = parse_xml(path)
1192 if "package" in xml.attrib:
1193 s = xml.attrib["package"]
1194 if app_matches_packagename(app, s):
1196 if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1197 version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"]
1198 base_dir = os.path.dirname(path)
1199 version = retrieve_string_singleline(base_dir, version)
1200 if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1201 a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"]
1202 if string_is_integer(a):
1205 logging.warning("Problem with xml at {0}".format(path))
1207 # Remember package name, may be defined separately from version+vercode
1209 package = max_package
1211 logging.debug("..got package={0}, version={1}, vercode={2}"
1212 .format(package, version, vercode))
1214 # Always grab the package name and version name in case they are not
1215 # together with the highest version code
1216 if max_package is None and package is not None:
1217 max_package = package
1218 if max_version is None and version is not None:
1219 max_version = version
1221 if vercode is not None \
1222 and (max_vercode is None or vercode > max_vercode):
1223 if not ignoresearch or not ignoresearch(version):
1224 if version is not None:
1225 max_version = version
1226 if vercode is not None:
1227 max_vercode = vercode
1228 if package is not None:
1229 max_package = package
1231 max_version = "Ignore"
1233 if max_version is None:
1234 max_version = "Unknown"
1236 if max_package and not is_valid_package_name(max_package):
1237 raise FDroidException("Invalid package name {0}".format(max_package))
1239 return (max_version, max_vercode, max_package)
1242 def is_valid_package_name(name):
1243 return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1246 class FDroidException(Exception):
1248 def __init__(self, value, detail=None):
1250 self.detail = detail
1252 def shortened_detail(self):
1253 if len(self.detail) < 16000:
1255 return '[...]\n' + self.detail[-16000:]
1257 def get_wikitext(self):
1258 ret = repr(self.value) + "\n"
1261 ret += "<pre>\n" + self.shortened_detail() + "</pre>\n"
1267 ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1271 class VCSException(FDroidException):
1275 class BuildException(FDroidException):
1279 # Get the specified source library.
1280 # Returns the path to it. Normally this is the path to be used when referencing
1281 # it, which may be a subdirectory of the actual project. If you want the base
1282 # directory of the project, pass 'basepath=True'.
1283 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1284 raw=False, prepare=True, preponly=False, refresh=True,
1293 name, ref = spec.split('@')
1295 number, name = name.split(':', 1)
1297 name, subdir = name.split('/', 1)
1299 if name not in fdroidserver.metadata.srclibs:
1300 raise VCSException('srclib ' + name + ' not found.')
1302 srclib = fdroidserver.metadata.srclibs[name]
1304 sdir = os.path.join(srclib_dir, name)
1307 vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1308 vcs.srclib = (name, number, sdir)
1310 vcs.gotorevision(ref, refresh)
1317 libdir = os.path.join(sdir, subdir)
1318 elif srclib["Subdir"]:
1319 for subdir in srclib["Subdir"]:
1320 libdir_candidate = os.path.join(sdir, subdir)
1321 if os.path.exists(libdir_candidate):
1322 libdir = libdir_candidate
1328 remove_signing_keys(sdir)
1329 remove_debuggable_flags(sdir)
1333 if srclib["Prepare"]:
1334 cmd = replace_config_vars(srclib["Prepare"], build)
1336 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1337 if p.returncode != 0:
1338 raise BuildException("Error running prepare command for srclib %s"
1344 return (name, number, libdir)
1347 gradle_version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1350 # Prepare the source code for a particular build
1351 # 'vcs' - the appropriate vcs object for the application
1352 # 'app' - the application details from the metadata
1353 # 'build' - the build details from the metadata
1354 # 'build_dir' - the path to the build directory, usually
1356 # 'srclib_dir' - the path to the source libraries directory, usually
1358 # 'extlib_dir' - the path to the external libraries directory, usually
1360 # Returns the (root, srclibpaths) where:
1361 # 'root' is the root directory, which may be the same as 'build_dir' or may
1362 # be a subdirectory of it.
1363 # 'srclibpaths' is information on the srclibs being used
1364 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False, refresh=True):
1366 # Optionally, the actual app source can be in a subdirectory
1368 root_dir = os.path.join(build_dir, build.subdir)
1370 root_dir = build_dir
1372 # Get a working copy of the right revision
1373 logging.info("Getting source for revision " + build.commit)
1374 vcs.gotorevision(build.commit, refresh)
1376 # Initialise submodules if required
1377 if build.submodules:
1378 logging.info("Initialising submodules")
1379 vcs.initsubmodules()
1381 # Check that a subdir (if we're using one) exists. This has to happen
1382 # after the checkout, since it might not exist elsewhere
1383 if not os.path.exists(root_dir):
1384 raise BuildException('Missing subdir ' + root_dir)
1386 # Run an init command if one is required
1388 cmd = replace_config_vars(build.init, build)
1389 logging.info("Running 'init' commands in %s" % root_dir)
1391 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1392 if p.returncode != 0:
1393 raise BuildException("Error running init command for %s:%s" %
1394 (app.id, build.versionName), p.output)
1396 # Apply patches if any
1398 logging.info("Applying patches")
1399 for patch in build.patch:
1400 patch = patch.strip()
1401 logging.info("Applying " + patch)
1402 patch_path = os.path.join('metadata', app.id, patch)
1403 p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1404 if p.returncode != 0:
1405 raise BuildException("Failed to apply patch %s" % patch_path)
1407 # Get required source libraries
1410 logging.info("Collecting source libraries")
1411 for lib in build.srclibs:
1412 srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver,
1413 refresh=refresh, build=build))
1415 for name, number, libpath in srclibpaths:
1416 place_srclib(root_dir, int(number) if number else None, libpath)
1418 basesrclib = vcs.getsrclib()
1419 # If one was used for the main source, add that too.
1421 srclibpaths.append(basesrclib)
1423 # Update the local.properties file
1424 localprops = [os.path.join(build_dir, 'local.properties')]
1426 parts = build.subdir.split(os.sep)
1429 cur = os.path.join(cur, d)
1430 localprops += [os.path.join(cur, 'local.properties')]
1431 for path in localprops:
1433 if os.path.isfile(path):
1434 logging.info("Updating local.properties file at %s" % path)
1435 with open(path, 'r', encoding='iso-8859-1') as f:
1439 logging.info("Creating local.properties file at %s" % path)
1440 # Fix old-fashioned 'sdk-location' by copying
1441 # from sdk.dir, if necessary
1443 sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1444 re.S | re.M).group(1)
1445 props += "sdk-location=%s\n" % sdkloc
1447 props += "sdk.dir=%s\n" % config['sdk_path']
1448 props += "sdk-location=%s\n" % config['sdk_path']
1449 ndk_path = build.ndk_path()
1450 # if for any reason the path isn't valid or the directory
1451 # doesn't exist, some versions of Gradle will error with a
1452 # cryptic message (even if the NDK is not even necessary).
1453 # https://gitlab.com/fdroid/fdroidserver/issues/171
1454 if ndk_path and os.path.exists(ndk_path):
1456 props += "ndk.dir=%s\n" % ndk_path
1457 props += "ndk-location=%s\n" % ndk_path
1458 # Add java.encoding if necessary
1460 props += "java.encoding=%s\n" % build.encoding
1461 with open(path, 'w', encoding='iso-8859-1') as f:
1465 if build.build_method() == 'gradle':
1466 flavours = build.gradle
1469 n = build.target.split('-')[1]
1470 regsub_file(r'compileSdkVersion[ =]+[0-9]+',
1471 r'compileSdkVersion %s' % n,
1472 os.path.join(root_dir, 'build.gradle'))
1474 # Remove forced debuggable flags
1475 remove_debuggable_flags(root_dir)
1477 # Insert version code and number into the manifest if necessary
1478 if build.forceversion:
1479 logging.info("Changing the version name")
1480 for path in manifest_paths(root_dir, flavours):
1481 if not os.path.isfile(path):
1483 if has_extension(path, 'xml'):
1484 regsub_file(r'android:versionName="[^"]*"',
1485 r'android:versionName="%s"' % build.versionName,
1487 elif has_extension(path, 'gradle'):
1488 regsub_file(r"""(\s*)versionName[\s'"=]+.*""",
1489 r"""\1versionName '%s'""" % build.versionName,
1492 if build.forcevercode:
1493 logging.info("Changing the version code")
1494 for path in manifest_paths(root_dir, flavours):
1495 if not os.path.isfile(path):
1497 if has_extension(path, 'xml'):
1498 regsub_file(r'android:versionCode="[^"]*"',
1499 r'android:versionCode="%s"' % build.versionCode,
1501 elif has_extension(path, 'gradle'):
1502 regsub_file(r'versionCode[ =]+[0-9]+',
1503 r'versionCode %s' % build.versionCode,
1506 # Delete unwanted files
1508 logging.info("Removing specified files")
1509 for part in getpaths(build_dir, build.rm):
1510 dest = os.path.join(build_dir, part)
1511 logging.info("Removing {0}".format(part))
1512 if os.path.lexists(dest):
1513 if os.path.islink(dest):
1514 FDroidPopen(['unlink', dest], output=False)
1516 FDroidPopen(['rm', '-rf', dest], output=False)
1518 logging.info("...but it didn't exist")
1520 remove_signing_keys(build_dir)
1522 # Add required external libraries
1524 logging.info("Collecting prebuilt libraries")
1525 libsdir = os.path.join(root_dir, 'libs')
1526 if not os.path.exists(libsdir):
1528 for lib in build.extlibs:
1530 logging.info("...installing extlib {0}".format(lib))
1531 libf = os.path.basename(lib)
1532 libsrc = os.path.join(extlib_dir, lib)
1533 if not os.path.exists(libsrc):
1534 raise BuildException("Missing extlib file {0}".format(libsrc))
1535 shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1537 # Run a pre-build command if one is required
1539 logging.info("Running 'prebuild' commands in %s" % root_dir)
1541 cmd = replace_config_vars(build.prebuild, build)
1543 # Substitute source library paths into prebuild commands
1544 for name, number, libpath in srclibpaths:
1545 libpath = os.path.relpath(libpath, root_dir)
1546 cmd = cmd.replace('$$' + name + '$$', libpath)
1548 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1549 if p.returncode != 0:
1550 raise BuildException("Error running prebuild command for %s:%s" %
1551 (app.id, build.versionName), p.output)
1553 # Generate (or update) the ant build file, build.xml...
1554 if build.build_method() == 'ant' and build.androidupdate != ['no']:
1555 parms = ['android', 'update', 'lib-project']
1556 lparms = ['android', 'update', 'project']
1559 parms += ['-t', build.target]
1560 lparms += ['-t', build.target]
1561 if build.androidupdate:
1562 update_dirs = build.androidupdate
1564 update_dirs = ant_subprojects(root_dir) + ['.']
1566 for d in update_dirs:
1567 subdir = os.path.join(root_dir, d)
1569 logging.debug("Updating main project")
1570 cmd = parms + ['-p', d]
1572 logging.debug("Updating subproject %s" % d)
1573 cmd = lparms + ['-p', d]
1574 p = SdkToolsPopen(cmd, cwd=root_dir)
1575 # Check to see whether an error was returned without a proper exit
1576 # code (this is the case for the 'no target set or target invalid'
1578 if p.returncode != 0 or p.output.startswith("Error: "):
1579 raise BuildException("Failed to update project at %s" % d, p.output)
1580 # Clean update dirs via ant
1582 logging.info("Cleaning subproject %s" % d)
1583 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1585 return (root_dir, srclibpaths)
1588 # Extend via globbing the paths from a field and return them as a map from
1589 # original path to resulting paths
1590 def getpaths_map(build_dir, globpaths):
1594 full_path = os.path.join(build_dir, p)
1595 full_path = os.path.normpath(full_path)
1596 paths[p] = [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1598 raise FDroidException("glob path '%s' did not match any files/dirs" % p)
1602 # Extend via globbing the paths from a field and return them as a set
1603 def getpaths(build_dir, globpaths):
1604 paths_map = getpaths_map(build_dir, globpaths)
1606 for k, v in paths_map.items():
1613 return [int(sp) if sp.isdigit() else sp for sp in re.split(r'(\d+)', s)]
1619 self.path = os.path.join('stats', 'known_apks.txt')
1621 if os.path.isfile(self.path):
1622 with open(self.path, 'r', encoding='utf8') as f:
1624 t = line.rstrip().split(' ')
1626 self.apks[t[0]] = (t[1], None)
1628 self.apks[t[0]] = (t[1], datetime.strptime(t[2], '%Y-%m-%d'))
1629 self.changed = False
1631 def writeifchanged(self):
1632 if not self.changed:
1635 if not os.path.exists('stats'):
1639 for apk, app in self.apks.items():
1641 line = apk + ' ' + appid
1643 line += ' ' + added.strftime('%Y-%m-%d')
1646 with open(self.path, 'w', encoding='utf8') as f:
1647 for line in sorted(lst, key=natural_key):
1648 f.write(line + '\n')
1650 def recordapk(self, apk, app, default_date=None):
1652 Record an apk (if it's new, otherwise does nothing)
1653 Returns the date it was added as a datetime instance
1655 if apk not in self.apks:
1656 if default_date is None:
1657 default_date = datetime.utcnow()
1658 self.apks[apk] = (app, default_date)
1660 _, added = self.apks[apk]
1663 # Look up information - given the 'apkname', returns (app id, date added/None).
1664 # Or returns None for an unknown apk.
1665 def getapp(self, apkname):
1666 if apkname in self.apks:
1667 return self.apks[apkname]
1670 # Get the most recent 'num' apps added to the repo, as a list of package ids
1671 # with the most recent first.
1672 def getlatest(self, num):
1674 for apk, app in self.apks.items():
1678 if apps[appid] > added:
1682 sortedapps = sorted(apps.items(), key=operator.itemgetter(1))[-num:]
1683 lst = [app for app, _ in sortedapps]
1688 def get_file_extension(filename):
1689 """get the normalized file extension, can be blank string but never None"""
1690 if isinstance(filename, bytes):
1691 filename = filename.decode('utf-8')
1692 return os.path.splitext(filename)[1].lower()[1:]
1695 def get_apk_debuggable_aapt(apkfile):
1696 p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1698 if p.returncode != 0:
1699 logging.critical("Failed to get apk manifest information")
1701 for line in p.output.splitlines():
1702 if 'android:debuggable' in line and not line.endswith('0x0'):
1707 def get_apk_debuggable_androguard(apkfile):
1709 from androguard.core.bytecodes.apk import APK
1711 logging.critical("androguard library is not installed and aapt not present")
1714 apkobject = APK(apkfile)
1715 if apkobject.is_valid_APK():
1716 debuggable = apkobject.get_element("application", "debuggable")
1717 if debuggable is not None:
1718 return bool(strtobool(debuggable))
1722 def isApkAndDebuggable(apkfile, config):
1723 """Returns True if the given file is an APK and is debuggable
1725 :param apkfile: full path to the apk to check"""
1727 if get_file_extension(apkfile) != 'apk':
1730 if SdkToolsPopen(['aapt', 'version'], output=False):
1731 return get_apk_debuggable_aapt(apkfile)
1733 return get_apk_debuggable_androguard(apkfile)
1738 self.returncode = None
1742 def SdkToolsPopen(commands, cwd=None, output=True):
1744 if cmd not in config:
1745 config[cmd] = find_sdk_tools_cmd(commands[0])
1746 abscmd = config[cmd]
1748 logging.critical("Could not find '%s' on your system" % cmd)
1751 test_aapt_version(config['aapt'])
1752 return FDroidPopen([abscmd] + commands[1:],
1753 cwd=cwd, output=output)
1756 def FDroidPopenBytes(commands, cwd=None, envs=None, output=True, stderr_to_stdout=True):
1758 Run a command and capture the possibly huge output as bytes.
1760 :param commands: command and argument list like in subprocess.Popen
1761 :param cwd: optionally specifies a working directory
1762 :param envs: a optional dictionary of environment variables and their values
1763 :returns: A PopenResult.
1768 set_FDroidPopen_env()
1770 process_env = env.copy()
1771 if envs is not None and len(envs) > 0:
1772 process_env.update(envs)
1775 cwd = os.path.normpath(cwd)
1776 logging.debug("Directory: %s" % cwd)
1777 logging.debug("> %s" % ' '.join(commands))
1779 stderr_param = subprocess.STDOUT if stderr_to_stdout else subprocess.PIPE
1780 result = PopenResult()
1783 p = subprocess.Popen(commands, cwd=cwd, shell=False, env=process_env,
1784 stdout=subprocess.PIPE, stderr=stderr_param)
1785 except OSError as e:
1786 raise BuildException("OSError while trying to execute " +
1787 ' '.join(commands) + ': ' + str(e))
1789 if not stderr_to_stdout and options.verbose:
1790 stderr_queue = Queue()
1791 stderr_reader = AsynchronousFileReader(p.stderr, stderr_queue)
1793 while not stderr_reader.eof():
1794 while not stderr_queue.empty():
1795 line = stderr_queue.get()
1796 sys.stderr.buffer.write(line)
1801 stdout_queue = Queue()
1802 stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1805 # Check the queue for output (until there is no more to get)
1806 while not stdout_reader.eof():
1807 while not stdout_queue.empty():
1808 line = stdout_queue.get()
1809 if output and options.verbose:
1810 # Output directly to console
1811 sys.stderr.buffer.write(line)
1817 result.returncode = p.wait()
1818 result.output = buf.getvalue()
1823 def FDroidPopen(commands, cwd=None, envs=None, output=True, stderr_to_stdout=True):
1825 Run a command and capture the possibly huge output as a str.
1827 :param commands: command and argument list like in subprocess.Popen
1828 :param cwd: optionally specifies a working directory
1829 :param envs: a optional dictionary of environment variables and their values
1830 :returns: A PopenResult.
1832 result = FDroidPopenBytes(commands, cwd, envs, output, stderr_to_stdout)
1833 result.output = result.output.decode('utf-8', 'ignore')
1837 gradle_comment = re.compile(r'[ ]*//')
1838 gradle_signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1839 gradle_line_matches = [
1840 re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1841 re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1842 re.compile(r'.*\.readLine\(.*'),
1846 def remove_signing_keys(build_dir):
1847 for root, dirs, files in os.walk(build_dir):
1848 if 'build.gradle' in files:
1849 path = os.path.join(root, 'build.gradle')
1851 with open(path, "r", encoding='utf8') as o:
1852 lines = o.readlines()
1858 with open(path, "w", encoding='utf8') as o:
1859 while i < len(lines):
1862 while line.endswith('\\\n'):
1863 line = line.rstrip('\\\n') + lines[i]
1866 if gradle_comment.match(line):
1871 opened += line.count('{')
1872 opened -= line.count('}')
1875 if gradle_signing_configs.match(line):
1880 if any(s.match(line) for s in gradle_line_matches):
1888 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1891 'project.properties',
1893 'default.properties',
1894 'ant.properties', ]:
1895 if propfile in files:
1896 path = os.path.join(root, propfile)
1898 with open(path, "r", encoding='iso-8859-1') as o:
1899 lines = o.readlines()
1903 with open(path, "w", encoding='iso-8859-1') as o:
1905 if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1912 logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1915 def set_FDroidPopen_env(build=None):
1917 set up the environment variables for the build environment
1919 There is only a weak standard, the variables used by gradle, so also set
1920 up the most commonly used environment variables for SDK and NDK. Also, if
1921 there is no locale set, this will set the locale (e.g. LANG) to en_US.UTF-8.
1923 global env, orig_path
1927 orig_path = env['PATH']
1928 for n in ['ANDROID_HOME', 'ANDROID_SDK']:
1929 env[n] = config['sdk_path']
1930 for k, v in config['java_paths'].items():
1931 env['JAVA%s_HOME' % k] = v
1933 missinglocale = True
1934 for k, v in env.items():
1935 if k == 'LANG' and v != 'C':
1936 missinglocale = False
1938 missinglocale = False
1940 env['LANG'] = 'en_US.UTF-8'
1942 if build is not None:
1943 path = build.ndk_path()
1944 paths = orig_path.split(os.pathsep)
1945 if path not in paths:
1946 paths = [path] + paths
1947 env['PATH'] = os.pathsep.join(paths)
1948 for n in ['ANDROID_NDK', 'NDK', 'ANDROID_NDK_HOME']:
1949 env[n] = build.ndk_path()
1952 def replace_build_vars(cmd, build):
1953 cmd = cmd.replace('$$COMMIT$$', build.commit)
1954 cmd = cmd.replace('$$VERSION$$', build.versionName)
1955 cmd = cmd.replace('$$VERCODE$$', build.versionCode)
1959 def replace_config_vars(cmd, build):
1960 cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1961 cmd = cmd.replace('$$NDK$$', build.ndk_path())
1962 cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1963 cmd = cmd.replace('$$QT$$', config['qt_sdk_path'] or '')
1964 if build is not None:
1965 cmd = replace_build_vars(cmd, build)
1969 def place_srclib(root_dir, number, libpath):
1972 relpath = os.path.relpath(libpath, root_dir)
1973 proppath = os.path.join(root_dir, 'project.properties')
1976 if os.path.isfile(proppath):
1977 with open(proppath, "r", encoding='iso-8859-1') as o:
1978 lines = o.readlines()
1980 with open(proppath, "w", encoding='iso-8859-1') as o:
1983 if line.startswith('android.library.reference.%d=' % number):
1984 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1989 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1992 apk_sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA|DSA|EC)')
1995 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
1996 """Verify that two apks are the same
1998 One of the inputs is signed, the other is unsigned. The signature metadata
1999 is transferred from the signed to the unsigned apk, and then jarsigner is
2000 used to verify that the signature from the signed apk is also varlid for
2001 the unsigned one. If the APK given as unsigned actually does have a
2002 signature, it will be stripped out and ignored.
2004 There are two SHA1 git commit IDs that fdroidserver includes in the builds
2005 it makes: fdroidserverid and buildserverid. Originally, these were inserted
2006 into AndroidManifest.xml, but that makes the build not reproducible. So
2007 instead they are included as separate files in the APK's META-INF/ folder.
2008 If those files exist in the signed APK, they will be part of the signature
2009 and need to also be included in the unsigned APK for it to validate.
2011 :param signed_apk: Path to a signed apk file
2012 :param unsigned_apk: Path to an unsigned apk file expected to match it
2013 :param tmp_dir: Path to directory for temporary files
2014 :returns: None if the verification is successful, otherwise a string
2015 describing what went wrong.
2018 signed = ZipFile(signed_apk, 'r')
2019 meta_inf_files = ['META-INF/MANIFEST.MF']
2020 for f in signed.namelist():
2021 if apk_sigfile.match(f) \
2022 or f in ['META-INF/fdroidserverid', 'META-INF/buildserverid']:
2023 meta_inf_files.append(f)
2024 if len(meta_inf_files) < 3:
2025 return "Signature files missing from {0}".format(signed_apk)
2027 tmp_apk = os.path.join(tmp_dir, 'sigcp_' + os.path.basename(unsigned_apk))
2028 unsigned = ZipFile(unsigned_apk, 'r')
2029 # only read the signature from the signed APK, everything else from unsigned
2030 with ZipFile(tmp_apk, 'w') as tmp:
2031 for filename in meta_inf_files:
2032 tmp.writestr(signed.getinfo(filename), signed.read(filename))
2033 for info in unsigned.infolist():
2034 if info.filename in meta_inf_files:
2035 logging.warning('Ignoring ' + info.filename + ' from ' + unsigned_apk)
2037 if info.filename in tmp.namelist():
2038 return "duplicate filename found: " + info.filename
2039 tmp.writestr(info, unsigned.read(info.filename))
2043 verified = verify_apk_signature(tmp_apk)
2046 logging.info("...NOT verified - {0}".format(tmp_apk))
2047 return compare_apks(signed_apk, tmp_apk, tmp_dir,
2048 os.path.dirname(unsigned_apk))
2050 logging.info("...successfully verified")
2054 def verify_apk_signature(apk, jar=False):
2055 """verify the signature on an APK
2057 Try to use apksigner whenever possible since jarsigner is very
2058 shitty: unsigned APKs pass as "verified"! So this has to turn on
2059 -strict then check for result 4.
2061 You can set :param: jar to True if you want to use this method
2062 to verify jar signatures.
2064 if set_command_in_config('apksigner'):
2065 args = [config['apksigner'], 'verify']
2067 args += ['--min-sdk-version=1']
2068 return subprocess.call(args + [apk]) == 0
2070 logging.warning("Using Java's jarsigner, not recommended for verifying APKs! Use apksigner")
2071 return subprocess.call([config['jarsigner'], '-strict', '-verify', apk]) == 4
2074 apk_badchars = re.compile('''[/ :;'"]''')
2077 def compare_apks(apk1, apk2, tmp_dir, log_dir=None):
2080 Returns None if the apk content is the same (apart from the signing key),
2081 otherwise a string describing what's different, or what went wrong when
2082 trying to do the comparison.
2088 absapk1 = os.path.abspath(apk1)
2089 absapk2 = os.path.abspath(apk2)
2091 if set_command_in_config('diffoscope'):
2092 logfilename = os.path.join(log_dir, os.path.basename(absapk1))
2093 htmlfile = logfilename + '.diffoscope.html'
2094 textfile = logfilename + '.diffoscope.txt'
2095 if subprocess.call([config['diffoscope'],
2096 '--max-report-size', '12345678', '--max-diff-block-lines', '100',
2097 '--html', htmlfile, '--text', textfile,
2098 absapk1, absapk2]) != 0:
2099 return("Failed to unpack " + apk1)
2101 apk1dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk1[0:-4])) # trim .apk
2102 apk2dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk2[0:-4])) # trim .apk
2103 for d in [apk1dir, apk2dir]:
2104 if os.path.exists(d):
2107 os.mkdir(os.path.join(d, 'jar-xf'))
2109 if subprocess.call(['jar', 'xf',
2110 os.path.abspath(apk1)],
2111 cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
2112 return("Failed to unpack " + apk1)
2113 if subprocess.call(['jar', 'xf',
2114 os.path.abspath(apk2)],
2115 cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
2116 return("Failed to unpack " + apk2)
2118 if set_command_in_config('apktool'):
2119 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
2121 return("Failed to unpack " + apk1)
2122 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
2124 return("Failed to unpack " + apk2)
2126 p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
2127 lines = p.output.splitlines()
2128 if len(lines) != 1 or 'META-INF' not in lines[0]:
2129 if set_command_in_config('meld'):
2130 p = FDroidPopen([config['meld'], apk1dir, apk2dir], output=False)
2131 return("Unexpected diff output - " + p.output)
2133 # since everything verifies, delete the comparison to keep cruft down
2134 shutil.rmtree(apk1dir)
2135 shutil.rmtree(apk2dir)
2137 # If we get here, it seems like they're the same!
2141 def set_command_in_config(command):
2142 '''Try to find specified command in the path, if it hasn't been
2143 manually set in config.py. If found, it is added to the config
2144 dict. The return value says whether the command is available.
2147 if command in config:
2150 tmp = find_command(command)
2152 config[command] = tmp
2157 def find_command(command):
2158 '''find the full path of a command, or None if it can't be found in the PATH'''
2161 return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
2163 fpath, fname = os.path.split(command)
2168 for path in os.environ["PATH"].split(os.pathsep):
2169 path = path.strip('"')
2170 exe_file = os.path.join(path, command)
2171 if is_exe(exe_file):
2178 '''generate a random password for when generating keys'''
2179 h = hashlib.sha256()
2180 h.update(os.urandom(16)) # salt
2181 h.update(socket.getfqdn().encode('utf-8'))
2182 passwd = base64.b64encode(h.digest()).strip()
2183 return passwd.decode('utf-8')
2186 def genkeystore(localconfig):
2188 Generate a new key with password provided in :param localconfig and add it to new keystore
2189 :return: hexed public key, public key fingerprint
2191 logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
2192 keystoredir = os.path.dirname(localconfig['keystore'])
2193 if keystoredir is None or keystoredir == '':
2194 keystoredir = os.path.join(os.getcwd(), keystoredir)
2195 if not os.path.exists(keystoredir):
2196 os.makedirs(keystoredir, mode=0o700)
2199 'FDROID_KEY_STORE_PASS': localconfig['keystorepass'],
2200 'FDROID_KEY_PASS': localconfig['keypass'],
2202 p = FDroidPopen([config['keytool'], '-genkey',
2203 '-keystore', localconfig['keystore'],
2204 '-alias', localconfig['repo_keyalias'],
2205 '-keyalg', 'RSA', '-keysize', '4096',
2206 '-sigalg', 'SHA256withRSA',
2207 '-validity', '10000',
2208 '-storepass:env', 'FDROID_KEY_STORE_PASS',
2209 '-keypass:env', 'FDROID_KEY_PASS',
2210 '-dname', localconfig['keydname']], envs=env_vars)
2211 if p.returncode != 0:
2212 raise BuildException("Failed to generate key", p.output)
2213 os.chmod(localconfig['keystore'], 0o0600)
2214 if not options.quiet:
2215 # now show the lovely key that was just generated
2216 p = FDroidPopen([config['keytool'], '-list', '-v',
2217 '-keystore', localconfig['keystore'],
2218 '-alias', localconfig['repo_keyalias'],
2219 '-storepass:env', 'FDROID_KEY_STORE_PASS'], envs=env_vars)
2220 logging.info(p.output.strip() + '\n\n')
2221 # get the public key
2222 p = FDroidPopenBytes([config['keytool'], '-exportcert',
2223 '-keystore', localconfig['keystore'],
2224 '-alias', localconfig['repo_keyalias'],
2225 '-storepass:env', 'FDROID_KEY_STORE_PASS']
2226 + config['smartcardoptions'],
2227 envs=env_vars, output=False, stderr_to_stdout=False)
2228 if p.returncode != 0 or len(p.output) < 20:
2229 raise BuildException("Failed to get public key", p.output)
2231 fingerprint = get_cert_fingerprint(pubkey)
2232 return hexlify(pubkey), fingerprint
2235 def get_cert_fingerprint(pubkey):
2237 Generate a certificate fingerprint the same way keytool does it
2238 (but with slightly different formatting)
2240 digest = hashlib.sha256(pubkey).digest()
2241 ret = [' '.join("%02X" % b for b in bytearray(digest))]
2242 return " ".join(ret)
2245 def get_certificate(certificate_file):
2247 Extracts a certificate from the given file.
2248 :param certificate_file: file bytes (as string) representing the certificate
2249 :return: A binary representation of the certificate's public key, or None in case of error
2251 content = decoder.decode(certificate_file, asn1Spec=rfc2315.ContentInfo())[0]
2252 if content.getComponentByName('contentType') != rfc2315.signedData:
2254 content = decoder.decode(content.getComponentByName('content'),
2255 asn1Spec=rfc2315.SignedData())[0]
2257 certificates = content.getComponentByName('certificates')
2258 cert = certificates[0].getComponentByName('certificate')
2260 logging.error("Certificates not found.")
2262 return encoder.encode(cert)
2265 def write_to_config(thisconfig, key, value=None, config_file=None):
2266 '''write a key/value to the local config.py
2268 NOTE: only supports writing string variables.
2270 :param thisconfig: config dictionary
2271 :param key: variable name in config.py to be overwritten/added
2272 :param value: optional value to be written, instead of fetched
2273 from 'thisconfig' dictionary.
2276 origkey = key + '_orig'
2277 value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
2278 cfg = config_file if config_file else 'config.py'
2281 with open(cfg, 'r', encoding="utf-8") as f:
2282 lines = f.readlines()
2284 # make sure the file ends with a carraige return
2286 if not lines[-1].endswith('\n'):
2289 # regex for finding and replacing python string variable
2290 # definitions/initializations
2291 pattern = re.compile('^[\s#]*' + key + '\s*=\s*"[^"]*"')
2292 repl = key + ' = "' + value + '"'
2293 pattern2 = re.compile('^[\s#]*' + key + "\s*=\s*'[^']*'")
2294 repl2 = key + " = '" + value + "'"
2296 # If we replaced this line once, we make sure won't be a
2297 # second instance of this line for this key in the document.
2300 with open(cfg, 'w', encoding="utf-8") as f:
2302 if pattern.match(line) or pattern2.match(line):
2304 line = pattern.sub(repl, line)
2305 line = pattern2.sub(repl2, line)
2316 def parse_xml(path):
2317 return XMLElementTree.parse(path).getroot()
2320 def string_is_integer(string):
2328 def get_per_app_repos():
2329 '''per-app repos are dirs named with the packageName of a single app'''
2331 # Android packageNames are Java packages, they may contain uppercase or
2332 # lowercase letters ('A' through 'Z'), numbers, and underscores
2333 # ('_'). However, individual package name parts may only start with
2334 # letters. https://developer.android.com/guide/topics/manifest/manifest-element.html#package
2335 p = re.compile('^([a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*)?$')
2338 for root, dirs, files in os.walk(os.getcwd()):
2340 print('checking', root, 'for', d)
2341 if d in ('archive', 'metadata', 'repo', 'srclibs', 'tmp'):
2342 # standard parts of an fdroid repo, so never packageNames
2345 and os.path.exists(os.path.join(d, 'fdroid', 'repo', 'index.jar')):
2351 def is_repo_file(filename):
2352 '''Whether the file in a repo is a build product to be delivered to users'''
2353 if isinstance(filename, str):
2354 filename = filename.encode('utf-8', errors="surrogateescape")
2355 return os.path.isfile(filename) \
2356 and not filename.endswith(b'.asc') \
2357 and not filename.endswith(b'.sig') \
2358 and os.path.basename(filename) not in [
2360 b'index_unsigned.jar',