Merge branch 'signing-server' into 'master'

complete workflow for porting the signing server for config.yml

See merge request fdroid/fdroidserver!1610
This commit is contained in:
Hans-Christoph Steiner 2025-03-12 13:11:21 +00:00
commit 3e6cb67e69
6 changed files with 387 additions and 129 deletions

View file

@ -25,8 +25,32 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# common.py is imported by all modules, so do not import third-party
# libraries here as they will become a requirement for all commands. """Collection of functions shared by subcommands.
This is basically the "shared library" for all the fdroid subcommands.
The contains core functionality and a number of utility functions.
This is imported by all modules, so do not import third-party
libraries here as they will become a requirement for all commands.
Config
------
Parsing and using the configuration settings from config.yml is
handled here. The data format is YAML 1.2. The config has its own
supported data types:
* Boolean (e.g. deploy_process_logs:)
* Integer (e.g. archive_older:, repo_maxage:)
* String-only (e.g. repo_name:, sdk_path:)
* Multi-String (string, list of strings, or list of dicts with
strings, e.g. serverwebroot:, mirrors:)
String-only fields can also use a special value {env: varname}, which
is a dict with a single key 'env' and a value that is the name of the
environment variable to include.
"""
import copy import copy
import difflib import difflib
@ -574,24 +598,18 @@ def read_config():
'sun.security.pkcs11.SunPKCS11', 'sun.security.pkcs11.SunPKCS11',
'-providerArg', 'opensc-fdroid.cfg'] '-providerArg', 'opensc-fdroid.cfg']
if any(k in config for k in ["keystore", "keystorepass", "keypass"]):
st = os.stat(CONFIG_FILE)
if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
logging.warning(
_("unsafe permissions on '{config_file}' (should be 0600)!").format(
config_file=CONFIG_FILE
)
)
fill_config_defaults(config) fill_config_defaults(config)
if 'serverwebroot' in config: if 'serverwebroot' in config:
roots = parse_mirrors_config(config['serverwebroot']) roots = parse_list_of_dicts(config['serverwebroot'])
rootlist = [] rootlist = []
for d in roots: for d in roots:
# since this is used with rsync, where trailing slashes have # since this is used with rsync, where trailing slashes have
# meaning, ensure there is always a trailing slash # meaning, ensure there is always a trailing slash
rootstr = d['url'] rootstr = d.get('url')
if not rootstr:
logging.error('serverwebroot: has blank value!')
continue
if rootstr[-1] != '/': if rootstr[-1] != '/':
rootstr += '/' rootstr += '/'
d['url'] = rootstr.replace('//', '/') d['url'] = rootstr.replace('//', '/')
@ -599,7 +617,7 @@ def read_config():
config['serverwebroot'] = rootlist config['serverwebroot'] = rootlist
if 'servergitmirrors' in config: if 'servergitmirrors' in config:
config['servergitmirrors'] = parse_mirrors_config(config['servergitmirrors']) config['servergitmirrors'] = parse_list_of_dicts(config['servergitmirrors'])
limit = config['git_mirror_size_limit'] limit = config['git_mirror_size_limit']
config['git_mirror_size_limit'] = parse_human_readable_size(limit) config['git_mirror_size_limit'] = parse_human_readable_size(limit)
@ -639,19 +657,63 @@ def read_config():
for configname in confignames_to_delete: for configname in confignames_to_delete:
del config[configname] del config[configname]
if any(
k in config and config.get(k)
for k in ["awssecretkey", "keystorepass", "keypass"]
):
st = os.stat(CONFIG_FILE)
if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
logging.warning(
_("unsafe permissions on '{config_file}' (should be 0600)!").format(
config_file=CONFIG_FILE
)
)
return config return config
def parse_mirrors_config(mirrors): def expand_env_dict(s):
"""Mirrors can be specified as a string, list of strings, or dictionary map.""" """Expand env var dict to a string value.
if isinstance(mirrors, str):
return [{"url": mirrors}] {env: varName} syntax can be used to replace any string value in the
elif all(isinstance(item, str) for item in mirrors): config with the value of an environment variable "varName". This
return [{'url': i} for i in mirrors] allows for secrets management when commiting the config file to a
elif all(isinstance(item, dict) for item in mirrors): public git repo.
return mirrors
else: """
raise TypeError(_('only accepts strings, lists, and tuples')) if not s or type(s) not in (str, dict):
return
if isinstance(s, dict):
if 'env' not in s or len(s) > 1:
raise TypeError(_('Only accepts a single key "env"'))
var = s['env']
s = os.getenv(var)
if not s:
logging.error(
_('Environment variable {{env: {var}}} is not set!').format(var=var)
)
return
return os.path.expanduser(s)
def parse_list_of_dicts(l_of_d):
"""Parse config data structure that is a list of dicts of strings.
The value can be specified as a string, list of strings, or list of dictionary maps
where the values are strings.
"""
if isinstance(l_of_d, str):
return [{"url": expand_env_dict(l_of_d)}]
if isinstance(l_of_d, dict):
return [{"url": expand_env_dict(l_of_d)}]
if all(isinstance(item, str) for item in l_of_d):
return [{'url': expand_env_dict(i)} for i in l_of_d]
if all(isinstance(item, dict) for item in l_of_d):
for item in l_of_d:
item['url'] = expand_env_dict(item['url'])
return l_of_d
raise TypeError(_('only accepts strings, lists, and tuples'))
def get_mirrors(url, filename=None): def get_mirrors(url, filename=None):
@ -663,7 +725,7 @@ def get_mirrors(url, filename=None):
if url.netloc == 'f-droid.org': if url.netloc == 'f-droid.org':
mirrors = FDROIDORG_MIRRORS mirrors = FDROIDORG_MIRRORS
else: else:
mirrors = parse_mirrors_config(url.geturl()) mirrors = parse_list_of_dicts(url.geturl())
if filename: if filename:
return append_filename_to_mirrors(filename, mirrors) return append_filename_to_mirrors(filename, mirrors)

View file

@ -20,6 +20,16 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
"""Process the index files.
This module is loaded by all fdroid subcommands since it is loaded in
fdroidserver/__init__.py. Any narrowly used dependencies should be
imported where they are used to limit dependencies for subcommands
like publish/signindex/gpgsign. This eliminates the need to have
these installed on the signing server.
"""
import collections import collections
import hashlib import hashlib
import json import json
@ -32,7 +42,6 @@ import tempfile
import urllib.parse import urllib.parse
import zipfile import zipfile
import calendar import calendar
import qrcode
from binascii import hexlify, unhexlify from binascii import hexlify, unhexlify
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@ -41,7 +50,6 @@ from xml.dom.minidom import Document
from . import _ from . import _
from . import common from . import common
from . import metadata from . import metadata
from . import net
from . import signindex from . import signindex
from fdroidserver.common import ANTIFEATURES_CONFIG_NAME, CATEGORIES_CONFIG_NAME, CONFIG_CONFIG_NAME, MIRRORS_CONFIG_NAME, RELEASECHANNELS_CONFIG_NAME, DEFAULT_LOCALE, FDroidPopen, FDroidPopenBytes, load_stats_fdroid_signing_key_fingerprints from fdroidserver.common import ANTIFEATURES_CONFIG_NAME, CATEGORIES_CONFIG_NAME, CONFIG_CONFIG_NAME, MIRRORS_CONFIG_NAME, RELEASECHANNELS_CONFIG_NAME, DEFAULT_LOCALE, FDroidPopen, FDroidPopenBytes, load_stats_fdroid_signing_key_fingerprints
from fdroidserver._yaml import yaml from fdroidserver._yaml import yaml
@ -160,6 +168,7 @@ def make_website(apps, repodir, repodict):
html_file = os.path.join(repodir, html_name) html_file = os.path.join(repodir, html_name)
if _should_file_be_generated(html_file, autogenerate_comment): if _should_file_be_generated(html_file, autogenerate_comment):
import qrcode
qrcode.make(link_fingerprinted).save(os.path.join(repodir, "index.png")) qrcode.make(link_fingerprinted).save(os.path.join(repodir, "index.png"))
with open(html_file, 'w') as f: with open(html_file, 'w') as f:
name = repodict["name"] name = repodict["name"]
@ -1378,7 +1387,15 @@ def make_v0(apps, apks, repodir, repodict, requestsdict, fdroid_signing_key_fing
% repo_icon) % repo_icon)
os.makedirs(os.path.dirname(iconfilename), exist_ok=True) os.makedirs(os.path.dirname(iconfilename), exist_ok=True)
try: try:
import qrcode
qrcode.make(common.config['repo_url']).save(iconfilename) qrcode.make(common.config['repo_url']).save(iconfilename)
except ModuleNotFoundError as e:
raise ModuleNotFoundError(
_(
'The "qrcode" Python package is not installed (e.g. apt-get install python3-qrcode)!'
)
) from e
except Exception: except Exception:
exampleicon = os.path.join(common.get_examples_dir(), exampleicon = os.path.join(common.get_examples_dir(),
common.default_config['repo_icon']) common.default_config['repo_icon'])
@ -1624,6 +1641,8 @@ def download_repo_index_v1(url_str, etag=None, verify_fingerprint=True, timeout=
- The new eTag as returned by the HTTP request - The new eTag as returned by the HTTP request
""" """
from . import net
url = urllib.parse.urlsplit(url_str) url = urllib.parse.urlsplit(url_str)
fingerprint = None fingerprint = None
@ -1675,6 +1694,8 @@ def download_repo_index_v2(url_str, etag=None, verify_fingerprint=True, timeout=
- The new eTag as returned by the HTTP request - The new eTag as returned by the HTTP request
""" """
from . import net
etag # etag is unused but needs to be there to keep the same API as the earlier functions. etag # etag is unused but needs to be there to keep the same API as the earlier functions.
url = urllib.parse.urlsplit(url_str) url = urllib.parse.urlsplit(url_str)

View file

@ -236,6 +236,7 @@ bool_keys = (
check_config_keys = ( check_config_keys = (
'ant', 'ant',
'apk_signing_key_block_list',
'archive', 'archive',
'archive_description', 'archive_description',
'archive_icon', 'archive_icon',
@ -899,7 +900,7 @@ def lint_config(arg):
show_error = False show_error = False
if t is str: if t is str:
if type(data[key]) not in (str, dict): if type(data[key]) not in (str, list, dict):
passed = False passed = False
show_error = True show_error = True
elif type(data[key]) != t: elif type(data[key]) != t:

View file

@ -92,7 +92,7 @@ def download_using_mirrors(mirrors, local_filename=None):
logic will try it twice: first without SNI, then again with SNI. logic will try it twice: first without SNI, then again with SNI.
""" """
mirrors = common.parse_mirrors_config(mirrors) mirrors = common.parse_list_of_dicts(mirrors)
mirror_configs_to_try = [] mirror_configs_to_try = []
for mirror in mirrors: for mirror in mirrors:
mirror_configs_to_try.append(mirror) mirror_configs_to_try.append(mirror)

View file

@ -4,7 +4,6 @@ import difflib
import git import git
import glob import glob
import importlib import importlib
import inspect
import json import json
import logging import logging
import os import os
@ -29,7 +28,7 @@ import fdroidserver
import fdroidserver.signindex import fdroidserver.signindex
import fdroidserver.common import fdroidserver.common
import fdroidserver.metadata import fdroidserver.metadata
from .shared_test_code import TmpCwd, mkdtemp from .shared_test_code import TmpCwd, mkdtemp, mkdir_testfiles
from fdroidserver.common import ANTIFEATURES_CONFIG_NAME, CATEGORIES_CONFIG_NAME from fdroidserver.common import ANTIFEATURES_CONFIG_NAME, CATEGORIES_CONFIG_NAME
from fdroidserver._yaml import yaml, yaml_dumper, config_dump from fdroidserver._yaml import yaml, yaml_dumper, config_dump
from fdroidserver.exception import FDroidException, VCSException,\ from fdroidserver.exception import FDroidException, VCSException,\
@ -46,16 +45,13 @@ def _mock_common_module_options_instance():
fdroidserver.common.options.verbose = False fdroidserver.common.options.verbose = False
class CommonTest(unittest.TestCase): class SetUpTearDownMixin:
'''fdroidserver/common.py''' """A mixin with no tests in it for shared setUp and tearDown."""
def setUp(self): def setUp(self):
logging.basicConfig(level=logging.DEBUG) logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger('androguard.axml') logger = logging.getLogger('androguard.axml')
logger.setLevel(logging.INFO) # tame the axml debug messages logger.setLevel(logging.INFO) # tame the axml debug messages
self.tmpdir = os.path.abspath(os.path.join(basedir, '..', '.testfiles'))
if not os.path.exists(self.tmpdir):
os.makedirs(self.tmpdir)
os.chdir(basedir) os.chdir(basedir)
self.verbose = '-v' in sys.argv or '--verbose' in sys.argv self.verbose = '-v' in sys.argv or '--verbose' in sys.argv
@ -66,16 +62,18 @@ class CommonTest(unittest.TestCase):
fdroidserver.common.options = None fdroidserver.common.options = None
fdroidserver.metadata.srclibs = None fdroidserver.metadata.srclibs = None
self._td = mkdtemp() self.testdir = mkdir_testfiles(basedir, self)
self.testdir = self._td.name
def tearDown(self): def tearDown(self):
fdroidserver.common.config = None fdroidserver.common.config = None
fdroidserver.common.options = None fdroidserver.common.options = None
os.chdir(basedir) os.chdir(basedir)
self._td.cleanup() if os.path.exists(self.testdir):
if os.path.exists(self.tmpdir): shutil.rmtree(self.testdir)
shutil.rmtree(self.tmpdir)
class CommonTest(SetUpTearDownMixin, unittest.TestCase):
'''fdroidserver/common.py'''
def test_yaml_1_2(self): def test_yaml_1_2(self):
"""Return a ruamel.yaml instance that supports YAML 1.2 """Return a ruamel.yaml instance that supports YAML 1.2
@ -174,7 +172,7 @@ class CommonTest(unittest.TestCase):
print('no build-tools found: ' + build_tools) print('no build-tools found: ' + build_tools)
def test_find_java_root_path(self): def test_find_java_root_path(self):
os.chdir(self.tmpdir) os.chdir(self.testdir)
all_pathlists = [ all_pathlists = [
( (
@ -306,11 +304,11 @@ class CommonTest(unittest.TestCase):
shutil.copytree( shutil.copytree(
os.path.join(basedir, 'source-files'), os.path.join(basedir, 'source-files'),
os.path.join(self.tmpdir, 'source-files'), os.path.join(self.testdir, 'source-files'),
) )
fdroidclient_testdir = os.path.join( fdroidclient_testdir = os.path.join(
self.tmpdir, 'source-files', 'fdroid', 'fdroidclient' self.testdir, 'source-files', 'fdroid', 'fdroidclient'
) )
config = dict() config = dict()
@ -421,7 +419,7 @@ class CommonTest(unittest.TestCase):
def test_prepare_sources_refresh(self): def test_prepare_sources_refresh(self):
_mock_common_module_options_instance() _mock_common_module_options_instance()
packageName = 'org.fdroid.ci.test.app' packageName = 'org.fdroid.ci.test.app'
os.chdir(self.tmpdir) os.chdir(self.testdir)
os.mkdir('build') os.mkdir('build')
os.mkdir('metadata') os.mkdir('metadata')
@ -439,7 +437,7 @@ class CommonTest(unittest.TestCase):
with open(os.path.join('metadata', packageName + '.yml'), 'w') as fp: with open(os.path.join('metadata', packageName + '.yml'), 'w') as fp:
yaml_dumper.dump(metadata, fp) yaml_dumper.dump(metadata, fp)
gitrepo = os.path.join(self.tmpdir, 'build', packageName) gitrepo = os.path.join(self.testdir, 'build', packageName)
vcs0 = fdroidserver.common.getvcs('git', git_url, gitrepo) vcs0 = fdroidserver.common.getvcs('git', git_url, gitrepo)
vcs0.gotorevision('0.3', refresh=True) vcs0.gotorevision('0.3', refresh=True)
vcs1 = fdroidserver.common.getvcs('git', git_url, gitrepo) vcs1 = fdroidserver.common.getvcs('git', git_url, gitrepo)
@ -508,18 +506,15 @@ class CommonTest(unittest.TestCase):
fdroidserver.signindex.config = config fdroidserver.signindex.config = config
sourcedir = os.path.join(basedir, 'signindex') sourcedir = os.path.join(basedir, 'signindex')
with tempfile.TemporaryDirectory( for f in ('testy.jar', 'guardianproject.jar'):
prefix=inspect.currentframe().f_code.co_name, dir=self.tmpdir sourcefile = os.path.join(sourcedir, f)
) as testsdir: testfile = os.path.join(self.testdir, f)
for f in ('testy.jar', 'guardianproject.jar'): shutil.copy(sourcefile, self.testdir)
sourcefile = os.path.join(sourcedir, f) fdroidserver.signindex.sign_jar(testfile, use_old_algs=True)
testfile = os.path.join(testsdir, f) # these should be resigned, and therefore different
shutil.copy(sourcefile, testsdir) self.assertNotEqual(
fdroidserver.signindex.sign_jar(testfile, use_old_algs=True) open(sourcefile, 'rb').read(), open(testfile, 'rb').read()
# these should be resigned, and therefore different )
self.assertNotEqual(
open(sourcefile, 'rb').read(), open(testfile, 'rb').read()
)
def test_verify_apk_signature(self): def test_verify_apk_signature(self):
_mock_common_module_options_instance() _mock_common_module_options_instance()
@ -618,7 +613,7 @@ class CommonTest(unittest.TestCase):
shutil.copy(sourceapk, copyapk) shutil.copy(sourceapk, copyapk)
self.assertTrue(fdroidserver.common.verify_apk_signature(copyapk)) self.assertTrue(fdroidserver.common.verify_apk_signature(copyapk))
self.assertIsNone( self.assertIsNone(
fdroidserver.common.verify_apks(sourceapk, copyapk, self.tmpdir) fdroidserver.common.verify_apks(sourceapk, copyapk, self.testdir)
) )
unsignedapk = os.path.join(self.testdir, 'urzip-unsigned.apk') unsignedapk = os.path.join(self.testdir, 'urzip-unsigned.apk')
@ -628,7 +623,7 @@ class CommonTest(unittest.TestCase):
if not info.filename.startswith('META-INF/'): if not info.filename.startswith('META-INF/'):
testapk.writestr(info, apk.read(info.filename)) testapk.writestr(info, apk.read(info.filename))
self.assertIsNone( self.assertIsNone(
fdroidserver.common.verify_apks(sourceapk, unsignedapk, self.tmpdir) fdroidserver.common.verify_apks(sourceapk, unsignedapk, self.testdir)
) )
twosigapk = os.path.join(self.testdir, 'urzip-twosig.apk') twosigapk = os.path.join(self.testdir, 'urzip-twosig.apk')
@ -641,7 +636,7 @@ class CommonTest(unittest.TestCase):
testapk.writestr(info.filename, otherapk.read(info.filename)) testapk.writestr(info.filename, otherapk.read(info.filename))
otherapk.close() otherapk.close()
self.assertFalse(fdroidserver.common.verify_apk_signature(twosigapk)) self.assertFalse(fdroidserver.common.verify_apk_signature(twosigapk))
self.assertIsNone(fdroidserver.common.verify_apks(sourceapk, twosigapk, self.tmpdir)) self.assertIsNone(fdroidserver.common.verify_apks(sourceapk, twosigapk, self.testdir))
def test_get_certificate_with_chain_sandisk(self): def test_get_certificate_with_chain_sandisk(self):
"""Test that APK signatures with a cert chain are parsed like apksigner. """Test that APK signatures with a cert chain are parsed like apksigner.
@ -821,14 +816,14 @@ class CommonTest(unittest.TestCase):
def test_find_apksigner_config_overrides(self): def test_find_apksigner_config_overrides(self):
"""apksigner should come from config before any auto-detection""" """apksigner should come from config before any auto-detection"""
os.chdir(self.tmpdir) os.chdir(self.testdir)
android_home = os.path.join(self.tmpdir, 'ANDROID_HOME') android_home = os.path.join(self.testdir, 'ANDROID_HOME')
do_not_use = os.path.join(android_home, 'build-tools', '30.0.3', 'apksigner') do_not_use = os.path.join(android_home, 'build-tools', '30.0.3', 'apksigner')
os.makedirs(os.path.dirname(do_not_use)) os.makedirs(os.path.dirname(do_not_use))
with open(do_not_use, 'w') as fp: with open(do_not_use, 'w') as fp:
fp.write('#!/bin/sh\ndate\n') fp.write('#!/bin/sh\ndate\n')
os.chmod(do_not_use, 0o0755) # nosec B103 os.chmod(do_not_use, 0o0755) # nosec B103
apksigner = os.path.join(self.tmpdir, 'apksigner') apksigner = os.path.join(self.testdir, 'apksigner')
config = {'apksigner': apksigner} config = {'apksigner': apksigner}
with mock.patch.dict(os.environ, clear=True): with mock.patch.dict(os.environ, clear=True):
os.environ['ANDROID_HOME'] = android_home os.environ['ANDROID_HOME'] = android_home
@ -838,13 +833,13 @@ class CommonTest(unittest.TestCase):
def test_find_apksigner_prefer_path(self): def test_find_apksigner_prefer_path(self):
"""apksigner should come from PATH before ANDROID_HOME""" """apksigner should come from PATH before ANDROID_HOME"""
os.chdir(self.tmpdir) os.chdir(self.testdir)
apksigner = os.path.join(self.tmpdir, 'apksigner') apksigner = os.path.join(self.testdir, 'apksigner')
with open(apksigner, 'w') as fp: with open(apksigner, 'w') as fp:
fp.write('#!/bin/sh\ndate\n') fp.write('#!/bin/sh\ndate\n')
os.chmod(apksigner, 0o0755) # nosec B103 os.chmod(apksigner, 0o0755) # nosec B103
android_home = os.path.join(self.tmpdir, 'ANDROID_HOME') android_home = os.path.join(self.testdir, 'ANDROID_HOME')
do_not_use = os.path.join(android_home, 'build-tools', '30.0.3', 'apksigner') do_not_use = os.path.join(android_home, 'build-tools', '30.0.3', 'apksigner')
os.makedirs(os.path.dirname(do_not_use)) os.makedirs(os.path.dirname(do_not_use))
with open(do_not_use, 'w') as fp: with open(do_not_use, 'w') as fp:
@ -860,8 +855,8 @@ class CommonTest(unittest.TestCase):
def test_find_apksigner_prefer_newest(self): def test_find_apksigner_prefer_newest(self):
"""apksigner should be the newest available in ANDROID_HOME""" """apksigner should be the newest available in ANDROID_HOME"""
os.chdir(self.tmpdir) os.chdir(self.testdir)
android_home = os.path.join(self.tmpdir, 'ANDROID_HOME') android_home = os.path.join(self.testdir, 'ANDROID_HOME')
apksigner = os.path.join(android_home, 'build-tools', '30.0.3', 'apksigner') apksigner = os.path.join(android_home, 'build-tools', '30.0.3', 'apksigner')
os.makedirs(os.path.dirname(apksigner)) os.makedirs(os.path.dirname(apksigner))
@ -883,7 +878,7 @@ class CommonTest(unittest.TestCase):
def test_find_apksigner_system_package_android_home(self): def test_find_apksigner_system_package_android_home(self):
"""Test that apksigner v30 or newer is found""" """Test that apksigner v30 or newer is found"""
os.chdir(self.tmpdir) os.chdir(self.testdir)
android_home = os.getenv('ANDROID_HOME') android_home = os.getenv('ANDROID_HOME')
if not android_home or not os.path.isdir(android_home): if not android_home or not os.path.isdir(android_home):
self.skipTest('SKIPPING since ANDROID_HOME (%s) is not a dir!' % android_home) self.skipTest('SKIPPING since ANDROID_HOME (%s) is not a dir!' % android_home)
@ -1045,7 +1040,7 @@ class CommonTest(unittest.TestCase):
fdroidserver.common.config = config fdroidserver.common.config = config
fdroidserver.signindex.config = config fdroidserver.signindex.config = config
os.chdir(self.tmpdir) os.chdir(self.testdir)
os.mkdir('unsigned') os.mkdir('unsigned')
os.mkdir('repo') os.mkdir('repo')
@ -1125,8 +1120,8 @@ class CommonTest(unittest.TestCase):
"""get_apk_id should never return None on error, only raise exceptions""" """get_apk_id should never return None on error, only raise exceptions"""
with self.assertRaises(KeyError): with self.assertRaises(KeyError):
fdroidserver.common.get_apk_id('Norway_bouvet_europe_2.obf.zip') fdroidserver.common.get_apk_id('Norway_bouvet_europe_2.obf.zip')
shutil.copy('Norway_bouvet_europe_2.obf.zip', self.tmpdir) shutil.copy('Norway_bouvet_europe_2.obf.zip', self.testdir)
os.chdir(self.tmpdir) os.chdir(self.testdir)
with ZipFile('Norway_bouvet_europe_2.obf.zip', 'a') as zipfp: with ZipFile('Norway_bouvet_europe_2.obf.zip', 'a') as zipfp:
zipfp.writestr('AndroidManifest.xml', 'not a manifest') zipfp.writestr('AndroidManifest.xml', 'not a manifest')
with self.assertRaises(KeyError): with self.assertRaises(KeyError):
@ -1143,7 +1138,7 @@ class CommonTest(unittest.TestCase):
) )
def test_get_apk_id_bad_zip(self): def test_get_apk_id_bad_zip(self):
os.chdir(self.tmpdir) os.chdir(self.testdir)
badzip = 'badzip.apk' badzip = 'badzip.apk'
with open(badzip, 'w') as fp: with open(badzip, 'w') as fp:
fp.write('not a ZIP') fp.write('not a ZIP')
@ -1559,9 +1554,9 @@ class CommonTest(unittest.TestCase):
def test_remove_signing_keys(self): def test_remove_signing_keys(self):
shutil.copytree( shutil.copytree(
os.path.join(basedir, 'source-files'), os.path.join(basedir, 'source-files'),
os.path.join(self.tmpdir, 'source-files'), os.path.join(self.testdir, 'source-files'),
) )
os.chdir(self.tmpdir) os.chdir(self.testdir)
with_signingConfigs = [ with_signingConfigs = [
'source-files/com.seafile.seadroid2/app/build.gradle', 'source-files/com.seafile.seadroid2/app/build.gradle',
'source-files/eu.siacs.conversations/build.gradle', 'source-files/eu.siacs.conversations/build.gradle',
@ -1730,11 +1725,11 @@ class CommonTest(unittest.TestCase):
self.assertEqual(f.read(), mocklogcontent) self.assertEqual(f.read(), mocklogcontent)
def test_deploy_status_json(self): def test_deploy_status_json(self):
os.chdir(self.tmpdir) os.chdir(self.testdir)
fakesubcommand = 'fakesubcommand' fakesubcommand = 'fakesubcommand'
fake_timestamp = 1234567890 fake_timestamp = 1234567890
fakeserver = 'example.com:/var/www/fbot/' fakeserver = 'example.com:/var/www/fbot/'
expected_dir = os.path.join(self.tmpdir, fakeserver.replace(':', ''), 'repo', 'status') expected_dir = os.path.join(self.testdir, fakeserver.replace(':', ''), 'repo', 'status')
fdroidserver.common.options = mock.Mock() fdroidserver.common.options = mock.Mock()
fdroidserver.common.config = {} fdroidserver.common.config = {}
@ -1742,7 +1737,7 @@ class CommonTest(unittest.TestCase):
fdroidserver.common.config['identity_file'] = 'ssh/id_rsa' fdroidserver.common.config['identity_file'] = 'ssh/id_rsa'
def assert_subprocess_call(cmd): def assert_subprocess_call(cmd):
dest_path = os.path.join(self.tmpdir, cmd[-1].replace(':', '')) dest_path = os.path.join(self.testdir, cmd[-1].replace(':', ''))
if not os.path.exists(dest_path): if not os.path.exists(dest_path):
os.makedirs(dest_path) os.makedirs(dest_path)
return subprocess.run(cmd[:-1] + [dest_path]).returncode return subprocess.run(cmd[:-1] + [dest_path]).returncode
@ -1898,14 +1893,14 @@ class CommonTest(unittest.TestCase):
def test_with_no_config(self): def test_with_no_config(self):
"""It should set defaults if no config file is found""" """It should set defaults if no config file is found"""
os.chdir(self.tmpdir) os.chdir(self.testdir)
self.assertFalse(os.path.exists(fdroidserver.common.CONFIG_FILE)) self.assertFalse(os.path.exists(fdroidserver.common.CONFIG_FILE))
config = fdroidserver.common.read_config() config = fdroidserver.common.read_config()
self.assertIsNotNone(config.get('char_limits')) self.assertIsNotNone(config.get('char_limits'))
def test_with_zero_size_config(self): def test_with_zero_size_config(self):
"""It should set defaults if config file has nothing in it""" """It should set defaults if config file has nothing in it"""
os.chdir(self.tmpdir) os.chdir(self.testdir)
fdroidserver.common.write_config_file('') fdroidserver.common.write_config_file('')
self.assertTrue(os.path.exists(fdroidserver.common.CONFIG_FILE)) self.assertTrue(os.path.exists(fdroidserver.common.CONFIG_FILE))
config = fdroidserver.common.read_config() config = fdroidserver.common.read_config()
@ -1913,7 +1908,7 @@ class CommonTest(unittest.TestCase):
def test_with_config_yml(self): def test_with_config_yml(self):
"""Make sure it is possible to use config.yml alone.""" """Make sure it is possible to use config.yml alone."""
os.chdir(self.tmpdir) os.chdir(self.testdir)
fdroidserver.common.write_config_file('apksigner: yml') fdroidserver.common.write_config_file('apksigner: yml')
self.assertTrue(os.path.exists(fdroidserver.common.CONFIG_FILE)) self.assertTrue(os.path.exists(fdroidserver.common.CONFIG_FILE))
config = fdroidserver.common.read_config() config = fdroidserver.common.read_config()
@ -1921,7 +1916,7 @@ class CommonTest(unittest.TestCase):
def test_with_config_yml_utf8(self): def test_with_config_yml_utf8(self):
"""Make sure it is possible to use config.yml in UTF-8 encoding.""" """Make sure it is possible to use config.yml in UTF-8 encoding."""
os.chdir(self.tmpdir) os.chdir(self.testdir)
teststr = '/πÇÇ现代通用字-български-عربي1/ö/yml' teststr = '/πÇÇ现代通用字-български-عربي1/ö/yml'
fdroidserver.common.write_config_file('apksigner: ' + teststr) fdroidserver.common.write_config_file('apksigner: ' + teststr)
self.assertTrue(os.path.exists(fdroidserver.common.CONFIG_FILE)) self.assertTrue(os.path.exists(fdroidserver.common.CONFIG_FILE))
@ -1930,7 +1925,7 @@ class CommonTest(unittest.TestCase):
def test_with_config_yml_utf8_as_ascii(self): def test_with_config_yml_utf8_as_ascii(self):
"""Make sure it is possible to use config.yml Unicode encoded as ASCII.""" """Make sure it is possible to use config.yml Unicode encoded as ASCII."""
os.chdir(self.tmpdir) os.chdir(self.testdir)
teststr = '/πÇÇ现代通用字-български-عربي1/ö/yml' teststr = '/πÇÇ现代通用字-български-عربي1/ö/yml'
with open(fdroidserver.common.CONFIG_FILE, 'w', encoding='utf-8') as fp: with open(fdroidserver.common.CONFIG_FILE, 'w', encoding='utf-8') as fp:
config_dump({'apksigner': teststr}, fp) config_dump({'apksigner': teststr}, fp)
@ -1940,7 +1935,7 @@ class CommonTest(unittest.TestCase):
def test_with_config_yml_with_env_var(self): def test_with_config_yml_with_env_var(self):
"""Make sure it is possible to use config.yml alone.""" """Make sure it is possible to use config.yml alone."""
os.chdir(self.tmpdir) os.chdir(self.testdir)
with mock.patch.dict(os.environ): with mock.patch.dict(os.environ):
os.environ['SECRET'] = 'mysecretpassword' # nosec B105 os.environ['SECRET'] = 'mysecretpassword' # nosec B105
fdroidserver.common.write_config_file("""keypass: {'env': 'SECRET'}\n""") fdroidserver.common.write_config_file("""keypass: {'env': 'SECRET'}\n""")
@ -1949,30 +1944,20 @@ class CommonTest(unittest.TestCase):
self.assertEqual(os.getenv('SECRET', 'fail'), config.get('keypass')) self.assertEqual(os.getenv('SECRET', 'fail'), config.get('keypass'))
def test_with_config_yml_is_dict(self): def test_with_config_yml_is_dict(self):
os.chdir(self.tmpdir) os.chdir(self.testdir)
Path(fdroidserver.common.CONFIG_FILE).write_text('apksigner = /bin/apksigner') Path(fdroidserver.common.CONFIG_FILE).write_text('apksigner = /bin/apksigner')
with self.assertRaises(TypeError): with self.assertRaises(TypeError):
fdroidserver.common.read_config() fdroidserver.common.read_config()
def test_with_config_yml_is_not_mixed_type(self): def test_with_config_yml_is_not_mixed_type(self):
os.chdir(self.tmpdir) os.chdir(self.testdir)
Path(fdroidserver.common.CONFIG_FILE).write_text('k: v\napksigner = /bin/apk') Path(fdroidserver.common.CONFIG_FILE).write_text('k: v\napksigner = /bin/apk')
with self.assertRaises(ruamel.yaml.scanner.ScannerError): with self.assertRaises(ruamel.yaml.scanner.ScannerError):
fdroidserver.common.read_config() fdroidserver.common.read_config()
def test_config_perm_warning(self):
"""Exercise the code path that issues a warning about unsafe permissions."""
os.chdir(self.tmpdir)
fdroidserver.common.write_config_file('keystore: foo.jks')
self.assertTrue(os.path.exists(fdroidserver.common.CONFIG_FILE))
os.chmod(fdroidserver.common.CONFIG_FILE, 0o666) # nosec B103
fdroidserver.common.read_config()
os.remove(fdroidserver.common.CONFIG_FILE)
fdroidserver.common.config = None
def test_config_repo_url(self): def test_config_repo_url(self):
"""repo_url ends in /repo, archive_url ends in /archive.""" """repo_url ends in /repo, archive_url ends in /archive."""
os.chdir(self.tmpdir) os.chdir(self.testdir)
fdroidserver.common.write_config_file( fdroidserver.common.write_config_file(
textwrap.dedent( textwrap.dedent(
"""\ """\
@ -1991,34 +1976,34 @@ class CommonTest(unittest.TestCase):
def test_config_repo_url_extra_slash(self): def test_config_repo_url_extra_slash(self):
"""repo_url ends in /repo, archive_url ends in /archive.""" """repo_url ends in /repo, archive_url ends in /archive."""
os.chdir(self.tmpdir) os.chdir(self.testdir)
fdroidserver.common.write_config_file('repo_url: https://MyFirstFDroidRepo.org/fdroid/repo/') fdroidserver.common.write_config_file('repo_url: https://MyFirstFDroidRepo.org/fdroid/repo/')
with self.assertRaises(FDroidException): with self.assertRaises(FDroidException):
fdroidserver.common.read_config() fdroidserver.common.read_config()
def test_config_repo_url_not_repo(self): def test_config_repo_url_not_repo(self):
"""repo_url ends in /repo, archive_url ends in /archive.""" """repo_url ends in /repo, archive_url ends in /archive."""
os.chdir(self.tmpdir) os.chdir(self.testdir)
fdroidserver.common.write_config_file('repo_url: https://MyFirstFDroidRepo.org/fdroid/foo') fdroidserver.common.write_config_file('repo_url: https://MyFirstFDroidRepo.org/fdroid/foo')
with self.assertRaises(FDroidException): with self.assertRaises(FDroidException):
fdroidserver.common.read_config() fdroidserver.common.read_config()
def test_config_archive_url_extra_slash(self): def test_config_archive_url_extra_slash(self):
"""repo_url ends in /repo, archive_url ends in /archive.""" """repo_url ends in /repo, archive_url ends in /archive."""
os.chdir(self.tmpdir) os.chdir(self.testdir)
fdroidserver.common.write_config_file('archive_url: https://MyFirstFDroidRepo.org/fdroid/archive/') fdroidserver.common.write_config_file('archive_url: https://MyFirstFDroidRepo.org/fdroid/archive/')
with self.assertRaises(FDroidException): with self.assertRaises(FDroidException):
fdroidserver.common.read_config() fdroidserver.common.read_config()
def test_config_archive_url_not_repo(self): def test_config_archive_url_not_repo(self):
"""repo_url ends in /repo, archive_url ends in /archive.""" """repo_url ends in /repo, archive_url ends in /archive."""
os.chdir(self.tmpdir) os.chdir(self.testdir)
fdroidserver.common.write_config_file('archive_url: https://MyFirstFDroidRepo.org/fdroid/foo') fdroidserver.common.write_config_file('archive_url: https://MyFirstFDroidRepo.org/fdroid/foo')
with self.assertRaises(FDroidException): with self.assertRaises(FDroidException):
fdroidserver.common.read_config() fdroidserver.common.read_config()
def test_write_to_config_yml(self): def test_write_to_config_yml(self):
os.chdir(self.tmpdir) os.chdir(self.testdir)
fdroidserver.common.write_config_file('apksigner: yml') fdroidserver.common.write_config_file('apksigner: yml')
os.chmod(fdroidserver.common.CONFIG_FILE, 0o0600) os.chmod(fdroidserver.common.CONFIG_FILE, 0o0600)
self.assertTrue(os.path.exists(fdroidserver.common.CONFIG_FILE)) self.assertTrue(os.path.exists(fdroidserver.common.CONFIG_FILE))
@ -2031,7 +2016,7 @@ class CommonTest(unittest.TestCase):
self.assertEqual('mysecretpassword', config['keypass']) self.assertEqual('mysecretpassword', config['keypass'])
def test_config_dict_with_int_keys(self): def test_config_dict_with_int_keys(self):
os.chdir(self.tmpdir) os.chdir(self.testdir)
fdroidserver.common.write_config_file( fdroidserver.common.write_config_file(
textwrap.dedent( textwrap.dedent(
""" """
@ -2122,8 +2107,29 @@ class CommonTest(unittest.TestCase):
) )
fdroidserver.common.read_config() fdroidserver.common.read_config()
@mock.patch.dict(os.environ, {'PATH': os.getenv('PATH')}, clear=True)
def test_config_with_env_string(self):
"""Test whether env works in keys with string values."""
os.chdir(self.testdir)
testvalue = 'this is just a test'
Path('config.yml').write_text('keypass: {env: foo}')
os.environ['foo'] = testvalue
self.assertEqual(testvalue, fdroidserver.common.get_config()['keypass'])
@mock.patch.dict(os.environ, {'PATH': os.getenv('PATH')}, clear=True)
def test_config_with_env_path(self):
"""Test whether env works in keys with path values."""
os.chdir(self.testdir)
path = 'user@server:/path/to/bar/'
os.environ['foo'] = path
Path('config.yml').write_text('serverwebroot: {env: foo}')
self.assertEqual(
[{'url': path}],
fdroidserver.common.get_config()['serverwebroot'],
)
def test_setup_status_output(self): def test_setup_status_output(self):
os.chdir(self.tmpdir) os.chdir(self.testdir)
start_timestamp = time.gmtime() start_timestamp = time.gmtime()
subcommand = 'test' subcommand = 'test'
@ -2139,9 +2145,9 @@ class CommonTest(unittest.TestCase):
self.assertEqual(subcommand, data['subcommand']) self.assertEqual(subcommand, data['subcommand'])
def test_setup_status_output_in_git_repo(self): def test_setup_status_output_in_git_repo(self):
os.chdir(self.tmpdir) os.chdir(self.testdir)
logging.getLogger('git.cmd').setLevel(logging.INFO) logging.getLogger('git.cmd').setLevel(logging.INFO)
git_repo = git.Repo.init(self.tmpdir) git_repo = git.Repo.init(self.testdir)
file_in_git = 'README.md' file_in_git = 'README.md'
with open(file_in_git, 'w') as fp: with open(file_in_git, 'w') as fp:
fp.write('this is just a test') fp.write('this is just a test')
@ -2395,40 +2401,40 @@ class CommonTest(unittest.TestCase):
@unittest.skip("This test downloads and unzips a 1GB file.") @unittest.skip("This test downloads and unzips a 1GB file.")
def test_install_ndk(self): def test_install_ndk(self):
"""NDK r10e is a special case since its missing source.properties""" """NDK r10e is a special case since its missing source.properties"""
config = {'sdk_path': self.tmpdir} config = {'sdk_path': self.testdir}
fdroidserver.common.config = config fdroidserver.common.config = config
fdroidserver.common._install_ndk('r10e') fdroidserver.common._install_ndk('r10e')
r10e = os.path.join(self.tmpdir, 'ndk', 'r10e') r10e = os.path.join(self.testdir, 'ndk', 'r10e')
self.assertEqual('r10e', fdroidserver.common.get_ndk_version(r10e)) self.assertEqual('r10e', fdroidserver.common.get_ndk_version(r10e))
fdroidserver.common.fill_config_defaults(config) fdroidserver.common.fill_config_defaults(config)
self.assertEqual({'r10e': r10e}, config['ndk_paths']) self.assertEqual({'r10e': r10e}, config['ndk_paths'])
def test_fill_config_defaults(self): def test_fill_config_defaults(self):
"""Test the auto-detection of NDKs installed in standard paths""" """Test the auto-detection of NDKs installed in standard paths"""
ndk_bundle = os.path.join(self.tmpdir, 'ndk-bundle') ndk_bundle = os.path.join(self.testdir, 'ndk-bundle')
os.makedirs(ndk_bundle) os.makedirs(ndk_bundle)
with open(os.path.join(ndk_bundle, 'source.properties'), 'w') as fp: with open(os.path.join(ndk_bundle, 'source.properties'), 'w') as fp:
fp.write('Pkg.Desc = Android NDK\nPkg.Revision = 17.2.4988734\n') fp.write('Pkg.Desc = Android NDK\nPkg.Revision = 17.2.4988734\n')
config = {'sdk_path': self.tmpdir} config = {'sdk_path': self.testdir}
fdroidserver.common.fill_config_defaults(config) fdroidserver.common.fill_config_defaults(config)
self.assertEqual({'17.2.4988734': ndk_bundle}, config['ndk_paths']) self.assertEqual({'17.2.4988734': ndk_bundle}, config['ndk_paths'])
r21e = os.path.join(self.tmpdir, 'ndk', '21.4.7075529') r21e = os.path.join(self.testdir, 'ndk', '21.4.7075529')
os.makedirs(r21e) os.makedirs(r21e)
with open(os.path.join(r21e, 'source.properties'), 'w') as fp: with open(os.path.join(r21e, 'source.properties'), 'w') as fp:
fp.write('Pkg.Desc = Android NDK\nPkg.Revision = 21.4.7075529\n') fp.write('Pkg.Desc = Android NDK\nPkg.Revision = 21.4.7075529\n')
config = {'sdk_path': self.tmpdir} config = {'sdk_path': self.testdir}
fdroidserver.common.fill_config_defaults(config) fdroidserver.common.fill_config_defaults(config)
self.assertEqual( self.assertEqual(
{'17.2.4988734': ndk_bundle, '21.4.7075529': r21e}, {'17.2.4988734': ndk_bundle, '21.4.7075529': r21e},
config['ndk_paths'], config['ndk_paths'],
) )
r10e = os.path.join(self.tmpdir, 'ndk', 'r10e') r10e = os.path.join(self.testdir, 'ndk', 'r10e')
os.makedirs(r10e) os.makedirs(r10e)
with open(os.path.join(r10e, 'RELEASE.TXT'), 'w') as fp: with open(os.path.join(r10e, 'RELEASE.TXT'), 'w') as fp:
fp.write('r10e-rc4 (64-bit)\n') fp.write('r10e-rc4 (64-bit)\n')
config = {'sdk_path': self.tmpdir} config = {'sdk_path': self.testdir}
fdroidserver.common.fill_config_defaults(config) fdroidserver.common.fill_config_defaults(config)
self.assertEqual( self.assertEqual(
{'r10e': r10e, '17.2.4988734': ndk_bundle, '21.4.7075529': r21e}, {'r10e': r10e, '17.2.4988734': ndk_bundle, '21.4.7075529': r21e},
@ -2438,7 +2444,7 @@ class CommonTest(unittest.TestCase):
@unittest.skipIf(not os.path.isdir('/usr/lib/jvm/default-java'), 'uses Debian path') @unittest.skipIf(not os.path.isdir('/usr/lib/jvm/default-java'), 'uses Debian path')
def test_fill_config_defaults_java(self): def test_fill_config_defaults_java(self):
"""Test the auto-detection of Java installed in standard paths""" """Test the auto-detection of Java installed in standard paths"""
config = {'sdk_path': self.tmpdir} config = {'sdk_path': self.testdir}
fdroidserver.common.fill_config_defaults(config) fdroidserver.common.fill_config_defaults(config)
java_paths = [] java_paths = []
# use presence of javac to make sure its JDK not just JRE # use presence of javac to make sure its JDK not just JRE
@ -2625,7 +2631,7 @@ class CommonTest(unittest.TestCase):
self.assertFalse(is_repo_file(d), d + ' not repo file') self.assertFalse(is_repo_file(d), d + ' not repo file')
def test_get_apksigner_smartcardoptions(self): def test_get_apksigner_smartcardoptions(self):
os.chdir(self.tmpdir) os.chdir(self.testdir)
with open(fdroidserver.common.CONFIG_FILE, 'w', encoding='utf-8') as fp: with open(fdroidserver.common.CONFIG_FILE, 'w', encoding='utf-8') as fp:
d = { d = {
'smartcardoptions': '-storetype PKCS11' 'smartcardoptions': '-storetype PKCS11'
@ -2653,7 +2659,7 @@ class CommonTest(unittest.TestCase):
) )
def test_get_smartcardoptions_list(self): def test_get_smartcardoptions_list(self):
os.chdir(self.tmpdir) os.chdir(self.testdir)
fdroidserver.common.write_config_file( fdroidserver.common.write_config_file(
textwrap.dedent( textwrap.dedent(
""" """
@ -2687,7 +2693,7 @@ class CommonTest(unittest.TestCase):
) )
def test_get_smartcardoptions_spaces(self): def test_get_smartcardoptions_spaces(self):
os.chdir(self.tmpdir) os.chdir(self.testdir)
fdroidserver.common.write_config_file( fdroidserver.common.write_config_file(
textwrap.dedent( textwrap.dedent(
""" """
@ -2847,25 +2853,81 @@ class CommonTest(unittest.TestCase):
fdroidserver.common.read_config()['serverwebroot'], fdroidserver.common.read_config()['serverwebroot'],
) )
def test_parse_mirrors_config_str(self): @mock.patch.dict(os.environ, {'PATH': os.getenv('PATH')}, clear=True)
def test_config_serverwebroot_list_of_dicts_env(self):
os.chdir(self.testdir)
url = 'foo@example.com:/var/www/'
os.environ['serverwebroot'] = url
fdroidserver.common.write_config_file(
textwrap.dedent(
"""\
serverwebroot:
- url: {env: serverwebroot}
index_only: true
"""
)
)
self.assertEqual(
[{'url': url, 'index_only': True}],
fdroidserver.common.read_config()['serverwebroot'],
)
def test_expand_env_dict_fake_str(self):
testvalue = '"{env: foo}"'
self.assertEqual(testvalue, fdroidserver.common.expand_env_dict(testvalue))
@mock.patch.dict(os.environ, {'PATH': os.getenv('PATH')}, clear=True)
def test_expand_env_dict_good(self):
name = 'foo'
value = 'bar'
os.environ[name] = value
self.assertEqual(value, fdroidserver.common.expand_env_dict({'env': name}))
@mock.patch.dict(os.environ, {'PATH': os.getenv('PATH')}, clear=True)
def test_expand_env_dict_bad_dict(self):
with self.assertRaises(TypeError):
fdroidserver.common.expand_env_dict({'env': 'foo', 'foo': 'bar'})
def test_parse_list_of_dicts_str(self):
s = 'foo@example.com:/var/www' s = 'foo@example.com:/var/www'
mirrors = yaml.load("""'%s'""" % s) mirrors = yaml.load("""'%s'""" % s)
self.assertEqual( self.assertEqual(
[{'url': s}], fdroidserver.common.parse_mirrors_config(mirrors) [{'url': s}], fdroidserver.common.parse_list_of_dicts(mirrors)
) )
def test_parse_mirrors_config_list(self): def test_parse_list_of_dicts_list(self):
s = 'foo@example.com:/var/www' s = 'foo@example.com:/var/www'
mirrors = yaml.load("""- '%s'""" % s) mirrors = yaml.load("""- '%s'""" % s)
self.assertEqual( self.assertEqual(
[{'url': s}], fdroidserver.common.parse_mirrors_config(mirrors) [{'url': s}], fdroidserver.common.parse_list_of_dicts(mirrors)
) )
def test_parse_mirrors_config_dict(self): def test_parse_list_of_dicts_dict(self):
s = 'foo@example.com:/var/www' s = 'foo@example.com:/var/www'
mirrors = yaml.load("""- url: '%s'""" % s) mirrors = yaml.load("""- url: '%s'""" % s)
self.assertEqual( self.assertEqual(
[{'url': s}], fdroidserver.common.parse_mirrors_config(mirrors) [{'url': s}], fdroidserver.common.parse_list_of_dicts(mirrors)
)
@mock.patch.dict(os.environ, {'PATH': os.getenv('PATH'), 'foo': 'bar'}, clear=True)
def test_parse_list_of_dicts_env_str(self):
mirrors = yaml.load('{env: foo}')
self.assertEqual(
[{'url': 'bar'}], fdroidserver.common.parse_list_of_dicts(mirrors)
)
def test_parse_list_of_dicts_env_list(self):
s = 'foo@example.com:/var/www'
mirrors = yaml.load("""- '%s'""" % s)
self.assertEqual(
[{'url': s}], fdroidserver.common.parse_list_of_dicts(mirrors)
)
def test_parse_list_of_dicts_env_dict(self):
s = 'foo@example.com:/var/www'
mirrors = yaml.load("""- url: '%s'""" % s)
self.assertEqual(
[{'url': s}], fdroidserver.common.parse_list_of_dicts(mirrors)
) )
def test_KnownApks_recordapk(self): def test_KnownApks_recordapk(self):
@ -3230,7 +3292,7 @@ class SignerExtractionTest(unittest.TestCase):
) )
class IgnoreApksignerV33Test(CommonTest): class IgnoreApksignerV33Test(SetUpTearDownMixin, unittest.TestCase):
"""apksigner v33 should be entirely ignored """apksigner v33 should be entirely ignored
https://gitlab.com/fdroid/fdroidserver/-/issues/1253 https://gitlab.com/fdroid/fdroidserver/-/issues/1253
@ -3363,3 +3425,44 @@ class ConfigOptionsScopeTest(unittest.TestCase):
'config' not in vars() and 'config' not in globals(), 'config' not in vars() and 'config' not in globals(),
"The config should not be set in the global context, only module-level.", "The config should not be set in the global context, only module-level.",
) )
class UnsafePermissionsTest(SetUpTearDownMixin, unittest.TestCase):
def setUp(self):
config = dict()
fdroidserver.common.find_apksigner(config)
if not config.get('apksigner'):
self.skipTest('SKIPPING, apksigner not installed!')
super().setUp()
os.chdir(self.testdir)
fdroidserver.common.write_config_file('keypass: {env: keypass}')
os.chmod(fdroidserver.common.CONFIG_FILE, 0o666) # nosec B103
def test_config_perm_no_warning(self):
fdroidserver.common.write_config_file('keystore: foo.jks')
with self.assertNoLogs(level=logging.WARNING):
fdroidserver.common.read_config()
def test_config_perm_keypass_warning(self):
fdroidserver.common.write_config_file('keypass: supersecret')
with self.assertLogs(level=logging.WARNING) as lw:
fdroidserver.common.read_config()
self.assertTrue('unsafe' in lw.output[0])
@mock.patch.dict(os.environ, {'PATH': os.getenv('PATH')}, clear=True)
def test_config_perm_env_warning(self):
os.environ['keypass'] = 'supersecret'
fdroidserver.common.write_config_file('keypass: {env: keypass}')
with self.assertLogs(level=logging.WARNING) as lw:
fdroidserver.common.read_config()
self.assertTrue('unsafe' in lw.output[0])
self.assertEqual(1, len(lw.output))
@mock.patch.dict(os.environ, {'PATH': os.getenv('PATH')}, clear=True)
def test_config_perm_unset_env_no_warning(self):
fdroidserver.common.write_config_file('keypass: {env: keypass}')
with self.assertLogs(level=logging.WARNING) as lw:
fdroidserver.common.read_config()
self.assertTrue('unsafe' not in lw.output[0])
self.assertEqual(1, len(lw.output))

View file

@ -4,8 +4,10 @@ import logging
import os import os
import shutil import shutil
import tempfile import tempfile
import textwrap
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest import mock
from .shared_test_code import mkdtemp from .shared_test_code import mkdtemp
@ -17,8 +19,8 @@ from fdroidserver._yaml import config_dump
basedir = Path(__file__).parent basedir = Path(__file__).parent
class LintTest(unittest.TestCase): class SetUpTearDownMixin:
'''fdroidserver/lint.py''' """A base class with no test in it for shared setUp and tearDown."""
def setUp(self): def setUp(self):
os.chdir(basedir) os.chdir(basedir)
@ -31,6 +33,10 @@ class LintTest(unittest.TestCase):
def tearDown(self): def tearDown(self):
self._td.cleanup() self._td.cleanup()
class LintTest(SetUpTearDownMixin, unittest.TestCase):
'''fdroidserver/lint.py'''
def test_check_for_unsupported_metadata_files(self): def test_check_for_unsupported_metadata_files(self):
self.assertTrue(fdroidserver.lint.check_for_unsupported_metadata_files()) self.assertTrue(fdroidserver.lint.check_for_unsupported_metadata_files())
@ -534,6 +540,13 @@ class LintAntiFeaturesTest(unittest.TestCase):
class ConfigYmlTest(LintTest): class ConfigYmlTest(LintTest):
"""Test data formats used in config.yml.
lint.py uses print() and not logging so hacks are used to control
the output when running in the test runner.
"""
def setUp(self): def setUp(self):
super().setUp() super().setUp()
self.config_yml = Path(self.testdir) / fdroidserver.common.CONFIG_FILE self.config_yml = Path(self.testdir) / fdroidserver.common.CONFIG_FILE
@ -542,6 +555,7 @@ class ConfigYmlTest(LintTest):
self.config_yml.write_text('repo_maxage: 1\n') self.config_yml.write_text('repo_maxage: 1\n')
self.assertTrue(fdroidserver.lint.lint_config(self.config_yml)) self.assertTrue(fdroidserver.lint.lint_config(self.config_yml))
@mock.patch('builtins.print', mock.Mock()) # hide error message
def test_config_yml_int_bad(self): def test_config_yml_int_bad(self):
self.config_yml.write_text('repo_maxage: "1"\n') self.config_yml.write_text('repo_maxage: "1"\n')
self.assertFalse(fdroidserver.lint.lint_config(self.config_yml)) self.assertFalse(fdroidserver.lint.lint_config(self.config_yml))
@ -550,10 +564,32 @@ class ConfigYmlTest(LintTest):
self.config_yml.write_text('sdk_path: /opt/android-sdk\n') self.config_yml.write_text('sdk_path: /opt/android-sdk\n')
self.assertTrue(fdroidserver.lint.lint_config(self.config_yml)) self.assertTrue(fdroidserver.lint.lint_config(self.config_yml))
def test_config_yml_str_dict(self): def test_config_yml_str_list(self):
self.config_yml.write_text('serverwebroot: [server1, server2]\n')
self.assertTrue(fdroidserver.lint.lint_config(self.config_yml))
def test_config_yml_str_list_of_dicts(self):
self.config_yml.write_text(
textwrap.dedent(
"""\
serverwebroot:
- url: 'me@b.az:/srv/fdroid'
index_only: true
"""
)
)
self.assertTrue(fdroidserver.lint.lint_config(self.config_yml))
def test_config_yml_str_list_of_dicts_env(self):
"""serverwebroot can be str, list of str, or list of dicts."""
self.config_yml.write_text('serverwebroot: {env: ANDROID_HOME}\n')
self.assertTrue(fdroidserver.lint.lint_config(self.config_yml))
def test_config_yml_str_env(self):
self.config_yml.write_text('sdk_path: {env: ANDROID_HOME}\n') self.config_yml.write_text('sdk_path: {env: ANDROID_HOME}\n')
self.assertTrue(fdroidserver.lint.lint_config(self.config_yml)) self.assertTrue(fdroidserver.lint.lint_config(self.config_yml))
@mock.patch('builtins.print', mock.Mock()) # hide error message
def test_config_yml_str_bad(self): def test_config_yml_str_bad(self):
self.config_yml.write_text('sdk_path: 1.0\n') self.config_yml.write_text('sdk_path: 1.0\n')
self.assertFalse(fdroidserver.lint.lint_config(self.config_yml)) self.assertFalse(fdroidserver.lint.lint_config(self.config_yml))
@ -562,6 +598,7 @@ class ConfigYmlTest(LintTest):
self.config_yml.write_text("deploy_process_logs: true\n") self.config_yml.write_text("deploy_process_logs: true\n")
self.assertTrue(fdroidserver.lint.lint_config(self.config_yml)) self.assertTrue(fdroidserver.lint.lint_config(self.config_yml))
@mock.patch('builtins.print', mock.Mock()) # hide error message
def test_config_yml_bool_bad(self): def test_config_yml_bool_bad(self):
self.config_yml.write_text('deploy_process_logs: 2342fe23\n') self.config_yml.write_text('deploy_process_logs: 2342fe23\n')
self.assertFalse(fdroidserver.lint.lint_config(self.config_yml)) self.assertFalse(fdroidserver.lint.lint_config(self.config_yml))
@ -570,14 +607,17 @@ class ConfigYmlTest(LintTest):
self.config_yml.write_text("keyaliases: {com.example: '@com.foo'}\n") self.config_yml.write_text("keyaliases: {com.example: '@com.foo'}\n")
self.assertTrue(fdroidserver.lint.lint_config(self.config_yml)) self.assertTrue(fdroidserver.lint.lint_config(self.config_yml))
@mock.patch('builtins.print', mock.Mock()) # hide error message
def test_config_yml_dict_bad(self): def test_config_yml_dict_bad(self):
self.config_yml.write_text('keyaliases: 2342fe23\n') self.config_yml.write_text('keyaliases: 2342fe23\n')
self.assertFalse(fdroidserver.lint.lint_config(self.config_yml)) self.assertFalse(fdroidserver.lint.lint_config(self.config_yml))
@mock.patch('builtins.print', mock.Mock()) # hide error message
def test_config_yml_bad_key_name(self): def test_config_yml_bad_key_name(self):
self.config_yml.write_text('keyalias: 2342fe23\n') self.config_yml.write_text('keyalias: 2342fe23\n')
self.assertFalse(fdroidserver.lint.lint_config(self.config_yml)) self.assertFalse(fdroidserver.lint.lint_config(self.config_yml))
@mock.patch('builtins.print', mock.Mock()) # hide error message
def test_config_yml_bad_value_for_all_keys(self): def test_config_yml_bad_value_for_all_keys(self):
"""Check all config keys with a bad value.""" """Check all config keys with a bad value."""
for key in fdroidserver.lint.check_config_keys: for key in fdroidserver.lint.check_config_keys:
@ -590,3 +630,34 @@ class ConfigYmlTest(LintTest):
fdroidserver.lint.lint_config(self.config_yml), fdroidserver.lint.lint_config(self.config_yml),
f'{key} should fail on value of "{value}"', f'{key} should fail on value of "{value}"',
) )
def test_config_yml_keyaliases(self):
self.config_yml.write_text(
textwrap.dedent(
"""\
keyaliases:
com.example: myalias
com.foo: '@com.example'
"""
)
)
self.assertTrue(fdroidserver.lint.lint_config(self.config_yml))
@mock.patch('builtins.print', mock.Mock()) # hide error message
def test_config_yml_keyaliases_bad_str(self):
"""The keyaliases: value is a dict not a str."""
self.config_yml.write_text("keyaliases: '@com.example'\n")
self.assertFalse(fdroidserver.lint.lint_config(self.config_yml))
@mock.patch('builtins.print', mock.Mock()) # hide error message
def test_config_yml_keyaliases_bad_list(self):
"""The keyaliases: value is a dict not a list."""
self.config_yml.write_text(
textwrap.dedent(
"""\
keyaliases:
- com.example: myalias
"""
)
)
self.assertFalse(fdroidserver.lint.lint_config(self.config_yml))