Skip to content

Commit 48679d2

Browse files
committed
Address review: update offsets only after moving each entry
Restoring every offset after a failed repack() is wrong once an entry has been moved over its old position: the restored offset then points at overwritten data. Instead, set each entry's header_offset only after its bytes have been copied, so entries that finished moving or were never touched stay readable. This also drops the list(removed) copy, which was only needed because the rollback read removed a second time.
1 parent 5f1fda3 commit 48679d2

2 files changed

Lines changed: 38 additions & 65 deletions

File tree

‎Lib/test/test_zipfile/test_core.py‎

Lines changed: 31 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -2664,28 +2664,10 @@ def test_overlap_with_archive_comment(self):
26642664
fz.seek(0)
26652665
self.assertEqual(fz.read(), expected)
26662666

2667-
def test_repack_removed_iterator(self):
2668-
# A one-shot iterable of removed entries must still be repacked.
2669-
buf = io.BytesIO()
2670-
with zipfile.ZipFile(buf, 'w') as zh:
2671-
for name in ['a.txt', 'b.txt', 'c.txt']:
2672-
zh.writestr(name, name[0].upper().encode() * 5000)
2673-
full_size = len(buf.getvalue())
2674-
2675-
fz = io.BytesIO(buf.getvalue())
2676-
with zipfile.ZipFile(fz, 'a') as zh:
2677-
zh.repack(iter([zh.remove('b.txt')]))
2678-
2679-
self.assertLess(len(fz.getvalue()), full_size - 5000)
2680-
fz.seek(0)
2681-
with zipfile.ZipFile(fz) as zh:
2682-
self.assertIsNone(zh.testzip())
2683-
self.assertEqual(zh.namelist(), ['a.txt', 'c.txt'])
2684-
2685-
def test_repack_failed_write_restores_header_offsets(self):
2686-
# A failed repack() must leave the in-memory offsets describing the
2687-
# file that is actually on disk, so that a later close() does not
2688-
# commit a central directory pointing at data that was never written.
2667+
def test_repack_write_error_keeps_entries_readable(self):
2668+
# close() after a failed repack() writes the in-memory offsets to
2669+
# the central directory, so an entry's offset must only change once
2670+
# its data has been moved.
26892671
class FlakyBytesIO(io.BytesIO):
26902672
countdown = None
26912673

@@ -2696,34 +2678,34 @@ def write(self, data):
26962678
raise OSError(errno.ENOSPC, 'No space left on device')
26972679
return super().write(data)
26982680

2699-
names = ['a.txt', 'b.txt', 'c.txt', 'd.txt']
2700-
buf = io.BytesIO()
2701-
with zipfile.ZipFile(buf, 'w') as zh:
2702-
for name in names:
2703-
zh.writestr(name, name[0].upper().encode() * 5000)
2704-
2705-
fz = FlakyBytesIO(buf.getvalue())
2706-
with zipfile.ZipFile(fz, 'a') as zh:
2707-
zi = zh.remove('b.txt')
2708-
expected = [z.header_offset for z in zh.infolist()]
2709-
expected_removed = zi.header_offset
2710-
2711-
fz.countdown = 1
2712-
with self.assertRaises(OSError):
2713-
zh.repack([zi], chunk_size=4096)
2714-
2715-
self.assertEqual([z.header_offset for z in zh.infolist()], expected)
2716-
self.assertEqual(zi.header_offset, expected_removed)
2717-
2718-
# The archive is still usable once the write error clears.
2719-
fz.countdown = None
2681+
for sizes, countdown, readable in [
2682+
# The error hits b, the first entry to move. The gap left by a
2683+
# is larger than one chunk, so b's old data is still intact.
2684+
([5000, 5000, 5000, 5000], 1, 'bcd'),
2685+
# b has been moved when the error hits c. c is not checked:
2686+
# the gap left by a is small, so its move overwrote its old data.
2687+
([100, 10000, 10000, 100], 4, 'bd'),
2688+
]:
2689+
with self.subTest(sizes=sizes):
2690+
contents = {name: randbytes(size)
2691+
for name, size in zip('abcd', sizes)}
2692+
buf = io.BytesIO()
2693+
with zipfile.ZipFile(buf, 'w') as zh:
2694+
for name, data in contents.items():
2695+
zh.writestr(name, data)
2696+
2697+
fz = FlakyBytesIO(buf.getvalue())
2698+
with zipfile.ZipFile(fz, 'a') as zh:
2699+
zi = zh.remove('a')
2700+
fz.countdown = countdown
2701+
with self.assertRaises(OSError):
2702+
zh.repack([zi], chunk_size=4096)
2703+
fz.countdown = None
27202704

