From 885bfbd10b9ce72d2495f8370e19242ed71ce737 Mon Sep 17 00:00:00 2001 From: Taeknology <20297177+Taeknology@users.noreply.github.com> Date: Sun, 26 Jul 2026 02:26:50 +0900 Subject: [PATCH] gh-154667: Validate zipfile timestamps consistently Signed-off-by: Taeknology <20297177+Taeknology@users.noreply.github.com> --- Lib/test/test_zipfile/test_core.py | 52 ++++++++++++++++++- Lib/zipfile/__init__.py | 23 +++++--- ...-07-25-17-24-45.gh-issue-154667.Nljm8l.rst | 2 + 3 files changed, 69 insertions(+), 8 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-07-25-17-24-45.gh-issue-154667.Nljm8l.rst diff --git a/Lib/test/test_zipfile/test_core.py b/Lib/test/test_zipfile/test_core.py index cd498ba13e6f461..40f4ddac0f0452c 100644 --- a/Lib/test/test_zipfile/test_core.py +++ b/Lib/test/test_zipfile/test_core.py @@ -676,12 +676,12 @@ def test_add_file_after_2107(self): self.skipTest(f"Linux VFS/XFS kernel bug detected: {mtime_ns=}") with zipfile.ZipFile(TESTFN2, "w") as zipfp: - self.assertRaises(struct.error, zipfp.write, TESTFN) + self.assertRaises(ValueError, zipfp.write, TESTFN) with zipfile.ZipFile(TESTFN2, "w", strict_timestamps=False) as zipfp: zipfp.write(TESTFN) zinfo = zipfp.getinfo(TESTFN) - self.assertEqual(zinfo.date_time, (2107, 12, 31, 23, 59, 59)) + self.assertEqual(zinfo.date_time, (2107, 12, 31, 23, 59, 58)) @requires_zlib() @@ -4476,6 +4476,10 @@ def test_create_zipinfo_before_1980(self): self.assertRaises(ValueError, zipfile.ZipInfo, 'seventies', (1979, 1, 1, 0, 0, 0)) + def test_create_zipinfo_after_2107(self): + self.assertRaises(ValueError, + zipfile.ZipInfo, 'future', (2108, 1, 1, 0, 0, 0)) + def test_create_empty_zipinfo_repr(self): """Before bpo-26185, repr() on empty ZipInfo object was failing.""" zi = zipfile.ZipInfo(filename="empty") @@ -5528,6 +5532,50 @@ def test_from_file(self): self.assertFalse(zi.is_dir()) self.assertEqual(zi.file_size, os.path.getsize(__file__)) + def test_from_file_rejects_timestamp_after_2107(self): + st = mock.Mock(st_mode=stat.S_IFREG | 0o644, + st_mtime=2**63, st_size=5) + mtime = (2108, 1, 1, 0, 0, 0, 0, 1, -1) + with mock.patch.object(zipfile.os, 'stat', return_value=st), \ + mock.patch.object(zipfile.time, 'localtime', return_value=mtime): + with self.assertRaises(ValueError): + zipfile.ZipInfo.from_file('future') + + def test_from_file_clamps_timestamp_after_2107_round_trip(self): + st = mock.Mock(st_mode=stat.S_IFREG | 0o644, + st_mtime=2**63, st_size=5) + mtime = (2108, 1, 1, 0, 0, 0, 0, 1, -1) + with mock.patch.object(zipfile.os, 'stat', return_value=st), \ + mock.patch.object(zipfile.time, 'localtime', return_value=mtime): + zinfo = zipfile.ZipInfo.from_file( + 'future', strict_timestamps=False) + + expected = (2107, 12, 31, 23, 59, 58) + self.assertEqual(zinfo.date_time, expected) + archive = io.BytesIO() + with zipfile.ZipFile(archive, 'w') as zf: + zf.writestr(zinfo, b'future') + with zipfile.ZipFile(archive) as zf: + self.assertEqual(zf.getinfo('future').date_time, expected) + + def test_from_file_clamps_localtime_overflow(self): + cases = ( + (2**63, (2107, 12, 31, 23, 59, 58)), + (-2**63, (1980, 1, 1, 0, 0, 0)), + ) + for exception in (OverflowError, OSError): + for timestamp, expected in cases: + with self.subTest(exception=exception, timestamp=timestamp): + st = mock.Mock(st_mode=stat.S_IFREG | 0o644, + st_mtime=timestamp, st_size=5) + with mock.patch.object( + zipfile.os, 'stat', return_value=st), \ + mock.patch.object(zipfile.time, 'localtime', + side_effect=exception): + zinfo = zipfile.ZipInfo.from_file( + 'future', strict_timestamps=False) + self.assertEqual(zinfo.date_time, expected) + def test_from_file_pathlike(self): zi = zipfile.ZipInfo.from_file(FakePath(__file__)) self.assertEqual(posixpath.basename(zi.filename), 'test_core.py') diff --git a/Lib/zipfile/__init__.py b/Lib/zipfile/__init__.py index 418933a2e8d9e87..9dc7f1a4640bf45 100644 --- a/Lib/zipfile/__init__.py +++ b/Lib/zipfile/__init__.py @@ -467,6 +467,8 @@ def __init__(self, filename="NoName", date_time=(1980,1,1,0,0,0)): if date_time[0] < 1980: raise ValueError('ZIP does not support timestamps before 1980') + if date_time[0] > 2107: + raise ValueError('ZIP does not support timestamps after 2107') # Standard values: self.compress_type = ZIP_STORED # Type of compression for the file @@ -643,12 +645,21 @@ def from_file(cls, filename, arcname=None, *, strict_timestamps=True): filename = os.fspath(filename) st = os.stat(filename) isdir = stat.S_ISDIR(st.st_mode) - mtime = time.localtime(st.st_mtime) - date_time = mtime[0:6] - if not strict_timestamps and date_time[0] < 1980: - date_time = (1980, 1, 1, 0, 0, 0) - elif not strict_timestamps and date_time[0] > 2107: - date_time = (2107, 12, 31, 23, 59, 59) + try: + mtime = time.localtime(st.st_mtime) + except (OverflowError, OSError): + if strict_timestamps: + raise + if st.st_mtime < 0: + date_time = (1980, 1, 1, 0, 0, 0) + else: + date_time = (2107, 12, 31, 23, 59, 58) + else: + date_time = mtime[0:6] + if not strict_timestamps and date_time[0] < 1980: + date_time = (1980, 1, 1, 0, 0, 0) + elif not strict_timestamps and date_time[0] > 2107: + date_time = (2107, 12, 31, 23, 59, 58) # Create ZipInfo instance to store file information if arcname is None: arcname = filename diff --git a/Misc/NEWS.d/next/Library/2026-07-25-17-24-45.gh-issue-154667.Nljm8l.rst b/Misc/NEWS.d/next/Library/2026-07-25-17-24-45.gh-issue-154667.Nljm8l.rst new file mode 100644 index 000000000000000..5098e43878ca6b7 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-25-17-24-45.gh-issue-154667.Nljm8l.rst @@ -0,0 +1,2 @@ +Improve :mod:`zipfile` timestamp validation. Reject years after 2107 and +clamp out-of-range filesystem timestamps when ``strict_timestamps`` is false.