biocape has uploaded this change for review.

View Change

Add GSM 7-bit packing and Network Name IE support

Add gsm7_pack()/gsm7_unpack() (TS 23.038 Section 6.1.2.1) and a
NetworkNameAdapter for the Network Name IE (TS 24.008 Section
10.5.3.5a), as used by EF.PNN and MM INFORMATION.

This is a separate adapter rather than a parameterization of
GsmOrUcs2Adapter: the Network Name header octet clashes with the
TS 102 221 Annex A magic bytes, e.g. a 10-septet GSM 7-bit name
starts with 0x82, which Annex A reads as UCS-2 variant 3.

Change-Id: I4661352e39031dc0041fc7851f675d6083a1035f
---
M src/osmocom/construct.py
M src/osmocom/utils.py
M tests/test_construct.py
M tests/test_utils.py
4 files changed, 117 insertions(+), 3 deletions(-)

git pull ssh://gerrit.osmocom.org:29418/python/pyosmocom refs/changes/71/43371/1
diff --git a/src/osmocom/construct.py b/src/osmocom/construct.py
index 548f22d..ef97943 100644
--- a/src/osmocom/construct.py
+++ b/src/osmocom/construct.py
@@ -18,7 +18,7 @@
from construct.core import evaluate
from construct.lib import integertypes

-from osmocom.utils import b2h, h2b, swap_nibbles, int_bytes_required
+from osmocom.utils import b2h, h2b, swap_nibbles, int_bytes_required, gsm7_pack, gsm7_unpack

# (C) 2021-2022 by Harald Welte <laforge@osmocom.org>
#
@@ -252,6 +252,39 @@
else:
return _encode_variant1(obj)

+class NetworkNameAdapter(Adapter):
+ """convert the value part of a Network Name IE (TS 24.008 Section 10.5.3.5a)
+ to a string (and back).
+
+ Wire format:
+ octet 1: header
+ bits 7-5: coding scheme
+ bit 4: add country initials
+ bits 3-1: spare bits in last octet
+ octet 2+: GSM 7-bit packed (TS 23.038) or UCS-2 encoded text
+
+ The encoder prefers GSM 7-bit packed and falls back to UCS-2.
+ It sets the add country initials bit to 0."""
+ def _decode(self, obj, context, path):
+ if len(obj) < 1:
+ return ''
+ coding_scheme = (obj[0] >> 4) & 0x07
+ num_spare_bits = obj[0] & 0x07
+ if coding_scheme == 0:
+ # GSM 7 bit default alphabet, packed
+ return codecs.decode(gsm7_unpack(obj[1:], num_spare_bits), 'gsm03.38')
+ if coding_scheme == 1:
+ # UCS-2 (16 bit)
+ return codecs.decode(obj[1:], 'utf_16_be')
+ raise ValueError('reserved Network Name coding scheme %u' % coding_scheme)
+
+ def _encode(self, obj, context, path):
+ try:
+ packed, num_spare_bits = gsm7_pack(codecs.encode(obj, 'gsm03.38'))
+ return bytes([0x80 | num_spare_bits]) + packed
+ except ValueError:
+ return b'\x90' + codecs.encode(obj, 'utf_16_be')
+
class BcdAdapter(Adapter):
"""convert a bytes() type to a string of BCD nibbles."""

diff --git a/src/osmocom/utils.py b/src/osmocom/utils.py
index 7963c05..def7cd7 100644
--- a/src/osmocom/utils.py
+++ b/src/osmocom/utils.py
@@ -8,7 +8,7 @@
import datetime
import argparse
from io import BytesIO
-from typing import Optional, List, NewType
+from typing import Optional, List, NewType, Tuple

# Copyright (C) 2009-2010 Sylvain Munaut <tnt@246tNt.com>
# Copyright (C) 2021 Harald Welte <laforge@osmocom.org>
@@ -109,6 +109,38 @@
return hexstr(''.join([x+y for x, y in zip(s[1::2], s[0::2])]))


