Skip to content
Snippets Groups Projects
Commit 7f7c3b82 authored by Dan Cashman's avatar Dan Cashman
Browse files

Add 26.0 api compatibility check infrastructure.

Add support to the treble_sepolicy_tests suite that explicitly look at
the old and current policy versions, as well as the compatibility file,
to determine if any new types have been added without a compatibility
entry.  This first test catches the most common and likely changes that
could change the type label of an object for which vendor policy may have
needed access.  It also should prove the basis for additional compatibility
checks between old and new policies.

Bug: 36899958
Test: Policy builds and tests pass.
Change-Id: I609c913e6354eb10a04cc1a029ddd9fa0e592a4c
parent aaa94fa9
No related branches found
No related tags found
No related merge requests found
......@@ -1155,30 +1155,121 @@ LOCAL_MODULE_TAGS := tests
include $(BUILD_SYSTEM)/base_rules.mk
# 26.0_compat - the current plat_sepolicy.cil built
# with the compatibility file targeting the 26.0
# SELinux release.
# 26.0_plat - the platform policy shipped as part of the 26.0 release. This is
# built to enable us to determine the diff between the current policy and the
# 26.0 policy, which will be used in tests to make sure that compatibility has
# been maintained by our mapping files.
26.0_PLAT_PUBLIC_POLICY := $(LOCAL_PATH)/prebuilts/api/26.0/public
26.0_PLAT_PRIVATE_POLICY := $(LOCAL_PATH)/prebuilts/api/26.0/private
26.0_plat_policy.conf := $(intermediates)/26.0_plat_policy.conf
$(26.0_plat_policy.conf): PRIVATE_MLS_SENS := $(MLS_SENS)
$(26.0_plat_policy.conf): PRIVATE_MLS_CATS := $(MLS_CATS)
$(26.0_plat_policy.conf): PRIVATE_TGT_ARCH := $(my_target_arch)
$(26.0_plat_policy.conf): PRIVATE_TGT_WITH_ASAN := $(with_asan)
$(26.0_plat_policy.conf): PRIVATE_ADDITIONAL_M4DEFS := $(LOCAL_ADDITIONAL_M4DEFS)
$(26.0_plat_policy.conf): PRIVATE_FULL_TREBLE := true
$(26.0_plat_policy.conf): $(call build_policy, $(sepolicy_build_files), \
$(26.0_PLAT_PUBLIC_POLICY) $(26.0_PLAT_PRIVATE_POLICY))
$(transform-policy-to-conf)
$(hide) sed '/dontaudit/d' $@ > $@.dontaudit
built_26.0_plat_sepolicy := $(intermediates)/built_26.0_plat_sepolicy
$(built_26.0_plat_sepolicy): PRIVATE_ADDITIONAL_CIL_FILES := \
$(call build_policy, technical_debt.cil , $(26.0_PLAT_PRIVATE_POLICY))
$(built_26.0_plat_sepolicy): $(26.0_plat_policy.conf) $(HOST_OUT_EXECUTABLES)/checkpolicy \
$(HOST_OUT_EXECUTABLES)/secilc \
$(call build_policy, technical_debt.cil, $(26.0_PLAT_PRIVATE_POLICY))
@mkdir -p $(dir $@)
$(hide) $(CHECKPOLICY_ASAN_OPTIONS) $(HOST_OUT_EXECUTABLES)/checkpolicy -M -C -c \
$(POLICYVERS) -o $@ $<
$(hide) cat $(PRIVATE_ADDITIONAL_CIL_FILES) >> $@
$(hide) $(HOST_OUT_EXECUTABLES)/secilc -M true -G -c $(POLICYVERS) $@ -o $@ -f /dev/null
26.0_plat_policy.conf :=
# 26.0_compat - the current plat_sepolicy.cil built with the compatibility file
# targeting the 26.0 SELinux release. This ensures that our policy will build
# when used on a device that has non-platform policy targetting the 26.0 release.
26.0_compat := $(intermediates)/26.0_compat
26.0_mapping_cil := $(LOCAL_PATH)/prebuilts/api/26.0/26.0.cil
26.0_mapping.cil := $(LOCAL_PATH)/prebuilts/api/26.0/26.0.cil
26.0_mapping.ignore.cil := $(LOCAL_PATH)/prebuilts/api/26.0/26.0.ignore.cil
26.0_nonplat := $(LOCAL_PATH)/prebuilts/api/26.0/nonplat_sepolicy.cil
$(26.0_compat): PRIVATE_CIL_FILES := \
$(built_plat_cil) $(26.0_mapping_cil) $(26.0_nonplat)
$(built_plat_cil) $(26.0_mapping.cil) $(26.0_nonplat)
$(26.0_compat): $(HOST_OUT_EXECUTABLES)/secilc \
$(built_plat_cil) $(26.0_mapping_cil) $(26.0_nonplat)
$(built_plat_cil) $(26.0_mapping.cil) $(26.0_nonplat)
$(hide) $(HOST_OUT_EXECUTABLES)/secilc -M true -G -N -c $(POLICYVERS) \
$(PRIVATE_CIL_FILES) -o $@ -f /dev/null
# 26.0_mapping.combined.cil - a combination of the mapping file used when
# combining the current platform policy with nonplatform policy based on the
# 26.0 policy release and also a special ignored file that exists purely for
# these tests.
26.0_mapping.combined.cil := $(intermediates)/26.0_mapping.combined.cil
$(26.0_mapping.combined.cil): $(26.0_mapping.cil) $(26.0_mapping.ignore.cil)
mkdir -p $(dir $@)
cat $^ > $@
# plat_sepolicy - the current platform policy only, built into a policy binary.
# TODO - this currently excludes partner extensions, but support should be added
# to enable partners to add their own compatibility mapping
BASE_PLAT_PUBLIC_POLICY := $(filter-out $(BOARD_PLAT_PUBLIC_SEPOLICY_DIR), $(PLAT_PUBLIC_POLICY))
BASE_PLAT_PRIVATE_POLICY := $(filter-out $(BOARD_PLAT_PRIVATE_SEPOLICY_DIR), $(PLAT_PRIVATE_POLICY))
base_plat_policy.conf := $(intermediates)/base_plat_policy.conf
$(base_plat_policy.conf): PRIVATE_MLS_SENS := $(MLS_SENS)
$(base_plat_policy.conf): PRIVATE_MLS_CATS := $(MLS_CATS)
$(base_plat_policy.conf): PRIVATE_TGT_ARCH := $(my_target_arch)
$(base_plat_policy.conf): PRIVATE_TGT_WITH_ASAN := $(with_asan)
$(base_plat_policy.conf): PRIVATE_ADDITIONAL_M4DEFS := $(LOCAL_ADDITIONAL_M4DEFS)
$(base_plat_policy.conf): PRIVATE_FULL_TREBLE := true
$(base_plat_policy.conf): $(call build_policy, $(sepolicy_build_files), \
$(BASE_PLAT_PUBLIC_POLICY) $(BASE_PLAT_PRIVATE_POLICY))
$(transform-policy-to-conf)
$(hide) sed '/dontaudit/d' $@ > $@.dontaudit
built_plat_sepolicy := $(intermediates)/built_plat_sepolicy
$(built_plat_sepolicy): PRIVATE_ADDITIONAL_CIL_FILES := \
$(call build_policy, $(sepolicy_build_cil_workaround_files), $(BASE_PLAT_PRIVATE_POLICY))
$(built_plat_sepolicy): $(base_plat_policy.conf) $(HOST_OUT_EXECUTABLES)/checkpolicy \
$(HOST_OUT_EXECUTABLES)/secilc \
$(call build_policy, $(sepolicy_build_cil_workaround_files), $(BASE_PLAT_PRIVATE_POLICY))
@mkdir -p $(dir $@)
$(hide) $(CHECKPOLICY_ASAN_OPTIONS) $(HOST_OUT_EXECUTABLES)/checkpolicy -M -C -c \
$(POLICYVERS) -o $@ $<
$(hide) cat $(PRIVATE_ADDITIONAL_CIL_FILES) >> $@
$(hide) $(HOST_OUT_EXECUTABLES)/secilc -M true -G -c $(POLICYVERS) $@ -o $@ -f /dev/null
treble_sepolicy_tests := $(intermediates)/treble_sepolicy_tests
$(treble_sepolicy_tests): PRIVATE_PLAT_FC := $(built_plat_fc)
$(treble_sepolicy_tests): PRIVATE_NONPLAT_FC := $(built_nonplat_fc)
$(treble_sepolicy_tests): PRIVATE_SEPOLICY := $(built_sepolicy)
$(treble_sepolicy_tests): PRIVATE_SEPOLICY_OLD := $(built_26.0_plat_sepolicy)
$(treble_sepolicy_tests): PRIVATE_COMBINED_MAPPING := $(26.0_mapping.combined.cil)
$(treble_sepolicy_tests): PRIVATE_PLAT_SEPOLICY := $(built_plat_sepolicy)
$(treble_sepolicy_tests): $(HOST_OUT_EXECUTABLES)/treble_sepolicy_tests.py \
$(built_plat_fc) $(built_nonplat_fc) $(built_sepolicy) $(26.0_compat)
$(built_plat_fc) $(built_nonplat_fc) $(built_sepolicy) $(built_plat_sepolicy) \
$(built_26.0_plat_sepolicy) $(26.0_compat) $(26.0_mapping.combined.cil)
@mkdir -p $(dir $@)
$(hide) python $(HOST_OUT_EXECUTABLES)/treble_sepolicy_tests.py -l $(HOST_OUT)/lib64 -f $(PRIVATE_PLAT_FC) -f $(PRIVATE_NONPLAT_FC) -p $(PRIVATE_SEPOLICY)
$(hide) python $(HOST_OUT_EXECUTABLES)/treble_sepolicy_tests.py -l \
$(HOST_OUT)/lib64 -f $(PRIVATE_PLAT_FC) -f $(PRIVATE_NONPLAT_FC) \
-b $(PRIVATE_PLAT_SEPOLICY) -m $(PRIVATE_COMBINED_MAPPING) \
-o $(PRIVATE_SEPOLICY_OLD) -p $(PRIVATE_SEPOLICY)
$(hide) touch $@
26.0_PLAT_PUBLIC_POLICY :=
26.0_PLAT_PRIVATE_POLICY :=
26.0_compat :=
26.0_mapping.cil :=
26.0_mapping.combined.cil :=
26.0_mapping.ignore.cil :=
26.0_nonplat :=
BASE_PLAT_PUBLIC_POLICY :=
BASE_PLAT_PRIVATE_POLICY :=
base_plat_policy.conf :=
built_26.0_plat_sepolicy :=
plat_sepolicy :=
endif # ($(PRODUCT_FULL_TREBLE),true)
#################################
......
......@@ -6,6 +6,12 @@ cc_library_host_shared {
export_include_dirs: ["include"],
}
cc_prebuilt_binary {
name: "mini_parser.py",
srcs: ["mini_parser.py"],
host_supported: true,
}
cc_prebuilt_binary {
name: "policy.py",
srcs: ["policy.py"],
......@@ -17,7 +23,7 @@ cc_prebuilt_binary {
name: "treble_sepolicy_tests.py",
srcs: ["treble_sepolicy_tests.py"],
host_supported: true,
required: ["policy.py"],
required: ["mini_parser.py", "policy.py"],
}
cc_prebuilt_binary {
......
from os.path import basename
import re
import sys
# A very limited parser whose job is to process the compatibility mapping
# files and retrieve type and attribute information until proper support is
# built into libsepol
# get the text in the next matching parens
class MiniCilParser:
types = set() # types declared in mapping
pubtypes = set()
typeattributes = set() # attributes declared in mapping
typeattributesets = {} # sets defined in mapping
rTypeattributesets = {} # reverse mapping of above sets
apiLevel = None
def _getNextStmt(self, infile):
parens = 0
s = ""
c = infile.read(1)
# get to first statement
while c and c != "(":
c = infile.read(1)
parens += 1
c = infile.read(1)
while c and parens != 0:
s += c
c = infile.read(1)
if c == ';':
# comment, get rid of rest of the line
while c != '\n':
c = infile.read(1)
elif c == '(':
parens += 1
elif c == ')':
parens -= 1
return s
def _parseType(self, stmt):
m = re.match(r"type\s+(.+)", stmt)
self.types.update(set(m.group(1)))
return
def _parseTypeattribute(self, stmt):
m = re.match(r"typeattribute\s+(.+)", stmt)
self.typeattributes.update(set(m.group(1)))
return
def _parseTypeattributeset(self, stmt):
m = re.match(r"typeattributeset\s+(.+?)\s+\((.+?)\)", stmt, flags = re.M |re.S)
ta = m.group(1)
# this isn't proper expression parsing, but will do for our
# current use
tas = m.group(2).split()
if self.typeattributesets.get(ta) is None:
self.typeattributesets[ta] = set()
self.typeattributesets[ta].update(set(tas))
for t in tas:
if self.rTypeattributesets.get(t) is None:
self.rTypeattributesets[t] = set()
self.rTypeattributesets[t].update(set(ta))
# check to see if this typeattributeset is a versioned public type
pub = re.match(r"(\w+)_\d+_\d+", ta)
if pub is not None:
self.pubtypes.update(set(pub.group(1)))
return
def _parseStmt(self, stmt):
if re.match(r"type\s+.+", stmt):
self._parseType(stmt)
elif re.match(r"typeattribute\s+.+", stmt):
self._parseTypeattribute(stmt)
elif re.match(r"typeattributeset\s+.+", stmt):
self._parseTypeattributeset(stmt)
else:
m = re.match(r"(\w+)\s+.+", stmt)
ret = "Warning: Unknown statement type (" + m.group(1) + ") in "
ret += "mapping file, perhaps consider adding support for it in "
ret += "system/sepolicy/tests/mini_parser.py!\n"
print ret
return
def __init__(self, policyFile):
with open(policyFile, 'r') as infile:
s = self._getNextStmt(infile)
while s:
self._parseStmt(s)
s = self._getNextStmt(infile)
fn = basename(policyFile)
m = re.match(r"(\d+\.\d+).+\.cil", fn)
self.apiLevel = m.group(1)
if __name__ == '__main__':
f = sys.argv[1]
p = MiniCilParser(f)
......@@ -123,6 +123,26 @@ class Policy:
continue
yield Rule
def GetAllTypes(self):
TypeIterP = self.__libsepolwrap.init_type_iter(self.__policydbP, None, False)
if (TypeIterP == None):
sys.exit("Failed to initialize type iterator")
buf = create_string_buffer(self.__BUFSIZE)
AllTypes = set()
while True:
ret = self.__libsepolwrap.get_type(buf, self.__BUFSIZE,
self.__policydbP, TypeIterP)
if ret == 0:
AllTypes.add(buf.value)
continue
if ret == 1:
break;
# We should never get here.
sys.exit("Failed to import policy")
self.__libsepolwrap.destroy_type_iter(TypeIterP)
return AllTypes
def __GetTypesByFilePathPrefix(self, MatchPrefixes, DoNotMatchPrefixes):
Types = set()
for Type in self.__FcDict:
......@@ -203,6 +223,8 @@ class Policy:
# load file_contexts
def __InitFC(self, FcPaths):
if FcPaths is None:
return
fc = []
for path in FcPaths:
if not os.path.exists(path):
......
......@@ -17,8 +17,11 @@
#include <android-base/strings.h>
#include <sepol_wrap.h>
#define TYPE_ITER_LOOKUP 0
#define TYPE_ITER_ALLTYPES 1
#define TYPE_ITER_ALLATTRS 2
struct type_iter {
unsigned int alltypes;
type_datum *d;
ebitmap_node *n;
unsigned int length;
......@@ -36,23 +39,33 @@ void *init_type_iter(void *policydbp, const char *type, bool is_attr)
return NULL;
}
out->d = static_cast<type_datum *>(hashtab_search(db->p_types.table, type));
if (is_attr && out->d->flavor != TYPE_ATTRIB) {
std::cerr << "\"" << type << "\" MUST be an attribute in the policy" << std::endl;
free(out);
return NULL;
} else if (!is_attr && out->d->flavor !=TYPE_TYPE) {
std::cerr << "\"" << type << "\" MUST be a type in the policy" << std::endl;
free(out);
return NULL;
}
if (is_attr) {
out->bit = ebitmap_start(&db->attr_type_map[out->d->s.value - 1], &out->n);
out->length = ebitmap_length(&db->attr_type_map[out->d->s.value - 1]);
if (type == NULL) {
out->length = db->p_types.nprim;
out->bit = 0;
if (is_attr)
out->alltypes = TYPE_ITER_ALLATTRS;
else
out->alltypes = TYPE_ITER_ALLTYPES;
} else {
out->bit = ebitmap_start(&db->type_attr_map[out->d->s.value - 1], &out->n);
out->length = ebitmap_length(&db->type_attr_map[out->d->s.value - 1]);
out->alltypes = TYPE_ITER_LOOKUP;
out->d = static_cast<type_datum *>(hashtab_search(db->p_types.table, type));
if (is_attr && out->d->flavor != TYPE_ATTRIB) {
std::cerr << "\"" << type << "\" MUST be an attribute in the policy" << std::endl;
free(out);
return NULL;
} else if (!is_attr && out->d->flavor !=TYPE_TYPE) {
std::cerr << "\"" << type << "\" MUST be a type in the policy" << std::endl;
free(out);
return NULL;
}
if (is_attr) {
out->bit = ebitmap_start(&db->attr_type_map[out->d->s.value - 1], &out->n);
out->length = ebitmap_length(&db->attr_type_map[out->d->s.value - 1]);
} else {
out->bit = ebitmap_start(&db->type_attr_map[out->d->s.value - 1], &out->n);
out->length = ebitmap_length(&db->type_attr_map[out->d->s.value - 1]);
}
}
return static_cast<void *>(out);
......@@ -65,7 +78,7 @@ void destroy_type_iter(void *type_iterp)
}
/*
* print allow rule into *out buffer.
* print type into *out buffer.
*
* Returns -1 on error.
* Returns 0 on successfully reading an avtab entry.
......@@ -77,20 +90,28 @@ int get_type(char *out, size_t max_size, void *policydbp, void *type_iterp)
policydb_t *db = static_cast<policydb_t *>(policydbp);
struct type_iter *i = static_cast<struct type_iter *>(type_iterp);
for (; i->bit < i->length; i->bit = ebitmap_next(&i->n, i->bit)) {
if (!ebitmap_node_get_bit(i->n, i->bit)) {
continue;
}
len = snprintf(out, max_size, "%s", db->p_type_val_to_name[i->bit]);
if (len >= max_size) {
std::cerr << "type name exceeds buffer size." << std::endl;
return -1;
if (!i->alltypes) {
for (; i->bit < i->length; i->bit = ebitmap_next(&i->n, i->bit)) {
if (!ebitmap_node_get_bit(i->n, i->bit)) {
continue;
}
break;
}
i->bit = ebitmap_next(&i->n, i->bit);
return 0;
}
return 1;
if (i->bit >= i->length)
return 1;
while ((i->alltypes == TYPE_ITER_ALLATTRS
&& db->type_val_to_struct[i->bit]->flavor != TYPE_ATTRIB)
|| (i->alltypes == TYPE_ITER_ALLTYPES
&& db->type_val_to_struct[i->bit]->flavor != TYPE_TYPE))
i->bit++;
len = snprintf(out, max_size, "%s", db->p_type_val_to_name[i->bit]);
if (len >= max_size) {
std::cerr << "type name exceeds buffer size." << std::endl;
return -1;
}
i->alltypes ? i->bit++ : i->bit = ebitmap_next(&i->n, i->bit);
return 0;
}
void *load_policy(const char *policy_path)
......
from optparse import OptionParser
from optparse import Option, OptionValueError
import os
import mini_parser
import policy
from policy import MatchPathPrefix
import re
......@@ -70,6 +71,11 @@ coredomains = set()
appdomains = set()
vendordomains = set()
# compat vars
alltypes = set()
oldalltypes = set()
compatMapping = None
def GetAllDomains(pol):
global alldomains
for result in pol.QueryTypeAttribute("domain", True):
......@@ -87,7 +93,6 @@ def GetAppDomains():
alldomains[d].appdomain = True
appdomains.add(d)
def GetCoreDomains():
global alldomains
global coredomains
......@@ -147,6 +152,12 @@ def GetAttributes(pol):
for result in pol.QueryTypeAttribute(domain, False):
alldomains[domain].attributes.add(result)
def GetAllTypes(pol, oldpol):
global alltypes
global oldalltypes
alltypes = pol.GetAllTypes()
oldalltypes = oldpol.GetAllTypes()
def setup(pol):
GetAllDomains(pol)
GetAttributes(pol)
......@@ -154,6 +165,13 @@ def setup(pol):
GetAppDomains()
GetCoreDomains()
# setup for the policy compatibility tests
def compatSetup(pol, oldpol, mapping):
global compatMapping
GetAllTypes(pol, oldpol)
compatMapping = mapping
#############################################################
# Tests
#############################################################
......@@ -189,6 +207,30 @@ def TestCoredomainViolations():
return ret
###
# Make sure that any new type introduced in the new policy that was not present
# in the old policy has been recorded in the mapping file.
def TestNoUnmappedNewTypes():
global alltypes
global oldalltypes
newt = alltypes - oldalltypes
ret = ""
violators = []
for n in newt:
if compatMapping.rTypeattributesets.get(n) is None:
violators.append(n)
if len(violators) > 0:
ret += "SELinux: The following types were found added to the policy "
ret += "without an entry into the compatibility mapping file(s) found "
ret += "in prebuilts/api/" + compatMapping.apiLevel + "/\n"
ret += " ".join(str(x) for x in sorted(violators)) + "\n"
return ret
def TestTrebleCompatMapping():
ret = TestNoUnmappedNewTypes()
return ret
###
# extend OptionParser to allow the same option flag to be used multiple times.
# This is used to allow multiple file_contexts files and tests to be
# specified.
......@@ -205,17 +247,23 @@ class MultipleOption(Option):
else:
Option.take_action(self, action, dest, opt, value, values, parser)
Tests = ["CoredomainViolations"]
Tests = {"CoredomainViolations": TestCoredomainViolations,
"TrebleCompatMapping": TestTrebleCompatMapping }
if __name__ == '__main__':
usage = "treble_sepolicy_tests.py -f nonplat_file_contexts -f "
usage +="plat_file_contexts -p policy [--test test] [--help]"
usage +="plat_file_contexts -p curr_policy -b base_policy -o old_policy "
usage +="-m mapping file [--test test] [--help]"
parser = OptionParser(option_class=MultipleOption, usage=usage)
parser.add_option("-b", "--basepolicy", dest="basepolicy", metavar="FILE")
parser.add_option("-f", "--file_contexts", dest="file_contexts",
metavar="FILE", action="extend", type="string")
parser.add_option("-p", "--policy", dest="policy", metavar="FILE")
parser.add_option("-l", "--library-path", dest="libpath", metavar="FILE")
parser.add_option("-m", "--mapping", dest="mapping", metavar="FILE")
parser.add_option("-o", "--oldpolicy", dest="oldpolicy", metavar="FILE")
parser.add_option("-p", "--policy", dest="policy", metavar="FILE")
parser.add_option("-t", "--test", dest="tests", action="extend",
help="Test options include "+str(Tests))
(options, args) = parser.parse_args()
......@@ -225,9 +273,14 @@ if __name__ == '__main__':
if not os.path.exists(options.libpath):
sys.exit("Error: library-path " + options.libpath + " does not exist\n"
+ parser.usage)
if not options.basepolicy:
sys.exit("Must specify the current platform-only policy file\n" + parser.usage)
if not options.mapping:
sys.exit("Must specify a compatibility mapping file\n" + parser.usage)
if not options.oldpolicy:
sys.exit("Must specify the previous monolithic policy file\n" + parser.usage)
if not options.policy:
sys.exit("Must specify monolithic policy file\n" + parser.usage)
sys.exit("Must specify current monolithic policy file\n" + parser.usage)
if not os.path.exists(options.policy):
sys.exit("Error: policy file " + options.policy + " does not exist\n"
+ parser.usage)
......@@ -241,17 +294,30 @@ if __name__ == '__main__':
pol = policy.Policy(options.policy, options.file_contexts, options.libpath)
setup(pol)
basepol = policy.Policy(options.basepolicy, None, options.libpath)
oldpol = policy.Policy(options.oldpolicy, None, options.libpath)
mapping = mini_parser.MiniCilParser(options.mapping)
compatSetup(basepol, oldpol, mapping)
if DEBUG:
PrintScontexts()
results = ""
# If an individual test is not specified, run all tests.
if ( options.tests is None
or ("CoredomainViolations" in options.tests and len(options.tests) == 1)):
results += TestCoredomainViolations()
if options.tests is None:
for t in Tests.values():
results += t()
else:
sys.exit("Error: unknown test(s): " + str(options.tests))
for tn in options.tests:
t = Tests.get(tn)
if t:
results += t()
else:
err = "Error: unknown test: " + tn + "\n"
err += "Available tests:\n"
for tn in Tests.keys():
err += tn + "\n"
sys.exit(err)
if len(results) > 0:
sys.exit(results)
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment