Skip to content

Commit 4e75957

Browse files
jktjktgertvdijk
andcommitted
Fix double-decode and double-encode in byte stuffing codec
Calling replace() multiple times is wrong, because it looks at partially decoded data which are decoded again. This could have been fixed by a different order for encode and decode, but the approach taken here also flags unrecognized or truncated stuffing sequences. Co-Authored-By: Gert van Dijk <github@gertvandijk.nl>
1 parent c2bb09b commit 4e75957

5 files changed

Lines changed: 113 additions & 25 deletions

File tree

docs/api/codec.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,5 @@ SPDX-License-Identifier: CC0-1.0
3838
::: pykmp.codec.InvalidDestinationAddressError
3939
::: pykmp.codec.UnsupportedDecimalExponentError
4040
::: pykmp.codec.CrcChecksumInvalidError
41+
::: pykmp.codec.InvalidStuffingByteError
42+
::: pykmp.codec.TruncatedStuffingError

docs/changelog.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,21 @@ SPDX-License-Identifier: CC0-1.0
99

1010
_In development._
1111

12+
### Noteworthy changes
13+
14+
- The physical byte-stuffing codec has been fixed to avoid double-decoding and
15+
double-encoding escaped (stuffed) byte sequences.
16+
17+
The refactor also improves handling of truncated or otherwise unrecognized stuffing
18+
sequences, with corresponding test coverage updates.
19+
20+
Introduces two new exceptions in the codec module:
21+
[`TruncatedStuffingError`][pykmp.codec.TruncatedStuffingError] and
22+
[`InvalidStuffingByteError`][pykmp.codec.InvalidStuffingByteError].
23+
24+
Thanks to [Jan Kundrát](https://github.com/jktjkt) for reporting the issue along
25+
with a proposed fix ([PR #6](https://github.com/gertvdijk/PyKMP/pull/6)).
26+
1227
### Tooling changes
1328

1429
- Project documentation has been migrated from [MkDocs](https://www.mkdocs.org/) with

docs/thanks.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ the possibilities integrating these MULTICAL® meters (as provided by Dutch util
1919
non-cloud smart home.
2020
[GoT: Kamstrup Multical 402 stadsverwarmingsmeter RPI3+IR-kop][got-multical402-topic]
2121

22+
# Contributors
23+
24+
[Jan Kundrát][jankundrát]
25+
2226
[meterlogger-wiki-kmp]: https://github.com/nabovarme/MeterLogger/wiki/Kamstrup-Protocol
2327
[github-ronaldvdmeer-multical402]: https://github.com/ronaldvdmeer/multical402-4-domoticz
2428
[got-multical402-topic]: https://gathering.tweakers.net/forum/list_messages/1776625
29+
[jankundrát]: https://github.com/jktjkt

src/pykmp/codec.py

Lines changed: 64 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
import enum
2020
import logging
2121
import math
22-
from types import MappingProxyType
2322
from typing import TYPE_CHECKING, Any, ClassVar, Final, Literal, NewType, cast
2423

2524
import attrs
@@ -28,7 +27,7 @@
2827
from . import constants
2928

3029
if TYPE_CHECKING:
31-
from collections.abc import Mapping
30+
from collections.abc import Iterator
3231

3332
logger = logging.getLogger(__name__)
3433

@@ -135,6 +134,26 @@ class CrcChecksumInvalidError(BaseCodecError):
135134
"""CRC checksum validation of the data link byte sequence did not pass."""
136135

137136

137+
class TruncatedStuffingError(BaseCodecError):
138+
"""Byte stuffing indicates one more byte at the end of the input."""
139+
140+
def __str__(self) -> str: # noqa: D105
141+
return "Byte stuffing indicates one more byte at the end of the input"
142+
143+
144+
@attrs.frozen(kw_only=True)
145+
class InvalidStuffingByteError(BaseCodecError):
146+
"""Byte stuffing encountered an unrecognized encoded byte."""
147+
148+
raw_byte: int
149+
150+
def __str__(self) -> str: # noqa: D105
151+
return (
152+
"Byte stuffing encountered an unrecognized encoded byte "
153+
f"{self.raw_byte:02X}"
154+
)
155+
156+
138157
@attrs.frozen(kw_only=True)
139158
class ApplicationData:
140159
"""Data class for the data in the application layer of the Kamstrup KMP protocol."""
@@ -178,21 +197,12 @@ class PhysicalCodec:
178197

179198
direction: PhysicalDirection = attrs.field()
180199
_start_byte: int = attrs.field(init=False) # depends on direction
181-
182-
BYTE_STUFFING_MAP: ClassVar[Mapping[bytes, bytes]] = MappingProxyType({
183-
the_byte.to_bytes(1, "big"): (
184-
constants.ByteCode.STUFFING.value.to_bytes(1, "big")
185-
+ (the_byte ^ 0xFF).to_bytes(1, "big")
186-
)
187-
for the_byte in (
188-
# Order matters for having BYTE_STUFFING as the first; itself is used in
189-
# the escaped sequence.
190-
constants.ByteCode.STUFFING.value,
191-
constants.ByteCode.ACK.value,
192-
constants.ByteCode.START_FROM_METER.value,
193-
constants.ByteCode.START_TO_METER.value,
194-
constants.ByteCode.STOP.value,
195-
)
200+
_stuffable_bytes: ClassVar[frozenset[int]] = frozenset({
201+
constants.ByteCode.ACK.value,
202+
constants.ByteCode.START_FROM_METER.value,
203+
constants.ByteCode.START_TO_METER.value,
204+
constants.ByteCode.STOP.value,
205+
constants.ByteCode.STUFFING.value,
196206
})
197207

198208
def __attrs_post_init__(self) -> None:
@@ -208,6 +218,40 @@ def _direction_to_start_byte(cls, direction: PhysicalDirection) -> int:
208218
case PhysicalDirection.TO_METER:
209219
return constants.ByteCode.START_TO_METER.value
210220

221+
@classmethod
222+
def _iter_destuffed_bytes(cls, stuffed: bytes) -> Iterator[int]:
223+
"""Yield destuffed byte values from a stuffed byte sequence."""
224+
in_stuffing = False
225+
226+
for raw_byte in stuffed:
227+
if in_stuffing:
228+
in_stuffing = False
229+
xored = raw_byte ^ 0xFF
230+
if xored not in cls._stuffable_bytes:
231+
raise InvalidStuffingByteError(raw_byte=raw_byte)
232+
yield xored
233+
continue
234+
235+
if raw_byte == constants.ByteCode.STUFFING.value:
236+
in_stuffing = True
237+
continue
238+
239+
yield raw_byte
240+
241+
if in_stuffing:
242+
raise TruncatedStuffingError
243+
244+
@classmethod
245+
def _iter_stuffed_bytes(cls, raw: DataLinkBytes) -> Iterator[bytes]:
246+
"""Yield stuffed byte chunks from an unescaped byte sequence."""
247+
stuffing_prefix = constants.ByteCode.STUFFING.value.to_bytes(1, "big")
248+
249+
for raw_byte in raw:
250+
if raw_byte in cls._stuffable_bytes:
251+
yield stuffing_prefix + (raw_byte ^ 0xFF).to_bytes(1, "big")
252+
else:
253+
yield raw_byte.to_bytes(1, "big")
254+
211255
def decode(self, frame: PhysicalBytes) -> DataLinkBytes:
212256
"""
213257
Decode a byte sequence of the physical layer into 'DataLinkBytes'.
@@ -237,10 +281,7 @@ def decode(self, frame: PhysicalBytes) -> DataLinkBytes:
237281
)
238282

239283
data_bytes = frame[1:-1]
240-
for unescaped_byte, escaped_bytes in self.BYTE_STUFFING_MAP.items():
241-
data_bytes = data_bytes.replace(escaped_bytes, unescaped_byte)
242-
243-
return cast(DataLinkBytes, data_bytes)
284+
return cast(DataLinkBytes, bytes(self._iter_destuffed_bytes(data_bytes)))
244285

245286
def encode(self, data_bytes: DataLinkBytes) -> PhysicalBytes:
246287
"""
@@ -255,13 +296,11 @@ def encode(self, data_bytes: DataLinkBytes) -> PhysicalBytes:
255296
what="Data link bytes", actual=0, length_expected=None
256297
)
257298

258-
raw = cast(bytes, data_bytes)
259-
for unescaped_byte, escaped_bytes in self.BYTE_STUFFING_MAP.items():
260-
raw = raw.replace(unescaped_byte, escaped_bytes)
299+
raw_stuffed = b"".join(self._iter_stuffed_bytes(data_bytes))
261300

262301
frame = (
263302
self._start_byte.to_bytes(1, "big")
264-
+ raw
303+
+ raw_stuffed
265304
+ constants.ByteCode.STOP.value.to_bytes(1, "big")
266305
)
267306
return cast(PhysicalBytes, frame)

tests/test_codec.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,12 @@
2424
DataLinkData,
2525
FloatCodec,
2626
InvalidDestinationAddressError,
27+
InvalidStuffingByteError,
2728
OutOfRangeError,
2829
PhysicalBytes,
2930
PhysicalCodec,
3031
PhysicalDirection,
32+
TruncatedStuffingError,
3133
UnsupportedDecimalExponentError,
3234
)
3335

@@ -49,6 +51,17 @@
4951
" 3F 10 00 80 16 04 11 012AF024 6303 ",
5052
id="Kamstrup doc 6.2.4 GetRegister response (destuffing needed)",
5153
),
54+
pytest.param(
55+
PhysicalDirection.FROM_METER,
56+
# Note that '1BE4' destuffs to '1B'. An implementation that would run
57+
# multiple destuffing rounds would wrongly attempt to destuff '1B__' again.
58+
# In this case where it is followed by '7F' ('1B7F') after initial
59+
# destuffing, it would be wrongly destuffed in another round to '80' while
60+
# it should not. See issue/PR gertvdijk/PyKMP#6.
61+
"40 1BE47F 0D",
62+
" 1B 7F ",
63+
id="stuffed stuff byte should not cause recursive destuffing",
64+
),
5265
],
5366
)
5467
def test_codec_physical_decode(
@@ -116,6 +129,20 @@ def test_codec_physical_decode_ack(
116129
"Frame is of zero length.",
117130
id="empty",
118131
),
132+
pytest.param(
133+
PhysicalDirection.FROM_METER,
134+
"40 3F 1BFF 0D",
135+
InvalidStuffingByteError,
136+
"Byte stuffing encountered an unrecognized encoded byte FF",
137+
id="Unrecongized stuffing value",
138+
),
139+
pytest.param(
140+
PhysicalDirection.FROM_METER,
141+
"40 3F 1B 0D",
142+
TruncatedStuffingError,
143+
"Byte stuffing indicates one more byte at the end of the input",
144+
id="Truncated stuffing value",
145+
),
119146
],
120147
)
121148
def test_codec_physical_decode_error(

0 commit comments

Comments
 (0)