+def gsm7_unpack(packed: bytes, num_spare_bits: int = 0) -> bytes:
+ """Unpack GSM 7-bit packed septets (3GPP TS 23.038 Section 6.1.2.1).
+
+ Args:
+ packed : packed 7-bit data
+ num_spare_bits : number of spare bits in the last octet (0..7)
+ Returns:
+ unpacked bytes, one septet per byte
+ """
+ packed_int = int.from_bytes(packed, byteorder='little')
+ num_septets = (len(packed) * 8 - num_spare_bits) // 7
+ return bytes((packed_int >> (i * 7)) & 0x7f for i in range(num_septets))
+
+
+def gsm7_pack(septets: bytes) -> Tuple[bytes, int]:
+ """Pack septets into GSM 7-bit packed format (3GPP TS 23.038 Section 6.1.2.1).
+
+ Args:
+ septets : one septet per byte; each byte must be within 0..127
+ Returns:
+ tuple of (packed bytes, number of spare bits in the last octet)
+ """
+ packed_int = 0
+ for i, septet in enumerate(septets):
+ if septet > 0x7f:
+ raise ValueError('septet value 0x%02x exceeds 7 bits' % septet)
+ packed_int |= septet << (i * 7)
+ num_bits = len(septets) * 7
+ num_bytes = (num_bits + 7) // 8
+ return packed_int.to_bytes(num_bytes, byteorder='little'), num_bytes * 8 - num_bits
+
+
def rpad(s: str, l: int, c='f') -> str:
"""pad string on the right side.
Args:
diff --git a/tests/test_construct.py b/tests/test_construct.py
index 195732d..3ccd169 100755
--- a/tests/test_construct.py
+++ b/tests/test_construct.py
@@ -4,6 +4,7 @@
from osmocom.utils import b2h, h2b
from osmocom.construct import Asn1DerInteger, Bytes, DnsAdapter, GreedyInteger, GreedyBytes, PlmnAdapter
from osmocom.construct import StripHeaderAdapter, StripTrailerAdapter, Ucs2Adapter, filter_dict
+from osmocom.construct import NetworkNameAdapter
# pylint: disable=no-name-in-module
from construct import FlagsEnum

@@ -79,6 +80,32 @@
re_enc = self.ad._encode(string, None, None)
self.assertEqual(encoded, re_enc)

+class TestNetworkNameAdapter(unittest.TestCase):
+ ad = NetworkNameAdapter(GreedyBytes)
+
+ testdata = [
+ # GSM 7-bit packed, 2 spare bits
+ ( "Telekom.de", '82d432bbbc7eb75de432' ),
+ # GSM 7-bit packed, 4 spare bits
+ ( "Cape", '84c330bc0c' ),
+ # UCS-2, as the string is not representable in the GSM default alphabet
+ ( "МТС", '90041c04220421' ),
+ ]
+
+ def test_data_decode(self):
+ for string, encoded_hex in self.testdata:
+ dec = self.ad._decode(h2b(encoded_hex), None, None)
+ self.assertEqual(dec, string)
+
+ def test_data_encode(self):
+ for string, encoded_hex in self.testdata:
+ re_enc = self.ad._encode(string, None, None)
+ self.assertEqual(h2b(encoded_hex), re_enc)
+
+ def test_reserved_coding_scheme(self):
+ with self.assertRaises(ValueError):
+ self.ad._decode(h2b('a0c330bc0c'), None, None)
+
class TestTrailerAdapter(unittest.TestCase):
Privileges = FlagsEnum(StripTrailerAdapter(GreedyBytes, 3), security_domain=0x800000,
dap_verification=0x400000,
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 1b80011..ca9a42b 100755
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -18,7 +18,7 @@

import unittest

-from osmocom.utils import hexstr, int_bytes_required
+from osmocom.utils import hexstr, int_bytes_required, gsm7_pack, gsm7_unpack

class TestHexstr(unittest.TestCase):
def test_cmp(self):
@@ -54,6 +54,28 @@
self.assertEqual(str(hexstr('ABCD')), 'abcd')


+class TestGsm7PackUnpack(unittest.TestCase):
+ # (packed, num_spare_bits, septets)
+ tests = [
+ ( b'', 0, b'' ),
+ ( b'\x61', 1, b'a' ),
+ ( b'\xd4\x32\xbb\xbc\x7e\xb7\x5d\xe4\x32', 2, b'Telekom.de' ),
+ ( b'\xc8\x32\x9b\xfd\x06\x5d\xdf\x72\x36\x19', 3, b'Hello World' ),
+ ]
+
+ def test_gsm7_unpack(self):
+ for packed, num_spare_bits, septets in self.tests:
+ self.assertEqual(gsm7_unpack(packed, num_spare_bits), septets)
+
+ def test_gsm7_pack(self):
+ for packed, num_spare_bits, septets in self.tests:
+ self.assertEqual(gsm7_pack(septets), (packed, num_spare_bits))
+
+ def test_gsm7_pack_septet_out_of_range(self):
+ with self.assertRaises(ValueError):
+ gsm7_pack(b'\x80')
+
+
class Test_int_bytes_required(unittest.TestCase):

def test_int_bytes_required(self):

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

Gerrit-MessageType: newchange
Gerrit-Project: python/pyosmocom
Gerrit-Branch: master
Gerrit-Change-Id: I4661352e39031dc0041fc7851f675d6083a1035f
Gerrit-Change-Number: 43371
Gerrit-PatchSet: 1
Gerrit-Owner: biocape <biofel@cape.co>