2721-
fz.seek(0)
2722-
with zipfile.ZipFile(fz) as zh:
2723-
self.assertIsNone(zh.testzip())
2724-
self.assertEqual(zh.namelist(), ['a.txt', 'c.txt', 'd.txt'])
2725-
for name in zh.namelist():
2726-
self.assertEqual(zh.read(name), name[0].upper().encode() * 5000)
2705+
fz.seek(0)
2706+
with zipfile.ZipFile(fz) as zh:
2707+
for name in readable:
2708+
self.assertEqual(zh.read(name), contents[name])
27272709

27282710
class ZipRepackerTests(unittest.TestCase):
27292711
def _generate_local_file_entry(self, arcname, raw_bytes,

‎Lib/zipfile/__init__.py‎

Lines changed: 7 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1581,17 +1581,18 @@ def repack(self, zfile, removed=None):
15811581
entry_size = entry_size_list[i]
15821582
used_entry_size = used_entry_size_list[i]
15831583

1584-
# update the header and move entry data to the new position
1584+
# move entry data to the new position, then update the header
15851585
old_header_offset = zinfo.header_offset
1586-
zinfo.header_offset -= entry_offset
1586+
new_header_offset = old_header_offset - entry_offset
15871587

15881588
if zinfo in removed_zinfos:
15891589
self._copy_bytes(
15901590
fp,
15911591
old_header_offset + used_entry_size,
1592-
zinfo.header_offset,
1592+
new_header_offset,
15931593
entry_size - used_entry_size,
15941594
)
1595+
zinfo.header_offset = new_header_offset
15951596

15961597
# update entry_offset for subsequent files to follow
15971598
entry_offset += used_entry_size
@@ -1601,7 +1602,7 @@ def repack(self, zfile, removed=None):
16011602
self._copy_bytes(
16021603
fp,
16031604
old_header_offset,
1604-
zinfo.header_offset,
1605+
new_header_offset,
16051606
used_entry_size,
16061607
)
16071608

@@ -1615,12 +1616,13 @@ def repack(self, zfile, removed=None):
16151616
self._copy_bytes(
16161617
fp,
16171618
old_header_offset + used_entry_size + stale_entry_size,
1618-
zinfo.header_offset + used_entry_size,
1619+
new_header_offset + used_entry_size,
16191620
entry_size - used_entry_size - stale_entry_size,
16201621
)
16211622

16221623
# update entry_offset for subsequent files to follow
16231624
entry_offset += stale_entry_size
1625+
zinfo.header_offset = new_header_offset
16241626

16251627
# update state
16261628
zfile.start_dir -= entry_offset
@@ -2441,23 +2443,12 @@ def repack(self, removed=None, *, strict_descriptor=True,
24412443
)
24422444

24432445
self._writing = True
2444-
# *removed* is read twice below, so a one-shot iterable must not
2445-
# be consumed by the first read. None (scan the archive) is not
2446-
# the same as an empty sequence, so keep it as is.
2447-
if removed is not None:
2448-
removed = list(removed)
2449-
header_offsets = [(zinfo, zinfo.header_offset)
2450-
for zinfo in (*self.filelist, *(removed or ()))]
24512446
try:
24522447
repacker = _ZipRepacker(
24532448
strict_descriptor=strict_descriptor,
24542449
chunk_size=chunk_size,
24552450
)
24562451
repacker.repack(self, removed)
2457-
except BaseException:
2458-
for zinfo, header_offset in header_offsets:
2459-
zinfo.header_offset = header_offset
2460-
raise
24612452
finally:
24622453
self._writing = False
24632454

0 commit comments

Comments
 (0)