neels has uploaded this change for review.

View Change

[2/7] personalization: refactor ConfigurableParameter, Iccid, Imsi

Main points/rationales of the refactoring, details below:
1) common validation implementation
2) offer classmethods

The new features are optional, and will be heavily used by batch
personalization patches coming soon.

Implement Iccid and Imsi to use the new way, with a common abstract
DecimalParam implementation.

So far leave the other parameter classes working as they always did, to
follow suit in subsequent commits.

Details:

1) common validation implementation:
There are very common validation steps in the various parameter
implementations. It is more convenient and much more readable to
implement those once and set simple validation parameters per subclass.
So there now is a validate_val() classmethod, which subclasses can use
as-is to apply the validation parameters -- or subclasses can override
their cls.validate_val() for specialized validation.
(Those subclasses that this patch doesn't touch still override the
self.validate() instance method. Hence they still work as before this
patch, but don't use the new common features yet.)

2) offer stateless classmethods:
It is useful for...
- batch processing of multiple profiles (in upcoming patches) and
- user input validation
to be able to have classmethods that do what self.validate() and
self.apply() do, but do not modify any self.* members.
So far the paradigm was to create a class instance to keep state about
the value. This remains available, but in addition we make available the
paradigm of a singleton that is stateless (the classmethods).
Using self.validate() and self.apply() still work the same as before
this patch, i.e. via self.input_value and self.value -- but in addition,
there are now classmethods that don't touch self.* members.

Related: SYS#6768
Change-Id: I6522be4c463e34897ca9bff2309b3706a88b3ce8
---
M pySim/esim/saip/personalization.py
1 file changed, 113 insertions(+), 32 deletions(-)

git pull ssh://gerrit.osmocom.org:29418/pysim refs/changes/42/39742/1
diff --git a/pySim/esim/saip/personalization.py b/pySim/esim/saip/personalization.py
index e31382c..c2d71d1 100644
--- a/pySim/esim/saip/personalization.py
+++ b/pySim/esim/saip/personalization.py
@@ -36,54 +36,135 @@
class ConfigurableParameter:
"""Base class representing a part of the eSIM profile that is configurable during the
personalization process (with dynamic data from elsewhere)."""
- def __init__(self, input_value):
+ allow_types = (str, int, )
+ allow_chars = None
+ strip_chars = None
+ min_len = None
+ max_len = None
+ allow_len = None # a list of specific lengths
+
+ def __init__(self, input_value=None):
self.input_value = input_value # the raw input value as given by caller
self.value = None # the processed input value (e.g. with check digit) as produced by validate()

def validate(self):
- """Optional validation method. Can be used by derived classes to perform validation
- of the input value (self.value). Will raise an exception if validation fails."""
- # default implementation: simply copy input_value over to value
- self.value = self.input_value
+ '''Validate self.input_value and place the result in self.value.
+ This is also called implicitly by apply(), if self.value is still None.
+ To override validation in a subclass, rather re-implement the classmethod validate_val().'''
+ try:
+ self.value = self.__class__.validate_val(self.input_value)
+ except (TypeError, ValueError, KeyError) as e:
+ raise ValueError(f'{self.name}: {e}') from e

- @abc.abstractmethod
def apply(self, pes: ProfileElementSequence):
+ '''Place self.value into the ProfileElementSequence at the right place.
+ If self.value is None, first call self.validate() to generate a sanitized self.value from self.input_value.
+ To override apply() in a subclass, rather re-implement the classmethod apply_val().'''
+ if self.value is None:
+ self.validate()
+ assert self.value is not None
+ try:
+ self.__class__.apply_val(pes, self.value)
+ except (TypeError, ValueError, KeyError) as e:
+ raise ValueError(f'{self.name}: {e}') from e
+
+ @classmethod
+ def validate_val(cls, val):
+ '''This function is a default implementation, with the behavior configured by subclasses' allow_types...max_len
+ settings.
+ subclasses may override this function:
+ Validate the contents of val, and raise ValueError on validation errors.
+ Return a sanitized version of val, that is ready for cls.apply_val().
+ '''
+
+ if cls.allow_types is not None:
+ if not isinstance(val, cls.allow_types):
+ raise ValueError(f'input value must be one of {cls.allow_types}, not {type(val)}')
+ elif val is None:
+ raise ValueError('there is no value (val is None)')
+
+ if isinstance(val, str):
+ if cls.strip_chars is not None:
+ val = ''.join(c for c in val if c not in cls.strip_chars)
+ if cls.allow_chars is not None:
+ if any(c not in cls.allow_chars for c in val):
+ raise ValueError(f"invalid characters in input value, valid are {cls.allow_chars}")
+ if cls.allow_len is not None:
+ l = cls.allow_len
+ if not isinstance(l, (tuple, list)):
+ l = (l,)
+ if len(val) not in l:
+ raise ValueError(f'length must be one of {cls.allow_len}, not {len(val)}')
+ if cls.min_len is not None:
+ if len(val) < cls.min_len:
+ raise ValueError(f'length must be at least {cls.min_len}, not {len(val)}')
+ if cls.max_len is not None:
+ if len(val) > cls.max_len:
+ raise ValueError(f'length must be at most {cls.max_len}, not {len(val)}')
+ return val
+
+ @classmethod
+ def apply_val(cls, pes: ProfileElementSequence, val):
+ '''This is what subclasses implement: store a value in a decoded profile package.
+ Write the given val in the right format in all the right places in pes.'''
pass

