Skip to content

Commit ac15b63

Browse files
dongwook-chanjulien-duponchelle
authored andcommitted
Release 1.0.16
1 parent 5d2692e commit ac15b63

8 files changed

Lines changed: 250 additions & 28 deletions

File tree

CHANGELOG

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
1.0.16 12/07/2026
2+
3+
* Parse FormatDescriptionEvent post-header lengths and use them to align event payloads.
4+
* Add malformed binlog event validation for invalid post-header lengths.
5+
* Support MySQL 8.4 binary log coordinate discovery with SHOW BINARY LOG STATUS.
6+
17
1.0.15 07/02/2026
28

39
* Security fix: Sensitive connection settings are no longer logged when logging is enabled.
@@ -284,4 +290,4 @@
284290

285291
1.0.13 18/01/2026
286292

287-
* Use a project logger instead of root logger (#637)
293+
* Use a project logger instead of root logger (#637)

pymysqlreplication/binlogstream.py

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
MYSQL_EXPECTED_ERROR_CODES = [2013, 2006]
5454

5555
PYMYSQL_VERSION_LT_06 = Version(pymysql.__version__) < Version("0.6")
56+
MYSQL_ER_PARSE_ERROR = 1064
5657

5758

5859
class ReportSlave(object):
@@ -263,12 +264,13 @@ def __init__(
263264

264265
# We can't filter on packet level TABLE_MAP and rotate event because
265266
# we need them for handling other operations
266-
self.__allowed_events_in_packet = frozenset([TableMapEvent, RotateEvent]).union(
267-
self.__allowed_events
268-
)
267+
self.__allowed_events_in_packet = frozenset(
268+
[FormatDescriptionEvent, TableMapEvent, RotateEvent]
269+
).union(self.__allowed_events)
269270

270271
self.__server_id = server_id
271272
self.__use_checksum = False
273+
self.__post_header_lengths = None
272274

273275
# Store table meta information
274276
self.table_map = {}
@@ -332,6 +334,15 @@ def __checksum_enabled(self):
332334
return False
333335
return True
334336

337+
def __show_binary_log_status(self, cur):
338+
try:
339+
cur.execute("SHOW BINARY LOG STATUS")
340+
except pymysql.err.ProgrammingError as exc:
341+
if not exc.args or exc.args[0] != MYSQL_ER_PARSE_ERROR:
342+
raise
343+
cur.execute("SHOW MASTER STATUS")
344+
return cur.fetchone()
345+
335346
def _register_slave(self):
336347
if not self.report_slave:
337348
return
@@ -404,12 +415,13 @@ def __connect_to_stream(self):
404415
# valid, if not, get the current position from master
405416
if self.log_file is None or self.log_pos is None:
406417
cur = self._stream_connection.cursor()
407-
cur.execute("SHOW MASTER STATUS")
408-
master_status = cur.fetchone()
409-
if master_status is None:
410-
raise BinLogNotEnabled()
411-
self.log_file, self.log_pos = master_status[:2]
412-
cur.close()
418+
try:
419+
master_status = self.__show_binary_log_status(cur)
420+
if master_status is None:
421+
raise BinLogNotEnabled()
422+
self.log_file, self.log_pos = master_status[:2]
423+
finally:
424+
cur.close()
413425

414426
prelude = struct.pack("<i", len(self.log_file) + 11) + bytes(
415427
bytearray([COM_BINLOG_DUMP])
@@ -636,6 +648,7 @@ def fetchone(self):
636648
self.__optional_meta_data,
637649
self.__enable_logging,
638650
self.__use_column_name_cache,
651+
self.__post_header_lengths,
639652
)
640653

641654
if binlog_event.event_type == ROTATE_EVENT:
@@ -660,6 +673,13 @@ def fetchone(self):
660673
# We're currently at, or past, the specified end log position.
661674
self.is_past_end_log_pos = True
662675

676+
if (
677+
binlog_event.event_type == FORMAT_DESCRIPTION_EVENT
678+
and binlog_event.event is not None
679+
):
680+
self.mysql_version = binlog_event.event.mysql_version
681+
self.__post_header_lengths = binlog_event.event.post_header_len
682+
663683
# This check must not occur before clearing the ``table_map`` as a
664684
# result of a RotateEvent.
665685
#
@@ -705,9 +725,6 @@ def fetchone(self):
705725
):
706726
continue
707727

708-
if binlog_event.event_type == FORMAT_DESCRIPTION_EVENT:
709-
self.mysql_version = binlog_event.event.mysql_version
710-
711728
return binlog_event.event
712729

713730
def _allowed_event_list(

pymysqlreplication/event.py

Lines changed: 80 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import zlib
66

77
from pymysqlreplication.constants.STATUS_VAR_KEY import *
8-
from pymysqlreplication.exceptions import StatusVariableMismatch
8+
from pymysqlreplication.exceptions import MalformedBinLogEvent, StatusVariableMismatch
99
from pymysqlreplication.util.bytes import parse_decimal_from_bytes
1010
from pymysqlreplication.logger import logger
1111
from typing import Union, Optional
@@ -27,15 +27,18 @@ def __init__(
2727
freeze_schema=False,
2828
ignore_decode_errors=False,
2929
verify_checksum=False,
30+
use_checksum=False,
3031
optional_meta_data=False,
3132
enable_logging=False,
3233
use_column_name_cache=False,
34+
post_header_lengths=None,
3335
):
3436
self.packet = from_packet
3537
self.table_map = table_map
3638
self.event_type = self.packet.event_type
3739
self.timestamp = self.packet.timestamp
3840
self.event_size = event_size
41+
self._post_header_lengths = post_header_lengths
3942
self._ctl_connection = ctl_connection
4043
self.mysql_version = mysql_version
4144
self._ignore_decode_errors = ignore_decode_errors
@@ -48,6 +51,38 @@ def __init__(
4851
self._verify_event()
4952
self.dbms = self._ctl_connection._get_dbms()
5053

54+
def _declared_post_header_len(self, default):
55+
if not self._post_header_lengths:
56+
return default
57+
58+
index = self.event_type - 1
59+
if index < 0 or index >= len(self._post_header_lengths):
60+
return default
61+
62+
return self._post_header_lengths[index]
63+
64+
def _advance_to_payload(self, known_post_header_len):
65+
declared_post_header_len = self._declared_post_header_len(known_post_header_len)
66+
67+
if declared_post_header_len < known_post_header_len:
68+
raise MalformedBinLogEvent(
69+
"Event type %s declares post-header length %s, shorter than "
70+
"the supported minimum %s"
71+
% (self.event_type, declared_post_header_len, known_post_header_len)
72+
)
73+
74+
extra_len = declared_post_header_len - known_post_header_len
75+
if self.packet.read_bytes + extra_len > self.event_size:
76+
raise MalformedBinLogEvent(
77+
"Event type %s declares post-header length %s beyond event size %s"
78+
% (self.event_type, declared_post_header_len, self.event_size)
79+
)
80+
81+
if extra_len:
82+
self.packet.advance(extra_len)
83+
84+
return declared_post_header_len
85+
5186
def _read_table_id(self):
5287
# Table ID is 6 byte
5388
# pad little-endian number
@@ -389,15 +424,37 @@ def __init__(self, from_packet, event_size, table_map, ctl_connection, **kwargs)
389424
self.mysql_version = tuple(map(int, numbers.split(".")))
390425
self.created = struct.unpack("<I", self.packet.read(4))[0]
391426
self.common_header_len = struct.unpack("<B", self.packet.read(1))[0]
392-
offset = (
393-
4 + 2 + 50 + 1
394-
) # created + binlog_version + mysql_version_str + common_header_len
395-
checksum_algorithm = 1
396-
checksum = 4
397-
n = event_size - offset - self.common_header_len - checksum_algorithm - checksum
398-
self.post_header_len = struct.unpack(f"<{n}B", self.packet.read(n))
399-
self.server_version_split = struct.unpack("<3B", self.packet.read(3))
400-
self.number_of_event_types = struct.unpack("<B", self.packet.read(1))[0]
427+
428+
fixed_part_len = 2 + 50 + 4 + 1
429+
if event_size < fixed_part_len:
430+
raise MalformedBinLogEvent(
431+
"FormatDescriptionEvent size %s is shorter than fixed part %s"
432+
% (event_size, fixed_part_len)
433+
)
434+
435+
remaining_len = event_size - fixed_part_len
436+
checksum_aware = self.mysql_version >= (5, 6) or self.mysql_version[0] >= 10
437+
checksum_algorithm_len = 1 if checksum_aware else 0
438+
checksum_len = 0 if kwargs.get("use_checksum") else 4 if checksum_aware else 0
439+
post_header_len = remaining_len - checksum_algorithm_len - checksum_len
440+
441+
if post_header_len < 0:
442+
raise MalformedBinLogEvent(
443+
"FormatDescriptionEvent size %s is too short for checksum metadata"
444+
% event_size
445+
)
446+
447+
self.post_header_len = struct.unpack(
448+
f"<{post_header_len}B", self.packet.read(post_header_len)
449+
)
450+
self.checksum_algorithm = (
451+
self.packet.read_uint8() if checksum_algorithm_len else None
452+
)
453+
if checksum_len:
454+
self.packet.advance(checksum_len)
455+
456+
self.server_version_split = self.mysql_version[:3]
457+
self.number_of_event_types = len(self.post_header_len)
401458

402459
def _dump(self):
403460
print(f"Binlog version: {self.binlog_version}")
@@ -488,6 +545,7 @@ def __init__(self, from_packet, event_size, table_map, ctl_connection, **kwargs)
488545
self.schema_length = struct.unpack("!B", self.packet.read(1))[0]
489546
self.error_code = self.packet.read_uint16()
490547
self.status_vars_length = self.packet.read_uint16()
548+
post_header_len = self._advance_to_payload(13)
491549

492550
# Payload
493551
status_vars_end_pos = self.packet.read_bytes + self.status_vars_length
@@ -499,9 +557,19 @@ def __init__(self, from_packet, event_size, table_map, ctl_connection, **kwargs)
499557

500558
self.schema = self.packet.read(self.schema_length)
501559
self.packet.advance(1)
502-
query = self.packet.read(
503-
event_size - 13 - self.status_vars_length - self.schema_length - 1
560+
query_len = (
561+
event_size
562+
- post_header_len
563+
- self.status_vars_length
564+
- self.schema_length
565+
- 1
504566
)
567+
if query_len < 0:
568+
raise MalformedBinLogEvent(
569+
"QueryEvent payload length is negative after applying post-header "
570+
"length %s" % post_header_len
571+
)
572+
query = self.packet.read(query_len)
505573
self.query = query.decode("utf-8", errors="backslashreplace")
506574
# string[EOF] query
507575

pymysqlreplication/exceptions.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ def __init__(self):
88
Exception.__init__(self, "MySQL binary logging is not enabled.")
99

1010

11+
class MalformedBinLogEvent(Exception):
12+
def __init__(self, message):
13+
Exception.__init__(self, message)
14+
15+
1116
class StatusVariableMismatch(Exception):
1217
def __init__(self):
1318
Exception.__init__(

pymysqlreplication/packet.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ def __init__(
7676
optional_meta_data,
7777
enable_logging,
7878
use_column_name_cache=False,
79+
post_header_lengths=None,
7980
):
8081
# -1 because we ignore the ok byte
8182
self.read_bytes = 0
@@ -133,6 +134,8 @@ def __init__(
133134
optional_meta_data=optional_meta_data,
134135
enable_logging=enable_logging,
135136
use_column_name_cache=use_column_name_cache,
137+
use_checksum=use_checksum,
138+
post_header_lengths=post_header_lengths,
136139
)
137140
if not self.event._processed:
138141
self.event = None

pymysqlreplication/row_event.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ def __init__(self, from_packet, event_size, table_map, ctl_connection, **kwargs)
130130
self.flags, self.extra_data_length = struct.unpack(
131131
"<HH", self.packet.read(4)
132132
)
133+
self._advance_to_payload(10)
133134
if self.extra_data_length > 2:
134135
self.extra_data_type = struct.unpack("<B", self.packet.read(1))[0]
135136
# ndb information
@@ -155,6 +156,7 @@ def __init__(self, from_packet, event_size, table_map, ctl_connection, **kwargs)
155156
self.extra_data = self.packet.read(self.extra_data_length - 3)
156157
else:
157158
self.flags = struct.unpack("<H", self.packet.read(2))[0]
159+
self._advance_to_payload(8)
158160

159161
# Body
160162
self.number_of_columns = self.packet.read_length_coded_binary()
@@ -833,6 +835,7 @@ def __init__(self, from_packet, event_size, table_map, ctl_connection, **kwargs)
833835
return
834836

835837
self.flags = struct.unpack("<H", self.packet.read(2))[0]
838+
self._advance_to_payload(8)
836839

837840
# Payload
838841
self.schema_length = struct.unpack("!B", self.packet.read(1))[0]

0 commit comments

Comments
 (0)