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 1/2] 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. From 7466ff4add8f724a768cdb9becc2f0c73a52c5be Mon Sep 17 00:00:00 2001 From: Taeknology <20297177+Taeknology@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:20:47 +0900 Subject: [PATCH 2/2] gh-154667: Distinguish platform timestamp limits from ZIP bounds --- Lib/test/test_zipfile/test_core.py | 35 ++++++++++++++++++++++++++++++ Lib/zipfile/__init__.py | 6 +++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/Lib/test/test_zipfile/test_core.py b/Lib/test/test_zipfile/test_core.py index 40f4ddac0f0452c..d157ef1fa6793c9 100644 --- a/Lib/test/test_zipfile/test_core.py +++ b/Lib/test/test_zipfile/test_core.py @@ -5561,7 +5561,13 @@ def test_from_file_clamps_timestamp_after_2107_round_trip(self): def test_from_file_clamps_localtime_overflow(self): cases = ( (2**63, (2107, 12, 31, 23, 59, 58)), + (2**48, (2107, 12, 31, 23, 59, 58)), + (4386441600, (2107, 12, 31, 23, 59, 58)), # 2109-01-01 UTC (-2**63, (1980, 1, 1, 0, 0, 0)), + (-2**48, (1980, 1, 1, 0, 0, 0)), + (0, (1980, 1, 1, 0, 0, 0)), # 1970-01-01 UTC + (157766400, (1980, 1, 1, 0, 0, 0)), # 1975-01-01 UTC + (283996799, (1980, 1, 1, 0, 0, 0)), # 1978-12-31 23:59:59 UTC ) for exception in (OverflowError, OSError): for timestamp, expected in cases: @@ -5572,10 +5578,39 @@ def test_from_file_clamps_localtime_overflow(self): zipfile.os, 'stat', return_value=st), \ mock.patch.object(zipfile.time, 'localtime', side_effect=exception): + with self.assertRaises(exception): + zipfile.ZipInfo.from_file('future') zinfo = zipfile.ZipInfo.from_file( 'future', strict_timestamps=False) self.assertEqual(zinfo.date_time, expected) + def test_from_file_preserves_in_range_localtime_errors(self): + timestamps = ( + 283996800, # 1979-01-01 00:00:00 UTC (conservative lower bound) + 315532799, # 1979-12-31 23:59:59 UTC + 315532800, # 1980-01-01 00:00:00 UTC + 2146003200, # 2038-01-02 00:00:00 UTC (AIX localtime overflow) + 2**31, # Beyond the signed 32-bit time_t range + 4354819199, # 2107-12-31 23:59:59 UTC + 4354819200, # 2108-01-01 00:00:00 UTC + 4386441599, # 2108-12-31 23:59:59 UTC + ) + for exception in (OverflowError, OSError): + for timestamp in timestamps: + for strict_timestamps in (True, False): + with self.subTest(exception=exception, timestamp=timestamp, + strict_timestamps=strict_timestamps): + 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): + with self.assertRaises(exception): + zipfile.ZipInfo.from_file( + 'in_range', + strict_timestamps=strict_timestamps) + 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 9dc7f1a4640bf45..9538f679cb98b41 100644 --- a/Lib/zipfile/__init__.py +++ b/Lib/zipfile/__init__.py @@ -648,9 +648,11 @@ def from_file(cls, filename, arcname=None, *, strict_timestamps=True): try: mtime = time.localtime(st.st_mtime) except (OverflowError, OSError): - if strict_timestamps: + # A platform limit (such as 2038) need not be a ZIP limit. + # UTC years 1979 through 2108 leave room for local time offsets. + if strict_timestamps or 283996800 <= st.st_mtime < 4386441600: raise - if st.st_mtime < 0: + if st.st_mtime < 283996800: date_time = (1980, 1, 1, 0, 0, 0) else: date_time = (2107, 12, 31, 23, 59, 58)