-class Iccid(ConfigurableParameter):
- """Configurable ICCID. Expects the value to be a string of decimal digits.
- If the string of digits is only 18 digits long, a Luhn check digit will be added."""
+ @classmethod
+ def get_len_range(cls):
+ vals = []
+ if cls.allow_len is not None:
+ if isinstance(cls.allow_len, (tuple, list)):
+ vals.extend(cls.allow_len)
+ else:
+ vals.append(cls.allow_len)
+ if cls.min_len is not None:
+ vals.append(cls.min_len)
+ if cls.max_len is not None:
+ vals.append(cls.max_len)
+ if not vals:
+ return (None, None)
+ return (min(vals), max(vals))

- def validate(self):
- # convert to string as it might be an integer
- iccid_str = str(self.input_value)
- if len(iccid_str) < 18 or len(iccid_str) > 20:
- raise ValueError('ICCID must be 18, 19 or 20 digits long')
- if not iccid_str.isdecimal():
- raise ValueError('ICCID must only contain decimal digits')
- self.value = sanitize_iccid(iccid_str)

- def apply(self, pes: ProfileElementSequence):
+class DecimalParam(ConfigurableParameter):
+ allow_types = (str, int)
+ allow_chars = '0123456789'
+
+ @classmethod
+ def validate_val(cls, val):
+ if isinstance(val, int):
+ min_len, max_len = cls.get_len_range()
+ l = min_len or 1
+ val = '%0*d' % (l, val)
+ return super().validate_val(val)
+
+class Iccid(DecimalParam):
+ """ICCID Parameter. Input: string of decimal digits.
+ If the string of digits is only 18 digits long, add a Luhn check digit."""
+ min_len = 18
+ max_len = 20
+
+ @classmethod
+ def validate_val(cls, val):
+ iccid_str = super().validate_val(val)
+ return sanitize_iccid(iccid_str)
+
+ @classmethod
+ def apply_val(cls, pes: ProfileElementSequence, val):
# patch the header
- pes.get_pe_for_type('header').decoded['iccid'] = h2b(rpad(self.value, 20))
+ pes.get_pe_for_type('header').decoded['iccid'] = h2b(rpad(val, 20))
# patch MF/EF.ICCID
- file_replace_content(pes.get_pe_for_type('mf').decoded['ef-iccid'], h2b(enc_iccid(self.value)))
+ file_replace_content(pes.get_pe_for_type('mf').decoded['ef-iccid'], h2b(enc_iccid(val)))

-class Imsi(ConfigurableParameter):
+class Imsi(DecimalParam):
"""Configurable IMSI. Expects value to be a string of digits. Automatically sets the ACC to
the last digit of the IMSI."""
+ min_len = 6
+ max_len = 15

- def validate(self):
- # convert to string as it might be an integer
- imsi_str = str(self.input_value)
- if len(imsi_str) < 6 or len(imsi_str) > 15:
- raise ValueError('IMSI must be 6..15 digits long')
- if not imsi_str.isdecimal():
- raise ValueError('IMSI must only contain decimal digits')
- self.value = imsi_str
-
- def apply(self, pes: ProfileElementSequence):
- imsi_str = self.value
+ @classmethod
+ def apply_val(cls, pes: ProfileElementSequence, val):
+ imsi_str = val
# we always use the least significant byte of the IMSI as ACC
acc = (1 << int(imsi_str[-1]))
# patch ADF.USIM/EF.IMSI

To view, visit change 39742. To unsubscribe, or for help writing mail filters, visit settings.

Gerrit-MessageType: newchange
Gerrit-Project: pysim
Gerrit-Branch: master
Gerrit-Change-Id: I6522be4c463e34897ca9bff2309b3706a88b3ce8
Gerrit-Change-Number: 39742
Gerrit-PatchSet: 1
Gerrit-Owner: neels <nhofmeyr@sysmocom.de>