@@ -127,18 +127,20 @@ def export(self, filename):
127127 pass
128128
129129
130+ def varint (data , offset ):
131+ """Decode an unsigned varint, returning (value, next offset)."""
132+ value = shift = 0
133+ while True :
134+ byte = data [offset ]
135+ offset += 1
136+ value |= (byte & 0x7F ) << shift
137+ shift += 7
138+ if byte < 0x80 :
139+ return value , offset
140+
141+
130142def read_block_columns (filename ):
131143 """Return the (decoded size, encoding) of each column of every block."""
132- def varint (data , offset ):
133- value = shift = 0
134- while True :
135- byte = data [offset ]
136- offset += 1
137- value |= (byte & 0x7F ) << shift
138- shift += 7
139- if byte < 0x80 :
140- return value , offset
141-
142144 with open (filename , "rb" ) as raw :
143145 data = raw .read ()
144146 sample_end = struct .unpack_from ("=Q" , data , 40 )[0 ]
@@ -1443,13 +1445,14 @@ def test_replay_rejects_sample_count_mismatch(self):
14431445 def test_replay_rejects_trailing_partial_sample_header (self ):
14441446 """Replay rejects partial sample bytes instead of silently stopping."""
14451447 filename = self .create_binary_file ([], compression = "none" )
1446- sample_data_end = self .FILE_HEADER_PLACEHOLDER_SIZE + 1
1447-
1448- with open (filename , "r+b" ) as raw :
1449- raw .seek (self .HDR_OFF_STR_TABLE )
1450- raw .write (struct .pack ("=Q" , sample_data_end ))
1451- raw .seek (self .HDR_OFF_FRAME_TABLE )
1452- raw .write (struct .pack ("=Q" , sample_data_end ))
1448+ data = bytearray (pathlib .Path (filename ).read_bytes ())
1449+ data [self .FILE_HEADER_PLACEHOLDER_SIZE :
1450+ self .FILE_HEADER_PLACEHOLDER_SIZE ] = b"\0 "
1451+ for offset in (self .HDR_OFF_STR_TABLE , self .HDR_OFF_FRAME_TABLE ):
1452+ old_offset = struct .unpack_from ("=Q" , data , offset )[0 ]
1453+ struct .pack_into ("=Q" , data , offset , old_offset + 1 )
1454+ struct .pack_into ("=Q" , data , len (data ) - 24 , len (data ))
1455+ pathlib .Path (filename ).write_bytes (data )
14531456
14541457 with BinaryReader (filename ) as reader :
14551458 with self .assertRaises (ValueError ) as cm :
@@ -1461,26 +1464,47 @@ def test_replay_rejects_trailing_partial_sample_header(self):
14611464 MIN_STRING_ENTRY_SIZE = 1
14621465 MIN_FRAME_ENTRY_SIZE = 7
14631466
1464- def _read_offset (self , filename , hdr_off ):
1465- with open (filename , "rb" ) as raw :
1466- raw .seek (hdr_off )
1467- return struct .unpack ("=Q" , raw .read (8 ))[0 ]
1467+ def _table_bounds (self , data , hdr_off ):
1468+ start = struct .unpack_from ("=Q" , data , hdr_off )[0 ]
1469+ if hdr_off == self .HDR_OFF_STR_TABLE :
1470+ end = struct .unpack_from ("=Q" , data , self .HDR_OFF_FRAME_TABLE )[0 ]
1471+ else :
1472+ end = len (data ) - self .FILE_FOOTER_SIZE
1473+ return start , end
1474+
1475+ def _read_table_section (self , filename , hdr_off ):
1476+ """Return the decoded size, encoding and stored bytes of a table."""
1477+ data = pathlib .Path (filename ).read_bytes ()
1478+ start , end = self ._table_bounds (data , hdr_off )
1479+ decoded_size , offset = varint (data , start )
1480+ return decoded_size , data [offset ], data [offset + 1 :end ]
1481+
1482+ def _replace_table_section (self , filename , hdr_off , section ):
1483+ data = bytearray (pathlib .Path (filename ).read_bytes ())
1484+ start , end = self ._table_bounds (data , hdr_off )
1485+ data [start :end ] = section
1486+ if hdr_off == self .HDR_OFF_STR_TABLE :
1487+ frame_offset = start + len (section )
1488+ struct .pack_into ("=Q" , data , self .HDR_OFF_FRAME_TABLE ,
1489+ frame_offset )
1490+ struct .pack_into ("=Q" , data , len (data ) - 24 , len (data ))
1491+ pathlib .Path (filename ).write_bytes (data )
14681492
14691493 def _patch_footer_count (self , filename , ftr_off , value ):
14701494 size = os .path .getsize (filename )
14711495 with open (filename , "r+b" ) as raw :
14721496 raw .seek (size - self .FILE_FOOTER_SIZE + ftr_off )
14731497 raw .write (struct .pack ("=I" , value ))
14741498
1475- def test_open_rejects_string_count_larger_than_file (self ):
1476- """Open rejects a footer string count larger than the file ."""
1499+ def test_open_rejects_string_count_larger_than_table (self ):
1500+ """Open rejects a footer string count larger than its table ."""
14771501 samples = [[make_interpreter (0 , [
14781502 make_thread (1 , [make_frame ("s.py" , 10 , "s" )])
14791503 ])]]
14801504 filename = self .create_binary_file (samples , compression = "none" )
1481- size = os . path . getsize ( filename )
1482- str_off = self . _read_offset ( filename , self .HDR_OFF_STR_TABLE )
1483- max_strings = ( size - str_off ) // self .MIN_STRING_ENTRY_SIZE
1505+ decoded_size , _ , _ = self . _read_table_section (
1506+ filename , self .HDR_OFF_STR_TABLE )
1507+ max_strings = decoded_size // self .MIN_STRING_ENTRY_SIZE
14841508 self ._patch_footer_count (filename , self .FTR_OFF_STRINGS , 0xFFFFFFFF )
14851509
14861510 with self .assertRaises (ValueError ) as cm :
@@ -1492,15 +1516,15 @@ def test_open_rejects_string_count_larger_than_file(self):
14921516 f"possible { max_strings } " ,
14931517 )
14941518
1495- def test_open_rejects_frame_count_larger_than_file (self ):
1496- """Open rejects a footer frame count larger than the file ."""
1519+ def test_open_rejects_frame_count_larger_than_table (self ):
1520+ """Open rejects a footer frame count larger than its table ."""
14971521 samples = [[make_interpreter (0 , [
14981522 make_thread (1 , [make_frame ("f.py" , 10 , "f" )])
14991523 ])]]
15001524 filename = self .create_binary_file (samples , compression = "none" )
1501- size = os . path . getsize ( filename )
1502- frame_off = self . _read_offset ( filename , self .HDR_OFF_FRAME_TABLE )
1503- max_frames = ( size - frame_off ) // self .MIN_FRAME_ENTRY_SIZE
1525+ decoded_size , _ , _ = self . _read_table_section (
1526+ filename , self .HDR_OFF_FRAME_TABLE )
1527+ max_frames = decoded_size // self .MIN_FRAME_ENTRY_SIZE
15041528 self ._patch_footer_count (filename , self .FTR_OFF_FRAMES , 0xFFFFFFFF )
15051529
15061530 # On a 32-bit build this count overflows the allocation size, so the
@@ -1516,14 +1540,14 @@ def test_open_rejects_frame_count_larger_than_file(self):
15161540 )
15171541
15181542 def test_open_accepts_frame_count_at_capacity_boundary (self ):
1519- """A frame count at the file -size cap opens; one more is rejected."""
1543+ """A frame count at the table -size cap opens; one more is rejected."""
15201544 samples = [[make_interpreter (0 , [
15211545 make_thread (1 , [make_frame ("f.py" , 10 , "f" )])
15221546 ])]]
15231547 filename = self .create_binary_file (samples , compression = "none" )
1524- size = os . path . getsize ( filename )
1525- frame_off = self . _read_offset ( filename , self .HDR_OFF_FRAME_TABLE )
1526- max_frames = ( size - frame_off ) // self .MIN_FRAME_ENTRY_SIZE
1548+ decoded_size , _ , _ = self . _read_table_section (
1549+ filename , self .HDR_OFF_FRAME_TABLE )
1550+ max_frames = decoded_size // self .MIN_FRAME_ENTRY_SIZE
15271551
15281552 self ._patch_footer_count (filename , self .FTR_OFF_FRAMES , max_frames )
15291553 with BinaryReader (filename ):
@@ -1539,6 +1563,120 @@ def test_open_accepts_frame_count_at_capacity_boundary(self):
15391563 f"possible { max_frames } " ,
15401564 )
15411565
1566+ def test_raw_table_sections_record_decoded_size (self ):
1567+ samples = [[make_interpreter (0 , [
1568+ make_thread (1 , [make_frame ("raw.py" , 10 , "raw" )])
1569+ ])]]
1570+ filename = self .create_binary_file (samples , compression = "none" )
1571+ for hdr_off in (self .HDR_OFF_STR_TABLE , self .HDR_OFF_FRAME_TABLE ):
1572+ with self .subTest (hdr_off = hdr_off ):
1573+ decoded_size , encoding , payload = self ._read_table_section (
1574+ filename , hdr_off )
1575+ self .assertEqual (encoding , 0 )
1576+ self .assertEqual (decoded_size , len (payload ))
1577+
1578+ @unittest .skipUnless (ZSTD_AVAILABLE , "zstd compression not available" )
1579+ def test_repetitive_tables_use_zstd (self ):
1580+ frames = [
1581+ make_frame (
1582+ f"package/with/repeated/prefix/module_{ index :04} .py" ,
1583+ index + 1 ,
1584+ f"function_with_repeated_prefix_{ index :04} " ,
1585+ )
1586+ for index in range (256 )
1587+ ]
1588+ samples = [[make_interpreter (0 , [make_thread (1 , frames )])]]
1589+ filename = self .create_binary_file (samples , compression = "zstd" )
1590+ for hdr_off in (self .HDR_OFF_STR_TABLE , self .HDR_OFF_FRAME_TABLE ):
1591+ with self .subTest (hdr_off = hdr_off ):
1592+ decoded_size , encoding , payload = self ._read_table_section (
1593+ filename , hdr_off )
1594+ self .assertEqual (encoding , 1 )
1595+ self .assertLess (len (payload ), decoded_size )
1596+
1597+ def test_open_rejects_invalid_table_sections (self ):
1598+ samples = [[make_interpreter (0 , [
1599+ make_thread (1 , [make_frame ("table.py" , 10 , "table" )])
1600+ ])]]
1601+ cases = [
1602+ ("Malformed string table header" , self .HDR_OFF_STR_TABLE ,
1603+ lambda size , payload : b"" ),
1604+ ("Unknown frame table encoding: 255" , self .HDR_OFF_FRAME_TABLE ,
1605+ lambda size , payload : self ._varint (size ) + b"\xff " + payload ),
1606+ ("Raw string table size does not match its header" ,
1607+ self .HDR_OFF_STR_TABLE ,
1608+ lambda size , payload : self ._varint (size + 1 ) + b"\0 " + payload ),
1609+ ("Frame table has trailing data" , self .HDR_OFF_FRAME_TABLE ,
1610+ lambda size , payload :
1611+ self ._varint (size + 1 ) + b"\0 " + payload + b"\0 " ),
1612+ ("zstd frame table in an uncompressed file" ,
1613+ self .HDR_OFF_FRAME_TABLE ,
1614+ lambda size , payload : self ._varint (size ) + b"\1 " + payload ),
1615+ ]
1616+ for message , hdr_off , make_section in cases :
1617+ with self .subTest (message = message ):
1618+ filename = self .create_binary_file (
1619+ samples , compression = "none" )
1620+ decoded_size , encoding , payload = self ._read_table_section (
1621+ filename , hdr_off )
1622+ self .assertEqual (encoding , 0 )
1623+ self ._replace_table_section (
1624+ filename , hdr_off , make_section (decoded_size , payload ))
1625+ with self .assertRaisesRegex (ValueError , message ):
1626+ with BinaryReader (filename ):
1627+ pass
1628+
1629+ @unittest .skipUnless (ZSTD_AVAILABLE , "zstd compression not available" )
1630+ def test_open_rejects_invalid_zstd_table (self ):
1631+ frames = [
1632+ make_frame (f"repeated/module_{ index :04} .py" , index + 1 ,
1633+ f"repeated_function_{ index :04} " )
1634+ for index in range (256 )
1635+ ]
1636+ samples = [[make_interpreter (0 , [make_thread (1 , frames )])]]
1637+
1638+ filename = self .create_binary_file (samples , compression = "zstd" )
1639+ decoded_size , encoding , payload = self ._read_table_section (
1640+ filename , self .HDR_OFF_FRAME_TABLE )
1641+ self .assertEqual (encoding , 1 )
1642+ self ._replace_table_section (
1643+ filename , self .HDR_OFF_FRAME_TABLE ,
1644+ self ._varint (decoded_size ) + bytes ([encoding ]) + payload [:- 1 ])
1645+ with self .assertRaisesRegex (ValueError ,
1646+ "Corrupted zstd frame header" ):
1647+ with BinaryReader (filename ):
1648+ pass
1649+
1650+ filename = self .create_binary_file (samples , compression = "zstd" )
1651+ decoded_size , encoding , payload = self ._read_table_section (
1652+ filename , self .HDR_OFF_FRAME_TABLE )
1653+ self ._replace_table_section (
1654+ filename , self .HDR_OFF_FRAME_TABLE ,
1655+ self ._varint (decoded_size + 1 ) + bytes ([encoding ]) + payload )
1656+ with self .assertRaisesRegex (
1657+ ValueError ,
1658+ "zstd frame size does not match the decoded size" ):
1659+ with BinaryReader (filename ):
1660+ pass
1661+
1662+ @unittest .skipUnless (ZSTD_AVAILABLE , "zstd compression not available" )
1663+ def test_open_rejects_oversized_zstd_table (self ):
1664+ # A table claiming 2**40 bytes must be rejected before anything is
1665+ # allocated for it.
1666+ samples = [[make_interpreter (0 , [
1667+ make_thread (1 , [make_frame ("table.py" , 10 , "table" )])
1668+ ])]]
1669+ filename = self .create_binary_file (samples , compression = "zstd" )
1670+ _ , _ , payload = self ._read_table_section (
1671+ filename , self .HDR_OFF_FRAME_TABLE )
1672+ self ._replace_table_section (
1673+ filename , self .HDR_OFF_FRAME_TABLE ,
1674+ self ._varint (1 << 40 ) + b"\1 " + payload )
1675+ with self .assertRaisesRegex (
1676+ ValueError , "frame table exceeds the maximum decoded size" ):
1677+ with BinaryReader (filename ):
1678+ pass
1679+
15421680 def test_open_rejects_older_format_version (self ):
15431681 filename = self .create_binary_file ([], compression = "none" )
15441682 with BinaryReader (filename ) as reader :
0 commit comments