diff --git a/owrx/dsp.py b/owrx/dsp.py index d6bd241..0d36cc2 100644 --- a/owrx/dsp.py +++ b/owrx/dsp.py @@ -4,16 +4,26 @@ from owrx.js8 import Js8Parser from owrx.aprs import AprsParser from owrx.pocsag import PocsagParser from owrx.source import SdrSource, SdrSourceEventClient -from owrx.property import PropertyStack, PropertyLayer +from owrx.property import PropertyStack, PropertyLayer, PropertyValidator +from owrx.property.validators import OrValidator, RegexValidator, BoolValidator from owrx.modes import Modes from csdr import csdr import threading +import re import logging logger = logging.getLogger(__name__) +class ModulationValidator(OrValidator): + """ + This validator only allows alphanumeric characters and numbers, but no spaces or special characters + """ + def __init__(self): + super().__init__(BoolValidator(), RegexValidator(re.compile("^[a-z0-9]+$"))) + + class DspManager(csdr.output, SdrSourceEventClient): def __init__(self, handler, sdrSource): self.handler = handler @@ -27,22 +37,42 @@ class DspManager(csdr.output, SdrSourceEventClient): } self.props = PropertyStack() + # local demodulator properties not forwarded to the sdr - self.props.addLayer( - 0, - PropertyLayer().filter( - "output_rate", - "hd_output_rate", - "squelch_level", - "secondary_mod", - "low_cut", - "high_cut", - "offset_freq", - "mod", - "secondary_offset_freq", - "dmr_filter", - ), - ) + # ensure strict validation since these can be set from the client + # and are used to build executable commands + validators = { + "output_rate": "int", + "hd_output_rate": "int", + "squelch_level": "num", + "secondary_mod": ModulationValidator(), + "low_cut": "num", + "high_cut": "num", + "offset_freq": "int", + "mod": ModulationValidator(), + "secondary_offset_freq": "int", + "dmr_filter": "int", + } + self.localProps = PropertyValidator(PropertyLayer().filter(*validators.keys()), validators) + + self.props.addLayer(0, self.localProps) + # ensure strict validation since these can be set from the client + # and are used to build executable commands + validators = { + "output_rate": "int", + "hd_output_rate": "int", + "squelch_level": "num", + "secondary_mod": ModulationValidator(), + "low_cut": "num", + "high_cut": "num", + "offset_freq": "int", + "mod": ModulationValidator(), + "secondary_offset_freq": "int", + "dmr_filter": "int", + } + self.localProps = PropertyValidator(PropertyLayer().filter(*validators.keys()), validators) + + self.props.addLayer(0, self.localProps) # properties that we inherit from the sdr self.props.addLayer( 1, @@ -188,7 +218,7 @@ class DspManager(csdr.output, SdrSourceEventClient): self.setProperty(k, v) def setProperty(self, prop, value): - self.props[prop] = value + self.localProps[prop] = value def getClientClass(self): return SdrSource.CLIENT_USER diff --git a/owrx/property/__init__.py b/owrx/property/__init__.py index 23058d2..ee1d614 100644 --- a/owrx/property/__init__.py +++ b/owrx/property/__init__.py @@ -1,4 +1,5 @@ from abc import ABC, abstractmethod +from owrx.property.validators import Validator import logging logger = logging.getLogger(__name__) @@ -154,6 +155,50 @@ class PropertyFilter(PropertyManager): return [k for k in self.pm.keys() if k in self.props] +class PropertyValidationError(Exception): + def __init__(self, key, value): + super().__init__('Invalid value for property "{key}": "{value}"'.format(key=key, value=str(value))) + + +class PropertyValidator(PropertyManager): + def __init__(self, pm: PropertyManager, validators=None): + self.pm = pm + self.pm.wire(self._fireCallbacks) + if validators is None: + self.validators = {} + else: + self.validators = {k: Validator.of(v) for k, v in validators.items()} + super().__init__() + + def validate(self, key, value): + if key not in self.validators: + return + if not self.validators[key].isValid(value): + raise PropertyValidationError(key, value) + + def setValidator(self, key, validator): + self.validators[key] = Validator.of(validator) + + def __getitem__(self, item): + return self.pm.__getitem__(item) + + def __setitem__(self, key, value): + self.validate(key, value) + return self.pm.__setitem__(key, value) + + def __contains__(self, item): + return self.pm.__contains__(item) + + def __dict__(self): + return self.pm.__dict__() + + def __delitem__(self, key): + return self.pm.__delitem__(key) + + def keys(self): + return self.pm.keys() + + class PropertyStack(PropertyManager): def __init__(self): super().__init__() diff --git a/owrx/property/validators.py b/owrx/property/validators.py new file mode 100644 index 0000000..23008d4 --- /dev/null +++ b/owrx/property/validators.py @@ -0,0 +1,98 @@ +from abc import ABC, abstractmethod +from functools import reduce +from operator import or_ +import re + + +class ValidatorException(Exception): + pass + + +class Validator(ABC): + @staticmethod + def of(x): + if isinstance(x, Validator): + return x + if callable(x): + return LambdaValidator(x) + if x in validator_types: + return validator_types[x]() + raise ValidatorException("Cannot create validator") + + @abstractmethod + def isValid(self, value): + pass + + +class LambdaValidator(Validator): + def __init__(self, c): + self.callable = c + + def isValid(self, value): + return self.callable(value) + + +class TypeValidator(Validator): + def __init__(self, type): + self.type = type + super().__init__() + + def isValid(self, value): + return isinstance(value, self.type) + + +class IntegerValidator(TypeValidator): + def __init__(self): + super().__init__(int) + + +class FloatValidator(TypeValidator): + def __init__(self): + super().__init__(float) + + +class StringValidator(TypeValidator): + def __init__(self): + super().__init__(str) + + +class BoolValidator(TypeValidator): + def __init__(self): + super().__init__(bool) + + +class OrValidator(Validator): + def __init__(self, *validators): + self.validators = validators + super().__init__() + + def isValid(self, value): + return reduce( + or_, + [v.isValid(value) for v in self.validators], + False + ) + + +class NumberValidator(OrValidator): + def __init__(self): + super().__init__(IntegerValidator(), FloatValidator()) + + +class RegexValidator(StringValidator): + def __init__(self, regex: re.Pattern): + self.regex = regex + super().__init__() + + def isValid(self, value): + return super().isValid(value) and self.regex.match(value) is not None + + +validator_types = { + "string": StringValidator, + "str": StringValidator, + "integer": IntegerValidator, + "int": IntegerValidator, + "number": NumberValidator, + "num": NumberValidator, +} diff --git a/test/property/test_property_filter.py b/test/property/test_property_filter.py index 0c9ac81..37b9e21 100644 --- a/test/property/test_property_filter.py +++ b/test/property/test_property_filter.py @@ -48,3 +48,11 @@ class PropertyFilterTest(TestCase): pf["testkey"] = "new value" self.assertEqual(pm["testkey"], "new value") self.assertEqual(pf["testkey"], "new value") + + def testRejectsWrite(self): + pm = PropertyLayer() + pm["testkey"] = "old value" + pf = PropertyFilter(pm, "otherkey") + with self.assertRaises(KeyError): + pf["testkey"] = "new value" + self.assertEqual(pm["testkey"], "old value") diff --git a/test/property/test_property_validator.py b/test/property/test_property_validator.py new file mode 100644 index 0000000..a246031 --- /dev/null +++ b/test/property/test_property_validator.py @@ -0,0 +1,37 @@ +from unittest import TestCase +from owrx.property import PropertyLayer, PropertyValidator, PropertyValidationError +from owrx.property.validators import NumberValidator, StringValidator + + +class PropertyValidatorTest(TestCase): + def testPassesUnvalidated(self): + pm = PropertyLayer() + pv = PropertyValidator(pm) + pv["testkey"] = "testvalue" + self.assertEqual(pv["testkey"], "testvalue") + self.assertEqual(pm["testkey"], "testvalue") + + def testPassesValidValue(self): + pv = PropertyValidator(PropertyLayer(), {"testkey": NumberValidator()}) + pv["testkey"] = 42 + self.assertEqual(pv["testkey"], 42) + + def testThrowsErrorOnInvalidValue(self): + pv = PropertyValidator(PropertyLayer(), {"testkey": NumberValidator()}) + with self.assertRaises(PropertyValidationError): + pv["testkey"] = "text" + + def testSetValidator(self): + pv = PropertyValidator(PropertyLayer()) + pv.setValidator("testkey", NumberValidator()) + with self.assertRaises(PropertyValidationError): + pv["testkey"] = "text" + + def testUpdateValidator(self): + pv = PropertyValidator(PropertyLayer(), {"testkey": StringValidator()}) + # this should pass + pv["testkey"] = "text" + pv.setValidator("testkey", NumberValidator()) + # this should raise + with self.assertRaises(PropertyValidationError): + pv["testkey"] = "text" diff --git a/test/property/validators/__init__.py b/test/property/validators/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/property/validators/test_bool_validator.py b/test/property/validators/test_bool_validator.py new file mode 100644 index 0000000..08cfea6 --- /dev/null +++ b/test/property/validators/test_bool_validator.py @@ -0,0 +1,17 @@ +from unittest import TestCase +from owrx.property.validators import BoolValidator + + +class NumberValidatorTest(TestCase): + def testPassesNumbers(self): + validator = BoolValidator() + self.assertTrue(validator.isValid(True)) + self.assertTrue(validator.isValid(False)) + + def testDoesntPassOthers(self): + validator = BoolValidator() + self.assertFalse(validator.isValid(123)) + self.assertFalse(validator.isValid(-2)) + self.assertFalse(validator.isValid(.5)) + self.assertFalse(validator.isValid("text")) + self.assertFalse(validator.isValid(object())) diff --git a/test/property/validators/test_float_validator.py b/test/property/validators/test_float_validator.py new file mode 100644 index 0000000..f4e43ec --- /dev/null +++ b/test/property/validators/test_float_validator.py @@ -0,0 +1,15 @@ +from unittest import TestCase +from owrx.property.validators import FloatValidator + + +class FloatValidatorTest(TestCase): + def testPassesNumbers(self): + validator = FloatValidator() + self.assertTrue(validator.isValid(.5)) + + def testDoesntPassOthers(self): + validator = FloatValidator() + self.assertFalse(validator.isValid(123)) + self.assertFalse(validator.isValid(-2)) + self.assertFalse(validator.isValid("text")) + self.assertFalse(validator.isValid(object())) diff --git a/test/property/validators/test_integer_validator.py b/test/property/validators/test_integer_validator.py new file mode 100644 index 0000000..454918a --- /dev/null +++ b/test/property/validators/test_integer_validator.py @@ -0,0 +1,15 @@ +from unittest import TestCase +from owrx.property.validators import IntegerValidator + + +class IntegerValidatorTest(TestCase): + def testPassesIntegers(self): + validator = IntegerValidator() + self.assertTrue(validator.isValid(123)) + self.assertTrue(validator.isValid(-2)) + + def testDoesntPassOthers(self): + validator = IntegerValidator() + self.assertFalse(validator.isValid(.5)) + self.assertFalse(validator.isValid("text")) + self.assertFalse(validator.isValid(object())) diff --git a/test/property/validators/test_lambda_validator.py b/test/property/validators/test_lambda_validator.py new file mode 100644 index 0000000..969dce6 --- /dev/null +++ b/test/property/validators/test_lambda_validator.py @@ -0,0 +1,21 @@ +from unittest import TestCase +from unittest.mock import Mock +from owrx.property.validators import LambdaValidator + + +class LambdaValidatorTest(TestCase): + def testPassesValue(self): + mock = Mock() + validator = LambdaValidator(mock.method) + validator.isValid("test") + mock.method.assert_called_once_with("test") + + def testReturnsTrue(self): + validator = LambdaValidator(lambda x: True) + self.assertTrue(validator.isValid("any value")) + self.assertTrue(validator.isValid(3.1415926)) + + def testReturnsFalse(self): + validator = LambdaValidator(lambda x: False) + self.assertFalse(validator.isValid("any value")) + self.assertFalse(validator.isValid(42)) diff --git a/test/property/validators/test_number_validator.py b/test/property/validators/test_number_validator.py new file mode 100644 index 0000000..3eff11b --- /dev/null +++ b/test/property/validators/test_number_validator.py @@ -0,0 +1,18 @@ +from unittest import TestCase +from owrx.property.validators import NumberValidator + + +class NumberValidatorTest(TestCase): + def testPassesNumbers(self): + validator = NumberValidator() + self.assertTrue(validator.isValid(123)) + self.assertTrue(validator.isValid(-2)) + self.assertTrue(validator.isValid(.5)) + + def testDoesntPassOthers(self): + validator = NumberValidator() + # bool is a subclass of int, so it passes this test. + # not sure if we need to be more specific or if this is alright. + # self.assertFalse(validator.isValid(True)) + self.assertFalse(validator.isValid("text")) + self.assertFalse(validator.isValid(object())) diff --git a/test/property/validators/test_or_validator.py b/test/property/validators/test_or_validator.py new file mode 100644 index 0000000..0f7f79d --- /dev/null +++ b/test/property/validators/test_or_validator.py @@ -0,0 +1,17 @@ +from unittest import TestCase +from owrx.property.validators import OrValidator, IntegerValidator, StringValidator + + +class OrValidatorTest(TestCase): + def testPassesAnyValidators(self): + validator = OrValidator(IntegerValidator(), StringValidator()) + self.assertTrue(validator.isValid(42)) + self.assertTrue(validator.isValid("text")) + + def testRejectsOtherTypes(self): + validator = OrValidator(IntegerValidator(), StringValidator()) + self.assertFalse(validator.isValid(.5)) + + def testRejectsIfNoValidator(self): + validator = OrValidator() + self.assertFalse(validator.isValid("any value")) diff --git a/test/property/validators/test_regex_validator.py b/test/property/validators/test_regex_validator.py new file mode 100644 index 0000000..2151d1b --- /dev/null +++ b/test/property/validators/test_regex_validator.py @@ -0,0 +1,17 @@ +from unittest import TestCase +from owrx.property.validators import RegexValidator +import re + + +class RegexValidatorTest(TestCase): + def testMatchesRegex(self): + validator = RegexValidator(re.compile("abc")) + self.assertTrue(validator.isValid("abc")) + + def testDoesntMatchRegex(self): + validator = RegexValidator(re.compile("abc")) + self.assertFalse(validator.isValid("xyz")) + + def testFailsIfValueIsNoString(self): + validator = RegexValidator(re.compile("abc")) + self.assertFalse(validator.isValid(42)) diff --git a/test/property/validators/test_string_validator.py b/test/property/validators/test_string_validator.py new file mode 100644 index 0000000..d285f1a --- /dev/null +++ b/test/property/validators/test_string_validator.py @@ -0,0 +1,14 @@ +from unittest import TestCase +from owrx.property.validators import StringValidator + +class StringValidatorTest(TestCase): + def testPassesStrings(self): + validator = StringValidator() + self.assertTrue(validator.isValid("text")) + + def testDoesntPassOthers(self): + validator = StringValidator() + self.assertFalse(validator.isValid(123)) + self.assertFalse(validator.isValid(-2)) + self.assertFalse(validator.isValid(.5)) + self.assertFalse(validator.isValid(object())) diff --git a/test/property/validators/test_validator.py b/test/property/validators/test_validator.py new file mode 100644 index 0000000..6fbbf78 --- /dev/null +++ b/test/property/validators/test_validator.py @@ -0,0 +1,20 @@ +from unittest import TestCase +from owrx.property.validators import Validator, NumberValidator, LambdaValidator, StringValidator + + +class ValidatorTest(TestCase): + + def testReturnsValidator(self): + validator = NumberValidator() + self.assertIs(validator, Validator.of(validator)) + + def testTransformsLambda(self): + def my_callable(v): + return True + validator = Validator.of(my_callable) + self.assertIsInstance(validator, LambdaValidator) + self.assertTrue(validator.isValid("test")) + + def testGetsValidatorByKey(self): + validator = Validator.of("str") + self.assertIsInstance(validator, StringValidator)